##// END OF EJS Templates
Makes MailHandler accept all issue attributes and custom fields that can be set/updated (#4071, #4807, #5622, #6110)....
Jean-Philippe Lang -
r4280:e0e8c14c2aef
parent child
Show More
@@ -1,884 +1,887
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 Issue < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :tracker
21 21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27 27
28 28 has_many :journals, :as => :journalized, :dependent => :destroy
29 29 has_many :time_entries, :dependent => :delete_all
30 30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31 31
32 32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 34
35 35 acts_as_nested_set :scope => 'root_id'
36 36 acts_as_attachable :after_remove => :attachment_removed
37 37 acts_as_customizable
38 38 acts_as_watchable
39 39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 40 :include => [:project, :journals],
41 41 # sort by id so that limited eager loading doesn't break with postgresql
42 42 :order_column => "#{table_name}.id"
43 43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46 46
47 47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 48 :author_key => :author_id
49 49
50 50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51 51
52 52 attr_reader :current_journal
53 53
54 54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55 55
56 56 validates_length_of :subject, :maximum => 255
57 57 validates_inclusion_of :done_ratio, :in => 0..100
58 58 validates_numericality_of :estimated_hours, :allow_nil => true
59 59
60 60 named_scope :visible, lambda {|*args| { :include => :project,
61 61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62 62
63 63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64 64
65 65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
66 66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 69 named_scope :for_gantt, lambda {
70 70 {
71 71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
72 72 :order => "#{Issue.table_name}.due_date ASC, #{Issue.table_name}.start_date ASC, #{Issue.table_name}.id ASC"
73 73 }
74 74 }
75 75
76 76 named_scope :without_version, lambda {
77 77 {
78 78 :conditions => { :fixed_version_id => nil}
79 79 }
80 80 }
81 81
82 82 named_scope :with_query, lambda {|query|
83 83 {
84 84 :conditions => Query.merge_conditions(query.statement)
85 85 }
86 86 }
87 87
88 88 before_create :default_assign
89 89 before_save :close_duplicates, :update_done_ratio_from_issue_status
90 90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 91 after_destroy :destroy_children
92 92 after_destroy :update_parent_attributes
93 93
94 94 # Returns true if usr or current user is allowed to view the issue
95 95 def visible?(usr=nil)
96 96 (usr || User.current).allowed_to?(:view_issues, self.project)
97 97 end
98 98
99 99 def after_initialize
100 100 if new_record?
101 101 # set default values for new records only
102 102 self.status ||= IssueStatus.default
103 103 self.priority ||= IssuePriority.default
104 104 end
105 105 end
106 106
107 107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
108 108 def available_custom_fields
109 109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
110 110 end
111 111
112 112 def copy_from(arg)
113 113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
115 115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
116 116 self.status = issue.status
117 117 self
118 118 end
119 119
120 120 # Moves/copies an issue to a new project and tracker
121 121 # Returns the moved/copied issue on success, false on failure
122 122 def move_to_project(*args)
123 123 ret = Issue.transaction do
124 124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
125 125 end || false
126 126 end
127 127
128 128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
129 129 options ||= {}
130 130 issue = options[:copy] ? self.class.new.copy_from(self) : self
131 131
132 132 if new_project && issue.project_id != new_project.id
133 133 # delete issue relations
134 134 unless Setting.cross_project_issue_relations?
135 135 issue.relations_from.clear
136 136 issue.relations_to.clear
137 137 end
138 138 # issue is moved to another project
139 139 # reassign to the category with same name if any
140 140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
141 141 issue.category = new_category
142 142 # Keep the fixed_version if it's still valid in the new_project
143 143 unless new_project.shared_versions.include?(issue.fixed_version)
144 144 issue.fixed_version = nil
145 145 end
146 146 issue.project = new_project
147 147 if issue.parent && issue.parent.project_id != issue.project_id
148 148 issue.parent_issue_id = nil
149 149 end
150 150 end
151 151 if new_tracker
152 152 issue.tracker = new_tracker
153 153 issue.reset_custom_values!
154 154 end
155 155 if options[:copy]
156 156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 157 issue.status = if options[:attributes] && options[:attributes][:status_id]
158 158 IssueStatus.find_by_id(options[:attributes][:status_id])
159 159 else
160 160 self.status
161 161 end
162 162 end
163 163 # Allow bulk setting of attributes on the issue
164 164 if options[:attributes]
165 165 issue.attributes = options[:attributes]
166 166 end
167 167 if issue.save
168 168 unless options[:copy]
169 169 # Manually update project_id on related time entries
170 170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
171 171
172 172 issue.children.each do |child|
173 173 unless child.move_to_project_without_transaction(new_project)
174 174 # Move failed and transaction was rollback'd
175 175 return false
176 176 end
177 177 end
178 178 end
179 179 else
180 180 return false
181 181 end
182 182 issue
183 183 end
184 184
185 185 def status_id=(sid)
186 186 self.status = nil
187 187 write_attribute(:status_id, sid)
188 188 end
189 189
190 190 def priority_id=(pid)
191 191 self.priority = nil
192 192 write_attribute(:priority_id, pid)
193 193 end
194 194
195 195 def tracker_id=(tid)
196 196 self.tracker = nil
197 197 result = write_attribute(:tracker_id, tid)
198 198 @custom_field_values = nil
199 199 result
200 200 end
201 201
202 202 # Overrides attributes= so that tracker_id gets assigned first
203 203 def attributes_with_tracker_first=(new_attributes, *args)
204 204 return if new_attributes.nil?
205 205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
206 206 if new_tracker_id
207 207 self.tracker_id = new_tracker_id
208 208 end
209 209 send :attributes_without_tracker_first=, new_attributes, *args
210 210 end
211 211 # Do not redefine alias chain on reload (see #4838)
212 212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213 213
214 214 def estimated_hours=(h)
215 215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
216 216 end
217 217
218 218 SAFE_ATTRIBUTES = %w(
219 219 tracker_id
220 220 status_id
221 221 parent_issue_id
222 222 category_id
223 223 assigned_to_id
224 224 priority_id
225 225 fixed_version_id
226 226 subject
227 227 description
228 228 start_date
229 229 due_date
230 230 done_ratio
231 231 estimated_hours
232 232 custom_field_values
233 233 lock_version
234 234 ) unless const_defined?(:SAFE_ATTRIBUTES)
235 235
236 236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
237 237 status_id
238 238 assigned_to_id
239 239 fixed_version_id
240 240 done_ratio
241 241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
242 242
243 243 # Safely sets attributes
244 244 # Should be called from controllers instead of #attributes=
245 245 # attr_accessible is too rough because we still want things like
246 246 # Issue.new(:project => foo) to work
247 247 # TODO: move workflow/permission checks from controllers to here
248 248 def safe_attributes=(attrs, user=User.current)
249 249 return unless attrs.is_a?(Hash)
250 250
251 new_statuses_allowed = new_statuses_allowed_to(user)
252
253 251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
254 252 if new_record? || user.allowed_to?(:edit_issues, project)
255 253 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
256 elsif new_statuses_allowed.any?
254 elsif new_statuses_allowed_to(user).any?
257 255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
258 256 else
259 257 return
260 258 end
261 259
260 # Tracker must be set before since new_statuses_allowed_to depends on it.
261 if t = attrs.delete('tracker_id')
262 self.tracker_id = t
263 end
264
262 265 if attrs['status_id']
263 unless new_statuses_allowed.collect(&:id).include?(attrs['status_id'].to_i)
266 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
264 267 attrs.delete('status_id')
265 268 end
266 269 end
267 270
268 271 unless leaf?
269 272 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
270 273 end
271 274
272 275 if attrs.has_key?('parent_issue_id')
273 276 if !user.allowed_to?(:manage_subtasks, project)
274 277 attrs.delete('parent_issue_id')
275 278 elsif !attrs['parent_issue_id'].blank?
276 279 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
277 280 end
278 281 end
279 282
280 283 self.attributes = attrs
281 284 end
282 285
283 286 def done_ratio
284 287 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
285 288 status.default_done_ratio
286 289 else
287 290 read_attribute(:done_ratio)
288 291 end
289 292 end
290 293
291 294 def self.use_status_for_done_ratio?
292 295 Setting.issue_done_ratio == 'issue_status'
293 296 end
294 297
295 298 def self.use_field_for_done_ratio?
296 299 Setting.issue_done_ratio == 'issue_field'
297 300 end
298 301
299 302 def validate
300 303 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
301 304 errors.add :due_date, :not_a_date
302 305 end
303 306
304 307 if self.due_date and self.start_date and self.due_date < self.start_date
305 308 errors.add :due_date, :greater_than_start_date
306 309 end
307 310
308 311 if start_date && soonest_start && start_date < soonest_start
309 312 errors.add :start_date, :invalid
310 313 end
311 314
312 315 if fixed_version
313 316 if !assignable_versions.include?(fixed_version)
314 317 errors.add :fixed_version_id, :inclusion
315 318 elsif reopened? && fixed_version.closed?
316 319 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
317 320 end
318 321 end
319 322
320 323 # Checks that the issue can not be added/moved to a disabled tracker
321 324 if project && (tracker_id_changed? || project_id_changed?)
322 325 unless project.trackers.include?(tracker)
323 326 errors.add :tracker_id, :inclusion
324 327 end
325 328 end
326 329
327 330 # Checks parent issue assignment
328 331 if @parent_issue
329 332 if @parent_issue.project_id != project_id
330 333 errors.add :parent_issue_id, :not_same_project
331 334 elsif !new_record?
332 335 # moving an existing issue
333 336 if @parent_issue.root_id != root_id
334 337 # we can always move to another tree
335 338 elsif move_possible?(@parent_issue)
336 339 # move accepted inside tree
337 340 else
338 341 errors.add :parent_issue_id, :not_a_valid_parent
339 342 end
340 343 end
341 344 end
342 345 end
343 346
344 347 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
345 348 # even if the user turns off the setting later
346 349 def update_done_ratio_from_issue_status
347 350 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
348 351 self.done_ratio = status.default_done_ratio
349 352 end
350 353 end
351 354
352 355 def init_journal(user, notes = "")
353 356 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
354 357 @issue_before_change = self.clone
355 358 @issue_before_change.status = self.status
356 359 @custom_values_before_change = {}
357 360 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
358 361 # Make sure updated_on is updated when adding a note.
359 362 updated_on_will_change!
360 363 @current_journal
361 364 end
362 365
363 366 # Return true if the issue is closed, otherwise false
364 367 def closed?
365 368 self.status.is_closed?
366 369 end
367 370
368 371 # Return true if the issue is being reopened
369 372 def reopened?
370 373 if !new_record? && status_id_changed?
371 374 status_was = IssueStatus.find_by_id(status_id_was)
372 375 status_new = IssueStatus.find_by_id(status_id)
373 376 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
374 377 return true
375 378 end
376 379 end
377 380 false
378 381 end
379 382
380 383 # Return true if the issue is being closed
381 384 def closing?
382 385 if !new_record? && status_id_changed?
383 386 status_was = IssueStatus.find_by_id(status_id_was)
384 387 status_new = IssueStatus.find_by_id(status_id)
385 388 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
386 389 return true
387 390 end
388 391 end
389 392 false
390 393 end
391 394
392 395 # Returns true if the issue is overdue
393 396 def overdue?
394 397 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
395 398 end
396 399
397 400 # Is the amount of work done less than it should for the due date
398 401 def behind_schedule?
399 402 return false if start_date.nil? || due_date.nil?
400 403 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
401 404 return done_date <= Date.today
402 405 end
403 406
404 407 # Does this issue have children?
405 408 def children?
406 409 !leaf?
407 410 end
408 411
409 412 # Users the issue can be assigned to
410 413 def assignable_users
411 414 users = project.assignable_users
412 415 users << author if author
413 416 users.uniq.sort
414 417 end
415 418
416 419 # Versions that the issue can be assigned to
417 420 def assignable_versions
418 421 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
419 422 end
420 423
421 424 # Returns true if this issue is blocked by another issue that is still open
422 425 def blocked?
423 426 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
424 427 end
425 428
426 429 # Returns an array of status that user is able to apply
427 430 def new_statuses_allowed_to(user, include_default=false)
428 431 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
429 432 statuses << status unless statuses.empty?
430 433 statuses << IssueStatus.default if include_default
431 434 statuses = statuses.uniq.sort
432 435 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
433 436 end
434 437
435 438 # Returns the mail adresses of users that should be notified
436 439 def recipients
437 440 notified = project.notified_users
438 441 # Author and assignee are always notified unless they have been
439 442 # locked or don't want to be notified
440 443 notified << author if author && author.active? && author.notify_about?(self)
441 444 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
442 445 notified.uniq!
443 446 # Remove users that can not view the issue
444 447 notified.reject! {|user| !visible?(user)}
445 448 notified.collect(&:mail)
446 449 end
447 450
448 451 # Returns the total number of hours spent on this issue and its descendants
449 452 #
450 453 # Example:
451 454 # spent_hours => 0.0
452 455 # spent_hours => 50.2
453 456 def spent_hours
454 457 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
455 458 end
456 459
457 460 def relations
458 461 (relations_from + relations_to).sort
459 462 end
460 463
461 464 def all_dependent_issues
462 465 dependencies = []
463 466 relations_from.each do |relation|
464 467 dependencies << relation.issue_to
465 468 dependencies += relation.issue_to.all_dependent_issues
466 469 end
467 470 dependencies
468 471 end
469 472
470 473 # Returns an array of issues that duplicate this one
471 474 def duplicates
472 475 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
473 476 end
474 477
475 478 # Returns the due date or the target due date if any
476 479 # Used on gantt chart
477 480 def due_before
478 481 due_date || (fixed_version ? fixed_version.effective_date : nil)
479 482 end
480 483
481 484 # Returns the time scheduled for this issue.
482 485 #
483 486 # Example:
484 487 # Start Date: 2/26/09, End Date: 3/04/09
485 488 # duration => 6
486 489 def duration
487 490 (start_date && due_date) ? due_date - start_date : 0
488 491 end
489 492
490 493 def soonest_start
491 494 @soonest_start ||= (
492 495 relations_to.collect{|relation| relation.successor_soonest_start} +
493 496 ancestors.collect(&:soonest_start)
494 497 ).compact.max
495 498 end
496 499
497 500 def reschedule_after(date)
498 501 return if date.nil?
499 502 if leaf?
500 503 if start_date.nil? || start_date < date
501 504 self.start_date, self.due_date = date, date + duration
502 505 save
503 506 end
504 507 else
505 508 leaves.each do |leaf|
506 509 leaf.reschedule_after(date)
507 510 end
508 511 end
509 512 end
510 513
511 514 def <=>(issue)
512 515 if issue.nil?
513 516 -1
514 517 elsif root_id != issue.root_id
515 518 (root_id || 0) <=> (issue.root_id || 0)
516 519 else
517 520 (lft || 0) <=> (issue.lft || 0)
518 521 end
519 522 end
520 523
521 524 def to_s
522 525 "#{tracker} ##{id}: #{subject}"
523 526 end
524 527
525 528 # Returns a string of css classes that apply to the issue
526 529 def css_classes
527 530 s = "issue status-#{status.position} priority-#{priority.position}"
528 531 s << ' closed' if closed?
529 532 s << ' overdue' if overdue?
530 533 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
531 534 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
532 535 s
533 536 end
534 537
535 538 # Saves an issue, time_entry, attachments, and a journal from the parameters
536 539 # Returns false if save fails
537 540 def save_issue_with_child_records(params, existing_time_entry=nil)
538 541 Issue.transaction do
539 542 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
540 543 @time_entry = existing_time_entry || TimeEntry.new
541 544 @time_entry.project = project
542 545 @time_entry.issue = self
543 546 @time_entry.user = User.current
544 547 @time_entry.spent_on = Date.today
545 548 @time_entry.attributes = params[:time_entry]
546 549 self.time_entries << @time_entry
547 550 end
548 551
549 552 if valid?
550 553 attachments = Attachment.attach_files(self, params[:attachments])
551 554
552 555 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
553 556 # TODO: Rename hook
554 557 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
555 558 begin
556 559 if save
557 560 # TODO: Rename hook
558 561 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
559 562 else
560 563 raise ActiveRecord::Rollback
561 564 end
562 565 rescue ActiveRecord::StaleObjectError
563 566 attachments[:files].each(&:destroy)
564 567 errors.add_to_base l(:notice_locking_conflict)
565 568 raise ActiveRecord::Rollback
566 569 end
567 570 end
568 571 end
569 572 end
570 573
571 574 # Unassigns issues from +version+ if it's no longer shared with issue's project
572 575 def self.update_versions_from_sharing_change(version)
573 576 # Update issues assigned to the version
574 577 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
575 578 end
576 579
577 580 # Unassigns issues from versions that are no longer shared
578 581 # after +project+ was moved
579 582 def self.update_versions_from_hierarchy_change(project)
580 583 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
581 584 # Update issues of the moved projects and issues assigned to a version of a moved project
582 585 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
583 586 end
584 587
585 588 def parent_issue_id=(arg)
586 589 parent_issue_id = arg.blank? ? nil : arg.to_i
587 590 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
588 591 @parent_issue.id
589 592 else
590 593 @parent_issue = nil
591 594 nil
592 595 end
593 596 end
594 597
595 598 def parent_issue_id
596 599 if instance_variable_defined? :@parent_issue
597 600 @parent_issue.nil? ? nil : @parent_issue.id
598 601 else
599 602 parent_id
600 603 end
601 604 end
602 605
603 606 # Extracted from the ReportsController.
604 607 def self.by_tracker(project)
605 608 count_and_group_by(:project => project,
606 609 :field => 'tracker_id',
607 610 :joins => Tracker.table_name)
608 611 end
609 612
610 613 def self.by_version(project)
611 614 count_and_group_by(:project => project,
612 615 :field => 'fixed_version_id',
613 616 :joins => Version.table_name)
614 617 end
615 618
616 619 def self.by_priority(project)
617 620 count_and_group_by(:project => project,
618 621 :field => 'priority_id',
619 622 :joins => IssuePriority.table_name)
620 623 end
621 624
622 625 def self.by_category(project)
623 626 count_and_group_by(:project => project,
624 627 :field => 'category_id',
625 628 :joins => IssueCategory.table_name)
626 629 end
627 630
628 631 def self.by_assigned_to(project)
629 632 count_and_group_by(:project => project,
630 633 :field => 'assigned_to_id',
631 634 :joins => User.table_name)
632 635 end
633 636
634 637 def self.by_author(project)
635 638 count_and_group_by(:project => project,
636 639 :field => 'author_id',
637 640 :joins => User.table_name)
638 641 end
639 642
640 643 def self.by_subproject(project)
641 644 ActiveRecord::Base.connection.select_all("select s.id as status_id,
642 645 s.is_closed as closed,
643 646 i.project_id as project_id,
644 647 count(i.id) as total
645 648 from
646 649 #{Issue.table_name} i, #{IssueStatus.table_name} s
647 650 where
648 651 i.status_id=s.id
649 652 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
650 653 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
651 654 end
652 655 # End ReportsController extraction
653 656
654 657 # Returns an array of projects that current user can move issues to
655 658 def self.allowed_target_projects_on_move
656 659 projects = []
657 660 if User.current.admin?
658 661 # admin is allowed to move issues to any active (visible) project
659 662 projects = Project.visible.all
660 663 elsif User.current.logged?
661 664 if Role.non_member.allowed_to?(:move_issues)
662 665 projects = Project.visible.all
663 666 else
664 667 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
665 668 end
666 669 end
667 670 projects
668 671 end
669 672
670 673 private
671 674
672 675 def update_nested_set_attributes
673 676 if root_id.nil?
674 677 # issue was just created
675 678 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
676 679 set_default_left_and_right
677 680 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
678 681 if @parent_issue
679 682 move_to_child_of(@parent_issue)
680 683 end
681 684 reload
682 685 elsif parent_issue_id != parent_id
683 686 former_parent_id = parent_id
684 687 # moving an existing issue
685 688 if @parent_issue && @parent_issue.root_id == root_id
686 689 # inside the same tree
687 690 move_to_child_of(@parent_issue)
688 691 else
689 692 # to another tree
690 693 unless root?
691 694 move_to_right_of(root)
692 695 reload
693 696 end
694 697 old_root_id = root_id
695 698 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
696 699 target_maxright = nested_set_scope.maximum(right_column_name) || 0
697 700 offset = target_maxright + 1 - lft
698 701 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
699 702 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
700 703 self[left_column_name] = lft + offset
701 704 self[right_column_name] = rgt + offset
702 705 if @parent_issue
703 706 move_to_child_of(@parent_issue)
704 707 end
705 708 end
706 709 reload
707 710 # delete invalid relations of all descendants
708 711 self_and_descendants.each do |issue|
709 712 issue.relations.each do |relation|
710 713 relation.destroy unless relation.valid?
711 714 end
712 715 end
713 716 # update former parent
714 717 recalculate_attributes_for(former_parent_id) if former_parent_id
715 718 end
716 719 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
717 720 end
718 721
719 722 def update_parent_attributes
720 723 recalculate_attributes_for(parent_id) if parent_id
721 724 end
722 725
723 726 def recalculate_attributes_for(issue_id)
724 727 if issue_id && p = Issue.find_by_id(issue_id)
725 728 # priority = highest priority of children
726 729 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
727 730 p.priority = IssuePriority.find_by_position(priority_position)
728 731 end
729 732
730 733 # start/due dates = lowest/highest dates of children
731 734 p.start_date = p.children.minimum(:start_date)
732 735 p.due_date = p.children.maximum(:due_date)
733 736 if p.start_date && p.due_date && p.due_date < p.start_date
734 737 p.start_date, p.due_date = p.due_date, p.start_date
735 738 end
736 739
737 740 # done ratio = weighted average ratio of leaves
738 741 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
739 742 leaves_count = p.leaves.count
740 743 if leaves_count > 0
741 744 average = p.leaves.average(:estimated_hours).to_f
742 745 if average == 0
743 746 average = 1
744 747 end
745 748 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
746 749 progress = done / (average * leaves_count)
747 750 p.done_ratio = progress.round
748 751 end
749 752 end
750 753
751 754 # estimate = sum of leaves estimates
752 755 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
753 756 p.estimated_hours = nil if p.estimated_hours == 0.0
754 757
755 758 # ancestors will be recursively updated
756 759 p.save(false)
757 760 end
758 761 end
759 762
760 763 def destroy_children
761 764 unless leaf?
762 765 children.each do |child|
763 766 child.destroy
764 767 end
765 768 end
766 769 end
767 770
768 771 # Update issues so their versions are not pointing to a
769 772 # fixed_version that is not shared with the issue's project
770 773 def self.update_versions(conditions=nil)
771 774 # Only need to update issues with a fixed_version from
772 775 # a different project and that is not systemwide shared
773 776 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
774 777 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
775 778 " AND #{Version.table_name}.sharing <> 'system'",
776 779 conditions),
777 780 :include => [:project, :fixed_version]
778 781 ).each do |issue|
779 782 next if issue.project.nil? || issue.fixed_version.nil?
780 783 unless issue.project.shared_versions.include?(issue.fixed_version)
781 784 issue.init_journal(User.current)
782 785 issue.fixed_version = nil
783 786 issue.save
784 787 end
785 788 end
786 789 end
787 790
788 791 # Callback on attachment deletion
789 792 def attachment_removed(obj)
790 793 journal = init_journal(User.current)
791 794 journal.details << JournalDetail.new(:property => 'attachment',
792 795 :prop_key => obj.id,
793 796 :old_value => obj.filename)
794 797 journal.save
795 798 end
796 799
797 800 # Default assignment based on category
798 801 def default_assign
799 802 if assigned_to.nil? && category && category.assigned_to
800 803 self.assigned_to = category.assigned_to
801 804 end
802 805 end
803 806
804 807 # Updates start/due dates of following issues
805 808 def reschedule_following_issues
806 809 if start_date_changed? || due_date_changed?
807 810 relations_from.each do |relation|
808 811 relation.set_issue_to_dates
809 812 end
810 813 end
811 814 end
812 815
813 816 # Closes duplicates if the issue is being closed
814 817 def close_duplicates
815 818 if closing?
816 819 duplicates.each do |duplicate|
817 820 # Reload is need in case the duplicate was updated by a previous duplicate
818 821 duplicate.reload
819 822 # Don't re-close it if it's already closed
820 823 next if duplicate.closed?
821 824 # Same user and notes
822 825 if @current_journal
823 826 duplicate.init_journal(@current_journal.user, @current_journal.notes)
824 827 end
825 828 duplicate.update_attribute :status, self.status
826 829 end
827 830 end
828 831 end
829 832
830 833 # Saves the changes in a Journal
831 834 # Called after_save
832 835 def create_journal
833 836 if @current_journal
834 837 # attributes changes
835 838 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
836 839 @current_journal.details << JournalDetail.new(:property => 'attr',
837 840 :prop_key => c,
838 841 :old_value => @issue_before_change.send(c),
839 842 :value => send(c)) unless send(c)==@issue_before_change.send(c)
840 843 }
841 844 # custom fields changes
842 845 custom_values.each {|c|
843 846 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
844 847 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
845 848 @current_journal.details << JournalDetail.new(:property => 'cf',
846 849 :prop_key => c.custom_field_id,
847 850 :old_value => @custom_values_before_change[c.custom_field_id],
848 851 :value => c.value)
849 852 }
850 853 @current_journal.save
851 854 # reset current journal
852 855 init_journal @current_journal.user, @current_journal.notes
853 856 end
854 857 end
855 858
856 859 # Query generator for selecting groups of issue counts for a project
857 860 # based on specific criteria
858 861 #
859 862 # Options
860 863 # * project - Project to search in.
861 864 # * field - String. Issue field to key off of in the grouping.
862 865 # * joins - String. The table name to join against.
863 866 def self.count_and_group_by(options)
864 867 project = options.delete(:project)
865 868 select_field = options.delete(:field)
866 869 joins = options.delete(:joins)
867 870
868 871 where = "i.#{select_field}=j.id"
869 872
870 873 ActiveRecord::Base.connection.select_all("select s.id as status_id,
871 874 s.is_closed as closed,
872 875 j.id as #{select_field},
873 876 count(i.id) as total
874 877 from
875 878 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
876 879 where
877 880 i.status_id=s.id
878 881 and #{where}
879 882 and i.project_id=#{project.id}
880 883 group by s.id, s.is_closed, j.id")
881 884 end
882 885
883 886
884 887 end
@@ -1,336 +1,333
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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
21 21 class UnauthorizedAction < StandardError; end
22 22 class MissingInformation < StandardError; end
23 23
24 24 attr_reader :email, :user
25 25
26 26 def self.receive(email, options={})
27 27 @@handler_options = options.dup
28 28
29 29 @@handler_options[:issue] ||= {}
30 30
31 31 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
32 32 @@handler_options[:allow_override] ||= []
33 33 # Project needs to be overridable if not specified
34 34 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
35 35 # Status overridable by default
36 36 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
37 37
38 38 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
39 39 super email
40 40 end
41 41
42 42 # Processes incoming emails
43 43 # Returns the created object (eg. an issue, a message) or false
44 44 def receive(email)
45 45 @email = email
46 46 sender_email = email.from.to_a.first.to_s.strip
47 47 # Ignore emails received from the application emission address to avoid hell cycles
48 48 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
49 49 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
50 50 return false
51 51 end
52 52 @user = User.find_by_mail(sender_email) if sender_email.present?
53 53 if @user && !@user.active?
54 54 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
55 55 return false
56 56 end
57 57 if @user.nil?
58 58 # Email was submitted by an unknown user
59 59 case @@handler_options[:unknown_user]
60 60 when 'accept'
61 61 @user = User.anonymous
62 62 when 'create'
63 63 @user = MailHandler.create_user_from_email(email)
64 64 if @user
65 65 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
66 66 Mailer.deliver_account_information(@user, @user.password)
67 67 else
68 68 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
69 69 return false
70 70 end
71 71 else
72 72 # Default behaviour, emails from unknown users are ignored
73 73 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
74 74 return false
75 75 end
76 76 end
77 77 User.current = @user
78 78 dispatch
79 79 end
80 80
81 81 private
82 82
83 83 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
84 84 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
85 85 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
86 86
87 87 def dispatch
88 88 headers = [email.in_reply_to, email.references].flatten.compact
89 89 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
90 90 klass, object_id = $1, $2.to_i
91 91 method_name = "receive_#{klass}_reply"
92 92 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
93 93 send method_name, object_id
94 94 else
95 95 # ignoring it
96 96 end
97 97 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
98 98 receive_issue_reply(m[1].to_i)
99 99 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
100 100 receive_message_reply(m[1].to_i)
101 101 else
102 102 receive_issue
103 103 end
104 104 rescue ActiveRecord::RecordInvalid => e
105 105 # TODO: send a email to the user
106 106 logger.error e.message if logger
107 107 false
108 108 rescue MissingInformation => e
109 109 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
110 110 false
111 111 rescue UnauthorizedAction => e
112 112 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
113 113 false
114 114 end
115 115
116 116 # Creates a new issue
117 117 def receive_issue
118 118 project = target_project
119 tracker = (get_keyword(:tracker) && project.trackers.find_by_name(get_keyword(:tracker))) || project.trackers.find(:first)
120 category = (get_keyword(:category) && project.issue_categories.find_by_name(get_keyword(:category)))
121 priority = (get_keyword(:priority) && IssuePriority.find_by_name(get_keyword(:priority)))
122 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
123 assigned_to = (get_keyword(:assigned_to, :override => true) && find_user_from_keyword(get_keyword(:assigned_to, :override => true)))
124 due_date = get_keyword(:due_date, :override => true)
125 start_date = get_keyword(:start_date, :override => true)
126
127 119 # check permission
128 120 unless @@handler_options[:no_permission_check]
129 121 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
130 122 end
131 123
132 issue = Issue.new(:author => user, :project => project, :tracker => tracker, :category => category, :priority => priority, :due_date => due_date, :start_date => start_date, :assigned_to => assigned_to)
133 # check workflow
134 if status && issue.new_statuses_allowed_to(user).include?(status)
135 issue.status = status
136 end
137 issue.subject = email.subject.chomp[0,255]
124 issue = Issue.new(:author => user, :project => project)
125 issue.safe_attributes = issue_attributes_from_keywords(issue)
126 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
127 issue.subject = email.subject.to_s.chomp[0,255]
138 128 if issue.subject.blank?
139 129 issue.subject = '(no subject)'
140 130 end
141 # custom fields
142 issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c|
143 if value = get_keyword(c.name, :override => true)
144 h[c.id] = value
145 end
146 h
147 end
148 131 issue.description = cleaned_up_text_body
132
149 133 # add To and Cc as watchers before saving so the watchers can reply to Redmine
150 134 add_watchers(issue)
151 135 issue.save!
152 136 add_attachments(issue)
153 137 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
154 138 issue
155 139 end
156 140
157 def target_project
158 # TODO: other ways to specify project:
159 # * parse the email To field
160 # * specific project (eg. Setting.mail_handler_target_project)
161 target = Project.find_by_identifier(get_keyword(:project))
162 raise MissingInformation.new('Unable to determine target project') if target.nil?
163 target
164 end
165
166 141 # Adds a note to an existing issue
167 142 def receive_issue_reply(issue_id)
168 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
169 due_date = get_keyword(:due_date, :override => true)
170 start_date = get_keyword(:start_date, :override => true)
171 assigned_to = (get_keyword(:assigned_to, :override => true) && find_user_from_keyword(get_keyword(:assigned_to, :override => true)))
172
173 143 issue = Issue.find_by_id(issue_id)
174 144 return unless issue
175 145 # check permission
176 146 unless @@handler_options[:no_permission_check]
177 147 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
178 raise UnauthorizedAction unless status.nil? || user.allowed_to?(:edit_issues, issue.project)
179 148 end
180
181 # add the note
149
182 150 journal = issue.init_journal(user, cleaned_up_text_body)
151 issue.safe_attributes = issue_attributes_from_keywords(issue)
152 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
183 153 add_attachments(issue)
184 # check workflow
185 if status && issue.new_statuses_allowed_to(user).include?(status)
186 issue.status = status
187 end
188 issue.start_date = start_date if start_date
189 issue.due_date = due_date if due_date
190 issue.assigned_to = assigned_to if assigned_to
191
192 154 issue.save!
193 155 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
194 156 journal
195 157 end
196 158
197 159 # Reply will be added to the issue
198 160 def receive_journal_reply(journal_id)
199 161 journal = Journal.find_by_id(journal_id)
200 162 if journal && journal.journalized_type == 'Issue'
201 163 receive_issue_reply(journal.journalized_id)
202 164 end
203 165 end
204 166
205 167 # Receives a reply to a forum message
206 168 def receive_message_reply(message_id)
207 169 message = Message.find_by_id(message_id)
208 170 if message
209 171 message = message.root
210 172
211 173 unless @@handler_options[:no_permission_check]
212 174 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
213 175 end
214 176
215 177 if !message.locked?
216 178 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
217 179 :content => cleaned_up_text_body)
218 180 reply.author = user
219 181 reply.board = message.board
220 182 message.children << reply
221 183 add_attachments(reply)
222 184 reply
223 185 else
224 186 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
225 187 end
226 188 end
227 189 end
228 190
229 191 def add_attachments(obj)
230 192 if email.has_attachments?
231 193 email.attachments.each do |attachment|
232 194 Attachment.create(:container => obj,
233 195 :file => attachment,
234 196 :author => user,
235 197 :content_type => attachment.content_type)
236 198 end
237 199 end
238 200 end
239 201
240 202 # Adds To and Cc as watchers of the given object if the sender has the
241 203 # appropriate permission
242 204 def add_watchers(obj)
243 205 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
244 206 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
245 207 unless addresses.empty?
246 208 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
247 209 watchers.each {|w| obj.add_watcher(w)}
248 210 end
249 211 end
250 212 end
251 213
252 214 def get_keyword(attr, options={})
253 215 @keywords ||= {}
254 216 if @keywords.has_key?(attr)
255 217 @keywords[attr]
256 218 else
257 219 @keywords[attr] = begin
258 220 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && plain_text_body.gsub!(/^#{attr.to_s.humanize}[ \t]*:[ \t]*(.+)\s*$/i, '')
259 221 $1.strip
260 222 elsif !@@handler_options[:issue][attr].blank?
261 223 @@handler_options[:issue][attr]
262 224 end
263 225 end
264 226 end
265 227 end
228
229 def target_project
230 # TODO: other ways to specify project:
231 # * parse the email To field
232 # * specific project (eg. Setting.mail_handler_target_project)
233 target = Project.find_by_identifier(get_keyword(:project))
234 raise MissingInformation.new('Unable to determine target project') if target.nil?
235 target
236 end
237
238 # Returns a Hash of issue attributes extracted from keywords in the email body
239 def issue_attributes_from_keywords(issue)
240 {
241 'tracker_id' => ((k = get_keyword(:tracker)) && issue.project.trackers.find_by_name(k).try(:id)) || issue.project.trackers.find(:first).try(:id),
242 'status_id' => (k = get_keyword(:status)) && IssueStatus.find_by_name(k).try(:id),
243 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.find_by_name(k).try(:id),
244 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.find_by_name(k).try(:id),
245 'assigned_to_id' => (k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k).try(:id),
246 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.find_by_name(k).try(:id),
247 'start_date' => get_keyword(:start_date, :override => true),
248 'due_date' => get_keyword(:due_date, :override => true),
249 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
250 'done_ratio' => get_keyword(:done_ratio, :override => true),
251 }.delete_if {|k, v| v.blank? }
252 end
253
254 # Returns a Hash of issue custom field values extracted from keywords in the email body
255 def custom_field_values_from_keywords(customized)
256 customized.custom_field_values.inject({}) do |h, v|
257 if value = get_keyword(v.custom_field.name, :override => true)
258 h[v.custom_field.id.to_s] = value
259 end
260 h
261 end
262 end
266 263
267 264 # Returns the text/plain part of the email
268 265 # If not found (eg. HTML-only email), returns the body with tags removed
269 266 def plain_text_body
270 267 return @plain_text_body unless @plain_text_body.nil?
271 268 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
272 269 if parts.empty?
273 270 parts << @email
274 271 end
275 272 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
276 273 if plain_text_part.nil?
277 274 # no text/plain part found, assuming html-only email
278 275 # strip html tags and remove doctype directive
279 276 @plain_text_body = strip_tags(@email.body.to_s)
280 277 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
281 278 else
282 279 @plain_text_body = plain_text_part.body.to_s
283 280 end
284 281 @plain_text_body.strip!
285 282 @plain_text_body
286 283 end
287 284
288 285 def cleaned_up_text_body
289 286 cleanup_body(plain_text_body)
290 287 end
291 288
292 289 def self.full_sanitizer
293 290 @full_sanitizer ||= HTML::FullSanitizer.new
294 291 end
295 292
296 293 # Creates a user account for the +email+ sender
297 294 def self.create_user_from_email(email)
298 295 addr = email.from_addrs.to_a.first
299 296 if addr && !addr.spec.blank?
300 297 user = User.new
301 298 user.mail = addr.spec
302 299
303 300 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
304 301 user.firstname = names.shift
305 302 user.lastname = names.join(' ')
306 303 user.lastname = '-' if user.lastname.blank?
307 304
308 305 user.login = user.mail
309 306 user.password = ActiveSupport::SecureRandom.hex(5)
310 307 user.language = Setting.default_language
311 308 user.save ? user : nil
312 309 end
313 310 end
314 311
315 312 private
316 313
317 314 # Removes the email body of text after the truncation configurations.
318 315 def cleanup_body(body)
319 316 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
320 317 unless delimiters.empty?
321 318 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
322 319 body = body.gsub(regex, '')
323 320 end
324 321 body.strip
325 322 end
326 323
327 324 def find_user_from_keyword(keyword)
328 325 user ||= User.find_by_mail(keyword)
329 326 user ||= User.find_by_login(keyword)
330 327 if user.nil? && keyword.match(/ /)
331 328 firstname, lastname = *(keyword.split) # "First Last Throwaway"
332 329 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
333 330 end
334 331 user
335 332 end
336 333 end
@@ -1,57 +1,60
1 1 Return-Path: <JSmith@somenet.foo>
2 2 Received: from osiris ([127.0.0.1])
3 3 by OSIRIS
4 4 with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200
5 5 Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris>
6 6 From: "John Smith" <JSmith@somenet.foo>
7 7 To: <redmine@somenet.foo>
8 8 Subject: New ticket on a given project
9 9 Date: Sun, 22 Jun 2008 12:28:07 +0200
10 10 MIME-Version: 1.0
11 11 Content-Type: text/plain;
12 12 format=flowed;
13 13 charset="iso-8859-1";
14 14 reply-type=original
15 15 Content-Transfer-Encoding: 7bit
16 16 X-Priority: 3
17 17 X-MSMail-Priority: Normal
18 18 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
19 19 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
20 20
21 21 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas imperdiet
22 22 turpis et odio. Integer eget pede vel dolor euismod varius. Phasellus
23 23 blandit eleifend augue. Nulla facilisi. Duis id diam. Class aptent taciti
24 24 sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In
25 25 in urna sed tellus aliquet lobortis. Morbi scelerisque tortor in dolor. Cras
26 26 sagittis odio eu lacus. Aliquam sem tortor, consequat sit amet, vestibulum
27 27 id, iaculis at, lectus. Fusce tortor libero, congue ut, euismod nec, luctus
28 28 eget, eros. Pellentesque tortor enim, feugiat in, dignissim eget, tristique
29 29 sed, mauris --- Pellentesque habitant morbi tristique senectus et netus et
30 30 malesuada fames ac turpis egestas. Quisque sit amet libero. In hac habitasse
31 31 platea dictumst.
32 32
33 33 --- This line starts with a delimiter and should not be stripped
34 34
35 35 This paragraph is before delimiters.
36 36
37 37 BREAK
38 38
39 39 This paragraph is between delimiters.
40 40
41 41 ---
42 42
43 43 This paragraph is after the delimiter so it shouldn't appear.
44 44
45 45 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
46 46 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
47 47 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
48 48 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
49 49 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
50 50 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
51 51
52 52 Project: onlinestore
53 53 Status: Resolved
54 54 due date: 2010-12-31
55 55 Start Date:2010-01-01
56 56 Assigned to: John Smith
57 fixed version: alpha
58 estimated hours: 2.5
59 done ratio: 30
57 60
@@ -1,79 +1,80
1 1 Return-Path: <jsmith@somenet.foo>
2 2 Received: from osiris ([127.0.0.1])
3 3 by OSIRIS
4 4 with hMailServer ; Sat, 21 Jun 2008 18:41:39 +0200
5 5 Message-ID: <006a01c8d3bd$ad9baec0$0a00a8c0@osiris>
6 6 From: "John Smith" <jsmith@somenet.foo>
7 7 To: <redmine@somenet.foo>
8 8 References: <485d0ad366c88_d7014663a025f@osiris.tmail>
9 9 Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories
10 10 Date: Sat, 21 Jun 2008 18:41:39 +0200
11 11 MIME-Version: 1.0
12 12 Content-Type: multipart/alternative;
13 13 boundary="----=_NextPart_000_0067_01C8D3CE.711F9CC0"
14 14 X-Priority: 3
15 15 X-MSMail-Priority: Normal
16 16 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
17 17 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
18 18
19 19 This is a multi-part message in MIME format.
20 20
21 21 ------=_NextPart_000_0067_01C8D3CE.711F9CC0
22 22 Content-Type: text/plain;
23 23 charset="utf-8"
24 24 Content-Transfer-Encoding: quoted-printable
25 25
26 26 This is reply
27 27
28 28 Status: Resolved
29 29 due date: 2010-12-31
30 30 Start Date:2010-01-01
31 31 Assigned to: jsmith@somenet.foo
32 searchable field: Updated custom value
32 33
33 34 ------=_NextPart_000_0067_01C8D3CE.711F9CC0
34 35 Content-Type: text/html;
35 36 charset="utf-8"
36 37 Content-Transfer-Encoding: quoted-printable
37 38
38 39 =EF=BB=BF<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
39 40 <HTML><HEAD>
40 41 <META http-equiv=3DContent-Type content=3D"text/html; charset=3Dutf-8">
41 42 <STYLE>BODY {
42 43 FONT-SIZE: 0.8em; COLOR: #484848; FONT-FAMILY: Verdana, sans-serif
43 44 }
44 45 BODY H1 {
45 46 FONT-SIZE: 1.2em; MARGIN: 0px; FONT-FAMILY: "Trebuchet MS", Verdana, =
46 47 sans-serif
47 48 }
48 49 A {
49 50 COLOR: #2a5685
50 51 }
51 52 A:link {
52 53 COLOR: #2a5685
53 54 }
54 55 A:visited {
55 56 COLOR: #2a5685
56 57 }
57 58 A:hover {
58 59 COLOR: #c61a1a
59 60 }
60 61 A:active {
61 62 COLOR: #c61a1a
62 63 }
63 64 HR {
64 65 BORDER-RIGHT: 0px; BORDER-TOP: 0px; BACKGROUND: #ccc; BORDER-LEFT: 0px; =
65 66 WIDTH: 100%; BORDER-BOTTOM: 0px; HEIGHT: 1px
66 67 }
67 68 .footer {
68 69 FONT-SIZE: 0.8em; FONT-STYLE: italic
69 70 }
70 71 </STYLE>
71 72
72 73 <META content=3D"MSHTML 6.00.2900.2883" name=3DGENERATOR></HEAD>
73 74 <BODY bgColor=3D#ffffff>
74 75 <DIV><SPAN class=3Dfooter><FONT face=3DArial color=3D#000000 =
75 76 size=3D2>This is=20
76 77 reply Status: Resolved</FONT></DIV></SPAN></BODY></HTML>
77 78
78 79 ------=_NextPart_000_0067_01C8D3CE.711F9CC0--
79 80
@@ -1,405 +1,410
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2009 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.dirname(__FILE__) + '/../test_helper'
21 21
22 22 class MailHandlerTest < ActiveSupport::TestCase
23 23 fixtures :users, :projects,
24 24 :enabled_modules,
25 25 :roles,
26 26 :members,
27 27 :member_roles,
28 28 :issues,
29 29 :issue_statuses,
30 30 :workflows,
31 31 :trackers,
32 32 :projects_trackers,
33 :versions,
33 34 :enumerations,
34 35 :issue_categories,
35 36 :custom_fields,
36 37 :custom_fields_trackers,
37 38 :boards,
38 39 :messages
39 40
40 41 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
41 42
42 43 def setup
43 44 ActionMailer::Base.deliveries.clear
44 45 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
45 46 end
46 47
47 48 def test_add_issue
48 49 ActionMailer::Base.deliveries.clear
49 50 # This email contains: 'Project: onlinestore'
50 51 issue = submit_email('ticket_on_given_project.eml')
51 52 assert issue.is_a?(Issue)
52 53 assert !issue.new_record?
53 54 issue.reload
54 55 assert_equal 'New ticket on a given project', issue.subject
55 56 assert_equal User.find_by_login('jsmith'), issue.author
56 57 assert_equal Project.find(2), issue.project
57 58 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
58 59 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
59 60 assert_equal '2010-01-01', issue.start_date.to_s
60 61 assert_equal '2010-12-31', issue.due_date.to_s
61 62 assert_equal User.find_by_login('jsmith'), issue.assigned_to
63 assert_equal Version.find_by_name('alpha'), issue.fixed_version
64 assert_equal 2.5, issue.estimated_hours
65 assert_equal 30, issue.done_ratio
62 66 # keywords should be removed from the email body
63 67 assert !issue.description.match(/^Project:/i)
64 68 assert !issue.description.match(/^Status:/i)
65 69 # Email notification should be sent
66 70 mail = ActionMailer::Base.deliveries.last
67 71 assert_not_nil mail
68 72 assert mail.subject.include?('New ticket on a given project')
69 73 end
70 74
71 75 def test_add_issue_with_status
72 76 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
73 77 issue = submit_email('ticket_on_given_project.eml')
74 78 assert issue.is_a?(Issue)
75 79 assert !issue.new_record?
76 80 issue.reload
77 81 assert_equal Project.find(2), issue.project
78 82 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
79 83 end
80 84
81 85 def test_add_issue_with_attributes_override
82 86 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
83 87 assert issue.is_a?(Issue)
84 88 assert !issue.new_record?
85 89 issue.reload
86 90 assert_equal 'New ticket on a given project', issue.subject
87 91 assert_equal User.find_by_login('jsmith'), issue.author
88 92 assert_equal Project.find(2), issue.project
89 93 assert_equal 'Feature request', issue.tracker.to_s
90 94 assert_equal 'Stock management', issue.category.to_s
91 95 assert_equal 'Urgent', issue.priority.to_s
92 96 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
93 97 end
94 98
95 99 def test_add_issue_with_partial_attributes_override
96 100 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
97 101 assert issue.is_a?(Issue)
98 102 assert !issue.new_record?
99 103 issue.reload
100 104 assert_equal 'New ticket on a given project', issue.subject
101 105 assert_equal User.find_by_login('jsmith'), issue.author
102 106 assert_equal Project.find(2), issue.project
103 107 assert_equal 'Feature request', issue.tracker.to_s
104 108 assert_nil issue.category
105 109 assert_equal 'High', issue.priority.to_s
106 110 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
107 111 end
108 112
109 113 def test_add_issue_with_spaces_between_attribute_and_separator
110 114 issue = submit_email('ticket_with_spaces_between_attribute_and_separator.eml', :allow_override => 'tracker,category,priority')
111 115 assert issue.is_a?(Issue)
112 116 assert !issue.new_record?
113 117 issue.reload
114 118 assert_equal 'New ticket on a given project', issue.subject
115 119 assert_equal User.find_by_login('jsmith'), issue.author
116 120 assert_equal Project.find(2), issue.project
117 121 assert_equal 'Feature request', issue.tracker.to_s
118 122 assert_equal 'Stock management', issue.category.to_s
119 123 assert_equal 'Urgent', issue.priority.to_s
120 124 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
121 125 end
122 126
123 127
124 128 def test_add_issue_with_attachment_to_specific_project
125 129 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
126 130 assert issue.is_a?(Issue)
127 131 assert !issue.new_record?
128 132 issue.reload
129 133 assert_equal 'Ticket created by email with attachment', issue.subject
130 134 assert_equal User.find_by_login('jsmith'), issue.author
131 135 assert_equal Project.find(2), issue.project
132 136 assert_equal 'This is a new ticket with attachments', issue.description
133 137 # Attachment properties
134 138 assert_equal 1, issue.attachments.size
135 139 assert_equal 'Paella.jpg', issue.attachments.first.filename
136 140 assert_equal 'image/jpeg', issue.attachments.first.content_type
137 141 assert_equal 10790, issue.attachments.first.filesize
138 142 end
139 143
140 144 def test_add_issue_with_custom_fields
141 145 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
142 146 assert issue.is_a?(Issue)
143 147 assert !issue.new_record?
144 148 issue.reload
145 149 assert_equal 'New ticket with custom field values', issue.subject
146 150 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
147 151 assert !issue.description.match(/^searchable field:/i)
148 152 end
149 153
150 154 def test_add_issue_with_cc
151 155 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
152 156 assert issue.is_a?(Issue)
153 157 assert !issue.new_record?
154 158 issue.reload
155 159 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
156 160 assert_equal 1, issue.watcher_user_ids.size
157 161 end
158 162
159 163 def test_add_issue_by_unknown_user
160 164 assert_no_difference 'User.count' do
161 165 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'})
162 166 end
163 167 end
164 168
165 169 def test_add_issue_by_anonymous_user
166 170 Role.anonymous.add_permission!(:add_issues)
167 171 assert_no_difference 'User.count' do
168 172 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
169 173 assert issue.is_a?(Issue)
170 174 assert issue.author.anonymous?
171 175 end
172 176 end
173 177
174 178 def test_add_issue_by_anonymous_user_with_no_from_address
175 179 Role.anonymous.add_permission!(:add_issues)
176 180 assert_no_difference 'User.count' do
177 181 issue = submit_email('ticket_by_empty_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
178 182 assert issue.is_a?(Issue)
179 183 assert issue.author.anonymous?
180 184 end
181 185 end
182 186
183 187 def test_add_issue_by_anonymous_user_on_private_project
184 188 Role.anonymous.add_permission!(:add_issues)
185 189 assert_no_difference 'User.count' do
186 190 assert_no_difference 'Issue.count' do
187 191 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :unknown_user => 'accept')
188 192 end
189 193 end
190 194 end
191 195
192 196 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
193 197 assert_no_difference 'User.count' do
194 198 assert_difference 'Issue.count' do
195 199 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :no_permission_check => '1', :unknown_user => 'accept')
196 200 assert issue.is_a?(Issue)
197 201 assert issue.author.anonymous?
198 202 assert !issue.project.is_public?
199 203 end
200 204 end
201 205 end
202 206
203 207 def test_add_issue_by_created_user
204 208 Setting.default_language = 'en'
205 209 assert_difference 'User.count' do
206 210 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
207 211 assert issue.is_a?(Issue)
208 212 assert issue.author.active?
209 213 assert_equal 'john.doe@somenet.foo', issue.author.mail
210 214 assert_equal 'John', issue.author.firstname
211 215 assert_equal 'Doe', issue.author.lastname
212 216
213 217 # account information
214 218 email = ActionMailer::Base.deliveries.first
215 219 assert_not_nil email
216 220 assert email.subject.include?('account activation')
217 221 login = email.body.match(/\* Login: (.*)$/)[1]
218 222 password = email.body.match(/\* Password: (.*)$/)[1]
219 223 assert_equal issue.author, User.try_to_login(login, password)
220 224 end
221 225 end
222 226
223 227 def test_add_issue_without_from_header
224 228 Role.anonymous.add_permission!(:add_issues)
225 229 assert_equal false, submit_email('ticket_without_from_header.eml')
226 230 end
227 231
228 232 def test_add_issue_with_japanese_keywords
229 233 tracker = Tracker.create!(:name => 'ι–‹η™Ί')
230 234 Project.find(1).trackers << tracker
231 235 issue = submit_email('japanese_keywords_iso_2022_jp.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'tracker')
232 236 assert_kind_of Issue, issue
233 237 assert_equal tracker, issue.tracker
234 238 end
235 239
236 240 def test_should_ignore_emails_from_emission_address
237 241 Role.anonymous.add_permission!(:add_issues)
238 242 assert_no_difference 'User.count' do
239 243 assert_equal false, submit_email('ticket_from_emission_address.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
240 244 end
241 245 end
242 246
243 247 def test_add_issue_should_send_email_notification
244 248 Setting.notified_events = ['issue_added']
245 249 ActionMailer::Base.deliveries.clear
246 250 # This email contains: 'Project: onlinestore'
247 251 issue = submit_email('ticket_on_given_project.eml')
248 252 assert issue.is_a?(Issue)
249 253 assert_equal 1, ActionMailer::Base.deliveries.size
250 254 end
251 255
252 256 def test_add_issue_note
253 257 journal = submit_email('ticket_reply.eml')
254 258 assert journal.is_a?(Journal)
255 259 assert_equal User.find_by_login('jsmith'), journal.user
256 260 assert_equal Issue.find(2), journal.journalized
257 261 assert_match /This is reply/, journal.notes
258 262 end
259 263
260 264 def test_add_issue_note_with_attribute_changes
261 265 # This email contains: 'Status: Resolved'
262 266 journal = submit_email('ticket_reply_with_status.eml')
263 267 assert journal.is_a?(Journal)
264 268 issue = Issue.find(journal.issue.id)
265 269 assert_equal User.find_by_login('jsmith'), journal.user
266 270 assert_equal Issue.find(2), journal.journalized
267 271 assert_match /This is reply/, journal.notes
268 272 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
269 273 assert_equal '2010-01-01', issue.start_date.to_s
270 274 assert_equal '2010-12-31', issue.due_date.to_s
271 275 assert_equal User.find_by_login('jsmith'), issue.assigned_to
276 assert_equal 'Updated custom value', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
272 277 end
273 278
274 279 def test_add_issue_note_should_send_email_notification
275 280 ActionMailer::Base.deliveries.clear
276 281 journal = submit_email('ticket_reply.eml')
277 282 assert journal.is_a?(Journal)
278 283 assert_equal 1, ActionMailer::Base.deliveries.size
279 284 end
280 285
281 286 def test_reply_to_a_message
282 287 m = submit_email('message_reply.eml')
283 288 assert m.is_a?(Message)
284 289 assert !m.new_record?
285 290 m.reload
286 291 assert_equal 'Reply via email', m.subject
287 292 # The email replies to message #2 which is part of the thread of message #1
288 293 assert_equal Message.find(1), m.parent
289 294 end
290 295
291 296 def test_reply_to_a_message_by_subject
292 297 m = submit_email('message_reply_by_subject.eml')
293 298 assert m.is_a?(Message)
294 299 assert !m.new_record?
295 300 m.reload
296 301 assert_equal 'Reply to the first post', m.subject
297 302 assert_equal Message.find(1), m.parent
298 303 end
299 304
300 305 def test_should_strip_tags_of_html_only_emails
301 306 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
302 307 assert issue.is_a?(Issue)
303 308 assert !issue.new_record?
304 309 issue.reload
305 310 assert_equal 'HTML email', issue.subject
306 311 assert_equal 'This is a html-only email.', issue.description
307 312 end
308 313
309 314 context "truncate emails based on the Setting" do
310 315 context "with no setting" do
311 316 setup do
312 317 Setting.mail_handler_body_delimiters = ''
313 318 end
314 319
315 320 should "add the entire email into the issue" do
316 321 issue = submit_email('ticket_on_given_project.eml')
317 322 assert_issue_created(issue)
318 323 assert issue.description.include?('---')
319 324 assert issue.description.include?('This paragraph is after the delimiter')
320 325 end
321 326 end
322 327
323 328 context "with a single string" do
324 329 setup do
325 330 Setting.mail_handler_body_delimiters = '---'
326 331 end
327 332
328 333 should "truncate the email at the delimiter for the issue" do
329 334 issue = submit_email('ticket_on_given_project.eml')
330 335 assert_issue_created(issue)
331 336 assert issue.description.include?('This paragraph is before delimiters')
332 337 assert issue.description.include?('--- This line starts with a delimiter')
333 338 assert !issue.description.match(/^---$/)
334 339 assert !issue.description.include?('This paragraph is after the delimiter')
335 340 end
336 341 end
337 342
338 343 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
339 344 setup do
340 345 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
341 346 end
342 347
343 348 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
344 349 journal = submit_email('issue_update_with_quoted_reply_above.eml')
345 350 assert journal.is_a?(Journal)
346 351 assert journal.notes.include?('An update to the issue by the sender.')
347 352 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
348 353 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
349 354
350 355 end
351 356
352 357 end
353 358
354 359 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
355 360 setup do
356 361 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
357 362 end
358 363
359 364 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
360 365 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
361 366 assert journal.is_a?(Journal)
362 367 assert journal.notes.include?('An update to the issue by the sender.')
363 368 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
364 369 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
365 370
366 371 end
367 372
368 373 end
369 374
370 375 context "with multiple strings" do
371 376 setup do
372 377 Setting.mail_handler_body_delimiters = "---\nBREAK"
373 378 end
374 379
375 380 should "truncate the email at the first delimiter found (BREAK)" do
376 381 issue = submit_email('ticket_on_given_project.eml')
377 382 assert_issue_created(issue)
378 383 assert issue.description.include?('This paragraph is before delimiters')
379 384 assert !issue.description.include?('BREAK')
380 385 assert !issue.description.include?('This paragraph is between delimiters')
381 386 assert !issue.description.match(/^---$/)
382 387 assert !issue.description.include?('This paragraph is after the delimiter')
383 388 end
384 389 end
385 390 end
386 391
387 392 def test_email_with_long_subject_line
388 393 issue = submit_email('ticket_with_long_subject.eml')
389 394 assert issue.is_a?(Issue)
390 395 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]
391 396 end
392 397
393 398 private
394 399
395 400 def submit_email(filename, options={})
396 401 raw = IO.read(File.join(FIXTURES_PATH, filename))
397 402 MailHandler.receive(raw, options)
398 403 end
399 404
400 405 def assert_issue_created(issue)
401 406 assert issue.is_a?(Issue)
402 407 assert !issue.new_record?
403 408 issue.reload
404 409 end
405 410 end
General Comments 0
You need to be logged in to leave comments. Login now