##// END OF EJS Templates
Merged r9390 from trunk....
Jean-Philippe Lang -
r9266:8fefb7c05bb9
parent child
Show More
@@ -1,437 +1,446
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 18 class MailHandler < ActionMailer::Base
19 19 include ActionView::Helpers::SanitizeHelper
20 20 include Redmine::I18n
21 21
22 22 class UnauthorizedAction < StandardError; end
23 23 class MissingInformation < StandardError; end
24 24
25 25 attr_reader :email, :user
26 26
27 27 def self.receive(email, options={})
28 28 @@handler_options = options.dup
29 29
30 30 @@handler_options[:issue] ||= {}
31 31
32 32 if @@handler_options[:allow_override].is_a?(String)
33 33 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip)
34 34 end
35 35 @@handler_options[:allow_override] ||= []
36 36 # Project needs to be overridable if not specified
37 37 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
38 38 # Status overridable by default
39 39 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
40 40
41 41 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
42 42 super email
43 43 end
44 44
45 cattr_accessor :ignored_emails_headers
46 @@ignored_emails_headers = {
47 'X-Auto-Response-Suppress' => 'OOF',
48 'Auto-Submitted' => 'auto-replied'
49 }
50
45 51 # Processes incoming emails
46 52 # Returns the created object (eg. an issue, a message) or false
47 53 def receive(email)
48 54 @email = email
49 55 sender_email = email.from.to_a.first.to_s.strip
50 56 # Ignore emails received from the application emission address to avoid hell cycles
51 57 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
52 58 if logger && logger.info
53 59 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
54 60 end
55 61 return false
56 62 end
57 # Ignore out-of-office emails
58 if email.header_string("X-Auto-Response-Suppress") == 'OOF'
59 if logger && logger.info
60 logger.info "MailHandler: ignoring out-of-office email"
63 # Ignore auto generated emails
64 self.class.ignored_emails_headers.each do |key, ignored_value|
65 value = email.header_string(key)
66 if value && value.to_s.downcase == ignored_value.downcase
67 if logger && logger.info
68 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
69 end
70 return false
61 71 end
62 return false
63 72 end
64 73 @user = User.find_by_mail(sender_email) if sender_email.present?
65 74 if @user && !@user.active?
66 75 if logger && logger.info
67 76 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
68 77 end
69 78 return false
70 79 end
71 80 if @user.nil?
72 81 # Email was submitted by an unknown user
73 82 case @@handler_options[:unknown_user]
74 83 when 'accept'
75 84 @user = User.anonymous
76 85 when 'create'
77 86 @user = create_user_from_email
78 87 if @user
79 88 if logger && logger.info
80 89 logger.info "MailHandler: [#{@user.login}] account created"
81 90 end
82 91 Mailer.deliver_account_information(@user, @user.password)
83 92 else
84 93 if logger && logger.error
85 94 logger.error "MailHandler: could not create account for [#{sender_email}]"
86 95 end
87 96 return false
88 97 end
89 98 else
90 99 # Default behaviour, emails from unknown users are ignored
91 100 if logger && logger.info
92 101 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
93 102 end
94 103 return false
95 104 end
96 105 end
97 106 User.current = @user
98 107 dispatch
99 108 end
100 109
101 110 private
102 111
103 112 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
104 113 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
105 114 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
106 115
107 116 def dispatch
108 117 headers = [email.in_reply_to, email.references].flatten.compact
109 118 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
110 119 klass, object_id = $1, $2.to_i
111 120 method_name = "receive_#{klass}_reply"
112 121 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
113 122 send method_name, object_id
114 123 else
115 124 # ignoring it
116 125 end
117 126 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
118 127 receive_issue_reply(m[1].to_i)
119 128 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
120 129 receive_message_reply(m[1].to_i)
121 130 else
122 131 dispatch_to_default
123 132 end
124 133 rescue ActiveRecord::RecordInvalid => e
125 134 # TODO: send a email to the user
126 135 logger.error e.message if logger
127 136 false
128 137 rescue MissingInformation => e
129 138 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
130 139 false
131 140 rescue UnauthorizedAction => e
132 141 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
133 142 false
134 143 end
135 144
136 145 def dispatch_to_default
137 146 receive_issue
138 147 end
139 148
140 149 # Creates a new issue
141 150 def receive_issue
142 151 project = target_project
143 152 # check permission
144 153 unless @@handler_options[:no_permission_check]
145 154 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
146 155 end
147 156
148 157 issue = Issue.new(:author => user, :project => project)
149 158 issue.safe_attributes = issue_attributes_from_keywords(issue)
150 159 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
151 160 issue.subject = email.subject.to_s.chomp[0,255]
152 161 if issue.subject.blank?
153 162 issue.subject = '(no subject)'
154 163 end
155 164 issue.description = cleaned_up_text_body
156 165
157 166 # add To and Cc as watchers before saving so the watchers can reply to Redmine
158 167 add_watchers(issue)
159 168 issue.save!
160 169 add_attachments(issue)
161 170 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
162 171 issue
163 172 end
164 173
165 174 # Adds a note to an existing issue
166 175 def receive_issue_reply(issue_id)
167 176 issue = Issue.find_by_id(issue_id)
168 177 return unless issue
169 178 # check permission
170 179 unless @@handler_options[:no_permission_check]
171 180 unless user.allowed_to?(:add_issue_notes, issue.project) ||
172 181 user.allowed_to?(:edit_issues, issue.project)
173 182 raise UnauthorizedAction
174 183 end
175 184 end
176 185
177 186 # ignore CLI-supplied defaults for new issues
178 187 @@handler_options[:issue].clear
179 188
180 189 journal = issue.init_journal(user)
181 190 issue.safe_attributes = issue_attributes_from_keywords(issue)
182 191 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
183 192 journal.notes = cleaned_up_text_body
184 193 add_attachments(issue)
185 194 issue.save!
186 195 if logger && logger.info
187 196 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
188 197 end
189 198 journal
190 199 end
191 200
192 201 # Reply will be added to the issue
193 202 def receive_journal_reply(journal_id)
194 203 journal = Journal.find_by_id(journal_id)
195 204 if journal && journal.journalized_type == 'Issue'
196 205 receive_issue_reply(journal.journalized_id)
197 206 end
198 207 end
199 208
200 209 # Receives a reply to a forum message
201 210 def receive_message_reply(message_id)
202 211 message = Message.find_by_id(message_id)
203 212 if message
204 213 message = message.root
205 214
206 215 unless @@handler_options[:no_permission_check]
207 216 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
208 217 end
209 218
210 219 if !message.locked?
211 220 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
212 221 :content => cleaned_up_text_body)
213 222 reply.author = user
214 223 reply.board = message.board
215 224 message.children << reply
216 225 add_attachments(reply)
217 226 reply
218 227 else
219 228 if logger && logger.info
220 229 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
221 230 end
222 231 end
223 232 end
224 233 end
225 234
226 235 def add_attachments(obj)
227 236 if email.attachments && email.attachments.any?
228 237 email.attachments.each do |attachment|
229 238 obj.attachments << Attachment.create(:container => obj,
230 239 :file => attachment,
231 240 :author => user,
232 241 :content_type => attachment.content_type)
233 242 end
234 243 end
235 244 end
236 245
237 246 # Adds To and Cc as watchers of the given object if the sender has the
238 247 # appropriate permission
239 248 def add_watchers(obj)
240 249 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
241 250 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
242 251 unless addresses.empty?
243 252 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
244 253 watchers.each {|w| obj.add_watcher(w)}
245 254 end
246 255 end
247 256 end
248 257
249 258 def get_keyword(attr, options={})
250 259 @keywords ||= {}
251 260 if @keywords.has_key?(attr)
252 261 @keywords[attr]
253 262 else
254 263 @keywords[attr] = begin
255 264 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
256 265 (v = extract_keyword!(plain_text_body, attr, options[:format]))
257 266 v
258 267 elsif !@@handler_options[:issue][attr].blank?
259 268 @@handler_options[:issue][attr]
260 269 end
261 270 end
262 271 end
263 272 end
264 273
265 274 # Destructively extracts the value for +attr+ in +text+
266 275 # Returns nil if no matching keyword found
267 276 def extract_keyword!(text, attr, format=nil)
268 277 keys = [attr.to_s.humanize]
269 278 if attr.is_a?(Symbol)
270 279 if user && user.language.present?
271 280 keys << l("field_#{attr}", :default => '', :locale => user.language)
272 281 end
273 282 if Setting.default_language.present?
274 283 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
275 284 end
276 285 end
277 286 keys.reject! {|k| k.blank?}
278 287 keys.collect! {|k| Regexp.escape(k)}
279 288 format ||= '.+'
280 289 text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '')
281 290 $2 && $2.strip
282 291 end
283 292
284 293 def target_project
285 294 # TODO: other ways to specify project:
286 295 # * parse the email To field
287 296 # * specific project (eg. Setting.mail_handler_target_project)
288 297 target = Project.find_by_identifier(get_keyword(:project))
289 298 raise MissingInformation.new('Unable to determine target project') if target.nil?
290 299 target
291 300 end
292 301
293 302 # Returns a Hash of issue attributes extracted from keywords in the email body
294 303 def issue_attributes_from_keywords(issue)
295 304 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
296 305
297 306 attrs = {
298 307 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
299 308 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
300 309 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
301 310 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
302 311 'assigned_to_id' => assigned_to.try(:id),
303 312 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
304 313 issue.project.shared_versions.named(k).first.try(:id),
305 314 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
306 315 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
307 316 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
308 317 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
309 318 }.delete_if {|k, v| v.blank? }
310 319
311 320 if issue.new_record? && attrs['tracker_id'].nil?
312 321 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
313 322 end
314 323
315 324 attrs
316 325 end
317 326
318 327 # Returns a Hash of issue custom field values extracted from keywords in the email body
319 328 def custom_field_values_from_keywords(customized)
320 329 customized.custom_field_values.inject({}) do |h, v|
321 330 if value = get_keyword(v.custom_field.name, :override => true)
322 331 h[v.custom_field.id.to_s] = value
323 332 end
324 333 h
325 334 end
326 335 end
327 336
328 337 # Returns the text/plain part of the email
329 338 # If not found (eg. HTML-only email), returns the body with tags removed
330 339 def plain_text_body
331 340 return @plain_text_body unless @plain_text_body.nil?
332 341 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
333 342 if parts.empty?
334 343 parts << @email
335 344 end
336 345 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
337 346 if plain_text_part.nil?
338 347 # no text/plain part found, assuming html-only email
339 348 # strip html tags and remove doctype directive
340 349 @plain_text_body = strip_tags(@email.body.to_s)
341 350 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
342 351 else
343 352 @plain_text_body = plain_text_part.body.to_s
344 353 end
345 354 @plain_text_body.strip!
346 355 @plain_text_body
347 356 end
348 357
349 358 def cleaned_up_text_body
350 359 cleanup_body(plain_text_body)
351 360 end
352 361
353 362 def self.full_sanitizer
354 363 @full_sanitizer ||= HTML::FullSanitizer.new
355 364 end
356 365
357 366 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
358 367 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
359 368 value = value.to_s.slice(0, limit)
360 369 object.send("#{attribute}=", value)
361 370 end
362 371
363 372 # Returns a User from an email address and a full name
364 373 def self.new_user_from_attributes(email_address, fullname=nil)
365 374 user = User.new
366 375
367 376 # Truncating the email address would result in an invalid format
368 377 user.mail = email_address
369 378 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
370 379
371 380 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
372 381 assign_string_attribute_with_limit(user, 'firstname', names.shift)
373 382 assign_string_attribute_with_limit(user, 'lastname', names.join(' '))
374 383 user.lastname = '-' if user.lastname.blank?
375 384
376 385 password_length = [Setting.password_min_length.to_i, 10].max
377 386 user.password = Redmine::Utils.random_hex(password_length / 2 + 1)
378 387 user.language = Setting.default_language
379 388
380 389 unless user.valid?
381 390 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
382 391 user.firstname = "-" unless user.errors[:firstname].blank?
383 392 user.lastname = "-" unless user.errors[:lastname].blank?
384 393 end
385 394
386 395 user
387 396 end
388 397
389 398 # Creates a User for the +email+ sender
390 399 # Returns the user or nil if it could not be created
391 400 def create_user_from_email
392 401 addr = email.from_addrs.to_a.first
393 402 if addr && !addr.spec.blank?
394 403 user = self.class.new_user_from_attributes(addr.spec, TMail::Unquoter.unquote_and_convert_to(addr.name, 'utf-8'))
395 404 if user.save
396 405 user
397 406 else
398 407 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
399 408 nil
400 409 end
401 410 else
402 411 logger.error "MailHandler: failed to create User: no FROM address found" if logger
403 412 nil
404 413 end
405 414 end
406 415
407 416 # Removes the email body of text after the truncation configurations.
408 417 def cleanup_body(body)
409 418 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
410 419 unless delimiters.empty?
411 420 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
412 421 body = body.gsub(regex, '')
413 422 end
414 423 body.strip
415 424 end
416 425
417 426 def find_assignee_from_keyword(keyword, issue)
418 427 keyword = keyword.to_s.downcase
419 428 assignable = issue.assignable_users
420 429 assignee = nil
421 430 assignee ||= assignable.detect {|a|
422 431 a.mail.to_s.downcase == keyword ||
423 432 a.login.to_s.downcase == keyword
424 433 }
425 434 if assignee.nil? && keyword.match(/ /)
426 435 firstname, lastname = *(keyword.split) # "First Last Throwaway"
427 436 assignee ||= assignable.detect {|a|
428 437 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
429 438 a.lastname.to_s.downcase == lastname
430 439 }
431 440 end
432 441 if assignee.nil?
433 442 assignee ||= assignable.detect {|a| a.is_a?(Group) && a.name.downcase == keyword}
434 443 end
435 444 assignee
436 445 end
437 446 end
@@ -1,620 +1,626
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2011 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class MailHandlerTest < ActiveSupport::TestCase
23 23 fixtures :users, :projects, :enabled_modules, :roles,
24 24 :members, :member_roles, :users,
25 25 :issues, :issue_statuses,
26 26 :workflows, :trackers, :projects_trackers,
27 27 :versions, :enumerations, :issue_categories,
28 28 :custom_fields, :custom_fields_trackers, :custom_fields_projects,
29 29 :boards, :messages
30 30
31 31 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
32 32
33 33 def setup
34 34 ActionMailer::Base.deliveries.clear
35 35 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
36 36 end
37 37
38 38 def test_add_issue
39 39 ActionMailer::Base.deliveries.clear
40 40 # This email contains: 'Project: onlinestore'
41 41 issue = submit_email('ticket_on_given_project.eml')
42 42 assert issue.is_a?(Issue)
43 43 assert !issue.new_record?
44 44 issue.reload
45 45 assert_equal Project.find(2), issue.project
46 46 assert_equal issue.project.trackers.first, issue.tracker
47 47 assert_equal 'New ticket on a given project', issue.subject
48 48 assert_equal User.find_by_login('jsmith'), issue.author
49 49 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
50 50 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
51 51 assert_equal '2010-01-01', issue.start_date.to_s
52 52 assert_equal '2010-12-31', issue.due_date.to_s
53 53 assert_equal User.find_by_login('jsmith'), issue.assigned_to
54 54 assert_equal Version.find_by_name('Alpha'), issue.fixed_version
55 55 assert_equal 2.5, issue.estimated_hours
56 56 assert_equal 30, issue.done_ratio
57 57 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
58 58 # keywords should be removed from the email body
59 59 assert !issue.description.match(/^Project:/i)
60 60 assert !issue.description.match(/^Status:/i)
61 61 assert !issue.description.match(/^Start Date:/i)
62 62 # Email notification should be sent
63 63 mail = ActionMailer::Base.deliveries.last
64 64 assert_not_nil mail
65 65 assert mail.subject.include?('New ticket on a given project')
66 66 end
67 67
68 68 def test_add_issue_with_default_tracker
69 69 # This email contains: 'Project: onlinestore'
70 70 issue = submit_email(
71 71 'ticket_on_given_project.eml',
72 72 :issue => {:tracker => 'Support request'}
73 73 )
74 74 assert issue.is_a?(Issue)
75 75 assert !issue.new_record?
76 76 issue.reload
77 77 assert_equal 'Support request', issue.tracker.name
78 78 end
79 79
80 80 def test_add_issue_with_status
81 81 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
82 82 issue = submit_email('ticket_on_given_project.eml')
83 83 assert issue.is_a?(Issue)
84 84 assert !issue.new_record?
85 85 issue.reload
86 86 assert_equal Project.find(2), issue.project
87 87 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
88 88 end
89 89
90 90 def test_add_issue_with_attributes_override
91 91 issue = submit_email(
92 92 'ticket_with_attributes.eml',
93 93 :allow_override => 'tracker,category,priority'
94 94 )
95 95 assert issue.is_a?(Issue)
96 96 assert !issue.new_record?
97 97 issue.reload
98 98 assert_equal 'New ticket on a given project', issue.subject
99 99 assert_equal User.find_by_login('jsmith'), issue.author
100 100 assert_equal Project.find(2), issue.project
101 101 assert_equal 'Feature request', issue.tracker.to_s
102 102 assert_equal 'Stock management', issue.category.to_s
103 103 assert_equal 'Urgent', issue.priority.to_s
104 104 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
105 105 end
106 106
107 107 def test_add_issue_with_group_assignment
108 108 with_settings :issue_group_assignment => '1' do
109 109 issue = submit_email('ticket_on_given_project.eml') do |email|
110 110 email.gsub!('Assigned to: John Smith', 'Assigned to: B Team')
111 111 end
112 112 assert issue.is_a?(Issue)
113 113 assert !issue.new_record?
114 114 issue.reload
115 115 assert_equal Group.find(11), issue.assigned_to
116 116 end
117 117 end
118 118
119 119 def test_add_issue_with_partial_attributes_override
120 120 issue = submit_email(
121 121 'ticket_with_attributes.eml',
122 122 :issue => {:priority => 'High'},
123 123 :allow_override => ['tracker']
124 124 )
125 125 assert issue.is_a?(Issue)
126 126 assert !issue.new_record?
127 127 issue.reload
128 128 assert_equal 'New ticket on a given project', issue.subject
129 129 assert_equal User.find_by_login('jsmith'), issue.author
130 130 assert_equal Project.find(2), issue.project
131 131 assert_equal 'Feature request', issue.tracker.to_s
132 132 assert_nil issue.category
133 133 assert_equal 'High', issue.priority.to_s
134 134 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
135 135 end
136 136
137 137 def test_add_issue_with_spaces_between_attribute_and_separator
138 138 issue = submit_email(
139 139 'ticket_with_spaces_between_attribute_and_separator.eml',
140 140 :allow_override => 'tracker,category,priority'
141 141 )
142 142 assert issue.is_a?(Issue)
143 143 assert !issue.new_record?
144 144 issue.reload
145 145 assert_equal 'New ticket on a given project', issue.subject
146 146 assert_equal User.find_by_login('jsmith'), issue.author
147 147 assert_equal Project.find(2), issue.project
148 148 assert_equal 'Feature request', issue.tracker.to_s
149 149 assert_equal 'Stock management', issue.category.to_s
150 150 assert_equal 'Urgent', issue.priority.to_s
151 151 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
152 152 end
153 153
154 154 def test_add_issue_with_attachment_to_specific_project
155 155 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
156 156 assert issue.is_a?(Issue)
157 157 assert !issue.new_record?
158 158 issue.reload
159 159 assert_equal 'Ticket created by email with attachment', issue.subject
160 160 assert_equal User.find_by_login('jsmith'), issue.author
161 161 assert_equal Project.find(2), issue.project
162 162 assert_equal 'This is a new ticket with attachments', issue.description
163 163 # Attachment properties
164 164 assert_equal 1, issue.attachments.size
165 165 assert_equal 'Paella.jpg', issue.attachments.first.filename
166 166 assert_equal 'image/jpeg', issue.attachments.first.content_type
167 167 assert_equal 10790, issue.attachments.first.filesize
168 168 end
169 169
170 170 def test_add_issue_with_custom_fields
171 171 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
172 172 assert issue.is_a?(Issue)
173 173 assert !issue.new_record?
174 174 issue.reload
175 175 assert_equal 'New ticket with custom field values', issue.subject
176 176 assert_equal 'Value for a custom field',
177 177 issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
178 178 assert !issue.description.match(/^searchable field:/i)
179 179 end
180 180
181 181 def test_add_issue_with_cc
182 182 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
183 183 assert issue.is_a?(Issue)
184 184 assert !issue.new_record?
185 185 issue.reload
186 186 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
187 187 assert_equal 1, issue.watcher_user_ids.size
188 188 end
189 189
190 190 def test_add_issue_by_unknown_user
191 191 assert_no_difference 'User.count' do
192 192 assert_equal false,
193 193 submit_email(
194 194 'ticket_by_unknown_user.eml',
195 195 :issue => {:project => 'ecookbook'}
196 196 )
197 197 end
198 198 end
199 199
200 200 def test_add_issue_by_anonymous_user
201 201 Role.anonymous.add_permission!(:add_issues)
202 202 assert_no_difference 'User.count' do
203 203 issue = submit_email(
204 204 'ticket_by_unknown_user.eml',
205 205 :issue => {:project => 'ecookbook'},
206 206 :unknown_user => 'accept'
207 207 )
208 208 assert issue.is_a?(Issue)
209 209 assert issue.author.anonymous?
210 210 end
211 211 end
212 212
213 213 def test_add_issue_by_anonymous_user_with_no_from_address
214 214 Role.anonymous.add_permission!(:add_issues)
215 215 assert_no_difference 'User.count' do
216 216 issue = submit_email(
217 217 'ticket_by_empty_user.eml',
218 218 :issue => {:project => 'ecookbook'},
219 219 :unknown_user => 'accept'
220 220 )
221 221 assert issue.is_a?(Issue)
222 222 assert issue.author.anonymous?
223 223 end
224 224 end
225 225
226 226 def test_add_issue_by_anonymous_user_on_private_project
227 227 Role.anonymous.add_permission!(:add_issues)
228 228 assert_no_difference 'User.count' do
229 229 assert_no_difference 'Issue.count' do
230 230 assert_equal false,
231 231 submit_email(
232 232 'ticket_by_unknown_user.eml',
233 233 :issue => {:project => 'onlinestore'},
234 234 :unknown_user => 'accept'
235 235 )
236 236 end
237 237 end
238 238 end
239 239
240 240 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
241 241 assert_no_difference 'User.count' do
242 242 assert_difference 'Issue.count' do
243 243 issue = submit_email(
244 244 'ticket_by_unknown_user.eml',
245 245 :issue => {:project => 'onlinestore'},
246 246 :no_permission_check => '1',
247 247 :unknown_user => 'accept'
248 248 )
249 249 assert issue.is_a?(Issue)
250 250 assert issue.author.anonymous?
251 251 assert !issue.project.is_public?
252 252 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
253 253 end
254 254 end
255 255 end
256 256
257 257 def test_add_issue_by_created_user
258 258 Setting.default_language = 'en'
259 259 assert_difference 'User.count' do
260 260 issue = submit_email(
261 261 'ticket_by_unknown_user.eml',
262 262 :issue => {:project => 'ecookbook'},
263 263 :unknown_user => 'create'
264 264 )
265 265 assert issue.is_a?(Issue)
266 266 assert issue.author.active?
267 267 assert_equal 'john.doe@somenet.foo', issue.author.mail
268 268 assert_equal 'John', issue.author.firstname
269 269 assert_equal 'Doe', issue.author.lastname
270 270
271 271 # account information
272 272 email = ActionMailer::Base.deliveries.first
273 273 assert_not_nil email
274 274 assert email.subject.include?('account activation')
275 275 login = mail_body(email).match(/\* Login: (.*)$/)[1].strip
276 276 password = mail_body(email).match(/\* Password: (.*)$/)[1].strip
277 277 assert_equal issue.author, User.try_to_login(login, password)
278 278 end
279 279 end
280 280
281 281 def test_add_issue_without_from_header
282 282 Role.anonymous.add_permission!(:add_issues)
283 283 assert_equal false, submit_email('ticket_without_from_header.eml')
284 284 end
285 285
286 286 def test_add_issue_with_invalid_attributes
287 287 issue = submit_email(
288 288 'ticket_with_invalid_attributes.eml',
289 289 :allow_override => 'tracker,category,priority'
290 290 )
291 291 assert issue.is_a?(Issue)
292 292 assert !issue.new_record?
293 293 issue.reload
294 294 assert_nil issue.assigned_to
295 295 assert_nil issue.start_date
296 296 assert_nil issue.due_date
297 297 assert_equal 0, issue.done_ratio
298 298 assert_equal 'Normal', issue.priority.to_s
299 299 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
300 300 end
301 301
302 302 def test_add_issue_with_localized_attributes
303 303 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
304 304 issue = submit_email(
305 305 'ticket_with_localized_attributes.eml',
306 306 :allow_override => 'tracker,category,priority'
307 307 )
308 308 assert issue.is_a?(Issue)
309 309 assert !issue.new_record?
310 310 issue.reload
311 311 assert_equal 'New ticket on a given project', issue.subject
312 312 assert_equal User.find_by_login('jsmith'), issue.author
313 313 assert_equal Project.find(2), issue.project
314 314 assert_equal 'Feature request', issue.tracker.to_s
315 315 assert_equal 'Stock management', issue.category.to_s
316 316 assert_equal 'Urgent', issue.priority.to_s
317 317 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
318 318 end
319 319
320 320 def test_add_issue_with_japanese_keywords
321 321 ja_dev = "\xe9\x96\x8b\xe7\x99\xba"
322 322 ja_dev.force_encoding('UTF-8') if ja_dev.respond_to?(:force_encoding)
323 323 tracker = Tracker.create!(:name => ja_dev)
324 324 Project.find(1).trackers << tracker
325 325 issue = submit_email(
326 326 'japanese_keywords_iso_2022_jp.eml',
327 327 :issue => {:project => 'ecookbook'},
328 328 :allow_override => 'tracker'
329 329 )
330 330 assert_kind_of Issue, issue
331 331 assert_equal tracker, issue.tracker
332 332 end
333 333
334 334 def test_add_issue_from_apple_mail
335 335 issue = submit_email(
336 336 'apple_mail_with_attachment.eml',
337 337 :issue => {:project => 'ecookbook'}
338 338 )
339 339 assert_kind_of Issue, issue
340 340 assert_equal 1, issue.attachments.size
341 341
342 342 attachment = issue.attachments.first
343 343 assert_equal 'paella.jpg', attachment.filename
344 344 assert_equal 10790, attachment.filesize
345 345 assert File.exist?(attachment.diskfile)
346 346 assert_equal 10790, File.size(attachment.diskfile)
347 347 assert_equal 'caaf384198bcbc9563ab5c058acd73cd', attachment.digest
348 348 end
349 349
350 350 def test_should_ignore_emails_from_emission_address
351 351 Role.anonymous.add_permission!(:add_issues)
352 352 assert_no_difference 'User.count' do
353 353 assert_equal false,
354 354 submit_email(
355 355 'ticket_from_emission_address.eml',
356 356 :issue => {:project => 'ecookbook'},
357 357 :unknown_user => 'create'
358 358 )
359 359 end
360 360 end
361 361
362 def test_should_ignore_oof_emails
363 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
364 raw = "X-Auto-Response-Suppress: OOF\n" + raw
365
366 assert_no_difference 'Issue.count' do
367 assert_equal false, MailHandler.receive(raw)
362 def test_should_ignore_auto_replied_emails
363 [
364 "X-Auto-Response-Suppress: OOF",
365 "Auto-Submitted: auto-replied",
366 "Auto-Submitted: Auto-Replied"
367 ].each do |header|
368 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
369 raw = header + "\n" + raw
370
371 assert_no_difference 'Issue.count' do
372 assert_equal false, MailHandler.receive(raw), "email with #{header} header was not ignored"
373 end
368 374 end
369 375 end
370 376
371 377 def test_add_issue_should_send_email_notification
372 378 Setting.notified_events = ['issue_added']
373 379 ActionMailer::Base.deliveries.clear
374 380 # This email contains: 'Project: onlinestore'
375 381 issue = submit_email('ticket_on_given_project.eml')
376 382 assert issue.is_a?(Issue)
377 383 assert_equal 1, ActionMailer::Base.deliveries.size
378 384 end
379 385
380 386 def test_update_issue
381 387 journal = submit_email('ticket_reply.eml')
382 388 assert journal.is_a?(Journal)
383 389 assert_equal User.find_by_login('jsmith'), journal.user
384 390 assert_equal Issue.find(2), journal.journalized
385 391 assert_match /This is reply/, journal.notes
386 392 assert_equal 'Feature request', journal.issue.tracker.name
387 393 end
388 394
389 395 def test_update_issue_with_attribute_changes
390 396 # This email contains: 'Status: Resolved'
391 397 journal = submit_email('ticket_reply_with_status.eml')
392 398 assert journal.is_a?(Journal)
393 399 issue = Issue.find(journal.issue.id)
394 400 assert_equal User.find_by_login('jsmith'), journal.user
395 401 assert_equal Issue.find(2), journal.journalized
396 402 assert_match /This is reply/, journal.notes
397 403 assert_equal 'Feature request', journal.issue.tracker.name
398 404 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
399 405 assert_equal '2010-01-01', issue.start_date.to_s
400 406 assert_equal '2010-12-31', issue.due_date.to_s
401 407 assert_equal User.find_by_login('jsmith'), issue.assigned_to
402 408 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
403 409 # keywords should be removed from the email body
404 410 assert !journal.notes.match(/^Status:/i)
405 411 assert !journal.notes.match(/^Start Date:/i)
406 412 end
407 413
408 414 def test_update_issue_with_attachment
409 415 assert_difference 'Journal.count' do
410 416 assert_difference 'JournalDetail.count' do
411 417 assert_difference 'Attachment.count' do
412 418 assert_no_difference 'Issue.count' do
413 419 journal = submit_email('ticket_with_attachment.eml') do |raw|
414 420 raw.gsub! /^Subject: .*$/, 'Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories'
415 421 end
416 422 end
417 423 end
418 424 end
419 425 end
420 426 journal = Journal.first(:order => 'id DESC')
421 427 assert_equal Issue.find(2), journal.journalized
422 428 assert_equal 1, journal.details.size
423 429
424 430 detail = journal.details.first
425 431 assert_equal 'attachment', detail.property
426 432 assert_equal 'Paella.jpg', detail.value
427 433 end
428 434
429 435 def test_update_issue_should_send_email_notification
430 436 ActionMailer::Base.deliveries.clear
431 437 journal = submit_email('ticket_reply.eml')
432 438 assert journal.is_a?(Journal)
433 439 assert_equal 1, ActionMailer::Base.deliveries.size
434 440 end
435 441
436 442 def test_update_issue_should_not_set_defaults
437 443 journal = submit_email(
438 444 'ticket_reply.eml',
439 445 :issue => {:tracker => 'Support request', :priority => 'High'}
440 446 )
441 447 assert journal.is_a?(Journal)
442 448 assert_match /This is reply/, journal.notes
443 449 assert_equal 'Feature request', journal.issue.tracker.name
444 450 assert_equal 'Normal', journal.issue.priority.name
445 451 end
446 452
447 453 def test_reply_to_a_message
448 454 m = submit_email('message_reply.eml')
449 455 assert m.is_a?(Message)
450 456 assert !m.new_record?
451 457 m.reload
452 458 assert_equal 'Reply via email', m.subject
453 459 # The email replies to message #2 which is part of the thread of message #1
454 460 assert_equal Message.find(1), m.parent
455 461 end
456 462
457 463 def test_reply_to_a_message_by_subject
458 464 m = submit_email('message_reply_by_subject.eml')
459 465 assert m.is_a?(Message)
460 466 assert !m.new_record?
461 467 m.reload
462 468 assert_equal 'Reply to the first post', m.subject
463 469 assert_equal Message.find(1), m.parent
464 470 end
465 471
466 472 def test_should_strip_tags_of_html_only_emails
467 473 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
468 474 assert issue.is_a?(Issue)
469 475 assert !issue.new_record?
470 476 issue.reload
471 477 assert_equal 'HTML email', issue.subject
472 478 assert_equal 'This is a html-only email.', issue.description
473 479 end
474 480
475 481 context "truncate emails based on the Setting" do
476 482 context "with no setting" do
477 483 setup do
478 484 Setting.mail_handler_body_delimiters = ''
479 485 end
480 486
481 487 should "add the entire email into the issue" do
482 488 issue = submit_email('ticket_on_given_project.eml')
483 489 assert_issue_created(issue)
484 490 assert issue.description.include?('---')
485 491 assert issue.description.include?('This paragraph is after the delimiter')
486 492 end
487 493 end
488 494
489 495 context "with a single string" do
490 496 setup do
491 497 Setting.mail_handler_body_delimiters = '---'
492 498 end
493 499 should "truncate the email at the delimiter for the issue" do
494 500 issue = submit_email('ticket_on_given_project.eml')
495 501 assert_issue_created(issue)
496 502 assert issue.description.include?('This paragraph is before delimiters')
497 503 assert issue.description.include?('--- This line starts with a delimiter')
498 504 assert !issue.description.match(/^---$/)
499 505 assert !issue.description.include?('This paragraph is after the delimiter')
500 506 end
501 507 end
502 508
503 509 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
504 510 setup do
505 511 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
506 512 end
507 513 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
508 514 journal = submit_email('issue_update_with_quoted_reply_above.eml')
509 515 assert journal.is_a?(Journal)
510 516 assert journal.notes.include?('An update to the issue by the sender.')
511 517 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
512 518 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
513 519 end
514 520 end
515 521
516 522 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
517 523 setup do
518 524 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
519 525 end
520 526 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
521 527 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
522 528 assert journal.is_a?(Journal)
523 529 assert journal.notes.include?('An update to the issue by the sender.')
524 530 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
525 531 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
526 532 end
527 533 end
528 534
529 535 context "with multiple strings" do
530 536 setup do
531 537 Setting.mail_handler_body_delimiters = "---\nBREAK"
532 538 end
533 539 should "truncate the email at the first delimiter found (BREAK)" do
534 540 issue = submit_email('ticket_on_given_project.eml')
535 541 assert_issue_created(issue)
536 542 assert issue.description.include?('This paragraph is before delimiters')
537 543 assert !issue.description.include?('BREAK')
538 544 assert !issue.description.include?('This paragraph is between delimiters')
539 545 assert !issue.description.match(/^---$/)
540 546 assert !issue.description.include?('This paragraph is after the delimiter')
541 547 end
542 548 end
543 549 end
544 550
545 551 def test_email_with_long_subject_line
546 552 issue = submit_email('ticket_with_long_subject.eml')
547 553 assert issue.is_a?(Issue)
548 554 assert_equal issue.subject, 'New ticket on a given project with a very long subject line which exceeds 255 chars and should not be ignored but chopped off. And if the subject line is still not long enough, we just add more text. And more text. Wow, this is really annoying. Especially, if you have nothing to say...'[0,255]
549 555 end
550 556
551 557 def test_new_user_from_attributes_should_return_valid_user
552 558 to_test = {
553 559 # [address, name] => [login, firstname, lastname]
554 560 ['jsmith@example.net', nil] => ['jsmith@example.net', 'jsmith', '-'],
555 561 ['jsmith@example.net', 'John'] => ['jsmith@example.net', 'John', '-'],
556 562 ['jsmith@example.net', 'John Smith'] => ['jsmith@example.net', 'John', 'Smith'],
557 563 ['jsmith@example.net', 'John Paul Smith'] => ['jsmith@example.net', 'John', 'Paul Smith'],
558 564 ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsTheMaximumLength Smith'] => ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsT', 'Smith'],
559 565 ['jsmith@example.net', 'John AVeryLongLastnameThatExceedsTheMaximumLength'] => ['jsmith@example.net', 'John', 'AVeryLongLastnameThatExceedsTh']
560 566 }
561 567
562 568 to_test.each do |attrs, expected|
563 569 user = MailHandler.new_user_from_attributes(attrs.first, attrs.last)
564 570
565 571 assert user.valid?, user.errors.full_messages.to_s
566 572 assert_equal attrs.first, user.mail
567 573 assert_equal expected[0], user.login
568 574 assert_equal expected[1], user.firstname
569 575 assert_equal expected[2], user.lastname
570 576 end
571 577 end
572 578
573 579 def test_new_user_from_attributes_should_respect_minimum_password_length
574 580 with_settings :password_min_length => 15 do
575 581 user = MailHandler.new_user_from_attributes('jsmith@example.net')
576 582 assert user.valid?
577 583 assert user.password.length >= 15
578 584 end
579 585 end
580 586
581 587 def test_new_user_from_attributes_should_use_default_login_if_invalid
582 588 user = MailHandler.new_user_from_attributes('foo+bar@example.net')
583 589 assert user.valid?
584 590 assert user.login =~ /^user[a-f0-9]+$/
585 591 assert_equal 'foo+bar@example.net', user.mail
586 592 end
587 593
588 594 def test_new_user_with_utf8_encoded_fullname_should_be_decoded
589 595 assert_difference 'User.count' do
590 596 issue = submit_email(
591 597 'fullname_of_sender_as_utf8_encoded.eml',
592 598 :issue => {:project => 'ecookbook'},
593 599 :unknown_user => 'create'
594 600 )
595 601 end
596 602
597 603 user = User.first(:order => 'id DESC')
598 604 assert_equal "foo@example.org", user.mail
599 605 str1 = "\xc3\x84\xc3\xa4"
600 606 str2 = "\xc3\x96\xc3\xb6"
601 607 str1.force_encoding('UTF-8') if str1.respond_to?(:force_encoding)
602 608 str2.force_encoding('UTF-8') if str2.respond_to?(:force_encoding)
603 609 assert_equal str1, user.firstname
604 610 assert_equal str2, user.lastname
605 611 end
606 612
607 613 private
608 614
609 615 def submit_email(filename, options={})
610 616 raw = IO.read(File.join(FIXTURES_PATH, filename))
611 617 yield raw if block_given?
612 618 MailHandler.receive(raw, options)
613 619 end
614 620
615 621 def assert_issue_created(issue)
616 622 assert issue.is_a?(Issue)
617 623 assert !issue.new_record?
618 624 issue.reload
619 625 end
620 626 end
General Comments 0
You need to be logged in to leave comments. Login now