##// END OF EJS Templates
Merged r9796 from trunk....
Jean-Philippe Lang -
r9656:1d0fb85179e7
parent child
Show More
@@ -0,0 +1,11
1 Content-Type: application/ms-tnef; name="winmail.dat"
2 Content-Transfer-Encoding: binary
3 From: John Smith <JSmith@somenet.foo>
4 To: "redmine@somenet.foo" <redmine@somenet.foo>
5 Subject: =?iso-8859-1?Q?Testmail_from_Webmail:_=E4_=F6_=FC...?=
6 Date: Fri, 1 Jun 2012 14:39:38 +0200
7 Message-ID: <87C31D42249DD0489D1A1444E3232DD7019D6183@foo.bar>
8 Accept-Language: de-CH, en-US
9 Content-Language: de-CH
10
11 Fixture
@@ -1,455 +1,472
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 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
43 43 email.force_encoding('ASCII-8BIT') if email.respond_to?(:force_encoding)
44 44 super(email)
45 45 end
46 46
47 47 def logger
48 48 Rails.logger
49 49 end
50 50
51 51 cattr_accessor :ignored_emails_headers
52 52 @@ignored_emails_headers = {
53 53 'X-Auto-Response-Suppress' => 'OOF',
54 54 'Auto-Submitted' => 'auto-replied'
55 55 }
56 56
57 57 # Processes incoming emails
58 58 # Returns the created object (eg. an issue, a message) or false
59 59 def receive(email)
60 60 @email = email
61 61 sender_email = email.from.to_a.first.to_s.strip
62 62 # Ignore emails received from the application emission address to avoid hell cycles
63 63 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
64 64 if logger && logger.info
65 65 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
66 66 end
67 67 return false
68 68 end
69 69 # Ignore auto generated emails
70 70 self.class.ignored_emails_headers.each do |key, ignored_value|
71 71 value = email.header[key]
72 72 if value && value.to_s.downcase == ignored_value.downcase
73 73 if logger && logger.info
74 74 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
75 75 end
76 76 return false
77 77 end
78 78 end
79 79 @user = User.find_by_mail(sender_email) if sender_email.present?
80 80 if @user && !@user.active?
81 81 if logger && logger.info
82 82 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
83 83 end
84 84 return false
85 85 end
86 86 if @user.nil?
87 87 # Email was submitted by an unknown user
88 88 case @@handler_options[:unknown_user]
89 89 when 'accept'
90 90 @user = User.anonymous
91 91 when 'create'
92 92 @user = create_user_from_email
93 93 if @user
94 94 if logger && logger.info
95 95 logger.info "MailHandler: [#{@user.login}] account created"
96 96 end
97 97 Mailer.account_information(@user, @user.password).deliver
98 98 else
99 99 if logger && logger.error
100 100 logger.error "MailHandler: could not create account for [#{sender_email}]"
101 101 end
102 102 return false
103 103 end
104 104 else
105 105 # Default behaviour, emails from unknown users are ignored
106 106 if logger && logger.info
107 107 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
108 108 end
109 109 return false
110 110 end
111 111 end
112 112 User.current = @user
113 113 dispatch
114 114 end
115 115
116 116 private
117 117
118 118 MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
119 119 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
120 120 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
121 121
122 122 def dispatch
123 123 headers = [email.in_reply_to, email.references].flatten.compact
124 124 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
125 125 klass, object_id = $1, $2.to_i
126 126 method_name = "receive_#{klass}_reply"
127 127 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
128 128 send method_name, object_id
129 129 else
130 130 # ignoring it
131 131 end
132 132 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
133 133 receive_issue_reply(m[1].to_i)
134 134 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
135 135 receive_message_reply(m[1].to_i)
136 136 else
137 137 dispatch_to_default
138 138 end
139 139 rescue ActiveRecord::RecordInvalid => e
140 140 # TODO: send a email to the user
141 141 logger.error e.message if logger
142 142 false
143 143 rescue MissingInformation => e
144 144 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
145 145 false
146 146 rescue UnauthorizedAction => e
147 147 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
148 148 false
149 149 end
150 150
151 151 def dispatch_to_default
152 152 receive_issue
153 153 end
154 154
155 155 # Creates a new issue
156 156 def receive_issue
157 157 project = target_project
158 158 # check permission
159 159 unless @@handler_options[:no_permission_check]
160 160 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
161 161 end
162 162
163 163 issue = Issue.new(:author => user, :project => project)
164 164 issue.safe_attributes = issue_attributes_from_keywords(issue)
165 165 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
166 issue.subject = email.subject.to_s.chomp[0,255]
166 issue.subject = cleaned_up_subject
167 167 if issue.subject.blank?
168 168 issue.subject = '(no subject)'
169 169 end
170 170 issue.description = cleaned_up_text_body
171 171
172 172 # add To and Cc as watchers before saving so the watchers can reply to Redmine
173 173 add_watchers(issue)
174 174 issue.save!
175 175 add_attachments(issue)
176 176 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
177 177 issue
178 178 end
179 179
180 180 # Adds a note to an existing issue
181 181 def receive_issue_reply(issue_id)
182 182 issue = Issue.find_by_id(issue_id)
183 183 return unless issue
184 184 # check permission
185 185 unless @@handler_options[:no_permission_check]
186 186 unless user.allowed_to?(:add_issue_notes, issue.project) ||
187 187 user.allowed_to?(:edit_issues, issue.project)
188 188 raise UnauthorizedAction
189 189 end
190 190 end
191 191
192 192 # ignore CLI-supplied defaults for new issues
193 193 @@handler_options[:issue].clear
194 194
195 195 journal = issue.init_journal(user)
196 196 issue.safe_attributes = issue_attributes_from_keywords(issue)
197 197 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
198 198 journal.notes = cleaned_up_text_body
199 199 add_attachments(issue)
200 200 issue.save!
201 201 if logger && logger.info
202 202 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
203 203 end
204 204 journal
205 205 end
206 206
207 207 # Reply will be added to the issue
208 208 def receive_journal_reply(journal_id)
209 209 journal = Journal.find_by_id(journal_id)
210 210 if journal && journal.journalized_type == 'Issue'
211 211 receive_issue_reply(journal.journalized_id)
212 212 end
213 213 end
214 214
215 215 # Receives a reply to a forum message
216 216 def receive_message_reply(message_id)
217 217 message = Message.find_by_id(message_id)
218 218 if message
219 219 message = message.root
220 220
221 221 unless @@handler_options[:no_permission_check]
222 222 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
223 223 end
224 224
225 225 if !message.locked?
226 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
226 reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
227 227 :content => cleaned_up_text_body)
228 228 reply.author = user
229 229 reply.board = message.board
230 230 message.children << reply
231 231 add_attachments(reply)
232 232 reply
233 233 else
234 234 if logger && logger.info
235 235 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
236 236 end
237 237 end
238 238 end
239 239 end
240 240
241 241 def add_attachments(obj)
242 242 if email.attachments && email.attachments.any?
243 243 email.attachments.each do |attachment|
244 244 obj.attachments << Attachment.create(:container => obj,
245 245 :file => attachment.decoded,
246 246 :filename => attachment.filename,
247 247 :author => user,
248 248 :content_type => attachment.mime_type)
249 249 end
250 250 end
251 251 end
252 252
253 253 # Adds To and Cc as watchers of the given object if the sender has the
254 254 # appropriate permission
255 255 def add_watchers(obj)
256 256 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
257 257 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
258 258 unless addresses.empty?
259 259 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
260 260 watchers.each {|w| obj.add_watcher(w)}
261 261 end
262 262 end
263 263 end
264 264
265 265 def get_keyword(attr, options={})
266 266 @keywords ||= {}
267 267 if @keywords.has_key?(attr)
268 268 @keywords[attr]
269 269 else
270 270 @keywords[attr] = begin
271 271 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
272 272 (v = extract_keyword!(plain_text_body, attr, options[:format]))
273 273 v
274 274 elsif !@@handler_options[:issue][attr].blank?
275 275 @@handler_options[:issue][attr]
276 276 end
277 277 end
278 278 end
279 279 end
280 280
281 281 # Destructively extracts the value for +attr+ in +text+
282 282 # Returns nil if no matching keyword found
283 283 def extract_keyword!(text, attr, format=nil)
284 284 keys = [attr.to_s.humanize]
285 285 if attr.is_a?(Symbol)
286 286 if user && user.language.present?
287 287 keys << l("field_#{attr}", :default => '', :locale => user.language)
288 288 end
289 289 if Setting.default_language.present?
290 290 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
291 291 end
292 292 end
293 293 keys.reject! {|k| k.blank?}
294 294 keys.collect! {|k| Regexp.escape(k)}
295 295 format ||= '.+'
296 296 keyword = nil
297 297 regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
298 298 if m = text.match(regexp)
299 299 keyword = m[2].strip
300 300 text.gsub!(regexp, '')
301 301 end
302 302 keyword
303 303 end
304 304
305 305 def target_project
306 306 # TODO: other ways to specify project:
307 307 # * parse the email To field
308 308 # * specific project (eg. Setting.mail_handler_target_project)
309 309 target = Project.find_by_identifier(get_keyword(:project))
310 310 raise MissingInformation.new('Unable to determine target project') if target.nil?
311 311 target
312 312 end
313 313
314 314 # Returns a Hash of issue attributes extracted from keywords in the email body
315 315 def issue_attributes_from_keywords(issue)
316 316 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
317 317
318 318 attrs = {
319 319 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
320 320 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
321 321 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
322 322 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
323 323 'assigned_to_id' => assigned_to.try(:id),
324 324 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
325 325 issue.project.shared_versions.named(k).first.try(:id),
326 326 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
327 327 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
328 328 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
329 329 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
330 330 }.delete_if {|k, v| v.blank? }
331 331
332 332 if issue.new_record? && attrs['tracker_id'].nil?
333 333 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
334 334 end
335 335
336 336 attrs
337 337 end
338 338
339 339 # Returns a Hash of issue custom field values extracted from keywords in the email body
340 340 def custom_field_values_from_keywords(customized)
341 341 customized.custom_field_values.inject({}) do |h, v|
342 342 if value = get_keyword(v.custom_field.name, :override => true)
343 343 h[v.custom_field.id.to_s] = value
344 344 end
345 345 h
346 346 end
347 347 end
348 348
349 349 # Returns the text/plain part of the email
350 350 # If not found (eg. HTML-only email), returns the body with tags removed
351 351 def plain_text_body
352 352 return @plain_text_body unless @plain_text_body.nil?
353 353
354 354 part = email.text_part || email.html_part || email
355 355 @plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
356 356
357 357 # strip html tags and remove doctype directive
358 358 @plain_text_body = strip_tags(@plain_text_body.strip)
359 359 @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
360 360 @plain_text_body
361 361 end
362 362
363 363 def cleaned_up_text_body
364 364 cleanup_body(plain_text_body)
365 365 end
366 366
367 def cleaned_up_subject
368 subject = email.subject.to_s
369 unless subject.respond_to?(:encoding)
370 # try to reencode to utf8 manually with ruby1.8
371 begin
372 if h = email.header[:subject]
373 if m = h.value.match(/^=\?([^\?]+)\?/)
374 subject = Redmine::CodesetUtil.to_utf8(subject, m[1])
375 end
376 end
377 rescue
378 # nop
379 end
380 end
381 subject.strip[0,255]
382 end
383
367 384 def self.full_sanitizer
368 385 @full_sanitizer ||= HTML::FullSanitizer.new
369 386 end
370 387
371 388 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
372 389 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
373 390 value = value.to_s.slice(0, limit)
374 391 object.send("#{attribute}=", value)
375 392 end
376 393
377 394 # Returns a User from an email address and a full name
378 395 def self.new_user_from_attributes(email_address, fullname=nil)
379 396 user = User.new
380 397
381 398 # Truncating the email address would result in an invalid format
382 399 user.mail = email_address
383 400 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
384 401
385 402 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
386 403 assign_string_attribute_with_limit(user, 'firstname', names.shift)
387 404 assign_string_attribute_with_limit(user, 'lastname', names.join(' '))
388 405 user.lastname = '-' if user.lastname.blank?
389 406
390 407 password_length = [Setting.password_min_length.to_i, 10].max
391 408 user.password = Redmine::Utils.random_hex(password_length / 2 + 1)
392 409 user.language = Setting.default_language
393 410
394 411 unless user.valid?
395 412 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
396 413 user.firstname = "-" unless user.errors[:firstname].blank?
397 414 user.lastname = "-" unless user.errors[:lastname].blank?
398 415 end
399 416
400 417 user
401 418 end
402 419
403 420 # Creates a User for the +email+ sender
404 421 # Returns the user or nil if it could not be created
405 422 def create_user_from_email
406 423 from = email.header['from'].to_s
407 424 addr, name = from, nil
408 425 if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
409 426 addr, name = m[2], m[1]
410 427 end
411 428 if addr.present?
412 429 user = self.class.new_user_from_attributes(addr, name)
413 430 if user.save
414 431 user
415 432 else
416 433 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
417 434 nil
418 435 end
419 436 else
420 437 logger.error "MailHandler: failed to create User: no FROM address found" if logger
421 438 nil
422 439 end
423 440 end
424 441
425 442 # Removes the email body of text after the truncation configurations.
426 443 def cleanup_body(body)
427 444 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
428 445 unless delimiters.empty?
429 446 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
430 447 body = body.gsub(regex, '')
431 448 end
432 449 body.strip
433 450 end
434 451
435 452 def find_assignee_from_keyword(keyword, issue)
436 453 keyword = keyword.to_s.downcase
437 454 assignable = issue.assignable_users
438 455 assignee = nil
439 456 assignee ||= assignable.detect {|a|
440 457 a.mail.to_s.downcase == keyword ||
441 458 a.login.to_s.downcase == keyword
442 459 }
443 460 if assignee.nil? && keyword.match(/ /)
444 461 firstname, lastname = *(keyword.split) # "First Last Throwaway"
445 462 assignee ||= assignable.detect {|a|
446 463 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
447 464 a.lastname.to_s.downcase == lastname
448 465 }
449 466 end
450 467 if assignee.nil?
451 468 assignee ||= assignable.detect {|a| a.is_a?(Group) && a.name.downcase == keyword}
452 469 end
453 470 assignee
454 471 end
455 472 end
@@ -1,636 +1,645
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 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 def test_add_issue_with_iso_8859_1_subject
351 issue = submit_email(
352 'subject_as_iso-8859-1.eml',
353 :issue => {:project => 'ecookbook'}
354 )
355 assert_kind_of Issue, issue
356 assert_equal 'Testmail from Webmail: Γ€ ΓΆ ΓΌ...', issue.subject
357 end
358
350 359 def test_should_ignore_emails_from_locked_users
351 360 User.find(2).lock!
352 361
353 362 MailHandler.any_instance.expects(:dispatch).never
354 363 assert_no_difference 'Issue.count' do
355 364 assert_equal false, submit_email('ticket_on_given_project.eml')
356 365 end
357 366 end
358 367
359 368 def test_should_ignore_emails_from_emission_address
360 369 Role.anonymous.add_permission!(:add_issues)
361 370 assert_no_difference 'User.count' do
362 371 assert_equal false,
363 372 submit_email(
364 373 'ticket_from_emission_address.eml',
365 374 :issue => {:project => 'ecookbook'},
366 375 :unknown_user => 'create'
367 376 )
368 377 end
369 378 end
370 379
371 380 def test_should_ignore_auto_replied_emails
372 381 MailHandler.any_instance.expects(:dispatch).never
373 382 [
374 383 "X-Auto-Response-Suppress: OOF",
375 384 "Auto-Submitted: auto-replied",
376 385 "Auto-Submitted: Auto-Replied"
377 386 ].each do |header|
378 387 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
379 388 raw = header + "\n" + raw
380 389
381 390 assert_no_difference 'Issue.count' do
382 391 assert_equal false, MailHandler.receive(raw), "email with #{header} header was not ignored"
383 392 end
384 393 end
385 394 end
386 395
387 396 def test_add_issue_should_send_email_notification
388 397 Setting.notified_events = ['issue_added']
389 398 ActionMailer::Base.deliveries.clear
390 399 # This email contains: 'Project: onlinestore'
391 400 issue = submit_email('ticket_on_given_project.eml')
392 401 assert issue.is_a?(Issue)
393 402 assert_equal 1, ActionMailer::Base.deliveries.size
394 403 end
395 404
396 405 def test_update_issue
397 406 journal = submit_email('ticket_reply.eml')
398 407 assert journal.is_a?(Journal)
399 408 assert_equal User.find_by_login('jsmith'), journal.user
400 409 assert_equal Issue.find(2), journal.journalized
401 410 assert_match /This is reply/, journal.notes
402 411 assert_equal 'Feature request', journal.issue.tracker.name
403 412 end
404 413
405 414 def test_update_issue_with_attribute_changes
406 415 # This email contains: 'Status: Resolved'
407 416 journal = submit_email('ticket_reply_with_status.eml')
408 417 assert journal.is_a?(Journal)
409 418 issue = Issue.find(journal.issue.id)
410 419 assert_equal User.find_by_login('jsmith'), journal.user
411 420 assert_equal Issue.find(2), journal.journalized
412 421 assert_match /This is reply/, journal.notes
413 422 assert_equal 'Feature request', journal.issue.tracker.name
414 423 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
415 424 assert_equal '2010-01-01', issue.start_date.to_s
416 425 assert_equal '2010-12-31', issue.due_date.to_s
417 426 assert_equal User.find_by_login('jsmith'), issue.assigned_to
418 427 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
419 428 # keywords should be removed from the email body
420 429 assert !journal.notes.match(/^Status:/i)
421 430 assert !journal.notes.match(/^Start Date:/i)
422 431 end
423 432
424 433 def test_update_issue_with_attachment
425 434 assert_difference 'Journal.count' do
426 435 assert_difference 'JournalDetail.count' do
427 436 assert_difference 'Attachment.count' do
428 437 assert_no_difference 'Issue.count' do
429 438 journal = submit_email('ticket_with_attachment.eml') do |raw|
430 439 raw.gsub! /^Subject: .*$/, 'Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories'
431 440 end
432 441 end
433 442 end
434 443 end
435 444 end
436 445 journal = Journal.first(:order => 'id DESC')
437 446 assert_equal Issue.find(2), journal.journalized
438 447 assert_equal 1, journal.details.size
439 448
440 449 detail = journal.details.first
441 450 assert_equal 'attachment', detail.property
442 451 assert_equal 'Paella.jpg', detail.value
443 452 end
444 453
445 454 def test_update_issue_should_send_email_notification
446 455 ActionMailer::Base.deliveries.clear
447 456 journal = submit_email('ticket_reply.eml')
448 457 assert journal.is_a?(Journal)
449 458 assert_equal 1, ActionMailer::Base.deliveries.size
450 459 end
451 460
452 461 def test_update_issue_should_not_set_defaults
453 462 journal = submit_email(
454 463 'ticket_reply.eml',
455 464 :issue => {:tracker => 'Support request', :priority => 'High'}
456 465 )
457 466 assert journal.is_a?(Journal)
458 467 assert_match /This is reply/, journal.notes
459 468 assert_equal 'Feature request', journal.issue.tracker.name
460 469 assert_equal 'Normal', journal.issue.priority.name
461 470 end
462 471
463 472 def test_reply_to_a_message
464 473 m = submit_email('message_reply.eml')
465 474 assert m.is_a?(Message)
466 475 assert !m.new_record?
467 476 m.reload
468 477 assert_equal 'Reply via email', m.subject
469 478 # The email replies to message #2 which is part of the thread of message #1
470 479 assert_equal Message.find(1), m.parent
471 480 end
472 481
473 482 def test_reply_to_a_message_by_subject
474 483 m = submit_email('message_reply_by_subject.eml')
475 484 assert m.is_a?(Message)
476 485 assert !m.new_record?
477 486 m.reload
478 487 assert_equal 'Reply to the first post', m.subject
479 488 assert_equal Message.find(1), m.parent
480 489 end
481 490
482 491 def test_should_strip_tags_of_html_only_emails
483 492 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
484 493 assert issue.is_a?(Issue)
485 494 assert !issue.new_record?
486 495 issue.reload
487 496 assert_equal 'HTML email', issue.subject
488 497 assert_equal 'This is a html-only email.', issue.description
489 498 end
490 499
491 500 context "truncate emails based on the Setting" do
492 501 context "with no setting" do
493 502 setup do
494 503 Setting.mail_handler_body_delimiters = ''
495 504 end
496 505
497 506 should "add the entire email into the issue" do
498 507 issue = submit_email('ticket_on_given_project.eml')
499 508 assert_issue_created(issue)
500 509 assert issue.description.include?('---')
501 510 assert issue.description.include?('This paragraph is after the delimiter')
502 511 end
503 512 end
504 513
505 514 context "with a single string" do
506 515 setup do
507 516 Setting.mail_handler_body_delimiters = '---'
508 517 end
509 518 should "truncate the email at the delimiter for the issue" do
510 519 issue = submit_email('ticket_on_given_project.eml')
511 520 assert_issue_created(issue)
512 521 assert issue.description.include?('This paragraph is before delimiters')
513 522 assert issue.description.include?('--- This line starts with a delimiter')
514 523 assert !issue.description.match(/^---$/)
515 524 assert !issue.description.include?('This paragraph is after the delimiter')
516 525 end
517 526 end
518 527
519 528 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
520 529 setup do
521 530 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
522 531 end
523 532 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
524 533 journal = submit_email('issue_update_with_quoted_reply_above.eml')
525 534 assert journal.is_a?(Journal)
526 535 assert journal.notes.include?('An update to the issue by the sender.')
527 536 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
528 537 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
529 538 end
530 539 end
531 540
532 541 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
533 542 setup do
534 543 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
535 544 end
536 545 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
537 546 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
538 547 assert journal.is_a?(Journal)
539 548 assert journal.notes.include?('An update to the issue by the sender.')
540 549 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
541 550 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
542 551 end
543 552 end
544 553
545 554 context "with multiple strings" do
546 555 setup do
547 556 Setting.mail_handler_body_delimiters = "---\nBREAK"
548 557 end
549 558 should "truncate the email at the first delimiter found (BREAK)" do
550 559 issue = submit_email('ticket_on_given_project.eml')
551 560 assert_issue_created(issue)
552 561 assert issue.description.include?('This paragraph is before delimiters')
553 562 assert !issue.description.include?('BREAK')
554 563 assert !issue.description.include?('This paragraph is between delimiters')
555 564 assert !issue.description.match(/^---$/)
556 565 assert !issue.description.include?('This paragraph is after the delimiter')
557 566 end
558 567 end
559 568 end
560 569
561 570 def test_email_with_long_subject_line
562 571 issue = submit_email('ticket_with_long_subject.eml')
563 572 assert issue.is_a?(Issue)
564 573 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]
565 574 end
566 575
567 576 def test_new_user_from_attributes_should_return_valid_user
568 577 to_test = {
569 578 # [address, name] => [login, firstname, lastname]
570 579 ['jsmith@example.net', nil] => ['jsmith@example.net', 'jsmith', '-'],
571 580 ['jsmith@example.net', 'John'] => ['jsmith@example.net', 'John', '-'],
572 581 ['jsmith@example.net', 'John Smith'] => ['jsmith@example.net', 'John', 'Smith'],
573 582 ['jsmith@example.net', 'John Paul Smith'] => ['jsmith@example.net', 'John', 'Paul Smith'],
574 583 ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsTheMaximumLength Smith'] => ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsT', 'Smith'],
575 584 ['jsmith@example.net', 'John AVeryLongLastnameThatExceedsTheMaximumLength'] => ['jsmith@example.net', 'John', 'AVeryLongLastnameThatExceedsTh']
576 585 }
577 586
578 587 to_test.each do |attrs, expected|
579 588 user = MailHandler.new_user_from_attributes(attrs.first, attrs.last)
580 589
581 590 assert user.valid?, user.errors.full_messages.to_s
582 591 assert_equal attrs.first, user.mail
583 592 assert_equal expected[0], user.login
584 593 assert_equal expected[1], user.firstname
585 594 assert_equal expected[2], user.lastname
586 595 end
587 596 end
588 597
589 598 def test_new_user_from_attributes_should_respect_minimum_password_length
590 599 with_settings :password_min_length => 15 do
591 600 user = MailHandler.new_user_from_attributes('jsmith@example.net')
592 601 assert user.valid?
593 602 assert user.password.length >= 15
594 603 end
595 604 end
596 605
597 606 def test_new_user_from_attributes_should_use_default_login_if_invalid
598 607 user = MailHandler.new_user_from_attributes('foo+bar@example.net')
599 608 assert user.valid?
600 609 assert user.login =~ /^user[a-f0-9]+$/
601 610 assert_equal 'foo+bar@example.net', user.mail
602 611 end
603 612
604 613 def test_new_user_with_utf8_encoded_fullname_should_be_decoded
605 614 assert_difference 'User.count' do
606 615 issue = submit_email(
607 616 'fullname_of_sender_as_utf8_encoded.eml',
608 617 :issue => {:project => 'ecookbook'},
609 618 :unknown_user => 'create'
610 619 )
611 620 end
612 621
613 622 user = User.first(:order => 'id DESC')
614 623 assert_equal "foo@example.org", user.mail
615 624 str1 = "\xc3\x84\xc3\xa4"
616 625 str2 = "\xc3\x96\xc3\xb6"
617 626 str1.force_encoding('UTF-8') if str1.respond_to?(:force_encoding)
618 627 str2.force_encoding('UTF-8') if str2.respond_to?(:force_encoding)
619 628 assert_equal str1, user.firstname
620 629 assert_equal str2, user.lastname
621 630 end
622 631
623 632 private
624 633
625 634 def submit_email(filename, options={})
626 635 raw = IO.read(File.join(FIXTURES_PATH, filename))
627 636 yield raw if block_given?
628 637 MailHandler.receive(raw, options)
629 638 end
630 639
631 640 def assert_issue_created(issue)
632 641 assert issue.is_a?(Issue)
633 642 assert !issue.new_record?
634 643 issue.reload
635 644 end
636 645 end
General Comments 0
You need to be logged in to leave comments. Login now