Chris@0
|
1 # redMine - project management software
|
Chris@0
|
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
|
Chris@0
|
3 #
|
Chris@0
|
4 # This program is free software; you can redistribute it and/or
|
Chris@0
|
5 # modify it under the terms of the GNU General Public License
|
Chris@0
|
6 # as published by the Free Software Foundation; either version 2
|
Chris@0
|
7 # of the License, or (at your option) any later version.
|
Chris@0
|
8 #
|
Chris@0
|
9 # This program is distributed in the hope that it will be useful,
|
Chris@0
|
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
|
Chris@0
|
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
Chris@0
|
12 # GNU General Public License for more details.
|
Chris@0
|
13 #
|
Chris@0
|
14 # You should have received a copy of the GNU General Public License
|
Chris@0
|
15 # along with this program; if not, write to the Free Software
|
Chris@0
|
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
Chris@0
|
17
|
Chris@0
|
18 class MailHandler < ActionMailer::Base
|
Chris@0
|
19 include ActionView::Helpers::SanitizeHelper
|
chris@37
|
20 include Redmine::I18n
|
Chris@0
|
21
|
Chris@0
|
22 class UnauthorizedAction < StandardError; end
|
Chris@0
|
23 class MissingInformation < StandardError; end
|
Chris@0
|
24
|
Chris@0
|
25 attr_reader :email, :user
|
Chris@0
|
26
|
Chris@0
|
27 def self.receive(email, options={})
|
Chris@0
|
28 @@handler_options = options.dup
|
Chris@0
|
29
|
Chris@0
|
30 @@handler_options[:issue] ||= {}
|
Chris@0
|
31
|
Chris@0
|
32 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
|
Chris@0
|
33 @@handler_options[:allow_override] ||= []
|
Chris@0
|
34 # Project needs to be overridable if not specified
|
Chris@0
|
35 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
|
Chris@0
|
36 # Status overridable by default
|
Chris@0
|
37 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
|
Chris@0
|
38
|
Chris@0
|
39 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
|
Chris@0
|
40 super email
|
Chris@0
|
41 end
|
Chris@0
|
42
|
Chris@0
|
43 # Processes incoming emails
|
Chris@0
|
44 # Returns the created object (eg. an issue, a message) or false
|
Chris@0
|
45 def receive(email)
|
Chris@0
|
46 @email = email
|
Chris@0
|
47 sender_email = email.from.to_a.first.to_s.strip
|
Chris@0
|
48 # Ignore emails received from the application emission address to avoid hell cycles
|
Chris@0
|
49 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
|
Chris@0
|
50 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
|
Chris@0
|
51 return false
|
Chris@0
|
52 end
|
Chris@0
|
53 @user = User.find_by_mail(sender_email) if sender_email.present?
|
Chris@0
|
54 if @user && !@user.active?
|
Chris@0
|
55 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
|
Chris@0
|
56 return false
|
Chris@0
|
57 end
|
Chris@0
|
58 if @user.nil?
|
Chris@0
|
59 # Email was submitted by an unknown user
|
Chris@0
|
60 case @@handler_options[:unknown_user]
|
Chris@0
|
61 when 'accept'
|
Chris@0
|
62 @user = User.anonymous
|
Chris@0
|
63 when 'create'
|
Chris@0
|
64 @user = MailHandler.create_user_from_email(email)
|
Chris@0
|
65 if @user
|
Chris@0
|
66 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
|
Chris@0
|
67 Mailer.deliver_account_information(@user, @user.password)
|
Chris@0
|
68 else
|
Chris@0
|
69 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
|
Chris@0
|
70 return false
|
Chris@0
|
71 end
|
Chris@0
|
72 else
|
Chris@0
|
73 # Default behaviour, emails from unknown users are ignored
|
Chris@0
|
74 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
|
Chris@0
|
75 return false
|
Chris@0
|
76 end
|
Chris@0
|
77 end
|
Chris@0
|
78 User.current = @user
|
Chris@0
|
79 dispatch
|
Chris@0
|
80 end
|
Chris@0
|
81
|
Chris@0
|
82 private
|
Chris@0
|
83
|
Chris@0
|
84 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
|
Chris@0
|
85 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
|
Chris@0
|
86 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
|
Chris@0
|
87
|
Chris@0
|
88 def dispatch
|
Chris@0
|
89 headers = [email.in_reply_to, email.references].flatten.compact
|
Chris@0
|
90 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
|
Chris@0
|
91 klass, object_id = $1, $2.to_i
|
Chris@0
|
92 method_name = "receive_#{klass}_reply"
|
Chris@0
|
93 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
|
Chris@0
|
94 send method_name, object_id
|
Chris@0
|
95 else
|
Chris@0
|
96 # ignoring it
|
Chris@0
|
97 end
|
Chris@0
|
98 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
|
Chris@0
|
99 receive_issue_reply(m[1].to_i)
|
Chris@0
|
100 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
|
Chris@0
|
101 receive_message_reply(m[1].to_i)
|
Chris@0
|
102 else
|
Chris@0
|
103 receive_issue
|
Chris@0
|
104 end
|
Chris@0
|
105 rescue ActiveRecord::RecordInvalid => e
|
Chris@0
|
106 # TODO: send a email to the user
|
Chris@0
|
107 logger.error e.message if logger
|
Chris@0
|
108 false
|
Chris@0
|
109 rescue MissingInformation => e
|
Chris@0
|
110 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
|
Chris@0
|
111 false
|
Chris@0
|
112 rescue UnauthorizedAction => e
|
Chris@0
|
113 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
|
Chris@0
|
114 false
|
Chris@0
|
115 end
|
Chris@0
|
116
|
Chris@0
|
117 # Creates a new issue
|
Chris@0
|
118 def receive_issue
|
Chris@0
|
119 project = target_project
|
Chris@0
|
120 # check permission
|
Chris@0
|
121 unless @@handler_options[:no_permission_check]
|
Chris@0
|
122 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
|
Chris@0
|
123 end
|
Chris@0
|
124
|
chris@37
|
125 issue = Issue.new(:author => user, :project => project)
|
chris@37
|
126 issue.safe_attributes = issue_attributes_from_keywords(issue)
|
chris@37
|
127 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
|
chris@37
|
128 issue.subject = email.subject.to_s.chomp[0,255]
|
Chris@0
|
129 if issue.subject.blank?
|
Chris@0
|
130 issue.subject = '(no subject)'
|
Chris@0
|
131 end
|
Chris@0
|
132 issue.description = cleaned_up_text_body
|
chris@37
|
133
|
Chris@0
|
134 # add To and Cc as watchers before saving so the watchers can reply to Redmine
|
Chris@0
|
135 add_watchers(issue)
|
Chris@0
|
136 issue.save!
|
Chris@0
|
137 add_attachments(issue)
|
Chris@0
|
138 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
|
Chris@0
|
139 issue
|
Chris@0
|
140 end
|
Chris@0
|
141
|
Chris@0
|
142 # Adds a note to an existing issue
|
Chris@0
|
143 def receive_issue_reply(issue_id)
|
Chris@0
|
144 issue = Issue.find_by_id(issue_id)
|
Chris@0
|
145 return unless issue
|
Chris@0
|
146 # check permission
|
Chris@0
|
147 unless @@handler_options[:no_permission_check]
|
Chris@0
|
148 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
|
Chris@0
|
149 end
|
chris@37
|
150
|
Chris@117
|
151 # ignore CLI-supplied defaults for new issues
|
Chris@117
|
152 @@handler_options[:issue].clear
|
Chris@117
|
153
|
Chris@0
|
154 journal = issue.init_journal(user, cleaned_up_text_body)
|
chris@37
|
155 issue.safe_attributes = issue_attributes_from_keywords(issue)
|
chris@37
|
156 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
|
Chris@0
|
157 add_attachments(issue)
|
Chris@0
|
158 issue.save!
|
Chris@0
|
159 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
|
Chris@0
|
160 journal
|
Chris@0
|
161 end
|
Chris@0
|
162
|
Chris@0
|
163 # Reply will be added to the issue
|
Chris@0
|
164 def receive_journal_reply(journal_id)
|
Chris@0
|
165 journal = Journal.find_by_id(journal_id)
|
Chris@0
|
166 if journal && journal.journalized_type == 'Issue'
|
Chris@0
|
167 receive_issue_reply(journal.journalized_id)
|
Chris@0
|
168 end
|
Chris@0
|
169 end
|
Chris@0
|
170
|
Chris@0
|
171 # Receives a reply to a forum message
|
Chris@0
|
172 def receive_message_reply(message_id)
|
Chris@0
|
173 message = Message.find_by_id(message_id)
|
Chris@0
|
174 if message
|
Chris@0
|
175 message = message.root
|
Chris@0
|
176
|
Chris@0
|
177 unless @@handler_options[:no_permission_check]
|
Chris@0
|
178 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
|
Chris@0
|
179 end
|
Chris@0
|
180
|
Chris@0
|
181 if !message.locked?
|
Chris@0
|
182 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
|
Chris@0
|
183 :content => cleaned_up_text_body)
|
Chris@0
|
184 reply.author = user
|
Chris@0
|
185 reply.board = message.board
|
Chris@0
|
186 message.children << reply
|
Chris@0
|
187 add_attachments(reply)
|
Chris@0
|
188 reply
|
Chris@0
|
189 else
|
Chris@0
|
190 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
|
Chris@0
|
191 end
|
Chris@0
|
192 end
|
Chris@0
|
193 end
|
Chris@0
|
194
|
Chris@0
|
195 def add_attachments(obj)
|
Chris@0
|
196 if email.has_attachments?
|
Chris@0
|
197 email.attachments.each do |attachment|
|
Chris@0
|
198 Attachment.create(:container => obj,
|
Chris@0
|
199 :file => attachment,
|
Chris@0
|
200 :author => user,
|
Chris@0
|
201 :content_type => attachment.content_type)
|
Chris@0
|
202 end
|
Chris@0
|
203 end
|
Chris@0
|
204 end
|
Chris@0
|
205
|
Chris@0
|
206 # Adds To and Cc as watchers of the given object if the sender has the
|
Chris@0
|
207 # appropriate permission
|
Chris@0
|
208 def add_watchers(obj)
|
Chris@0
|
209 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
|
Chris@0
|
210 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
|
Chris@0
|
211 unless addresses.empty?
|
Chris@0
|
212 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
|
Chris@0
|
213 watchers.each {|w| obj.add_watcher(w)}
|
Chris@0
|
214 end
|
Chris@0
|
215 end
|
Chris@0
|
216 end
|
Chris@0
|
217
|
Chris@0
|
218 def get_keyword(attr, options={})
|
Chris@0
|
219 @keywords ||= {}
|
Chris@0
|
220 if @keywords.has_key?(attr)
|
Chris@0
|
221 @keywords[attr]
|
Chris@0
|
222 else
|
Chris@0
|
223 @keywords[attr] = begin
|
chris@37
|
224 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && (v = extract_keyword!(plain_text_body, attr, options[:format]))
|
chris@37
|
225 v
|
Chris@0
|
226 elsif !@@handler_options[:issue][attr].blank?
|
Chris@0
|
227 @@handler_options[:issue][attr]
|
Chris@0
|
228 end
|
Chris@0
|
229 end
|
Chris@0
|
230 end
|
Chris@0
|
231 end
|
Chris@0
|
232
|
chris@37
|
233 # Destructively extracts the value for +attr+ in +text+
|
chris@37
|
234 # Returns nil if no matching keyword found
|
chris@37
|
235 def extract_keyword!(text, attr, format=nil)
|
chris@37
|
236 keys = [attr.to_s.humanize]
|
chris@37
|
237 if attr.is_a?(Symbol)
|
chris@37
|
238 keys << l("field_#{attr}", :default => '', :locale => user.language) if user
|
chris@37
|
239 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
|
chris@37
|
240 end
|
chris@37
|
241 keys.reject! {|k| k.blank?}
|
chris@37
|
242 keys.collect! {|k| Regexp.escape(k)}
|
chris@37
|
243 format ||= '.+'
|
chris@37
|
244 text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '')
|
chris@37
|
245 $2 && $2.strip
|
chris@37
|
246 end
|
chris@37
|
247
|
chris@37
|
248 def target_project
|
chris@37
|
249 # TODO: other ways to specify project:
|
chris@37
|
250 # * parse the email To field
|
chris@37
|
251 # * specific project (eg. Setting.mail_handler_target_project)
|
chris@37
|
252 target = Project.find_by_identifier(get_keyword(:project))
|
chris@37
|
253 raise MissingInformation.new('Unable to determine target project') if target.nil?
|
chris@37
|
254 target
|
chris@37
|
255 end
|
chris@37
|
256
|
chris@37
|
257 # Returns a Hash of issue attributes extracted from keywords in the email body
|
chris@37
|
258 def issue_attributes_from_keywords(issue)
|
chris@37
|
259 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k)
|
chris@37
|
260 assigned_to = nil if assigned_to && !issue.assignable_users.include?(assigned_to)
|
chris@37
|
261
|
Chris@117
|
262 attrs = {
|
Chris@117
|
263 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.find_by_name(k).try(:id),
|
chris@37
|
264 'status_id' => (k = get_keyword(:status)) && IssueStatus.find_by_name(k).try(:id),
|
chris@37
|
265 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.find_by_name(k).try(:id),
|
chris@37
|
266 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.find_by_name(k).try(:id),
|
chris@37
|
267 'assigned_to_id' => assigned_to.try(:id),
|
chris@37
|
268 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.find_by_name(k).try(:id),
|
chris@37
|
269 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
|
chris@37
|
270 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
|
chris@37
|
271 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
|
chris@37
|
272 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
|
chris@37
|
273 }.delete_if {|k, v| v.blank? }
|
Chris@117
|
274
|
Chris@117
|
275 if issue.new_record? && attrs['tracker_id'].nil?
|
Chris@117
|
276 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
|
Chris@117
|
277 end
|
Chris@117
|
278
|
Chris@117
|
279 attrs
|
chris@37
|
280 end
|
chris@37
|
281
|
chris@37
|
282 # Returns a Hash of issue custom field values extracted from keywords in the email body
|
chris@37
|
283 def custom_field_values_from_keywords(customized)
|
chris@37
|
284 customized.custom_field_values.inject({}) do |h, v|
|
chris@37
|
285 if value = get_keyword(v.custom_field.name, :override => true)
|
chris@37
|
286 h[v.custom_field.id.to_s] = value
|
chris@37
|
287 end
|
chris@37
|
288 h
|
chris@37
|
289 end
|
chris@37
|
290 end
|
chris@37
|
291
|
Chris@0
|
292 # Returns the text/plain part of the email
|
Chris@0
|
293 # If not found (eg. HTML-only email), returns the body with tags removed
|
Chris@0
|
294 def plain_text_body
|
Chris@0
|
295 return @plain_text_body unless @plain_text_body.nil?
|
Chris@0
|
296 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
|
Chris@0
|
297 if parts.empty?
|
Chris@0
|
298 parts << @email
|
Chris@0
|
299 end
|
Chris@0
|
300 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
|
Chris@0
|
301 if plain_text_part.nil?
|
Chris@0
|
302 # no text/plain part found, assuming html-only email
|
Chris@0
|
303 # strip html tags and remove doctype directive
|
Chris@0
|
304 @plain_text_body = strip_tags(@email.body.to_s)
|
Chris@0
|
305 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
|
Chris@0
|
306 else
|
Chris@0
|
307 @plain_text_body = plain_text_part.body.to_s
|
Chris@0
|
308 end
|
Chris@0
|
309 @plain_text_body.strip!
|
Chris@0
|
310 @plain_text_body
|
Chris@0
|
311 end
|
Chris@0
|
312
|
Chris@0
|
313 def cleaned_up_text_body
|
Chris@0
|
314 cleanup_body(plain_text_body)
|
Chris@0
|
315 end
|
Chris@0
|
316
|
Chris@0
|
317 def self.full_sanitizer
|
Chris@0
|
318 @full_sanitizer ||= HTML::FullSanitizer.new
|
Chris@0
|
319 end
|
Chris@0
|
320
|
Chris@0
|
321 # Creates a user account for the +email+ sender
|
Chris@0
|
322 def self.create_user_from_email(email)
|
Chris@0
|
323 addr = email.from_addrs.to_a.first
|
Chris@0
|
324 if addr && !addr.spec.blank?
|
Chris@0
|
325 user = User.new
|
Chris@0
|
326 user.mail = addr.spec
|
Chris@0
|
327
|
Chris@0
|
328 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
|
Chris@0
|
329 user.firstname = names.shift
|
Chris@0
|
330 user.lastname = names.join(' ')
|
Chris@0
|
331 user.lastname = '-' if user.lastname.blank?
|
Chris@0
|
332
|
Chris@0
|
333 user.login = user.mail
|
Chris@0
|
334 user.password = ActiveSupport::SecureRandom.hex(5)
|
Chris@0
|
335 user.language = Setting.default_language
|
Chris@0
|
336 user.save ? user : nil
|
Chris@0
|
337 end
|
Chris@0
|
338 end
|
Chris@0
|
339
|
Chris@0
|
340 private
|
Chris@0
|
341
|
Chris@0
|
342 # Removes the email body of text after the truncation configurations.
|
Chris@0
|
343 def cleanup_body(body)
|
Chris@0
|
344 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
|
Chris@0
|
345 unless delimiters.empty?
|
chris@37
|
346 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
|
Chris@0
|
347 body = body.gsub(regex, '')
|
Chris@0
|
348 end
|
Chris@0
|
349 body.strip
|
Chris@0
|
350 end
|
Chris@0
|
351
|
Chris@0
|
352 def find_user_from_keyword(keyword)
|
Chris@0
|
353 user ||= User.find_by_mail(keyword)
|
Chris@0
|
354 user ||= User.find_by_login(keyword)
|
Chris@0
|
355 if user.nil? && keyword.match(/ /)
|
Chris@0
|
356 firstname, lastname = *(keyword.split) # "First Last Throwaway"
|
Chris@0
|
357 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
|
Chris@0
|
358 end
|
Chris@0
|
359 user
|
Chris@0
|
360 end
|
Chris@0
|
361 end
|