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