allura
Revision | 848e2f4f15eafd9a2826cbac86fe9bfd539db557 (tree) |
---|---|
Zeit | 2012-05-04 02:22:14 |
Autor | Dave Brondsema <dbrondsema@geek...> |
Commiter | Yaroslav Luzin |
[#4103] fix several encoding issues with mail sending
* Deeper mail tests
* Don't encode headers in parts - don't even encode it at all. Just
ensure it is unicode, and let email.header.Header do the right thing
* email body attempted as ascii instead of latin1, before going to utf-8
@@ -23,16 +23,39 @@ config = ConfigProxy( | ||
23 | 23 | return_path='forgemail.return_path') |
24 | 24 | EMAIL_VALIDATOR=fev.Email(not_empty=True) |
25 | 25 | |
26 | -def Header(text, charset): | |
27 | - '''Helper to make sure we don't over-encode headers | |
28 | - | |
29 | - (gmail barfs with encoded email addresses.)''' | |
26 | +def Header(text, *more_text): | |
27 | + '''Helper to make sure we encode headers properly''' | |
30 | 28 | if isinstance(text, header.Header): |
31 | 29 | return text |
32 | - hdr = header.Header('', charset) | |
33 | - for word in text.split(' '): | |
34 | - hdr.append(word) | |
35 | - return hdr | |
30 | + # email.header.Header handles str vs unicode differently | |
31 | + # see http://docs.python.org/library/email.header.html#email.header.Header.append | |
32 | + if type(text) != unicode: | |
33 | + raise TypeError('This must be unicode: %r' % text) | |
34 | + head = header.Header(text) | |
35 | + for m in more_text: | |
36 | + if type(m) != unicode: | |
37 | + raise TypeError('This must be unicode: %r' % text) | |
38 | + head.append(m) | |
39 | + return head | |
40 | + | |
41 | +def AddrHeader(fromaddr): | |
42 | + '''Accepts any of: | |
43 | + Header() instance | |
44 | + foo@bar.com | |
45 | + "Foo Bar" <foo@bar.com> | |
46 | + ''' | |
47 | + if isinstance(fromaddr, basestring) and ' <' in fromaddr: | |
48 | + name, addr = fromaddr.rsplit(' <', 1) | |
49 | + addr = '<' + addr # restore the char we just split off | |
50 | + addrheader = Header(name, addr) | |
51 | + if str(addrheader).startswith('=?'): # encoding escape chars | |
52 | + # then quoting the name is no longer necessary | |
53 | + name = name.strip('"') | |
54 | + addrheader = Header(name, addr) | |
55 | + else: | |
56 | + addrheader = Header(fromaddr) | |
57 | + return addrheader | |
58 | + | |
36 | 59 | |
37 | 60 | def parse_address(addr): |
38 | 61 | userpart, domain = addr.split('@') |
@@ -103,7 +126,7 @@ def identify_sender(peer, email_address, headers, msg): | ||
103 | 126 | |
104 | 127 | def encode_email_part(content, content_type): |
105 | 128 | try: |
106 | - return MIMEText(content.encode('iso-8859-1'), content_type, 'iso-8859-1') | |
129 | + return MIMEText(content.encode('ascii'), content_type, 'ascii') | |
107 | 130 | except: |
108 | 131 | return MIMEText(content.encode('utf-8'), content_type, 'utf-8') |
109 | 132 |
@@ -142,21 +165,19 @@ class SMTPClient(object): | ||
142 | 165 | def __init__(self): |
143 | 166 | self._client = None |
144 | 167 | |
145 | - def sendmail(self, addrs, addrfrom, reply_to, subject, message_id, in_reply_to, message): | |
168 | + def sendmail(self, addrs, fromaddr, reply_to, subject, message_id, in_reply_to, message): | |
146 | 169 | if not addrs: return |
147 | - charset = message.get_charset() | |
148 | - if charset is None: | |
149 | - charset = 'iso-8859-1' | |
150 | - message['To'] = Header(reply_to, charset) | |
151 | - message['From'] = Header(addrfrom, charset) | |
152 | - message['Reply-To'] = Header(reply_to, charset) | |
153 | - message['Subject'] = Header(subject, charset) | |
154 | - message['Message-ID'] = Header('<' + message_id + '>', charset) | |
170 | + # We send one message with multiple envelope recipients, so use a generic To: addr | |
171 | + # It might be nice to refactor to send one message per recipient, and use the actual To: addr | |
172 | + message['To'] = Header(reply_to) | |
173 | + message['From'] = AddrHeader(fromaddr) | |
174 | + message['Reply-To'] = Header(reply_to) | |
175 | + message['Subject'] = Header(subject) | |
176 | + message['Message-ID'] = Header('<' + message_id + u'>') | |
155 | 177 | if in_reply_to: |
156 | - if isinstance(in_reply_to, basestring): | |
157 | - in_reply_to = [ in_reply_to ] | |
158 | - in_reply_to = ','.join(('<' + irt + '>') for irt in in_reply_to) | |
159 | - message['In-Reply-To'] = Header(in_reply_to, charset) | |
178 | + if not isinstance(in_reply_to, basestring): | |
179 | + raise TypeError('Only strings are supported now, not lists') | |
180 | + message['In-Reply-To'] = Header(u'<%s>' % in_reply_to) | |
160 | 181 | content = message.as_string() |
161 | 182 | smtp_addrs = map(_parse_smtp_addr, addrs) |
162 | 183 | smtp_addrs = [ a for a in smtp_addrs if isvalid(a) ] |
@@ -56,14 +56,8 @@ def route_email( | ||
56 | 56 | log.exception('Error routing mail to %s', addr) |
57 | 57 | |
58 | 58 | @task |
59 | -def sendmail( | |
60 | - fromaddr, | |
61 | - destinations, | |
62 | - text, | |
63 | - reply_to, | |
64 | - subject, | |
65 | - message_id, | |
66 | - in_reply_to=None): | |
59 | +def sendmail(fromaddr, destinations, text, reply_to, subject, | |
60 | + message_id, in_reply_to=None): | |
67 | 61 | from allura import model as M |
68 | 62 | addrs_plain = [] |
69 | 63 | addrs_html = [] |
@@ -16,7 +16,8 @@ class TestAuth(TestController): | ||
16 | 16 | def test_login(self): |
17 | 17 | result = self.app.get('/auth/') |
18 | 18 | r = self.app.post('/auth/send_verification_link', params=dict(a='test@example.com')) |
19 | - r = self.app.post('/auth/send_verification_link', params=dict(a='Beta@wiki.test.projects.sourceforge.net')) | |
19 | + email = M.User.query.get(username='test-admin').email_addresses[0] | |
20 | + r = self.app.post('/auth/send_verification_link', params=dict(a=email)) | |
20 | 21 | ThreadLocalORMSession.flush_all() |
21 | 22 | r = self.app.get('/auth/verify_addr', params=dict(a='foo')) |
22 | 23 | assert json.loads(self.webflash(r))['status'] == 'error', self.webflash(r) |
@@ -2,7 +2,7 @@ import unittest | ||
2 | 2 | from datetime import timedelta |
3 | 3 | |
4 | 4 | from pylons import g, c |
5 | - | |
5 | +from nose.tools import assert_equal | |
6 | 6 | from ming.orm import ThreadLocalORMSession |
7 | 7 | |
8 | 8 | from alluratest.controller import setup_basic_test, setup_global_objects, REGISTRY |
@@ -103,7 +103,7 @@ class TestPostNotifications(unittest.TestCase): | ||
103 | 103 | ThreadLocalORMSession.flush_all() |
104 | 104 | M.MonQTask.run_ready() |
105 | 105 | ThreadLocalORMSession.flush_all() |
106 | - assert M.Notification.query.get()['from_address'].startswith('"Test Admin" <Beta') | |
106 | + assert_equal(M.Notification.query.get()['from_address'], '"Test Admin" <test-admin@users.localhost>') | |
107 | 107 | assert M.Mailbox.query.find().count()==1 |
108 | 108 | mbox = M.Mailbox.query.get() |
109 | 109 | assert len(mbox.queue) == 1 |
@@ -232,4 +232,3 @@ def _clear_subscriptions(): | ||
232 | 232 | |
233 | 233 | def _clear_notifications(): |
234 | 234 | M.Notification.query.remove({}) |
235 | - |
@@ -2,6 +2,7 @@ | ||
2 | 2 | import unittest |
3 | 3 | from email.MIMEMultipart import MIMEMultipart |
4 | 4 | from email.MIMEText import MIMEText |
5 | +from email import header | |
5 | 6 | |
6 | 7 | from nose.tools import raises, assert_equal |
7 | 8 | from ming.orm import ThreadLocalORMSession |
@@ -9,7 +10,7 @@ from ming.orm import ThreadLocalORMSession | ||
9 | 10 | from alluratest.controller import setup_basic_test, setup_global_objects |
10 | 11 | from allura.lib.utils import ConfigProxy |
11 | 12 | |
12 | -from allura.lib.mail_util import parse_address, parse_message | |
13 | +from allura.lib.mail_util import parse_address, parse_message, Header | |
13 | 14 | from allura.lib.exceptions import AddressException |
14 | 15 | from allura.tests import decorators as td |
15 | 16 |
@@ -88,3 +89,22 @@ class TestReactor(unittest.TestCase): | ||
88 | 89 | if part['payload'] is None: continue |
89 | 90 | assert isinstance(part['payload'], unicode) |
90 | 91 | |
92 | + | |
93 | +class TestHeader(object): | |
94 | + | |
95 | + @raises(TypeError) | |
96 | + def test_bytestring(self): | |
97 | + our_header = Header('[asdf2:wiki] Discussion for Home page') | |
98 | + assert_equal(str(our_header), '[asdf2:wiki] Discussion for Home page') | |
99 | + | |
100 | + def test_ascii(self): | |
101 | + our_header = Header(u'[asdf2:wiki] Discussion for Home page') | |
102 | + assert_equal(str(our_header), '[asdf2:wiki] Discussion for Home page') | |
103 | + | |
104 | + def test_utf8(self): | |
105 | + our_header = Header(u'теснятся') | |
106 | + assert_equal(str(our_header), '=?utf-8?b?0YLQtdGB0L3Rj9GC0YHRjw==?=') | |
107 | + | |
108 | + def test_name_addr(self): | |
109 | + our_header = Header(u'"теснятся"', u'<dave@b.com>') | |
110 | + assert_equal(str(our_header), '=?utf-8?b?ItGC0LXRgdC90Y/RgtGB0Y8i?= <dave@b.com>') | |
\ No newline at end of file |
@@ -1,10 +1,13 @@ | ||
1 | +# -*- coding: utf-8 -*- | |
1 | 2 | import sys |
2 | 3 | import shutil |
3 | 4 | import unittest |
5 | +from base64 import b64encode | |
4 | 6 | |
5 | 7 | import mock |
6 | 8 | from pylons import c, g |
7 | - | |
9 | +from datadiff.tools import assert_equal | |
10 | +from nose.tools import assert_in | |
8 | 11 | from ming.orm import FieldProperty, Mapper |
9 | 12 | |
10 | 13 | from alluratest.controller import setup_basic_test, setup_global_objects |
@@ -97,25 +100,56 @@ class TestMailTasks(unittest.TestCase): | ||
97 | 100 | setup_basic_test() |
98 | 101 | setup_global_objects() |
99 | 102 | |
100 | - def test_send_email(self): | |
103 | + # these tests go down through the mail_util.SMTPClient.sendmail method | |
104 | + # since usage is generally through the task, and not using mail_util directly | |
105 | + | |
106 | + def test_send_email_ascii_with_user_lookup(self): | |
101 | 107 | c.user = M.User.by_username('test-admin') |
102 | - with mock.patch.object(mail_tasks.smtp_client, 'sendmail') as f: | |
108 | + with mock.patch.object(mail_tasks.smtp_client, '_client') as _client: | |
103 | 109 | mail_tasks.sendmail( |
104 | - str(c.user._id), | |
105 | - [ str(c.user._id) ], | |
106 | - 'This is a test', | |
107 | - 'noreply@sf.net', | |
108 | - 'Test subject', | |
109 | - h.gen_message_id()) | |
110 | - assert len(f.call_args_list)==3, f.call_args_list | |
111 | - args,kwargs = f.call_args_list[0] | |
112 | - assert map(str, args[0]) == [ '"Test Admin" <None>' ] | |
113 | - assert str(args[1]) == '"Test Admin" <None>' | |
114 | - assert str(args[2]) == 'noreply@sf.net' | |
115 | - assert args[3] == 'Test subject' | |
116 | - assert '@' in args[4], args[4] | |
117 | - assert args[5] == None | |
118 | - assert 'This is a test' in str(args[6]), str(args[6]) | |
110 | + fromaddr=str(c.user._id), | |
111 | + destinations=[ str(c.user._id) ], | |
112 | + text=u'This is a test', | |
113 | + reply_to=u'noreply@sf.net', | |
114 | + subject=u'Test subject', | |
115 | + message_id=h.gen_message_id()) | |
116 | + assert_equal(_client.sendmail.call_count, 1) | |
117 | + return_path, rcpts, body = _client.sendmail.call_args[0] | |
118 | + body = body.split('\n') | |
119 | + | |
120 | + assert_equal(rcpts, [c.user.get_pref('email_address')]) | |
121 | + assert_in('Reply-To: noreply@sf.net', body) | |
122 | + assert_in('From: "Test Admin" <test-admin@users.localhost>', body) | |
123 | + assert_in('Subject: Test subject', body) | |
124 | + # plain | |
125 | + assert_in('This is a test', body) | |
126 | + # html | |
127 | + assert_in('<div class="markdown_content"><p>This is a test</p></div>', body) | |
128 | + | |
129 | + def test_send_email_nonascii(self): | |
130 | + with mock.patch.object(mail_tasks.smtp_client, '_client') as _client: | |
131 | + mail_tasks.sendmail( | |
132 | + fromaddr=u'"По" <foo@bar.com>', | |
133 | + destinations=[ 'blah@blah.com' ], | |
134 | + text=u'Громады стройные теснятся', | |
135 | + reply_to=u'noreply@sf.net', | |
136 | + subject=u'По оживлённым берегам', | |
137 | + message_id=h.gen_message_id()) | |
138 | + assert_equal(_client.sendmail.call_count, 1) | |
139 | + return_path, rcpts, body = _client.sendmail.call_args[0] | |
140 | + body = body.split('\n') | |
141 | + | |
142 | + assert_equal(rcpts, ['blah@blah.com']) | |
143 | + assert_in('Reply-To: noreply@sf.net', body) | |
144 | + | |
145 | + # The address portion must not be encoded, only the name portion can be. | |
146 | + # Also it is apparently not necessary to have the double-quote separators present | |
147 | + # when the name portion is encoded. That is, the encoding below is just По and not "По" | |
148 | + assert_in('From: =?utf-8?b?0J/Qvg==?= <foo@bar.com>', body) | |
149 | + assert_in('Subject: =?utf-8?b?0J/QviDQvtC20LjQstC70ZHQvdC90YvQvCDQsdC10YDQtdCz0LDQvA==?=', body) | |
150 | + assert_in('Content-Type: text/plain; charset="utf-8"', body) | |
151 | + assert_in('Content-Transfer-Encoding: base64', body) | |
152 | + assert_in(b64encode(u'Громады стройные теснятся'.encode('utf-8')), body) | |
119 | 153 | |
120 | 154 | @td.with_wiki |
121 | 155 | def test_receive_email_ok(self): |
@@ -192,7 +226,7 @@ def raise_exc(): | ||
192 | 226 | except: |
193 | 227 | errs.append(sys.exc_info()) |
194 | 228 | raise CompoundError(*errs) |
195 | - | |
229 | + | |
196 | 230 | class _TestArtifact(M.Artifact): |
197 | 231 | _shorthand_id = FieldProperty(str) |
198 | 232 | text = FieldProperty(str) |
@@ -132,7 +132,10 @@ def bootstrap(command, conf, vars): | ||
132 | 132 | # do the minimal needed |
133 | 133 | if asbool(conf.get('load_test_data')): |
134 | 134 | u_admin = make_user('Test Admin') |
135 | - u_admin.claim_address('Beta@wiki.test.projects.sourceforge.net') | |
135 | + u_admin.preferences = dict(email_address='test-admin@users.localhost') | |
136 | + u_admin.email_addresses = ['test-admin@users.localhost'] | |
137 | + u_admin.set_password('foo') | |
138 | + u_admin.claim_address('test-admin@users.localhost') | |
136 | 139 | else: |
137 | 140 | u_admin = make_user('Admin 1', username='admin1') |
138 | 141 | # Admin1 is almost root, with admin access for Users and Projects neighborhoods |