##// END OF EJS Templates
Fixed: MailHandler raises an error when processing an email without From header (#2916)....
Jean-Philippe Lang -
r2485:009b685b1d64
parent child
Show More
@@ -0,0 +1,40
1 Received: from osiris ([127.0.0.1])
2 by OSIRIS
3 with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200
4 Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris>
5 To: <redmine@somenet.foo>
6 Subject: New ticket on a given project
7 Date: Sun, 22 Jun 2008 12:28:07 +0200
8 MIME-Version: 1.0
9 Content-Type: text/plain;
10 format=flowed;
11 charset="iso-8859-1";
12 reply-type=original
13 Content-Transfer-Encoding: 7bit
14 X-Priority: 3
15 X-MSMail-Priority: Normal
16 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
17 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
18
19 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas imperdiet
20 turpis et odio. Integer eget pede vel dolor euismod varius. Phasellus
21 blandit eleifend augue. Nulla facilisi. Duis id diam. Class aptent taciti
22 sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In
23 in urna sed tellus aliquet lobortis. Morbi scelerisque tortor in dolor. Cras
24 sagittis odio eu lacus. Aliquam sem tortor, consequat sit amet, vestibulum
25 id, iaculis at, lectus. Fusce tortor libero, congue ut, euismod nec, luctus
26 eget, eros. Pellentesque tortor enim, feugiat in, dignissim eget, tristique
27 sed, mauris. Pellentesque habitant morbi tristique senectus et netus et
28 malesuada fames ac turpis egestas. Quisque sit amet libero. In hac habitasse
29 platea dictumst.
30
31 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
32 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
33 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
34 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
35 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
36 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
37
38 Project: onlinestore
39 Status: Resolved
40
@@ -1,245 +1,245
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class MailHandler < ActionMailer::Base
19 19 include ActionView::Helpers::SanitizeHelper
20 20
21 21 class UnauthorizedAction < StandardError; end
22 22 class MissingInformation < StandardError; end
23 23
24 24 attr_reader :email, :user
25 25
26 26 def self.receive(email, options={})
27 27 @@handler_options = options.dup
28 28
29 29 @@handler_options[:issue] ||= {}
30 30
31 31 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
32 32 @@handler_options[:allow_override] ||= []
33 33 # Project needs to be overridable if not specified
34 34 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
35 35 # Status overridable by default
36 36 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
37 37 super email
38 38 end
39 39
40 40 # Processes incoming emails
41 41 def receive(email)
42 42 @email = email
43 @user = User.active.find_by_mail(email.from.first.to_s.strip)
43 @user = User.active.find_by_mail(email.from.to_a.first.to_s.strip)
44 44 unless @user
45 45 # Unknown user => the email is ignored
46 46 # TODO: ability to create the user's account
47 47 logger.info "MailHandler: email submitted by unknown user [#{email.from.first}]" if logger && logger.info
48 48 return false
49 49 end
50 50 User.current = @user
51 51 dispatch
52 52 end
53 53
54 54 private
55 55
56 56 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
57 57 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]+#(\d+)\]}
58 58 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]+msg(\d+)\]}
59 59
60 60 def dispatch
61 61 headers = [email.in_reply_to, email.references].flatten.compact
62 62 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
63 63 klass, object_id = $1, $2.to_i
64 64 method_name = "receive_#{klass}_reply"
65 65 if self.class.private_instance_methods.include?(method_name)
66 66 send method_name, object_id
67 67 else
68 68 # ignoring it
69 69 end
70 70 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
71 71 receive_issue_reply(m[1].to_i)
72 72 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
73 73 receive_message_reply(m[1].to_i)
74 74 else
75 75 receive_issue
76 76 end
77 77 rescue ActiveRecord::RecordInvalid => e
78 78 # TODO: send a email to the user
79 79 logger.error e.message if logger
80 80 false
81 81 rescue MissingInformation => e
82 82 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
83 83 false
84 84 rescue UnauthorizedAction => e
85 85 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
86 86 false
87 87 end
88 88
89 89 # Creates a new issue
90 90 def receive_issue
91 91 project = target_project
92 92 tracker = (get_keyword(:tracker) && project.trackers.find_by_name(get_keyword(:tracker))) || project.trackers.find(:first)
93 93 category = (get_keyword(:category) && project.issue_categories.find_by_name(get_keyword(:category)))
94 94 priority = (get_keyword(:priority) && Enumeration.find_by_opt_and_name('IPRI', get_keyword(:priority)))
95 95 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
96 96
97 97 # check permission
98 98 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
99 99 issue = Issue.new(:author => user, :project => project, :tracker => tracker, :category => category, :priority => priority)
100 100 # check workflow
101 101 if status && issue.new_statuses_allowed_to(user).include?(status)
102 102 issue.status = status
103 103 end
104 104 issue.subject = email.subject.chomp.toutf8
105 105 issue.description = plain_text_body
106 106 # custom fields
107 107 issue.custom_field_values = issue.available_custom_fields.inject({}) do |h, c|
108 108 if value = get_keyword(c.name, :override => true)
109 109 h[c.id] = value
110 110 end
111 111 h
112 112 end
113 113 issue.save!
114 114 add_attachments(issue)
115 115 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
116 116 # add To and Cc as watchers
117 117 add_watchers(issue)
118 118 # send notification after adding watchers so that they can reply to Redmine
119 119 Mailer.deliver_issue_add(issue) if Setting.notified_events.include?('issue_added')
120 120 issue
121 121 end
122 122
123 123 def target_project
124 124 # TODO: other ways to specify project:
125 125 # * parse the email To field
126 126 # * specific project (eg. Setting.mail_handler_target_project)
127 127 target = Project.find_by_identifier(get_keyword(:project))
128 128 raise MissingInformation.new('Unable to determine target project') if target.nil?
129 129 target
130 130 end
131 131
132 132 # Adds a note to an existing issue
133 133 def receive_issue_reply(issue_id)
134 134 status = (get_keyword(:status) && IssueStatus.find_by_name(get_keyword(:status)))
135 135
136 136 issue = Issue.find_by_id(issue_id)
137 137 return unless issue
138 138 # check permission
139 139 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
140 140 raise UnauthorizedAction unless status.nil? || user.allowed_to?(:edit_issues, issue.project)
141 141
142 142 # add the note
143 143 journal = issue.init_journal(user, plain_text_body)
144 144 add_attachments(issue)
145 145 # check workflow
146 146 if status && issue.new_statuses_allowed_to(user).include?(status)
147 147 issue.status = status
148 148 end
149 149 issue.save!
150 150 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
151 151 Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
152 152 journal
153 153 end
154 154
155 155 # Reply will be added to the issue
156 156 def receive_journal_reply(journal_id)
157 157 journal = Journal.find_by_id(journal_id)
158 158 if journal && journal.journalized_type == 'Issue'
159 159 receive_issue_reply(journal.journalized_id)
160 160 end
161 161 end
162 162
163 163 # Receives a reply to a forum message
164 164 def receive_message_reply(message_id)
165 165 message = Message.find_by_id(message_id)
166 166 if message
167 167 message = message.root
168 168 if user.allowed_to?(:add_messages, message.project) && !message.locked?
169 169 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
170 170 :content => plain_text_body)
171 171 reply.author = user
172 172 reply.board = message.board
173 173 message.children << reply
174 174 add_attachments(reply)
175 175 reply
176 176 else
177 177 raise UnauthorizedAction
178 178 end
179 179 end
180 180 end
181 181
182 182 def add_attachments(obj)
183 183 if email.has_attachments?
184 184 email.attachments.each do |attachment|
185 185 Attachment.create(:container => obj,
186 186 :file => attachment,
187 187 :author => user,
188 188 :content_type => attachment.content_type)
189 189 end
190 190 end
191 191 end
192 192
193 193 # Adds To and Cc as watchers of the given object if the sender has the
194 194 # appropriate permission
195 195 def add_watchers(obj)
196 196 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
197 197 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
198 198 unless addresses.empty?
199 199 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
200 200 watchers.each {|w| obj.add_watcher(w)}
201 201 end
202 202 end
203 203 end
204 204
205 205 def get_keyword(attr, options={})
206 206 @keywords ||= {}
207 207 if @keywords.has_key?(attr)
208 208 @keywords[attr]
209 209 else
210 210 @keywords[attr] = begin
211 211 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && plain_text_body.gsub!(/^#{attr}:[ \t]*(.+)\s*$/i, '')
212 212 $1.strip
213 213 elsif !@@handler_options[:issue][attr].blank?
214 214 @@handler_options[:issue][attr]
215 215 end
216 216 end
217 217 end
218 218 end
219 219
220 220 # Returns the text/plain part of the email
221 221 # If not found (eg. HTML-only email), returns the body with tags removed
222 222 def plain_text_body
223 223 return @plain_text_body unless @plain_text_body.nil?
224 224 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
225 225 if parts.empty?
226 226 parts << @email
227 227 end
228 228 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
229 229 if plain_text_part.nil?
230 230 # no text/plain part found, assuming html-only email
231 231 # strip html tags and remove doctype directive
232 232 @plain_text_body = strip_tags(@email.body.to_s)
233 233 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
234 234 else
235 235 @plain_text_body = plain_text_part.body.to_s
236 236 end
237 237 @plain_text_body.strip!
238 238 @plain_text_body
239 239 end
240 240
241 241
242 242 def self.full_sanitizer
243 243 @full_sanitizer ||= HTML::FullSanitizer.new
244 244 end
245 245 end
@@ -1,185 +1,190
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class MailHandlerTest < Test::Unit::TestCase
21 21 fixtures :users, :projects,
22 22 :enabled_modules,
23 23 :roles,
24 24 :members,
25 25 :issues,
26 26 :issue_statuses,
27 27 :workflows,
28 28 :trackers,
29 29 :projects_trackers,
30 30 :enumerations,
31 31 :issue_categories,
32 32 :custom_fields,
33 33 :custom_fields_trackers,
34 34 :boards,
35 35 :messages
36 36
37 37 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
38 38
39 39 def setup
40 40 ActionMailer::Base.deliveries.clear
41 41 end
42 42
43 43 def test_add_issue
44 44 # This email contains: 'Project: onlinestore'
45 45 issue = submit_email('ticket_on_given_project.eml')
46 46 assert issue.is_a?(Issue)
47 47 assert !issue.new_record?
48 48 issue.reload
49 49 assert_equal 'New ticket on a given project', issue.subject
50 50 assert_equal User.find_by_login('jsmith'), issue.author
51 51 assert_equal Project.find(2), issue.project
52 52 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
53 53 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
54 54 # keywords should be removed from the email body
55 55 assert !issue.description.match(/^Project:/i)
56 56 assert !issue.description.match(/^Status:/i)
57 57 end
58 58
59 59 def test_add_issue_with_status
60 60 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
61 61 issue = submit_email('ticket_on_given_project.eml')
62 62 assert issue.is_a?(Issue)
63 63 assert !issue.new_record?
64 64 issue.reload
65 65 assert_equal Project.find(2), issue.project
66 66 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
67 67 end
68 68
69 69 def test_add_issue_with_attributes_override
70 70 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
71 71 assert issue.is_a?(Issue)
72 72 assert !issue.new_record?
73 73 issue.reload
74 74 assert_equal 'New ticket on a given project', issue.subject
75 75 assert_equal User.find_by_login('jsmith'), issue.author
76 76 assert_equal Project.find(2), issue.project
77 77 assert_equal 'Feature request', issue.tracker.to_s
78 78 assert_equal 'Stock management', issue.category.to_s
79 79 assert_equal 'Urgent', issue.priority.to_s
80 80 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
81 81 end
82 82
83 83 def test_add_issue_with_partial_attributes_override
84 84 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
85 85 assert issue.is_a?(Issue)
86 86 assert !issue.new_record?
87 87 issue.reload
88 88 assert_equal 'New ticket on a given project', issue.subject
89 89 assert_equal User.find_by_login('jsmith'), issue.author
90 90 assert_equal Project.find(2), issue.project
91 91 assert_equal 'Feature request', issue.tracker.to_s
92 92 assert_nil issue.category
93 93 assert_equal 'High', issue.priority.to_s
94 94 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
95 95 end
96 96
97 97 def test_add_issue_with_attachment_to_specific_project
98 98 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
99 99 assert issue.is_a?(Issue)
100 100 assert !issue.new_record?
101 101 issue.reload
102 102 assert_equal 'Ticket created by email with attachment', issue.subject
103 103 assert_equal User.find_by_login('jsmith'), issue.author
104 104 assert_equal Project.find(2), issue.project
105 105 assert_equal 'This is a new ticket with attachments', issue.description
106 106 # Attachment properties
107 107 assert_equal 1, issue.attachments.size
108 108 assert_equal 'Paella.jpg', issue.attachments.first.filename
109 109 assert_equal 'image/jpeg', issue.attachments.first.content_type
110 110 assert_equal 10790, issue.attachments.first.filesize
111 111 end
112 112
113 113 def test_add_issue_with_custom_fields
114 114 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
115 115 assert issue.is_a?(Issue)
116 116 assert !issue.new_record?
117 117 issue.reload
118 118 assert_equal 'New ticket with custom field values', issue.subject
119 119 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
120 120 assert !issue.description.match(/^searchable field:/i)
121 121 end
122 122
123 123 def test_add_issue_with_cc
124 124 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
125 125 assert issue.is_a?(Issue)
126 126 assert !issue.new_record?
127 127 issue.reload
128 128 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
129 129 assert_equal 1, issue.watchers.size
130 130 end
131 131
132 def test_add_issue_without_from_header
133 Role.anonymous.add_permission!(:add_issues)
134 assert_equal false, submit_email('ticket_without_from_header.eml')
135 end
136
132 137 def test_add_issue_note
133 138 journal = submit_email('ticket_reply.eml')
134 139 assert journal.is_a?(Journal)
135 140 assert_equal User.find_by_login('jsmith'), journal.user
136 141 assert_equal Issue.find(2), journal.journalized
137 142 assert_match /This is reply/, journal.notes
138 143 end
139 144
140 145 def test_add_issue_note_with_status_change
141 146 # This email contains: 'Status: Resolved'
142 147 journal = submit_email('ticket_reply_with_status.eml')
143 148 assert journal.is_a?(Journal)
144 149 issue = Issue.find(journal.issue.id)
145 150 assert_equal User.find_by_login('jsmith'), journal.user
146 151 assert_equal Issue.find(2), journal.journalized
147 152 assert_match /This is reply/, journal.notes
148 153 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
149 154 end
150 155
151 156 def test_reply_to_a_message
152 157 m = submit_email('message_reply.eml')
153 158 assert m.is_a?(Message)
154 159 assert !m.new_record?
155 160 m.reload
156 161 assert_equal 'Reply via email', m.subject
157 162 # The email replies to message #2 which is part of the thread of message #1
158 163 assert_equal Message.find(1), m.parent
159 164 end
160 165
161 166 def test_reply_to_a_message_by_subject
162 167 m = submit_email('message_reply_by_subject.eml')
163 168 assert m.is_a?(Message)
164 169 assert !m.new_record?
165 170 m.reload
166 171 assert_equal 'Reply to the first post', m.subject
167 172 assert_equal Message.find(1), m.parent
168 173 end
169 174
170 175 def test_should_strip_tags_of_html_only_emails
171 176 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
172 177 assert issue.is_a?(Issue)
173 178 assert !issue.new_record?
174 179 issue.reload
175 180 assert_equal 'HTML email', issue.subject
176 181 assert_equal 'This is a html-only email.', issue.description
177 182 end
178 183
179 184 private
180 185
181 186 def submit_email(filename, options={})
182 187 raw = IO.read(File.join(FIXTURES_PATH, filename))
183 188 MailHandler.receive(raw, options)
184 189 end
185 190 end
General Comments 0
You need to be logged in to leave comments. Login now