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