##// END OF EJS Templates
Use Mail instead of TMail in MailHandler....
Jean-Philippe Lang -
r9447:1ab261dda6df
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,235 +1,240
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/md5"
18 require "digest/md5"
19
19
20 class Attachment < ActiveRecord::Base
20 class Attachment < ActiveRecord::Base
21 belongs_to :container, :polymorphic => true
21 belongs_to :container, :polymorphic => true
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23
23
24 validates_presence_of :filename, :author
24 validates_presence_of :filename, :author
25 validates_length_of :filename, :maximum => 255
25 validates_length_of :filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
27 validate :validate_max_file_size
27 validate :validate_max_file_size
28
28
29 acts_as_event :title => :filename,
29 acts_as_event :title => :filename,
30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
31
31
32 acts_as_activity_provider :type => 'files',
32 acts_as_activity_provider :type => 'files',
33 :permission => :view_files,
33 :permission => :view_files,
34 :author_key => :author_id,
34 :author_key => :author_id,
35 :find_options => {:select => "#{Attachment.table_name}.*",
35 :find_options => {:select => "#{Attachment.table_name}.*",
36 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
36 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
37 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
37 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
38
38
39 acts_as_activity_provider :type => 'documents',
39 acts_as_activity_provider :type => 'documents',
40 :permission => :view_documents,
40 :permission => :view_documents,
41 :author_key => :author_id,
41 :author_key => :author_id,
42 :find_options => {:select => "#{Attachment.table_name}.*",
42 :find_options => {:select => "#{Attachment.table_name}.*",
43 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
43 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
44 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
44 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
45
45
46 cattr_accessor :storage_path
46 cattr_accessor :storage_path
47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
48
48
49 before_save :files_to_final_location
49 before_save :files_to_final_location
50 after_destroy :delete_from_disk
50 after_destroy :delete_from_disk
51
51
52 # Returns an unsaved copy of the attachment
52 # Returns an unsaved copy of the attachment
53 def copy(attributes=nil)
53 def copy(attributes=nil)
54 copy = self.class.new
54 copy = self.class.new
55 copy.attributes = self.attributes.dup.except("id", "downloads")
55 copy.attributes = self.attributes.dup.except("id", "downloads")
56 copy.attributes = attributes if attributes
56 copy.attributes = attributes if attributes
57 copy
57 copy
58 end
58 end
59
59
60 def validate_max_file_size
60 def validate_max_file_size
61 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
61 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
62 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
62 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
63 end
63 end
64 end
64 end
65
65
66 def file=(incoming_file)
66 def file=(incoming_file)
67 unless incoming_file.nil?
67 unless incoming_file.nil?
68 @temp_file = incoming_file
68 @temp_file = incoming_file
69 if @temp_file.size > 0
69 if @temp_file.size > 0
70 if @temp_file.respond_to?(:original_filename)
70 if @temp_file.respond_to?(:original_filename)
71 self.filename = @temp_file.original_filename
71 self.filename = @temp_file.original_filename
72 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
72 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
73 end
73 end
74 if @temp_file.respond_to?(:content_type)
74 if @temp_file.respond_to?(:content_type)
75 self.content_type = @temp_file.content_type.to_s.chomp
75 self.content_type = @temp_file.content_type.to_s.chomp
76 end
76 end
77 if content_type.blank? && filename.present?
77 if content_type.blank? && filename.present?
78 self.content_type = Redmine::MimeType.of(filename)
78 self.content_type = Redmine::MimeType.of(filename)
79 end
79 end
80 self.filesize = @temp_file.size
80 self.filesize = @temp_file.size
81 end
81 end
82 end
82 end
83 end
83 end
84
84
85 def file
85 def file
86 nil
86 nil
87 end
87 end
88
88
89 def filename=(arg)
89 def filename=(arg)
90 write_attribute :filename, sanitize_filename(arg.to_s)
90 write_attribute :filename, sanitize_filename(arg.to_s)
91 if new_record? && disk_filename.blank?
91 if new_record? && disk_filename.blank?
92 self.disk_filename = Attachment.disk_filename(filename)
92 self.disk_filename = Attachment.disk_filename(filename)
93 end
93 end
94 filename
94 filename
95 end
95 end
96
96
97 # Copies the temporary file to its final location
97 # Copies the temporary file to its final location
98 # and computes its MD5 hash
98 # and computes its MD5 hash
99 def files_to_final_location
99 def files_to_final_location
100 if @temp_file && (@temp_file.size > 0)
100 if @temp_file && (@temp_file.size > 0)
101 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
101 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
102 md5 = Digest::MD5.new
102 md5 = Digest::MD5.new
103 File.open(diskfile, "wb") do |f|
103 File.open(diskfile, "wb") do |f|
104 if @temp_file.respond_to?(:read)
104 buffer = ""
105 buffer = ""
105 while (buffer = @temp_file.read(8192))
106 while (buffer = @temp_file.read(8192))
106 f.write(buffer)
107 f.write(buffer)
107 md5.update(buffer)
108 md5.update(buffer)
108 end
109 end
110 else
111 f.write(@temp_file)
112 md5.update(@temp_file)
113 end
109 end
114 end
110 self.digest = md5.hexdigest
115 self.digest = md5.hexdigest
111 end
116 end
112 @temp_file = nil
117 @temp_file = nil
113 # Don't save the content type if it's longer than the authorized length
118 # Don't save the content type if it's longer than the authorized length
114 if self.content_type && self.content_type.length > 255
119 if self.content_type && self.content_type.length > 255
115 self.content_type = nil
120 self.content_type = nil
116 end
121 end
117 end
122 end
118
123
119 # Deletes the file from the file system if it's not referenced by other attachments
124 # Deletes the file from the file system if it's not referenced by other attachments
120 def delete_from_disk
125 def delete_from_disk
121 if Attachment.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil?
126 if Attachment.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil?
122 delete_from_disk!
127 delete_from_disk!
123 end
128 end
124 end
129 end
125
130
126 # Returns file's location on disk
131 # Returns file's location on disk
127 def diskfile
132 def diskfile
128 File.join(self.class.storage_path, disk_filename.to_s)
133 File.join(self.class.storage_path, disk_filename.to_s)
129 end
134 end
130
135
131 def increment_download
136 def increment_download
132 increment!(:downloads)
137 increment!(:downloads)
133 end
138 end
134
139
135 def project
140 def project
136 container.try(:project)
141 container.try(:project)
137 end
142 end
138
143
139 def visible?(user=User.current)
144 def visible?(user=User.current)
140 container && container.attachments_visible?(user)
145 container && container.attachments_visible?(user)
141 end
146 end
142
147
143 def deletable?(user=User.current)
148 def deletable?(user=User.current)
144 container && container.attachments_deletable?(user)
149 container && container.attachments_deletable?(user)
145 end
150 end
146
151
147 def image?
152 def image?
148 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
153 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
149 end
154 end
150
155
151 def is_text?
156 def is_text?
152 Redmine::MimeType.is_type?('text', filename)
157 Redmine::MimeType.is_type?('text', filename)
153 end
158 end
154
159
155 def is_diff?
160 def is_diff?
156 self.filename =~ /\.(patch|diff)$/i
161 self.filename =~ /\.(patch|diff)$/i
157 end
162 end
158
163
159 # Returns true if the file is readable
164 # Returns true if the file is readable
160 def readable?
165 def readable?
161 File.readable?(diskfile)
166 File.readable?(diskfile)
162 end
167 end
163
168
164 # Returns the attachment token
169 # Returns the attachment token
165 def token
170 def token
166 "#{id}.#{digest}"
171 "#{id}.#{digest}"
167 end
172 end
168
173
169 # Finds an attachment that matches the given token and that has no container
174 # Finds an attachment that matches the given token and that has no container
170 def self.find_by_token(token)
175 def self.find_by_token(token)
171 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
176 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
172 attachment_id, attachment_digest = $1, $2
177 attachment_id, attachment_digest = $1, $2
173 attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest})
178 attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest})
174 if attachment && attachment.container.nil?
179 if attachment && attachment.container.nil?
175 attachment
180 attachment
176 end
181 end
177 end
182 end
178 end
183 end
179
184
180 # Bulk attaches a set of files to an object
185 # Bulk attaches a set of files to an object
181 #
186 #
182 # Returns a Hash of the results:
187 # Returns a Hash of the results:
183 # :files => array of the attached files
188 # :files => array of the attached files
184 # :unsaved => array of the files that could not be attached
189 # :unsaved => array of the files that could not be attached
185 def self.attach_files(obj, attachments)
190 def self.attach_files(obj, attachments)
186 result = obj.save_attachments(attachments, User.current)
191 result = obj.save_attachments(attachments, User.current)
187 obj.attach_saved_attachments
192 obj.attach_saved_attachments
188 result
193 result
189 end
194 end
190
195
191 def self.latest_attach(attachments, filename)
196 def self.latest_attach(attachments, filename)
192 attachments.sort_by(&:created_on).reverse.detect {
197 attachments.sort_by(&:created_on).reverse.detect {
193 |att| att.filename.downcase == filename.downcase
198 |att| att.filename.downcase == filename.downcase
194 }
199 }
195 end
200 end
196
201
197 def self.prune(age=1.day)
202 def self.prune(age=1.day)
198 attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age])
203 attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age])
199 attachments.each(&:destroy)
204 attachments.each(&:destroy)
200 end
205 end
201
206
202 private
207 private
203
208
204 # Physically deletes the file from the file system
209 # Physically deletes the file from the file system
205 def delete_from_disk!
210 def delete_from_disk!
206 if disk_filename.present? && File.exist?(diskfile)
211 if disk_filename.present? && File.exist?(diskfile)
207 File.delete(diskfile)
212 File.delete(diskfile)
208 end
213 end
209 end
214 end
210
215
211 def sanitize_filename(value)
216 def sanitize_filename(value)
212 # get only the filename, not the whole path
217 # get only the filename, not the whole path
213 just_filename = value.gsub(/^.*(\\|\/)/, '')
218 just_filename = value.gsub(/^.*(\\|\/)/, '')
214
219
215 # Finally, replace invalid characters with underscore
220 # Finally, replace invalid characters with underscore
216 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
221 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
217 end
222 end
218
223
219 # Returns an ASCII or hashed filename
224 # Returns an ASCII or hashed filename
220 def self.disk_filename(filename)
225 def self.disk_filename(filename)
221 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
226 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
222 ascii = ''
227 ascii = ''
223 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
228 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
224 ascii = filename
229 ascii = filename
225 else
230 else
226 ascii = Digest::MD5.hexdigest(filename)
231 ascii = Digest::MD5.hexdigest(filename)
227 # keep the extension if any
232 # keep the extension if any
228 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
233 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
229 end
234 end
230 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
235 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
231 timestamp.succ!
236 timestamp.succ!
232 end
237 end
233 "#{timestamp}_#{ascii}"
238 "#{timestamp}_#{ascii}"
234 end
239 end
235 end
240 end
@@ -1,455 +1,459
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'vendor/tmail'
18 class MailHandler < ActionMailer::Base
19
20 class MailHandler
21 include ActionView::Helpers::SanitizeHelper
19 include ActionView::Helpers::SanitizeHelper
22 include Redmine::I18n
20 include Redmine::I18n
23
21
24 class UnauthorizedAction < StandardError; end
22 class UnauthorizedAction < StandardError; end
25 class MissingInformation < StandardError; end
23 class MissingInformation < StandardError; end
26
24
27 attr_reader :email, :user
25 attr_reader :email, :user
28
26
29 def self.receive(email, options={})
27 def self.receive(email, options={})
30 @@handler_options = options.dup
28 @@handler_options = options.dup
31
29
32 @@handler_options[:issue] ||= {}
30 @@handler_options[:issue] ||= {}
33
31
34 if @@handler_options[:allow_override].is_a?(String)
32 if @@handler_options[:allow_override].is_a?(String)
35 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip)
33 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip)
36 end
34 end
37 @@handler_options[:allow_override] ||= []
35 @@handler_options[:allow_override] ||= []
38 # Project needs to be overridable if not specified
36 # Project needs to be overridable if not specified
39 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
37 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
40 # Status overridable by default
38 # Status overridable by default
41 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
39 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
42
40
43 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
41 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
44
42
45 mail = TMail::Mail.parse(email)
43 email.force_encoding('ASCII-8BIT') if email.respond_to?(:force_encoding)
46 mail.base64_decode
44 super(email)
47 new.receive(mail)
48 end
45 end
49
46
50 def logger
47 def logger
51 Rails.logger
48 Rails.logger
52 end
49 end
53
50
54 cattr_accessor :ignored_emails_headers
51 cattr_accessor :ignored_emails_headers
55 @@ignored_emails_headers = {
52 @@ignored_emails_headers = {
56 'X-Auto-Response-Suppress' => 'OOF',
53 'X-Auto-Response-Suppress' => 'OOF',
57 'Auto-Submitted' => 'auto-replied'
54 'Auto-Submitted' => 'auto-replied'
58 }
55 }
59
56
60 # Processes incoming emails
57 # Processes incoming emails
61 # Returns the created object (eg. an issue, a message) or false
58 # Returns the created object (eg. an issue, a message) or false
62 def receive(email)
59 def receive(email)
63 @email = email
60 @email = email
64 sender_email = email.from.to_a.first.to_s.strip
61 sender_email = email.from.to_a.first.to_s.strip
65 # Ignore emails received from the application emission address to avoid hell cycles
62 # Ignore emails received from the application emission address to avoid hell cycles
66 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
63 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
67 if logger && logger.info
64 if logger && logger.info
68 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
65 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
69 end
66 end
70 return false
67 return false
71 end
68 end
72 # Ignore auto generated emails
69 # Ignore auto generated emails
73 self.class.ignored_emails_headers.each do |key, ignored_value|
70 self.class.ignored_emails_headers.each do |key, ignored_value|
74 value = email.header_string(key)
71 value = email.header[key]
75 if value && value.to_s.downcase == ignored_value.downcase
72 if value && value.to_s.downcase == ignored_value.downcase
76 if logger && logger.info
73 if logger && logger.info
77 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
74 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
78 end
75 end
79 return false
76 return false
80 end
77 end
81 end
78 end
82 @user = User.find_by_mail(sender_email) if sender_email.present?
79 @user = User.find_by_mail(sender_email) if sender_email.present?
83 if @user && !@user.active?
80 if @user && !@user.active?
84 if logger && logger.info
81 if logger && logger.info
85 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
82 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
86 end
83 end
87 return false
84 return false
88 end
85 end
89 if @user.nil?
86 if @user.nil?
90 # Email was submitted by an unknown user
87 # Email was submitted by an unknown user
91 case @@handler_options[:unknown_user]
88 case @@handler_options[:unknown_user]
92 when 'accept'
89 when 'accept'
93 @user = User.anonymous
90 @user = User.anonymous
94 when 'create'
91 when 'create'
95 @user = create_user_from_email
92 @user = create_user_from_email
96 if @user
93 if @user
97 if logger && logger.info
94 if logger && logger.info
98 logger.info "MailHandler: [#{@user.login}] account created"
95 logger.info "MailHandler: [#{@user.login}] account created"
99 end
96 end
100 Mailer.deliver_account_information(@user, @user.password)
97 Mailer.deliver_account_information(@user, @user.password)
101 else
98 else
102 if logger && logger.error
99 if logger && logger.error
103 logger.error "MailHandler: could not create account for [#{sender_email}]"
100 logger.error "MailHandler: could not create account for [#{sender_email}]"
104 end
101 end
105 return false
102 return false
106 end
103 end
107 else
104 else
108 # Default behaviour, emails from unknown users are ignored
105 # Default behaviour, emails from unknown users are ignored
109 if logger && logger.info
106 if logger && logger.info
110 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
107 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
111 end
108 end
112 return false
109 return false
113 end
110 end
114 end
111 end
115 User.current = @user
112 User.current = @user
116 dispatch
113 dispatch
117 end
114 end
118
115
119 private
116 private
120
117
121 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
118 MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
122 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
119 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
123 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
120 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
124
121
125 def dispatch
122 def dispatch
126 headers = [email.in_reply_to, email.references].flatten.compact
123 headers = [email.in_reply_to, email.references].flatten.compact
127 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
124 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
128 klass, object_id = $1, $2.to_i
125 klass, object_id = $1, $2.to_i
129 method_name = "receive_#{klass}_reply"
126 method_name = "receive_#{klass}_reply"
130 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
127 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
131 send method_name, object_id
128 send method_name, object_id
132 else
129 else
133 # ignoring it
130 # ignoring it
134 end
131 end
135 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
132 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
136 receive_issue_reply(m[1].to_i)
133 receive_issue_reply(m[1].to_i)
137 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
134 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
138 receive_message_reply(m[1].to_i)
135 receive_message_reply(m[1].to_i)
139 else
136 else
140 dispatch_to_default
137 dispatch_to_default
141 end
138 end
142 rescue ActiveRecord::RecordInvalid => e
139 rescue ActiveRecord::RecordInvalid => e
143 # TODO: send a email to the user
140 # TODO: send a email to the user
144 logger.error e.message if logger
141 logger.error e.message if logger
145 false
142 false
146 rescue MissingInformation => e
143 rescue MissingInformation => e
147 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
144 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
148 false
145 false
149 rescue UnauthorizedAction => e
146 rescue UnauthorizedAction => e
150 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
147 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
151 false
148 false
152 end
149 end
153
150
154 def dispatch_to_default
151 def dispatch_to_default
155 receive_issue
152 receive_issue
156 end
153 end
157
154
158 # Creates a new issue
155 # Creates a new issue
159 def receive_issue
156 def receive_issue
160 project = target_project
157 project = target_project
161 # check permission
158 # check permission
162 unless @@handler_options[:no_permission_check]
159 unless @@handler_options[:no_permission_check]
163 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
160 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
164 end
161 end
165
162
166 issue = Issue.new(:author => user, :project => project)
163 issue = Issue.new(:author => user, :project => project)
167 issue.safe_attributes = issue_attributes_from_keywords(issue)
164 issue.safe_attributes = issue_attributes_from_keywords(issue)
168 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
165 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
169 issue.subject = email.subject.to_s.chomp[0,255]
166 issue.subject = email.subject.to_s.chomp[0,255]
170 if issue.subject.blank?
167 if issue.subject.blank?
171 issue.subject = '(no subject)'
168 issue.subject = '(no subject)'
172 end
169 end
173 issue.description = cleaned_up_text_body
170 issue.description = cleaned_up_text_body
174
171
175 # add To and Cc as watchers before saving so the watchers can reply to Redmine
172 # add To and Cc as watchers before saving so the watchers can reply to Redmine
176 add_watchers(issue)
173 add_watchers(issue)
177 issue.save!
174 issue.save!
178 add_attachments(issue)
175 add_attachments(issue)
179 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
176 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
180 issue
177 issue
181 end
178 end
182
179
183 # Adds a note to an existing issue
180 # Adds a note to an existing issue
184 def receive_issue_reply(issue_id)
181 def receive_issue_reply(issue_id)
185 issue = Issue.find_by_id(issue_id)
182 issue = Issue.find_by_id(issue_id)
186 return unless issue
183 return unless issue
187 # check permission
184 # check permission
188 unless @@handler_options[:no_permission_check]
185 unless @@handler_options[:no_permission_check]
189 unless user.allowed_to?(:add_issue_notes, issue.project) ||
186 unless user.allowed_to?(:add_issue_notes, issue.project) ||
190 user.allowed_to?(:edit_issues, issue.project)
187 user.allowed_to?(:edit_issues, issue.project)
191 raise UnauthorizedAction
188 raise UnauthorizedAction
192 end
189 end
193 end
190 end
194
191
195 # ignore CLI-supplied defaults for new issues
192 # ignore CLI-supplied defaults for new issues
196 @@handler_options[:issue].clear
193 @@handler_options[:issue].clear
197
194
198 journal = issue.init_journal(user)
195 journal = issue.init_journal(user)
199 issue.safe_attributes = issue_attributes_from_keywords(issue)
196 issue.safe_attributes = issue_attributes_from_keywords(issue)
200 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
197 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
201 journal.notes = cleaned_up_text_body
198 journal.notes = cleaned_up_text_body
202 add_attachments(issue)
199 add_attachments(issue)
203 issue.save!
200 issue.save!
204 if logger && logger.info
201 if logger && logger.info
205 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
202 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
206 end
203 end
207 journal
204 journal
208 end
205 end
209
206
210 # Reply will be added to the issue
207 # Reply will be added to the issue
211 def receive_journal_reply(journal_id)
208 def receive_journal_reply(journal_id)
212 journal = Journal.find_by_id(journal_id)
209 journal = Journal.find_by_id(journal_id)
213 if journal && journal.journalized_type == 'Issue'
210 if journal && journal.journalized_type == 'Issue'
214 receive_issue_reply(journal.journalized_id)
211 receive_issue_reply(journal.journalized_id)
215 end
212 end
216 end
213 end
217
214
218 # Receives a reply to a forum message
215 # Receives a reply to a forum message
219 def receive_message_reply(message_id)
216 def receive_message_reply(message_id)
220 message = Message.find_by_id(message_id)
217 message = Message.find_by_id(message_id)
221 if message
218 if message
222 message = message.root
219 message = message.root
223
220
224 unless @@handler_options[:no_permission_check]
221 unless @@handler_options[:no_permission_check]
225 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
222 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
226 end
223 end
227
224
228 if !message.locked?
225 if !message.locked?
229 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
226 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
230 :content => cleaned_up_text_body)
227 :content => cleaned_up_text_body)
231 reply.author = user
228 reply.author = user
232 reply.board = message.board
229 reply.board = message.board
233 message.children << reply
230 message.children << reply
234 add_attachments(reply)
231 add_attachments(reply)
235 reply
232 reply
236 else
233 else
237 if logger && logger.info
234 if logger && logger.info
238 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
235 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
239 end
236 end
240 end
237 end
241 end
238 end
242 end
239 end
243
240
244 def add_attachments(obj)
241 def add_attachments(obj)
245 if email.attachments && email.attachments.any?
242 if email.attachments && email.attachments.any?
246 email.attachments.each do |attachment|
243 email.attachments.each do |attachment|
247 obj.attachments << Attachment.create(:container => obj,
244 obj.attachments << Attachment.create(:container => obj,
248 :file => attachment,
245 :file => attachment.decoded,
246 :filename => attachment.filename,
249 :author => user,
247 :author => user,
250 :content_type => attachment.content_type)
248 :content_type => attachment.mime_type)
251 end
249 end
252 end
250 end
253 end
251 end
254
252
255 # Adds To and Cc as watchers of the given object if the sender has the
253 # Adds To and Cc as watchers of the given object if the sender has the
256 # appropriate permission
254 # appropriate permission
257 def add_watchers(obj)
255 def add_watchers(obj)
258 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
256 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
259 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
257 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
260 unless addresses.empty?
258 unless addresses.empty?
261 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
259 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
262 watchers.each {|w| obj.add_watcher(w)}
260 watchers.each {|w| obj.add_watcher(w)}
263 end
261 end
264 end
262 end
265 end
263 end
266
264
267 def get_keyword(attr, options={})
265 def get_keyword(attr, options={})
268 @keywords ||= {}
266 @keywords ||= {}
269 if @keywords.has_key?(attr)
267 if @keywords.has_key?(attr)
270 @keywords[attr]
268 @keywords[attr]
271 else
269 else
272 @keywords[attr] = begin
270 @keywords[attr] = begin
273 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
271 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
274 (v = extract_keyword!(plain_text_body, attr, options[:format]))
272 (v = extract_keyword!(plain_text_body, attr, options[:format]))
275 v
273 v
276 elsif !@@handler_options[:issue][attr].blank?
274 elsif !@@handler_options[:issue][attr].blank?
277 @@handler_options[:issue][attr]
275 @@handler_options[:issue][attr]
278 end
276 end
279 end
277 end
280 end
278 end
281 end
279 end
282
280
283 # Destructively extracts the value for +attr+ in +text+
281 # Destructively extracts the value for +attr+ in +text+
284 # Returns nil if no matching keyword found
282 # Returns nil if no matching keyword found
285 def extract_keyword!(text, attr, format=nil)
283 def extract_keyword!(text, attr, format=nil)
286 keys = [attr.to_s.humanize]
284 keys = [attr.to_s.humanize]
287 if attr.is_a?(Symbol)
285 if attr.is_a?(Symbol)
288 if user && user.language.present?
286 if user && user.language.present?
289 keys << l("field_#{attr}", :default => '', :locale => user.language)
287 keys << l("field_#{attr}", :default => '', :locale => user.language)
290 end
288 end
291 if Setting.default_language.present?
289 if Setting.default_language.present?
292 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
290 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
293 end
291 end
294 end
292 end
295 keys.reject! {|k| k.blank?}
293 keys.reject! {|k| k.blank?}
296 keys.collect! {|k| Regexp.escape(k)}
294 keys.collect! {|k| Regexp.escape(k)}
297 format ||= '.+'
295 format ||= '.+'
298 text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '')
296 keyword = nil
299 $2 && $2.strip
297 regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
298 if m = text.match(regexp)
299 keyword = m[2].strip
300 text.gsub!(regexp, '')
301 end
302 keyword
300 end
303 end
301
304
302 def target_project
305 def target_project
303 # TODO: other ways to specify project:
306 # TODO: other ways to specify project:
304 # * parse the email To field
307 # * parse the email To field
305 # * specific project (eg. Setting.mail_handler_target_project)
308 # * specific project (eg. Setting.mail_handler_target_project)
306 target = Project.find_by_identifier(get_keyword(:project))
309 target = Project.find_by_identifier(get_keyword(:project))
307 raise MissingInformation.new('Unable to determine target project') if target.nil?
310 raise MissingInformation.new('Unable to determine target project') if target.nil?
308 target
311 target
309 end
312 end
310
313
311 # Returns a Hash of issue attributes extracted from keywords in the email body
314 # Returns a Hash of issue attributes extracted from keywords in the email body
312 def issue_attributes_from_keywords(issue)
315 def issue_attributes_from_keywords(issue)
313 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
316 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
314
317
315 attrs = {
318 attrs = {
316 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
319 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
317 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
320 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
318 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
321 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
319 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
322 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
320 'assigned_to_id' => assigned_to.try(:id),
323 'assigned_to_id' => assigned_to.try(:id),
321 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
324 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
322 issue.project.shared_versions.named(k).first.try(:id),
325 issue.project.shared_versions.named(k).first.try(:id),
323 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
326 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
324 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
327 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
325 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
328 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
326 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
329 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
327 }.delete_if {|k, v| v.blank? }
330 }.delete_if {|k, v| v.blank? }
328
331
329 if issue.new_record? && attrs['tracker_id'].nil?
332 if issue.new_record? && attrs['tracker_id'].nil?
330 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
333 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
331 end
334 end
332
335
333 attrs
336 attrs
334 end
337 end
335
338
336 # Returns a Hash of issue custom field values extracted from keywords in the email body
339 # Returns a Hash of issue custom field values extracted from keywords in the email body
337 def custom_field_values_from_keywords(customized)
340 def custom_field_values_from_keywords(customized)
338 customized.custom_field_values.inject({}) do |h, v|
341 customized.custom_field_values.inject({}) do |h, v|
339 if value = get_keyword(v.custom_field.name, :override => true)
342 if value = get_keyword(v.custom_field.name, :override => true)
340 h[v.custom_field.id.to_s] = value
343 h[v.custom_field.id.to_s] = value
341 end
344 end
342 h
345 h
343 end
346 end
344 end
347 end
345
348
346 # Returns the text/plain part of the email
349 # Returns the text/plain part of the email
347 # If not found (eg. HTML-only email), returns the body with tags removed
350 # If not found (eg. HTML-only email), returns the body with tags removed
348 def plain_text_body
351 def plain_text_body
349 return @plain_text_body unless @plain_text_body.nil?
352 return @plain_text_body unless @plain_text_body.nil?
350 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
353
351 if parts.empty?
354 part = email.text_part || email.html_part || email
352 parts << @email
355 @plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
356
357 if @plain_text_body.respond_to?(:force_encoding)
358 # @plain_text_body = @plain_text_body.force_encoding(@email.charset).encode("UTF-8")
353 end
359 end
354 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
360
355 if plain_text_part.nil?
356 # no text/plain part found, assuming html-only email
357 # strip html tags and remove doctype directive
361 # strip html tags and remove doctype directive
358 @plain_text_body = strip_tags(@email.body.to_s)
362 @plain_text_body = strip_tags(@plain_text_body.strip)
359 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
363 @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
360 else
361 @plain_text_body = plain_text_part.body.to_s
362 end
363 @plain_text_body.strip!
364 @plain_text_body
364 @plain_text_body
365 end
365 end
366
366
367 def cleaned_up_text_body
367 def cleaned_up_text_body
368 cleanup_body(plain_text_body)
368 cleanup_body(plain_text_body)
369 end
369 end
370
370
371 def self.full_sanitizer
371 def self.full_sanitizer
372 @full_sanitizer ||= HTML::FullSanitizer.new
372 @full_sanitizer ||= HTML::FullSanitizer.new
373 end
373 end
374
374
375 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
375 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
376 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
376 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
377 value = value.to_s.slice(0, limit)
377 value = value.to_s.slice(0, limit)
378 object.send("#{attribute}=", value)
378 object.send("#{attribute}=", value)
379 end
379 end
380
380
381 # Returns a User from an email address and a full name
381 # Returns a User from an email address and a full name
382 def self.new_user_from_attributes(email_address, fullname=nil)
382 def self.new_user_from_attributes(email_address, fullname=nil)
383 user = User.new
383 user = User.new
384
384
385 # Truncating the email address would result in an invalid format
385 # Truncating the email address would result in an invalid format
386 user.mail = email_address
386 user.mail = email_address
387 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
387 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
388
388
389 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
389 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
390 assign_string_attribute_with_limit(user, 'firstname', names.shift)
390 assign_string_attribute_with_limit(user, 'firstname', names.shift)
391 assign_string_attribute_with_limit(user, 'lastname', names.join(' '))
391 assign_string_attribute_with_limit(user, 'lastname', names.join(' '))
392 user.lastname = '-' if user.lastname.blank?
392 user.lastname = '-' if user.lastname.blank?
393
393
394 password_length = [Setting.password_min_length.to_i, 10].max
394 password_length = [Setting.password_min_length.to_i, 10].max
395 user.password = Redmine::Utils.random_hex(password_length / 2 + 1)
395 user.password = Redmine::Utils.random_hex(password_length / 2 + 1)
396 user.language = Setting.default_language
396 user.language = Setting.default_language
397
397
398 unless user.valid?
398 unless user.valid?
399 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
399 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
400 user.firstname = "-" unless user.errors[:firstname].blank?
400 user.firstname = "-" unless user.errors[:firstname].blank?
401 user.lastname = "-" unless user.errors[:lastname].blank?
401 user.lastname = "-" unless user.errors[:lastname].blank?
402 end
402 end
403
403
404 user
404 user
405 end
405 end
406
406
407 # Creates a User for the +email+ sender
407 # Creates a User for the +email+ sender
408 # Returns the user or nil if it could not be created
408 # Returns the user or nil if it could not be created
409 def create_user_from_email
409 def create_user_from_email
410 addr = email.from_addrs.to_a.first
410 from = email.header['from'].to_s
411 if addr && !addr.spec.blank?
411 addr, name = from, nil
412 user = self.class.new_user_from_attributes(addr.spec, TMail::Unquoter.unquote_and_convert_to(addr.name, 'utf-8'))
412 if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
413 addr, name = m[2], m[1]
414 end
415 if addr.present?
416 user = self.class.new_user_from_attributes(addr, name)
413 if user.save
417 if user.save
414 user
418 user
415 else
419 else
416 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
420 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
417 nil
421 nil
418 end
422 end
419 else
423 else
420 logger.error "MailHandler: failed to create User: no FROM address found" if logger
424 logger.error "MailHandler: failed to create User: no FROM address found" if logger
421 nil
425 nil
422 end
426 end
423 end
427 end
424
428
425 # Removes the email body of text after the truncation configurations.
429 # Removes the email body of text after the truncation configurations.
426 def cleanup_body(body)
430 def cleanup_body(body)
427 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
431 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
428 unless delimiters.empty?
432 unless delimiters.empty?
429 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
433 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
430 body = body.gsub(regex, '')
434 body = body.gsub(regex, '')
431 end
435 end
432 body.strip
436 body.strip
433 end
437 end
434
438
435 def find_assignee_from_keyword(keyword, issue)
439 def find_assignee_from_keyword(keyword, issue)
436 keyword = keyword.to_s.downcase
440 keyword = keyword.to_s.downcase
437 assignable = issue.assignable_users
441 assignable = issue.assignable_users
438 assignee = nil
442 assignee = nil
439 assignee ||= assignable.detect {|a|
443 assignee ||= assignable.detect {|a|
440 a.mail.to_s.downcase == keyword ||
444 a.mail.to_s.downcase == keyword ||
441 a.login.to_s.downcase == keyword
445 a.login.to_s.downcase == keyword
442 }
446 }
443 if assignee.nil? && keyword.match(/ /)
447 if assignee.nil? && keyword.match(/ /)
444 firstname, lastname = *(keyword.split) # "First Last Throwaway"
448 firstname, lastname = *(keyword.split) # "First Last Throwaway"
445 assignee ||= assignable.detect {|a|
449 assignee ||= assignable.detect {|a|
446 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
450 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
447 a.lastname.to_s.downcase == lastname
451 a.lastname.to_s.downcase == lastname
448 }
452 }
449 end
453 end
450 if assignee.nil?
454 if assignee.nil?
451 assignee ||= assignable.detect {|a| a.is_a?(Group) && a.name.downcase == keyword}
455 assignee ||= assignable.detect {|a| a.is_a?(Group) && a.name.downcase == keyword}
452 end
456 end
453 assignee
457 assignee
454 end
458 end
455 end
459 end
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (590 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (962 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (1162 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (578 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (1060 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (504 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (927 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (596 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: file was removed
NO CONTENT: file was removed
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now