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