##// END OF EJS Templates
Mail handler: adds --default-group option to add created user to one or more groups (#13340)....
Jean-Philippe Lang -
r11292:b25d496c2467
parent child
Show More
@@ -1,89 +1,90
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 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 Group < Principal
19 19 include Redmine::SafeAttributes
20 20
21 21 has_and_belongs_to_many :users, :after_add => :user_added,
22 22 :after_remove => :user_removed
23 23
24 24 acts_as_customizable
25 25
26 26 validates_presence_of :lastname
27 27 validates_uniqueness_of :lastname, :case_sensitive => false
28 28 validates_length_of :lastname, :maximum => 255
29 29
30 30 before_destroy :remove_references_before_destroy
31 31
32 32 scope :sorted, lambda { order("#{table_name}.lastname ASC") }
33 scope :named, lambda {|arg| where("LOWER(#{table_name}.lastname) = LOWER(?)", arg.to_s.strip)}
33 34
34 35 safe_attributes 'name',
35 36 'user_ids',
36 37 'custom_field_values',
37 38 'custom_fields',
38 39 :if => lambda {|group, user| user.admin?}
39 40
40 41 def to_s
41 42 lastname.to_s
42 43 end
43 44
44 45 def name
45 46 lastname
46 47 end
47 48
48 49 def name=(arg)
49 50 self.lastname = arg
50 51 end
51 52
52 53 def user_added(user)
53 54 members.each do |member|
54 55 next if member.project.nil?
55 56 user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id)
56 57 member.member_roles.each do |member_role|
57 58 user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id)
58 59 end
59 60 user_member.save!
60 61 end
61 62 end
62 63
63 64 def user_removed(user)
64 65 members.each do |member|
65 66 MemberRole.
66 67 includes(:member).
67 68 where("#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids).
68 69 all.
69 70 each(&:destroy)
70 71 end
71 72 end
72 73
73 74 def self.human_attribute_name(attribute_key_name, *args)
74 75 attr_name = attribute_key_name.to_s
75 76 if attr_name == 'lastname'
76 77 attr_name = "name"
77 78 end
78 79 super(attr_name, *args)
79 80 end
80 81
81 82 private
82 83
83 84 # Removes references that are not handled by associations
84 85 def remove_references_before_destroy
85 86 return if self.id.nil?
86 87
87 88 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
88 89 end
89 90 end
@@ -1,496 +1,510
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 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-/
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
73 73 value = value.to_s.downcase
74 74 if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
75 75 if logger && logger.info
76 76 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
77 77 end
78 78 return false
79 79 end
80 80 end
81 81 end
82 82 @user = User.find_by_mail(sender_email) if sender_email.present?
83 83 if @user && !@user.active?
84 84 if logger && logger.info
85 85 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
86 86 end
87 87 return false
88 88 end
89 89 if @user.nil?
90 90 # Email was submitted by an unknown user
91 91 case @@handler_options[:unknown_user]
92 92 when 'accept'
93 93 @user = User.anonymous
94 94 when 'create'
95 95 @user = create_user_from_email
96 96 if @user
97 97 if logger && logger.info
98 98 logger.info "MailHandler: [#{@user.login}] account created"
99 99 end
100 add_user_to_group(@@handler_options[:default_group])
100 101 Mailer.account_information(@user, @user.password).deliver
101 102 else
102 103 if logger && logger.error
103 104 logger.error "MailHandler: could not create account for [#{sender_email}]"
104 105 end
105 106 return false
106 107 end
107 108 else
108 109 # Default behaviour, emails from unknown users are ignored
109 110 if logger && logger.info
110 111 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
111 112 end
112 113 return false
113 114 end
114 115 end
115 116 User.current = @user
116 117 dispatch
117 118 end
118 119
119 120 private
120 121
121 122 MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
122 123 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
123 124 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
124 125
125 126 def dispatch
126 127 headers = [email.in_reply_to, email.references].flatten.compact
127 128 subject = email.subject.to_s
128 129 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
129 130 klass, object_id = $1, $2.to_i
130 131 method_name = "receive_#{klass}_reply"
131 132 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
132 133 send method_name, object_id
133 134 else
134 135 # ignoring it
135 136 end
136 137 elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE)
137 138 receive_issue_reply(m[1].to_i)
138 139 elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE)
139 140 receive_message_reply(m[1].to_i)
140 141 else
141 142 dispatch_to_default
142 143 end
143 144 rescue ActiveRecord::RecordInvalid => e
144 145 # TODO: send a email to the user
145 146 logger.error e.message if logger
146 147 false
147 148 rescue MissingInformation => e
148 149 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
149 150 false
150 151 rescue UnauthorizedAction => e
151 152 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
152 153 false
153 154 end
154 155
155 156 def dispatch_to_default
156 157 receive_issue
157 158 end
158 159
159 160 # Creates a new issue
160 161 def receive_issue
161 162 project = target_project
162 163 # check permission
163 164 unless @@handler_options[:no_permission_check]
164 165 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
165 166 end
166 167
167 168 issue = Issue.new(:author => user, :project => project)
168 169 issue.safe_attributes = issue_attributes_from_keywords(issue)
169 170 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
170 171 issue.subject = cleaned_up_subject
171 172 if issue.subject.blank?
172 173 issue.subject = '(no subject)'
173 174 end
174 175 issue.description = cleaned_up_text_body
175 176
176 177 # add To and Cc as watchers before saving so the watchers can reply to Redmine
177 178 add_watchers(issue)
178 179 issue.save!
179 180 add_attachments(issue)
180 181 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
181 182 issue
182 183 end
183 184
184 185 # Adds a note to an existing issue
185 186 def receive_issue_reply(issue_id, from_journal=nil)
186 187 issue = Issue.find_by_id(issue_id)
187 188 return unless issue
188 189 # check permission
189 190 unless @@handler_options[:no_permission_check]
190 191 unless user.allowed_to?(:add_issue_notes, issue.project) ||
191 192 user.allowed_to?(:edit_issues, issue.project)
192 193 raise UnauthorizedAction
193 194 end
194 195 end
195 196
196 197 # ignore CLI-supplied defaults for new issues
197 198 @@handler_options[:issue].clear
198 199
199 200 journal = issue.init_journal(user)
200 201 if from_journal && from_journal.private_notes?
201 202 # If the received email was a reply to a private note, make the added note private
202 203 issue.private_notes = true
203 204 end
204 205 issue.safe_attributes = issue_attributes_from_keywords(issue)
205 206 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
206 207 journal.notes = cleaned_up_text_body
207 208 add_attachments(issue)
208 209 issue.save!
209 210 if logger && logger.info
210 211 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
211 212 end
212 213 journal
213 214 end
214 215
215 216 # Reply will be added to the issue
216 217 def receive_journal_reply(journal_id)
217 218 journal = Journal.find_by_id(journal_id)
218 219 if journal && journal.journalized_type == 'Issue'
219 220 receive_issue_reply(journal.journalized_id, journal)
220 221 end
221 222 end
222 223
223 224 # Receives a reply to a forum message
224 225 def receive_message_reply(message_id)
225 226 message = Message.find_by_id(message_id)
226 227 if message
227 228 message = message.root
228 229
229 230 unless @@handler_options[:no_permission_check]
230 231 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
231 232 end
232 233
233 234 if !message.locked?
234 235 reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
235 236 :content => cleaned_up_text_body)
236 237 reply.author = user
237 238 reply.board = message.board
238 239 message.children << reply
239 240 add_attachments(reply)
240 241 reply
241 242 else
242 243 if logger && logger.info
243 244 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
244 245 end
245 246 end
246 247 end
247 248 end
248 249
249 250 def add_attachments(obj)
250 251 if email.attachments && email.attachments.any?
251 252 email.attachments.each do |attachment|
252 253 filename = attachment.filename
253 254 unless filename.respond_to?(:encoding)
254 255 # try to reencode to utf8 manually with ruby1.8
255 256 h = attachment.header['Content-Disposition']
256 257 unless h.nil?
257 258 begin
258 259 if m = h.value.match(/filename\*[0-9\*]*=([^=']+)'/)
259 260 filename = Redmine::CodesetUtil.to_utf8(filename, m[1])
260 261 elsif m = h.value.match(/filename=.*=\?([^\?]+)\?[BbQq]\?/)
261 262 # http://tools.ietf.org/html/rfc2047#section-4
262 263 filename = Redmine::CodesetUtil.to_utf8(filename, m[1])
263 264 end
264 265 rescue
265 266 # nop
266 267 end
267 268 end
268 269 end
269 270 obj.attachments << Attachment.create(:container => obj,
270 271 :file => attachment.decoded,
271 272 :filename => filename,
272 273 :author => user,
273 274 :content_type => attachment.mime_type)
274 275 end
275 276 end
276 277 end
277 278
278 279 # Adds To and Cc as watchers of the given object if the sender has the
279 280 # appropriate permission
280 281 def add_watchers(obj)
281 282 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
282 283 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
283 284 unless addresses.empty?
284 285 watchers = User.active.where('LOWER(mail) IN (?)', addresses).all
285 286 watchers.each {|w| obj.add_watcher(w)}
286 287 end
287 288 end
288 289 end
289 290
290 291 def get_keyword(attr, options={})
291 292 @keywords ||= {}
292 293 if @keywords.has_key?(attr)
293 294 @keywords[attr]
294 295 else
295 296 @keywords[attr] = begin
296 297 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
297 298 (v = extract_keyword!(plain_text_body, attr, options[:format]))
298 299 v
299 300 elsif !@@handler_options[:issue][attr].blank?
300 301 @@handler_options[:issue][attr]
301 302 end
302 303 end
303 304 end
304 305 end
305 306
306 307 # Destructively extracts the value for +attr+ in +text+
307 308 # Returns nil if no matching keyword found
308 309 def extract_keyword!(text, attr, format=nil)
309 310 keys = [attr.to_s.humanize]
310 311 if attr.is_a?(Symbol)
311 312 if user && user.language.present?
312 313 keys << l("field_#{attr}", :default => '', :locale => user.language)
313 314 end
314 315 if Setting.default_language.present?
315 316 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
316 317 end
317 318 end
318 319 keys.reject! {|k| k.blank?}
319 320 keys.collect! {|k| Regexp.escape(k)}
320 321 format ||= '.+'
321 322 keyword = nil
322 323 regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
323 324 if m = text.match(regexp)
324 325 keyword = m[2].strip
325 326 text.gsub!(regexp, '')
326 327 end
327 328 keyword
328 329 end
329 330
330 331 def target_project
331 332 # TODO: other ways to specify project:
332 333 # * parse the email To field
333 334 # * specific project (eg. Setting.mail_handler_target_project)
334 335 target = Project.find_by_identifier(get_keyword(:project))
335 336 raise MissingInformation.new('Unable to determine target project') if target.nil?
336 337 target
337 338 end
338 339
339 340 # Returns a Hash of issue attributes extracted from keywords in the email body
340 341 def issue_attributes_from_keywords(issue)
341 342 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
342 343
343 344 attrs = {
344 345 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
345 346 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
346 347 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
347 348 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
348 349 'assigned_to_id' => assigned_to.try(:id),
349 350 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
350 351 issue.project.shared_versions.named(k).first.try(:id),
351 352 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
352 353 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
353 354 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
354 355 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
355 356 }.delete_if {|k, v| v.blank? }
356 357
357 358 if issue.new_record? && attrs['tracker_id'].nil?
358 359 attrs['tracker_id'] = issue.project.trackers.first.try(:id)
359 360 end
360 361
361 362 attrs
362 363 end
363 364
364 365 # Returns a Hash of issue custom field values extracted from keywords in the email body
365 366 def custom_field_values_from_keywords(customized)
366 367 customized.custom_field_values.inject({}) do |h, v|
367 368 if keyword = get_keyword(v.custom_field.name, :override => true)
368 369 h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized)
369 370 end
370 371 h
371 372 end
372 373 end
373 374
374 375 # Returns the text/plain part of the email
375 376 # If not found (eg. HTML-only email), returns the body with tags removed
376 377 def plain_text_body
377 378 return @plain_text_body unless @plain_text_body.nil?
378 379
379 380 part = email.text_part || email.html_part || email
380 381 @plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
381 382
382 383 # strip html tags and remove doctype directive
383 384 @plain_text_body = strip_tags(@plain_text_body.strip)
384 385 @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
385 386 @plain_text_body
386 387 end
387 388
388 389 def cleaned_up_text_body
389 390 cleanup_body(plain_text_body)
390 391 end
391 392
392 393 def cleaned_up_subject
393 394 subject = email.subject.to_s
394 395 unless subject.respond_to?(:encoding)
395 396 # try to reencode to utf8 manually with ruby1.8
396 397 begin
397 398 if h = email.header[:subject]
398 399 # http://tools.ietf.org/html/rfc2047#section-4
399 400 if m = h.value.match(/=\?([^\?]+)\?[BbQq]\?/)
400 401 subject = Redmine::CodesetUtil.to_utf8(subject, m[1])
401 402 end
402 403 end
403 404 rescue
404 405 # nop
405 406 end
406 407 end
407 408 subject.strip[0,255]
408 409 end
409 410
410 411 def self.full_sanitizer
411 412 @full_sanitizer ||= HTML::FullSanitizer.new
412 413 end
413 414
414 415 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
415 416 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
416 417 value = value.to_s.slice(0, limit)
417 418 object.send("#{attribute}=", value)
418 419 end
419 420
420 421 # Returns a User from an email address and a full name
421 422 def self.new_user_from_attributes(email_address, fullname=nil)
422 423 user = User.new
423 424
424 425 # Truncating the email address would result in an invalid format
425 426 user.mail = email_address
426 427 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
427 428
428 429 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
429 430 assign_string_attribute_with_limit(user, 'firstname', names.shift, 30)
430 431 assign_string_attribute_with_limit(user, 'lastname', names.join(' '), 30)
431 432 user.lastname = '-' if user.lastname.blank?
432 433 user.language = Setting.default_language
433 434 user.generate_password = true
434 435
435 436 unless user.valid?
436 437 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
437 438 user.firstname = "-" unless user.errors[:firstname].blank?
438 439 (puts user.errors[:lastname];user.lastname = "-") unless user.errors[:lastname].blank?
439 440 end
440 441
441 442 user
442 443 end
443 444
444 445 # Creates a User for the +email+ sender
445 446 # Returns the user or nil if it could not be created
446 447 def create_user_from_email
447 448 from = email.header['from'].to_s
448 449 addr, name = from, nil
449 450 if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
450 451 addr, name = m[2], m[1]
451 452 end
452 453 if addr.present?
453 454 user = self.class.new_user_from_attributes(addr, name)
454 455 if user.save
455 456 user
456 457 else
457 458 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
458 459 nil
459 460 end
460 461 else
461 462 logger.error "MailHandler: failed to create User: no FROM address found" if logger
462 463 nil
463 464 end
464 465 end
465 466
467 # Adds the newly created user to default group
468 def add_user_to_group(default_group)
469 if default_group.present?
470 default_group.split(',').each do |group_name|
471 if group = Group.named(group_name).first
472 group.users << @user
473 elsif logger
474 logger.warn "MailHandler: could not add user to [#{group_name}], group not found"
475 end
476 end
477 end
478 end
479
466 480 # Removes the email body of text after the truncation configurations.
467 481 def cleanup_body(body)
468 482 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
469 483 unless delimiters.empty?
470 484 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
471 485 body = body.gsub(regex, '')
472 486 end
473 487 body.strip
474 488 end
475 489
476 490 def find_assignee_from_keyword(keyword, issue)
477 491 keyword = keyword.to_s.downcase
478 492 assignable = issue.assignable_users
479 493 assignee = nil
480 494 assignee ||= assignable.detect {|a|
481 495 a.mail.to_s.downcase == keyword ||
482 496 a.login.to_s.downcase == keyword
483 497 }
484 498 if assignee.nil? && keyword.match(/ /)
485 499 firstname, lastname = *(keyword.split) # "First Last Throwaway"
486 500 assignee ||= assignable.detect {|a|
487 501 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
488 502 a.lastname.to_s.downcase == lastname
489 503 }
490 504 end
491 505 if assignee.nil?
492 506 assignee ||= assignable.detect {|a| a.name.downcase == keyword}
493 507 end
494 508 assignee
495 509 end
496 510 end
@@ -1,150 +1,155
1 1 #!/usr/bin/env ruby
2 2
3 3 require 'net/http'
4 4 require 'net/https'
5 5 require 'uri'
6 6 require 'optparse'
7 7
8 8 module Net
9 9 class HTTPS < HTTP
10 10 def self.post_form(url, params, headers, options={})
11 11 request = Post.new(url.path)
12 12 request.form_data = params
13 13 request.initialize_http_header(headers)
14 14 request.basic_auth url.user, url.password if url.user
15 15 http = new(url.host, url.port)
16 16 http.use_ssl = (url.scheme == 'https')
17 17 if options[:no_check_certificate]
18 18 http.verify_mode = OpenSSL::SSL::VERIFY_NONE
19 19 end
20 20 http.start {|h| h.request(request) }
21 21 end
22 22 end
23 23 end
24 24
25 25 class RedmineMailHandler
26 VERSION = '0.2.1'
26 VERSION = '0.2.2'
27 27
28 attr_accessor :verbose, :issue_attributes, :allow_override, :unknown_user, :no_permission_check, :url, :key, :no_check_certificate
28 attr_accessor :verbose, :issue_attributes, :allow_override, :unknown_user, :default_group, :no_permission_check, :url, :key, :no_check_certificate
29 29
30 30 def initialize
31 31 self.issue_attributes = {}
32 32
33 33 optparse = OptionParser.new do |opts|
34 34 opts.banner = "Usage: rdm-mailhandler.rb [options] --url=<Redmine URL> --key=<API key>"
35 35 opts.separator("")
36 36 opts.separator("Reads an email from standard input and forward it to a Redmine server through a HTTP request.")
37 37 opts.separator("")
38 38 opts.separator("Required arguments:")
39 39 opts.on("-u", "--url URL", "URL of the Redmine server") {|v| self.url = v}
40 40 opts.on("-k", "--key KEY", "Redmine API key") {|v| self.key = v}
41 41 opts.separator("")
42 42 opts.separator("General options:")
43 opts.on("--unknown-user ACTION", "how to handle emails from an unknown user",
44 "ACTION can be one of the following values:",
45 "* ignore: email is ignored (default)",
46 "* accept: accept as anonymous user",
47 "* create: create a user account") {|v| self.unknown_user = v}
48 43 opts.on("--no-permission-check", "disable permission checking when receiving",
49 44 "the email") {self.no_permission_check = '1'}
50 45 opts.on("--key-file FILE", "path to a file that contains the Redmine",
51 46 "API key (use this option instead of --key",
52 47 "if you don't the key to appear in the",
53 48 "command line)") {|v| read_key_from_file(v)}
54 49 opts.on("--no-check-certificate", "do not check server certificate") {self.no_check_certificate = true}
55 50 opts.on("-h", "--help", "show this help") {puts opts; exit 1}
56 51 opts.on("-v", "--verbose", "show extra information") {self.verbose = true}
57 52 opts.on("-V", "--version", "show version information and exit") {puts VERSION; exit}
58 53 opts.separator("")
54 opts.separator("User creation options:")
55 opts.on("--unknown-user ACTION", "how to handle emails from an unknown user",
56 "ACTION can be one of the following values:",
57 "* ignore: email is ignored (default)",
58 "* accept: accept as anonymous user",
59 "* create: create a user account") {|v| self.unknown_user = v}
60 opts.on("--default-group GROUP", "add created user to GROUP (none by default)",
61 "GROUP can be a comma separated list of groups") { |v| self.default_group = v}
62 opts.separator("")
59 63 opts.separator("Issue attributes control options:")
60 64 opts.on("-p", "--project PROJECT", "identifier of the target project") {|v| self.issue_attributes['project'] = v}
61 65 opts.on("-s", "--status STATUS", "name of the target status") {|v| self.issue_attributes['status'] = v}
62 66 opts.on("-t", "--tracker TRACKER", "name of the target tracker") {|v| self.issue_attributes['tracker'] = v}
63 67 opts.on( "--category CATEGORY", "name of the target category") {|v| self.issue_attributes['category'] = v}
64 68 opts.on( "--priority PRIORITY", "name of the target priority") {|v| self.issue_attributes['priority'] = v}
65 69 opts.on("-o", "--allow-override ATTRS", "allow email content to override attributes",
66 70 "specified by previous options",
67 71 "ATTRS is a comma separated list of attributes") {|v| self.allow_override = v}
68 72 opts.separator("")
69 73 opts.separator("Examples:")
70 74 opts.separator("No project specified. Emails MUST contain the 'Project' keyword:")
71 75 opts.separator(" rdm-mailhandler.rb --url http://redmine.domain.foo --key secret")
72 76 opts.separator("")
73 77 opts.separator("Fixed project and default tracker specified, but emails can override")
74 78 opts.separator("both tracker and priority attributes using keywords:")
75 79 opts.separator(" rdm-mailhandler.rb --url https://domain.foo/redmine --key secret \\")
76 80 opts.separator(" --project foo \\")
77 81 opts.separator(" --tracker bug \\")
78 82 opts.separator(" --allow-override tracker,priority")
79 83
80 84 opts.summary_width = 27
81 85 end
82 86 optparse.parse!
83 87
84 88 unless url && key
85 89 puts "Some arguments are missing. Use `rdm-mailhandler.rb --help` for getting help."
86 90 exit 1
87 91 end
88 92 end
89 93
90 94 def submit(email)
91 95 uri = url.gsub(%r{/*$}, '') + '/mail_handler'
92 96
93 97 headers = { 'User-Agent' => "Redmine mail handler/#{VERSION}" }
94 98
95 99 data = { 'key' => key, 'email' => email,
96 100 'allow_override' => allow_override,
97 101 'unknown_user' => unknown_user,
102 'default_group' => default_group,
98 103 'no_permission_check' => no_permission_check}
99 104 issue_attributes.each { |attr, value| data["issue[#{attr}]"] = value }
100 105
101 106 debug "Posting to #{uri}..."
102 107 begin
103 108 response = Net::HTTPS.post_form(URI.parse(uri), data, headers, :no_check_certificate => no_check_certificate)
104 109 rescue SystemCallError => e # connection refused, etc.
105 110 warn "An error occured while contacting your Redmine server: #{e.message}"
106 111 return 75 # temporary failure
107 112 end
108 113 debug "Response received: #{response.code}"
109 114
110 115 case response.code.to_i
111 116 when 403
112 117 warn "Request was denied by your Redmine server. " +
113 118 "Make sure that 'WS for incoming emails' is enabled in application settings and that you provided the correct API key."
114 119 return 77
115 120 when 422
116 121 warn "Request was denied by your Redmine server. " +
117 122 "Possible reasons: email is sent from an invalid email address or is missing some information."
118 123 return 77
119 124 when 400..499
120 125 warn "Request was denied by your Redmine server (#{response.code})."
121 126 return 77
122 127 when 500..599
123 128 warn "Failed to contact your Redmine server (#{response.code})."
124 129 return 75
125 130 when 201
126 131 debug "Proccessed successfully"
127 132 return 0
128 133 else
129 134 return 1
130 135 end
131 136 end
132 137
133 138 private
134 139
135 140 def debug(msg)
136 141 puts msg if verbose
137 142 end
138 143
139 144 def read_key_from_file(filename)
140 145 begin
141 146 self.key = File.read(filename).strip
142 147 rescue Exception => e
143 148 $stderr.puts "Unable to read the key from #{filename}:\n#{e.message}"
144 149 exit 1
145 150 end
146 151 end
147 152 end
148 153
149 154 handler = RedmineMailHandler.new
150 155 exit(handler.submit(STDIN.read))
@@ -1,768 +1,784
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2013 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 teardown
39 39 Setting.clear_cache
40 40 end
41 41
42 42 def test_add_issue
43 43 ActionMailer::Base.deliveries.clear
44 44 # This email contains: 'Project: onlinestore'
45 45 issue = submit_email('ticket_on_given_project.eml')
46 46 assert issue.is_a?(Issue)
47 47 assert !issue.new_record?
48 48 issue.reload
49 49 assert_equal Project.find(2), issue.project
50 50 assert_equal issue.project.trackers.first, issue.tracker
51 51 assert_equal 'New ticket on a given project', issue.subject
52 52 assert_equal User.find_by_login('jsmith'), issue.author
53 53 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
54 54 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
55 55 assert_equal '2010-01-01', issue.start_date.to_s
56 56 assert_equal '2010-12-31', issue.due_date.to_s
57 57 assert_equal User.find_by_login('jsmith'), issue.assigned_to
58 58 assert_equal Version.find_by_name('Alpha'), issue.fixed_version
59 59 assert_equal 2.5, issue.estimated_hours
60 60 assert_equal 30, issue.done_ratio
61 61 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
62 62 # keywords should be removed from the email body
63 63 assert !issue.description.match(/^Project:/i)
64 64 assert !issue.description.match(/^Status:/i)
65 65 assert !issue.description.match(/^Start Date:/i)
66 66 # Email notification should be sent
67 67 mail = ActionMailer::Base.deliveries.last
68 68 assert_not_nil mail
69 69 assert mail.subject.include?('New ticket on a given project')
70 70 end
71 71
72 72 def test_add_issue_with_default_tracker
73 73 # This email contains: 'Project: onlinestore'
74 74 issue = submit_email(
75 75 'ticket_on_given_project.eml',
76 76 :issue => {:tracker => 'Support request'}
77 77 )
78 78 assert issue.is_a?(Issue)
79 79 assert !issue.new_record?
80 80 issue.reload
81 81 assert_equal 'Support request', issue.tracker.name
82 82 end
83 83
84 84 def test_add_issue_with_status
85 85 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
86 86 issue = submit_email('ticket_on_given_project.eml')
87 87 assert issue.is_a?(Issue)
88 88 assert !issue.new_record?
89 89 issue.reload
90 90 assert_equal Project.find(2), issue.project
91 91 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
92 92 end
93 93
94 94 def test_add_issue_with_attributes_override
95 95 issue = submit_email(
96 96 'ticket_with_attributes.eml',
97 97 :allow_override => 'tracker,category,priority'
98 98 )
99 99 assert issue.is_a?(Issue)
100 100 assert !issue.new_record?
101 101 issue.reload
102 102 assert_equal 'New ticket on a given project', issue.subject
103 103 assert_equal User.find_by_login('jsmith'), issue.author
104 104 assert_equal Project.find(2), issue.project
105 105 assert_equal 'Feature request', issue.tracker.to_s
106 106 assert_equal 'Stock management', issue.category.to_s
107 107 assert_equal 'Urgent', issue.priority.to_s
108 108 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
109 109 end
110 110
111 111 def test_add_issue_with_group_assignment
112 112 with_settings :issue_group_assignment => '1' do
113 113 issue = submit_email('ticket_on_given_project.eml') do |email|
114 114 email.gsub!('Assigned to: John Smith', 'Assigned to: B Team')
115 115 end
116 116 assert issue.is_a?(Issue)
117 117 assert !issue.new_record?
118 118 issue.reload
119 119 assert_equal Group.find(11), issue.assigned_to
120 120 end
121 121 end
122 122
123 123 def test_add_issue_with_partial_attributes_override
124 124 issue = submit_email(
125 125 'ticket_with_attributes.eml',
126 126 :issue => {:priority => 'High'},
127 127 :allow_override => ['tracker']
128 128 )
129 129 assert issue.is_a?(Issue)
130 130 assert !issue.new_record?
131 131 issue.reload
132 132 assert_equal 'New ticket on a given project', issue.subject
133 133 assert_equal User.find_by_login('jsmith'), issue.author
134 134 assert_equal Project.find(2), issue.project
135 135 assert_equal 'Feature request', issue.tracker.to_s
136 136 assert_nil issue.category
137 137 assert_equal 'High', issue.priority.to_s
138 138 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
139 139 end
140 140
141 141 def test_add_issue_with_spaces_between_attribute_and_separator
142 142 issue = submit_email(
143 143 'ticket_with_spaces_between_attribute_and_separator.eml',
144 144 :allow_override => 'tracker,category,priority'
145 145 )
146 146 assert issue.is_a?(Issue)
147 147 assert !issue.new_record?
148 148 issue.reload
149 149 assert_equal 'New ticket on a given project', issue.subject
150 150 assert_equal User.find_by_login('jsmith'), issue.author
151 151 assert_equal Project.find(2), issue.project
152 152 assert_equal 'Feature request', issue.tracker.to_s
153 153 assert_equal 'Stock management', issue.category.to_s
154 154 assert_equal 'Urgent', issue.priority.to_s
155 155 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
156 156 end
157 157
158 158 def test_add_issue_with_attachment_to_specific_project
159 159 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
160 160 assert issue.is_a?(Issue)
161 161 assert !issue.new_record?
162 162 issue.reload
163 163 assert_equal 'Ticket created by email with attachment', issue.subject
164 164 assert_equal User.find_by_login('jsmith'), issue.author
165 165 assert_equal Project.find(2), issue.project
166 166 assert_equal 'This is a new ticket with attachments', issue.description
167 167 # Attachment properties
168 168 assert_equal 1, issue.attachments.size
169 169 assert_equal 'Paella.jpg', issue.attachments.first.filename
170 170 assert_equal 'image/jpeg', issue.attachments.first.content_type
171 171 assert_equal 10790, issue.attachments.first.filesize
172 172 end
173 173
174 174 def test_add_issue_with_custom_fields
175 175 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
176 176 assert issue.is_a?(Issue)
177 177 assert !issue.new_record?
178 178 issue.reload
179 179 assert_equal 'New ticket with custom field values', issue.subject
180 180 assert_equal 'PostgreSQL', issue.custom_field_value(1)
181 181 assert_equal 'Value for a custom field', issue.custom_field_value(2)
182 182 assert !issue.description.match(/^searchable field:/i)
183 183 end
184 184
185 185 def test_add_issue_with_version_custom_fields
186 186 field = IssueCustomField.create!(:name => 'Affected version', :field_format => 'version', :is_for_all => true, :tracker_ids => [1,2,3])
187 187
188 188 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'ecookbook'}) do |email|
189 189 email << "Affected version: 1.0\n"
190 190 end
191 191 assert issue.is_a?(Issue)
192 192 assert !issue.new_record?
193 193 issue.reload
194 194 assert_equal '2', issue.custom_field_value(field)
195 195 end
196 196
197 197 def test_add_issue_should_match_assignee_on_display_name
198 198 user = User.generate!(:firstname => 'Foo Bar', :lastname => 'Foo Baz')
199 199 User.add_to_project(user, Project.find(2))
200 200 issue = submit_email('ticket_on_given_project.eml') do |email|
201 201 email.sub!(/^Assigned to.*$/, 'Assigned to: Foo Bar Foo baz')
202 202 end
203 203 assert issue.is_a?(Issue)
204 204 assert_equal user, issue.assigned_to
205 205 end
206 206
207 207 def test_add_issue_with_cc
208 208 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
209 209 assert issue.is_a?(Issue)
210 210 assert !issue.new_record?
211 211 issue.reload
212 212 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
213 213 assert_equal 1, issue.watcher_user_ids.size
214 214 end
215 215
216 216 def test_add_issue_by_unknown_user
217 217 assert_no_difference 'User.count' do
218 218 assert_equal false,
219 219 submit_email(
220 220 'ticket_by_unknown_user.eml',
221 221 :issue => {:project => 'ecookbook'}
222 222 )
223 223 end
224 224 end
225 225
226 226 def test_add_issue_by_anonymous_user
227 227 Role.anonymous.add_permission!(:add_issues)
228 228 assert_no_difference 'User.count' do
229 229 issue = submit_email(
230 230 'ticket_by_unknown_user.eml',
231 231 :issue => {:project => 'ecookbook'},
232 232 :unknown_user => 'accept'
233 233 )
234 234 assert issue.is_a?(Issue)
235 235 assert issue.author.anonymous?
236 236 end
237 237 end
238 238
239 239 def test_add_issue_by_anonymous_user_with_no_from_address
240 240 Role.anonymous.add_permission!(:add_issues)
241 241 assert_no_difference 'User.count' do
242 242 issue = submit_email(
243 243 'ticket_by_empty_user.eml',
244 244 :issue => {:project => 'ecookbook'},
245 245 :unknown_user => 'accept'
246 246 )
247 247 assert issue.is_a?(Issue)
248 248 assert issue.author.anonymous?
249 249 end
250 250 end
251 251
252 252 def test_add_issue_by_anonymous_user_on_private_project
253 253 Role.anonymous.add_permission!(:add_issues)
254 254 assert_no_difference 'User.count' do
255 255 assert_no_difference 'Issue.count' do
256 256 assert_equal false,
257 257 submit_email(
258 258 'ticket_by_unknown_user.eml',
259 259 :issue => {:project => 'onlinestore'},
260 260 :unknown_user => 'accept'
261 261 )
262 262 end
263 263 end
264 264 end
265 265
266 266 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
267 267 assert_no_difference 'User.count' do
268 268 assert_difference 'Issue.count' do
269 269 issue = submit_email(
270 270 'ticket_by_unknown_user.eml',
271 271 :issue => {:project => 'onlinestore'},
272 272 :no_permission_check => '1',
273 273 :unknown_user => 'accept'
274 274 )
275 275 assert issue.is_a?(Issue)
276 276 assert issue.author.anonymous?
277 277 assert !issue.project.is_public?
278 278 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
279 279 end
280 280 end
281 281 end
282 282
283 283 def test_add_issue_by_created_user
284 284 Setting.default_language = 'en'
285 285 assert_difference 'User.count' do
286 286 issue = submit_email(
287 287 'ticket_by_unknown_user.eml',
288 288 :issue => {:project => 'ecookbook'},
289 289 :unknown_user => 'create'
290 290 )
291 291 assert issue.is_a?(Issue)
292 292 assert issue.author.active?
293 293 assert_equal 'john.doe@somenet.foo', issue.author.mail
294 294 assert_equal 'John', issue.author.firstname
295 295 assert_equal 'Doe', issue.author.lastname
296 296
297 297 # account information
298 298 email = ActionMailer::Base.deliveries.first
299 299 assert_not_nil email
300 300 assert email.subject.include?('account activation')
301 301 login = mail_body(email).match(/\* Login: (.*)$/)[1].strip
302 302 password = mail_body(email).match(/\* Password: (.*)$/)[1].strip
303 303 assert_equal issue.author, User.try_to_login(login, password)
304 304 end
305 305 end
306 306
307 def test_created_user_should_be_added_to_groups
308 group1 = Group.generate!
309 group2 = Group.generate!
310
311 assert_difference 'User.count' do
312 submit_email(
313 'ticket_by_unknown_user.eml',
314 :issue => {:project => 'ecookbook'},
315 :unknown_user => 'create',
316 :default_group => "#{group1.name},#{group2.name}"
317 )
318 end
319 user = User.order('id DESC').first
320 assert_same_elements [group1, group2], user.groups
321 end
322
307 323 def test_add_issue_without_from_header
308 324 Role.anonymous.add_permission!(:add_issues)
309 325 assert_equal false, submit_email('ticket_without_from_header.eml')
310 326 end
311 327
312 328 def test_add_issue_with_invalid_attributes
313 329 issue = submit_email(
314 330 'ticket_with_invalid_attributes.eml',
315 331 :allow_override => 'tracker,category,priority'
316 332 )
317 333 assert issue.is_a?(Issue)
318 334 assert !issue.new_record?
319 335 issue.reload
320 336 assert_nil issue.assigned_to
321 337 assert_nil issue.start_date
322 338 assert_nil issue.due_date
323 339 assert_equal 0, issue.done_ratio
324 340 assert_equal 'Normal', issue.priority.to_s
325 341 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
326 342 end
327 343
328 344 def test_add_issue_with_localized_attributes
329 345 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
330 346 issue = submit_email(
331 347 'ticket_with_localized_attributes.eml',
332 348 :allow_override => 'tracker,category,priority'
333 349 )
334 350 assert issue.is_a?(Issue)
335 351 assert !issue.new_record?
336 352 issue.reload
337 353 assert_equal 'New ticket on a given project', issue.subject
338 354 assert_equal User.find_by_login('jsmith'), issue.author
339 355 assert_equal Project.find(2), issue.project
340 356 assert_equal 'Feature request', issue.tracker.to_s
341 357 assert_equal 'Stock management', issue.category.to_s
342 358 assert_equal 'Urgent', issue.priority.to_s
343 359 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
344 360 end
345 361
346 362 def test_add_issue_with_japanese_keywords
347 363 ja_dev = "\xe9\x96\x8b\xe7\x99\xba"
348 364 ja_dev.force_encoding('UTF-8') if ja_dev.respond_to?(:force_encoding)
349 365 tracker = Tracker.create!(:name => ja_dev)
350 366 Project.find(1).trackers << tracker
351 367 issue = submit_email(
352 368 'japanese_keywords_iso_2022_jp.eml',
353 369 :issue => {:project => 'ecookbook'},
354 370 :allow_override => 'tracker'
355 371 )
356 372 assert_kind_of Issue, issue
357 373 assert_equal tracker, issue.tracker
358 374 end
359 375
360 376 def test_add_issue_from_apple_mail
361 377 issue = submit_email(
362 378 'apple_mail_with_attachment.eml',
363 379 :issue => {:project => 'ecookbook'}
364 380 )
365 381 assert_kind_of Issue, issue
366 382 assert_equal 1, issue.attachments.size
367 383
368 384 attachment = issue.attachments.first
369 385 assert_equal 'paella.jpg', attachment.filename
370 386 assert_equal 10790, attachment.filesize
371 387 assert File.exist?(attachment.diskfile)
372 388 assert_equal 10790, File.size(attachment.diskfile)
373 389 assert_equal 'caaf384198bcbc9563ab5c058acd73cd', attachment.digest
374 390 end
375 391
376 392 def test_thunderbird_with_attachment_ja
377 393 issue = submit_email(
378 394 'thunderbird_with_attachment_ja.eml',
379 395 :issue => {:project => 'ecookbook'}
380 396 )
381 397 assert_kind_of Issue, issue
382 398 assert_equal 1, issue.attachments.size
383 399 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88.txt"
384 400 ja.force_encoding('UTF-8') if ja.respond_to?(:force_encoding)
385 401 attachment = issue.attachments.first
386 402 assert_equal ja, attachment.filename
387 403 assert_equal 5, attachment.filesize
388 404 assert File.exist?(attachment.diskfile)
389 405 assert_equal 5, File.size(attachment.diskfile)
390 406 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
391 407 end
392 408
393 409 def test_gmail_with_attachment_ja
394 410 issue = submit_email(
395 411 'gmail_with_attachment_ja.eml',
396 412 :issue => {:project => 'ecookbook'}
397 413 )
398 414 assert_kind_of Issue, issue
399 415 assert_equal 1, issue.attachments.size
400 416 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88.txt"
401 417 ja.force_encoding('UTF-8') if ja.respond_to?(:force_encoding)
402 418 attachment = issue.attachments.first
403 419 assert_equal ja, attachment.filename
404 420 assert_equal 5, attachment.filesize
405 421 assert File.exist?(attachment.diskfile)
406 422 assert_equal 5, File.size(attachment.diskfile)
407 423 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
408 424 end
409 425
410 426 def test_thunderbird_with_attachment_latin1
411 427 issue = submit_email(
412 428 'thunderbird_with_attachment_iso-8859-1.eml',
413 429 :issue => {:project => 'ecookbook'}
414 430 )
415 431 assert_kind_of Issue, issue
416 432 assert_equal 1, issue.attachments.size
417 433 u = ""
418 434 u.force_encoding('UTF-8') if u.respond_to?(:force_encoding)
419 435 u1 = "\xc3\x84\xc3\xa4\xc3\x96\xc3\xb6\xc3\x9c\xc3\xbc"
420 436 u1.force_encoding('UTF-8') if u1.respond_to?(:force_encoding)
421 437 11.times { u << u1 }
422 438 attachment = issue.attachments.first
423 439 assert_equal "#{u}.png", attachment.filename
424 440 assert_equal 130, attachment.filesize
425 441 assert File.exist?(attachment.diskfile)
426 442 assert_equal 130, File.size(attachment.diskfile)
427 443 assert_equal '4d80e667ac37dddfe05502530f152abb', attachment.digest
428 444 end
429 445
430 446 def test_gmail_with_attachment_latin1
431 447 issue = submit_email(
432 448 'gmail_with_attachment_iso-8859-1.eml',
433 449 :issue => {:project => 'ecookbook'}
434 450 )
435 451 assert_kind_of Issue, issue
436 452 assert_equal 1, issue.attachments.size
437 453 u = ""
438 454 u.force_encoding('UTF-8') if u.respond_to?(:force_encoding)
439 455 u1 = "\xc3\x84\xc3\xa4\xc3\x96\xc3\xb6\xc3\x9c\xc3\xbc"
440 456 u1.force_encoding('UTF-8') if u1.respond_to?(:force_encoding)
441 457 11.times { u << u1 }
442 458 attachment = issue.attachments.first
443 459 assert_equal "#{u}.txt", attachment.filename
444 460 assert_equal 5, attachment.filesize
445 461 assert File.exist?(attachment.diskfile)
446 462 assert_equal 5, File.size(attachment.diskfile)
447 463 assert_equal 'd8e8fca2dc0f896fd7cb4cb0031ba249', attachment.digest
448 464 end
449 465
450 466 def test_add_issue_with_iso_8859_1_subject
451 467 issue = submit_email(
452 468 'subject_as_iso-8859-1.eml',
453 469 :issue => {:project => 'ecookbook'}
454 470 )
455 471 str = "Testmail from Webmail: \xc3\xa4 \xc3\xb6 \xc3\xbc..."
456 472 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
457 473 assert_kind_of Issue, issue
458 474 assert_equal str, issue.subject
459 475 end
460 476
461 477 def test_add_issue_with_japanese_subject
462 478 issue = submit_email(
463 479 'subject_japanese_1.eml',
464 480 :issue => {:project => 'ecookbook'}
465 481 )
466 482 assert_kind_of Issue, issue
467 483 ja = "\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88"
468 484 ja.force_encoding('UTF-8') if ja.respond_to?(:force_encoding)
469 485 assert_equal ja, issue.subject
470 486 end
471 487
472 488 def test_add_issue_with_no_subject_header
473 489 issue = submit_email(
474 490 'no_subject_header.eml',
475 491 :issue => {:project => 'ecookbook'}
476 492 )
477 493 assert_kind_of Issue, issue
478 494 assert_equal '(no subject)', issue.subject
479 495 end
480 496
481 497 def test_add_issue_with_mixed_japanese_subject
482 498 issue = submit_email(
483 499 'subject_japanese_2.eml',
484 500 :issue => {:project => 'ecookbook'}
485 501 )
486 502 assert_kind_of Issue, issue
487 503 ja = "Re: \xe3\x83\x86\xe3\x82\xb9\xe3\x83\x88"
488 504 ja.force_encoding('UTF-8') if ja.respond_to?(:force_encoding)
489 505 assert_equal ja, issue.subject
490 506 end
491 507
492 508 def test_should_ignore_emails_from_locked_users
493 509 User.find(2).lock!
494 510
495 511 MailHandler.any_instance.expects(:dispatch).never
496 512 assert_no_difference 'Issue.count' do
497 513 assert_equal false, submit_email('ticket_on_given_project.eml')
498 514 end
499 515 end
500 516
501 517 def test_should_ignore_emails_from_emission_address
502 518 Role.anonymous.add_permission!(:add_issues)
503 519 assert_no_difference 'User.count' do
504 520 assert_equal false,
505 521 submit_email(
506 522 'ticket_from_emission_address.eml',
507 523 :issue => {:project => 'ecookbook'},
508 524 :unknown_user => 'create'
509 525 )
510 526 end
511 527 end
512 528
513 529 def test_should_ignore_auto_replied_emails
514 530 MailHandler.any_instance.expects(:dispatch).never
515 531 [
516 532 "X-Auto-Response-Suppress: OOF",
517 533 "Auto-Submitted: auto-replied",
518 534 "Auto-Submitted: Auto-Replied",
519 535 "Auto-Submitted: auto-generated"
520 536 ].each do |header|
521 537 raw = IO.read(File.join(FIXTURES_PATH, 'ticket_on_given_project.eml'))
522 538 raw = header + "\n" + raw
523 539
524 540 assert_no_difference 'Issue.count' do
525 541 assert_equal false, MailHandler.receive(raw), "email with #{header} header was not ignored"
526 542 end
527 543 end
528 544 end
529 545
530 546 def test_add_issue_should_send_email_notification
531 547 Setting.notified_events = ['issue_added']
532 548 ActionMailer::Base.deliveries.clear
533 549 # This email contains: 'Project: onlinestore'
534 550 issue = submit_email('ticket_on_given_project.eml')
535 551 assert issue.is_a?(Issue)
536 552 assert_equal 1, ActionMailer::Base.deliveries.size
537 553 end
538 554
539 555 def test_update_issue
540 556 journal = submit_email('ticket_reply.eml')
541 557 assert journal.is_a?(Journal)
542 558 assert_equal User.find_by_login('jsmith'), journal.user
543 559 assert_equal Issue.find(2), journal.journalized
544 560 assert_match /This is reply/, journal.notes
545 561 assert_equal false, journal.private_notes
546 562 assert_equal 'Feature request', journal.issue.tracker.name
547 563 end
548 564
549 565 def test_update_issue_with_attribute_changes
550 566 # This email contains: 'Status: Resolved'
551 567 journal = submit_email('ticket_reply_with_status.eml')
552 568 assert journal.is_a?(Journal)
553 569 issue = Issue.find(journal.issue.id)
554 570 assert_equal User.find_by_login('jsmith'), journal.user
555 571 assert_equal Issue.find(2), journal.journalized
556 572 assert_match /This is reply/, journal.notes
557 573 assert_equal 'Feature request', journal.issue.tracker.name
558 574 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
559 575 assert_equal '2010-01-01', issue.start_date.to_s
560 576 assert_equal '2010-12-31', issue.due_date.to_s
561 577 assert_equal User.find_by_login('jsmith'), issue.assigned_to
562 578 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
563 579 # keywords should be removed from the email body
564 580 assert !journal.notes.match(/^Status:/i)
565 581 assert !journal.notes.match(/^Start Date:/i)
566 582 end
567 583
568 584 def test_update_issue_with_attachment
569 585 assert_difference 'Journal.count' do
570 586 assert_difference 'JournalDetail.count' do
571 587 assert_difference 'Attachment.count' do
572 588 assert_no_difference 'Issue.count' do
573 589 journal = submit_email('ticket_with_attachment.eml') do |raw|
574 590 raw.gsub! /^Subject: .*$/, 'Subject: Re: [Cookbook - Feature #2] (New) Add ingredients categories'
575 591 end
576 592 end
577 593 end
578 594 end
579 595 end
580 596 journal = Journal.first(:order => 'id DESC')
581 597 assert_equal Issue.find(2), journal.journalized
582 598 assert_equal 1, journal.details.size
583 599
584 600 detail = journal.details.first
585 601 assert_equal 'attachment', detail.property
586 602 assert_equal 'Paella.jpg', detail.value
587 603 end
588 604
589 605 def test_update_issue_should_send_email_notification
590 606 ActionMailer::Base.deliveries.clear
591 607 journal = submit_email('ticket_reply.eml')
592 608 assert journal.is_a?(Journal)
593 609 assert_equal 1, ActionMailer::Base.deliveries.size
594 610 end
595 611
596 612 def test_update_issue_should_not_set_defaults
597 613 journal = submit_email(
598 614 'ticket_reply.eml',
599 615 :issue => {:tracker => 'Support request', :priority => 'High'}
600 616 )
601 617 assert journal.is_a?(Journal)
602 618 assert_match /This is reply/, journal.notes
603 619 assert_equal 'Feature request', journal.issue.tracker.name
604 620 assert_equal 'Normal', journal.issue.priority.name
605 621 end
606 622
607 623 def test_replying_to_a_private_note_should_add_reply_as_private
608 624 private_journal = Journal.create!(:notes => 'Private notes', :journalized => Issue.find(1), :private_notes => true, :user_id => 2)
609 625
610 626 assert_difference 'Journal.count' do
611 627 journal = submit_email('ticket_reply.eml') do |email|
612 628 email.sub! %r{^In-Reply-To:.*$}, "In-Reply-To: <redmine.journal-#{private_journal.id}.20060719210421@osiris>"
613 629 end
614 630
615 631 assert_kind_of Journal, journal
616 632 assert_match /This is reply/, journal.notes
617 633 assert_equal true, journal.private_notes
618 634 end
619 635 end
620 636
621 637 def test_reply_to_a_message
622 638 m = submit_email('message_reply.eml')
623 639 assert m.is_a?(Message)
624 640 assert !m.new_record?
625 641 m.reload
626 642 assert_equal 'Reply via email', m.subject
627 643 # The email replies to message #2 which is part of the thread of message #1
628 644 assert_equal Message.find(1), m.parent
629 645 end
630 646
631 647 def test_reply_to_a_message_by_subject
632 648 m = submit_email('message_reply_by_subject.eml')
633 649 assert m.is_a?(Message)
634 650 assert !m.new_record?
635 651 m.reload
636 652 assert_equal 'Reply to the first post', m.subject
637 653 assert_equal Message.find(1), m.parent
638 654 end
639 655
640 656 def test_should_strip_tags_of_html_only_emails
641 657 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
642 658 assert issue.is_a?(Issue)
643 659 assert !issue.new_record?
644 660 issue.reload
645 661 assert_equal 'HTML email', issue.subject
646 662 assert_equal 'This is a html-only email.', issue.description
647 663 end
648 664
649 665 test "truncate emails with no setting should add the entire email into the issue" do
650 666 with_settings :mail_handler_body_delimiters => '' do
651 667 issue = submit_email('ticket_on_given_project.eml')
652 668 assert_issue_created(issue)
653 669 assert issue.description.include?('---')
654 670 assert issue.description.include?('This paragraph is after the delimiter')
655 671 end
656 672 end
657 673
658 674 test "truncate emails with a single string should truncate the email at the delimiter for the issue" do
659 675 with_settings :mail_handler_body_delimiters => '---' do
660 676 issue = submit_email('ticket_on_given_project.eml')
661 677 assert_issue_created(issue)
662 678 assert issue.description.include?('This paragraph is before delimiters')
663 679 assert issue.description.include?('--- This line starts with a delimiter')
664 680 assert !issue.description.match(/^---$/)
665 681 assert !issue.description.include?('This paragraph is after the delimiter')
666 682 end
667 683 end
668 684
669 685 test "truncate emails with a single quoted reply should truncate the email at the delimiter with the quoted reply symbols (>)" do
670 686 with_settings :mail_handler_body_delimiters => '--- Reply above. Do not remove this line. ---' do
671 687 journal = submit_email('issue_update_with_quoted_reply_above.eml')
672 688 assert journal.is_a?(Journal)
673 689 assert journal.notes.include?('An update to the issue by the sender.')
674 690 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
675 691 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
676 692 end
677 693 end
678 694
679 695 test "truncate emails with multiple quoted replies should truncate the email at the delimiter with the quoted reply symbols (>)" do
680 696 with_settings :mail_handler_body_delimiters => '--- Reply above. Do not remove this line. ---' do
681 697 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
682 698 assert journal.is_a?(Journal)
683 699 assert journal.notes.include?('An update to the issue by the sender.')
684 700 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
685 701 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
686 702 end
687 703 end
688 704
689 705 test "truncate emails with multiple strings should truncate the email at the first delimiter found (BREAK)" do
690 706 with_settings :mail_handler_body_delimiters => "---\nBREAK" do
691 707 issue = submit_email('ticket_on_given_project.eml')
692 708 assert_issue_created(issue)
693 709 assert issue.description.include?('This paragraph is before delimiters')
694 710 assert !issue.description.include?('BREAK')
695 711 assert !issue.description.include?('This paragraph is between delimiters')
696 712 assert !issue.description.match(/^---$/)
697 713 assert !issue.description.include?('This paragraph is after the delimiter')
698 714 end
699 715 end
700 716
701 717 def test_email_with_long_subject_line
702 718 issue = submit_email('ticket_with_long_subject.eml')
703 719 assert issue.is_a?(Issue)
704 720 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]
705 721 end
706 722
707 723 def test_new_user_from_attributes_should_return_valid_user
708 724 to_test = {
709 725 # [address, name] => [login, firstname, lastname]
710 726 ['jsmith@example.net', nil] => ['jsmith@example.net', 'jsmith', '-'],
711 727 ['jsmith@example.net', 'John'] => ['jsmith@example.net', 'John', '-'],
712 728 ['jsmith@example.net', 'John Smith'] => ['jsmith@example.net', 'John', 'Smith'],
713 729 ['jsmith@example.net', 'John Paul Smith'] => ['jsmith@example.net', 'John', 'Paul Smith'],
714 730 ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsTheMaximumLength Smith'] => ['jsmith@example.net', 'AVeryLongFirstnameThatExceedsT', 'Smith'],
715 731 ['jsmith@example.net', 'John AVeryLongLastnameThatExceedsTheMaximumLength'] => ['jsmith@example.net', 'John', 'AVeryLongLastnameThatExceedsTh']
716 732 }
717 733
718 734 to_test.each do |attrs, expected|
719 735 user = MailHandler.new_user_from_attributes(attrs.first, attrs.last)
720 736
721 737 assert user.valid?, user.errors.full_messages.to_s
722 738 assert_equal attrs.first, user.mail
723 739 assert_equal expected[0], user.login
724 740 assert_equal expected[1], user.firstname
725 741 assert_equal expected[2], user.lastname
726 742 end
727 743 end
728 744
729 745 def test_new_user_from_attributes_should_use_default_login_if_invalid
730 746 user = MailHandler.new_user_from_attributes('foo+bar@example.net')
731 747 assert user.valid?
732 748 assert user.login =~ /^user[a-f0-9]+$/
733 749 assert_equal 'foo+bar@example.net', user.mail
734 750 end
735 751
736 752 def test_new_user_with_utf8_encoded_fullname_should_be_decoded
737 753 assert_difference 'User.count' do
738 754 issue = submit_email(
739 755 'fullname_of_sender_as_utf8_encoded.eml',
740 756 :issue => {:project => 'ecookbook'},
741 757 :unknown_user => 'create'
742 758 )
743 759 end
744 760
745 761 user = User.first(:order => 'id DESC')
746 762 assert_equal "foo@example.org", user.mail
747 763 str1 = "\xc3\x84\xc3\xa4"
748 764 str2 = "\xc3\x96\xc3\xb6"
749 765 str1.force_encoding('UTF-8') if str1.respond_to?(:force_encoding)
750 766 str2.force_encoding('UTF-8') if str2.respond_to?(:force_encoding)
751 767 assert_equal str1, user.firstname
752 768 assert_equal str2, user.lastname
753 769 end
754 770
755 771 private
756 772
757 773 def submit_email(filename, options={})
758 774 raw = IO.read(File.join(FIXTURES_PATH, filename))
759 775 yield raw if block_given?
760 776 MailHandler.receive(raw, options)
761 777 end
762 778
763 779 def assert_issue_created(issue)
764 780 assert issue.is_a?(Issue)
765 781 assert !issue.new_record?
766 782 issue.reload
767 783 end
768 784 end
General Comments 0
You need to be logged in to leave comments. Login now