• R/O
  • HTTP
  • SSH
  • HTTPS

Commit

Tags
Keine Tags

Frequently used words (click to add to your profile)

javac++androidlinuxc#windowsobjective-ccocoa誰得qtpythonphprubygameguibathyscaphec計画中(planning stage)翻訳omegatframeworktwitterdomtestvb.netdirectxゲームエンジンbtronarduinopreviewer

allura


Commit MetaInfo

Revision848e2f4f15eafd9a2826cbac86fe9bfd539db557 (tree)
Zeit2012-05-04 02:22:14
AutorDave Brondsema <dbrondsema@geek...>
CommiterYaroslav Luzin

Log Message

[#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

Ändern Zusammenfassung

Diff

--- a/Allura/allura/lib/mail_util.py
+++ b/Allura/allura/lib/mail_util.py
@@ -23,16 +23,39 @@ config = ConfigProxy(
2323 return_path='forgemail.return_path')
2424 EMAIL_VALIDATOR=fev.Email(not_empty=True)
2525
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'''
3028 if isinstance(text, header.Header):
3129 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+
3659
3760 def parse_address(addr):
3861 userpart, domain = addr.split('@')
@@ -103,7 +126,7 @@ def identify_sender(peer, email_address, headers, msg):
103126
104127 def encode_email_part(content, content_type):
105128 try:
106- return MIMEText(content.encode('iso-8859-1'), content_type, 'iso-8859-1')
129+ return MIMEText(content.encode('ascii'), content_type, 'ascii')
107130 except:
108131 return MIMEText(content.encode('utf-8'), content_type, 'utf-8')
109132
@@ -142,21 +165,19 @@ class SMTPClient(object):
142165 def __init__(self):
143166 self._client = None
144167
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):
146169 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'>')
155177 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)
160181 content = message.as_string()
161182 smtp_addrs = map(_parse_smtp_addr, addrs)
162183 smtp_addrs = [ a for a in smtp_addrs if isvalid(a) ]
--- a/Allura/allura/tasks/mail_tasks.py
+++ b/Allura/allura/tasks/mail_tasks.py
@@ -56,14 +56,8 @@ def route_email(
5656 log.exception('Error routing mail to %s', addr)
5757
5858 @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):
6761 from allura import model as M
6862 addrs_plain = []
6963 addrs_html = []
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -16,7 +16,8 @@ class TestAuth(TestController):
1616 def test_login(self):
1717 result = self.app.get('/auth/')
1818 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))
2021 ThreadLocalORMSession.flush_all()
2122 r = self.app.get('/auth/verify_addr', params=dict(a='foo'))
2223 assert json.loads(self.webflash(r))['status'] == 'error', self.webflash(r)
--- a/Allura/allura/tests/model/test_notification.py
+++ b/Allura/allura/tests/model/test_notification.py
@@ -2,7 +2,7 @@ import unittest
22 from datetime import timedelta
33
44 from pylons import g, c
5-
5+from nose.tools import assert_equal
66 from ming.orm import ThreadLocalORMSession
77
88 from alluratest.controller import setup_basic_test, setup_global_objects, REGISTRY
@@ -103,7 +103,7 @@ class TestPostNotifications(unittest.TestCase):
103103 ThreadLocalORMSession.flush_all()
104104 M.MonQTask.run_ready()
105105 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>')
107107 assert M.Mailbox.query.find().count()==1
108108 mbox = M.Mailbox.query.get()
109109 assert len(mbox.queue) == 1
@@ -232,4 +232,3 @@ def _clear_subscriptions():
232232
233233 def _clear_notifications():
234234 M.Notification.query.remove({})
235-
--- a/Allura/allura/tests/test_mail_util.py
+++ b/Allura/allura/tests/test_mail_util.py
@@ -2,6 +2,7 @@
22 import unittest
33 from email.MIMEMultipart import MIMEMultipart
44 from email.MIMEText import MIMEText
5+from email import header
56
67 from nose.tools import raises, assert_equal
78 from ming.orm import ThreadLocalORMSession
@@ -9,7 +10,7 @@ from ming.orm import ThreadLocalORMSession
910 from alluratest.controller import setup_basic_test, setup_global_objects
1011 from allura.lib.utils import ConfigProxy
1112
12-from allura.lib.mail_util import parse_address, parse_message
13+from allura.lib.mail_util import parse_address, parse_message, Header
1314 from allura.lib.exceptions import AddressException
1415 from allura.tests import decorators as td
1516
@@ -88,3 +89,22 @@ class TestReactor(unittest.TestCase):
8889 if part['payload'] is None: continue
8990 assert isinstance(part['payload'], unicode)
9091
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
--- a/Allura/allura/tests/test_tasks.py
+++ b/Allura/allura/tests/test_tasks.py
@@ -1,10 +1,13 @@
1+# -*- coding: utf-8 -*-
12 import sys
23 import shutil
34 import unittest
5+from base64 import b64encode
46
57 import mock
68 from pylons import c, g
7-
9+from datadiff.tools import assert_equal
10+from nose.tools import assert_in
811 from ming.orm import FieldProperty, Mapper
912
1013 from alluratest.controller import setup_basic_test, setup_global_objects
@@ -97,25 +100,56 @@ class TestMailTasks(unittest.TestCase):
97100 setup_basic_test()
98101 setup_global_objects()
99102
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):
101107 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:
103109 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)
119153
120154 @td.with_wiki
121155 def test_receive_email_ok(self):
@@ -192,7 +226,7 @@ def raise_exc():
192226 except:
193227 errs.append(sys.exc_info())
194228 raise CompoundError(*errs)
195-
229+
196230 class _TestArtifact(M.Artifact):
197231 _shorthand_id = FieldProperty(str)
198232 text = FieldProperty(str)
--- a/Allura/allura/websetup/bootstrap.py
+++ b/Allura/allura/websetup/bootstrap.py
@@ -132,7 +132,10 @@ def bootstrap(command, conf, vars):
132132 # do the minimal needed
133133 if asbool(conf.get('load_test_data')):
134134 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')
136139 else:
137140 u_admin = make_user('Admin 1', username='admin1')
138141 # Admin1 is almost root, with admin access for Users and Projects neighborhoods