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