Merge lp:~qwertyface/localmail/localmail into lp:localmail

Proposed by Peter Russell
Status: Merged
Approved by: Simon Davy
Approved revision: 54
Merged at revision: 53
Proposed branch: lp:~qwertyface/localmail/localmail
Merge into: lp:localmail
Diff against target: 543 lines (+221/-62)
10 files modified
.bzrignore (+1/-0)
localmail/cred.py (+11/-8)
localmail/imap.py (+6/-6)
localmail/inbox.py (+36/-16)
localmail/smtp.py (+18/-12)
tests/helpers.py (+6/-3)
tests/spam/check.py (+10/-9)
tests/test_localmail.py (+129/-5)
tox.ini (+2/-1)
twisted/plugins/localmail_tap.py (+2/-2)
To merge this branch: bzr merge lp:~qwertyface/localmail/localmail
Reviewer Review Type Date Requested Status
Simon Davy Approve
Review via email: mp+343177@code.launchpad.net
To post a comment you must log in.
lp:~qwertyface/localmail/localmail updated
54. By Peter Russell

Linter fixes.

Revision history for this message
Simon Davy (bloodearnest) wrote :

Thanks! Sorry for the delay, I was on holiday!

I'll merge this and do pypi release on monday.

Thanks again!

Revision history for this message
Simon Davy (bloodearnest) :
review: Approve
Revision history for this message
Peter Russell (qwertyface) wrote :

Actually there are done encoding bugs I've seen since making the merge
request. Best hold of from making a release for now.

Peter

On Sun, 15 Apr 2018, 19:48 Simon Davy, <email address hidden> wrote:

> Thanks! Sorry for the delay, I was on holiday!
>
> I'll merge this and do pypi release on monday.
>
> Thanks again!
> --
> https://code.launchpad.net/~qwertyface/localmail/localmail/+merge/343177
> You are the owner of lp:~qwertyface/localmail/localmail.
>

lp:~qwertyface/localmail/localmail updated
55. By Peter Russell

Fix a few errors relating to non-ascii mails

* Correctly convert message payloads to bytes in inbox.py
* Correctly decode message headers in inbox.py
* Convert a few strings including '\' characts to raw strings
* Add tests which round-trip non-ascii mail with differing encodings
  (these could probably do with a refactor)

Revision history for this message
Peter Russell (qwertyface) wrote :

I've updated the branch with fixes for the encoding related bugs. Could you
review again and merge?

Peter

On 15 April 2018 at 19:51, Peter Russell <email address hidden> wrote:

> Actually there are done encoding bugs I've seen since making the merge
> request. Best hold of from making a release for now.
>
> Peter
>
> On Sun, 15 Apr 2018, 19:48 Simon Davy, <email address hidden> wrote:
>
> > Thanks! Sorry for the delay, I was on holiday!
> >
> > I'll merge this and do pypi release on monday.
> >
> > Thanks again!
> > --
> > https://code.launchpad.net/~qwertyface/localmail/localmail/+merge/343177
> > You are the owner of lp:~qwertyface/localmail/localmail.
> >
>
> --
> https://code.launchpad.net/~qwertyface/localmail/localmail/+merge/343177
> You are the owner of lp:~qwertyface/localmail/localmail.
>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '.bzrignore'
2--- .bzrignore 2014-09-09 11:49:58 +0000
3+++ .bzrignore 2018-05-01 09:16:46 +0000
4@@ -6,3 +6,4 @@
5 build
6 .tox
7 *.egg
8+.eggs/
9
10=== modified file 'localmail/cred.py'
11--- localmail/cred.py 2012-11-13 14:25:36 +0000
12+++ localmail/cred.py 2018-05-01 09:16:46 +0000
13@@ -13,17 +13,17 @@
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
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17-from zope.interface import implements
18+from zope.interface import implementer
19 from twisted.internet import defer
20 from twisted.cred import portal, checkers, credentials
21 from twisted.mail import smtp, imap4
22
23-from imap import IMAPUserAccount
24-from smtp import MemoryDelivery
25-
26-
27+from .imap import IMAPUserAccount
28+from .smtp import MemoryDelivery
29+
30+
31+@implementer(portal.IRealm)
32 class TestServerRealm(object):
33- implements(portal.IRealm)
34 avatarInterfaces = {
35 imap4.IAccount: IMAPUserAccount,
36 smtp.IMessageDelivery: MemoryDelivery,
37@@ -34,16 +34,19 @@
38 if requestedInterface in self.avatarInterfaces:
39 avatarClass = self.avatarInterfaces[requestedInterface]
40 avatar = avatarClass()
41+
42 # null logout function: take no arguments and do nothing
43- logout = lambda: None
44+ def logout():
45+ pass
46+
47 return defer.succeed((requestedInterface, avatar, logout))
48
49 # none of the requested interfaces was supported
50 raise KeyError("None of the requested interfaces is supported")
51
52
53+@implementer(checkers.ICredentialsChecker)
54 class CredentialsNonChecker(object):
55- implements(checkers.ICredentialsChecker)
56 credentialInterfaces = (credentials.IUsernamePassword,
57 credentials.IUsernameHashedPassword)
58
59
60=== modified file 'localmail/imap.py'
61--- localmail/imap.py 2014-09-09 16:41:59 +0000
62+++ localmail/imap.py 2018-05-01 09:16:46 +0000
63@@ -15,13 +15,13 @@
64 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
65 from twisted.internet import protocol
66 from twisted.mail import imap4
67-from zope.interface import implements
68-
69-from inbox import INBOX
70-
71-
72+from zope.interface import implementer
73+
74+from .inbox import INBOX
75+
76+
77+@implementer(imap4.IAccount)
78 class IMAPUserAccount(object):
79- implements(imap4.IAccount)
80
81 def listMailboxes(self, ref, wildcard):
82 "only support one folder"
83
84=== modified file 'localmail/inbox.py'
85--- localmail/inbox.py 2015-10-14 13:24:14 +0000
86+++ localmail/inbox.py 2018-05-01 09:16:46 +0000
87@@ -1,3 +1,4 @@
88+# -*- coding: utf-8 -*-
89 # Copyright (C) 2012- Canonical Ltd
90 #
91 # This program is free software; you can redistribute it and/or modify
92@@ -20,15 +21,18 @@
93 import mailbox
94 import email.utils
95 from itertools import count
96-from cStringIO import StringIO
97+try:
98+ from cStringIO import StringIO
99+except ImportError:
100+ from io import BytesIO as StringIO
101
102-from zope.interface import implements
103+from zope.interface import implementer
104
105 from twisted.mail import imap4
106 from twisted.python import log
107
108 UID_GENERATOR = count()
109-LAST_UID = UID_GENERATOR.next()
110+LAST_UID = next(UID_GENERATOR)
111
112 SEEN = r'\Seen'
113 UNSEEN = r'\Unseen'
114@@ -40,12 +44,12 @@
115
116 def get_counter():
117 global LAST_UID
118- LAST_UID = UID_GENERATOR.next()
119+ LAST_UID = next(UID_GENERATOR)
120 return LAST_UID
121
122
123+@implementer(imap4.IMailbox)
124 class MemoryIMAPMailbox(object):
125- implements(imap4.IMailbox)
126
127 mbox = None
128
129@@ -117,7 +121,7 @@
130
131 def fetch(self, msg_set, uid):
132 messages = self._get_msgs(msg_set, uid)
133- return messages.items()
134+ return list(messages.items())
135
136 def addListener(self, listener):
137 self.listeners.append(listener)
138@@ -165,8 +169,8 @@
139 INBOX = MemoryIMAPMailbox()
140
141
142+@implementer(imap4.IMessagePart)
143 class MessagePart(object):
144- implements(imap4.IMessagePart)
145
146 def __init__(self, msg):
147 self.msg = msg
148@@ -185,7 +189,16 @@
149 def getBodyFile(self):
150 if self.msg.is_multipart():
151 raise TypeError("Requested body file of a multipart message")
152- return StringIO(self.msg.get_payload())
153+ # On Python 3, the payload may be a string created using
154+ # surrogate-escape encoding.
155+ # We can't get at this through the public API, without also undoing
156+ # any Content-Transfer-Encoding, which would be tedious to recreate
157+ # so we access the private field. This may cause issues in future.
158+ # ¯\_(ツ)_/¯
159+ payload = self.msg._payload
160+ if not isinstance(payload, bytes):
161+ payload = payload.encode('ascii', 'surrogateescape')
162+ return StringIO(payload)
163
164 def getSize(self):
165 return len(self.msg.as_string())
166@@ -211,17 +224,24 @@
167 def unicode(self, header):
168 """Converts a header to unicode"""
169 value = self.msg[header]
170- orig, enc = decode_header(value)[0]
171- if enc is None:
172- enc = self.parse_charset()
173- return orig.decode(enc)
174-
175-
176+ parts = decode_header(value)
177+ return ''.join(
178+ decoded_part.decode(codec)
179+ if codec is not None else decoded_part.decode('ascii')
180+ for decoded_part, codec in parts)
181+
182+
183+@implementer(imap4.IMessage)
184 class Message(MessagePart):
185- implements(imap4.IMessage)
186
187 def __init__(self, fp, flags, date):
188- super(Message, self).__init__(email.message_from_file(fp))
189+ # email.message_from_binary_file is new in Python 3.3,
190+ # and we need to use it if we are on Python3.
191+ if hasattr(email, 'message_from_binary_file'):
192+ parsed_message = email.message_from_binary_file(fp)
193+ else:
194+ parsed_message = email.message_from_file(fp)
195+ super(Message, self).__init__(parsed_message)
196 self.data = str(self.msg)
197 self.uid = get_counter()
198 self.flags = set(flags)
199
200=== modified file 'localmail/smtp.py'
201--- localmail/smtp.py 2014-09-09 16:41:59 +0000
202+++ localmail/smtp.py 2018-05-01 09:16:46 +0000
203@@ -14,25 +14,31 @@
204 # along with this program; if not, write to the Free Software
205 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
206
207-from cStringIO import StringIO
208-
209+try:
210+ from cStringIO import StringIO
211+except ImportError:
212+ from io import BytesIO as StringIO
213 from twisted.internet import defer
214 from twisted.mail import smtp
215 from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials
216-from zope.interface import implements
217-
218-from inbox import INBOX
219-
220-
221+from zope.interface import implementer
222+
223+from .inbox import INBOX
224+
225+
226+@implementer(smtp.IMessage)
227 class MemoryMessage(object):
228 """Reads a message into a StringIO, and passes on to global inbox"""
229- implements(smtp.IMessage)
230
231 def __init__(self):
232 self.file = StringIO()
233
234 def lineReceived(self, line):
235- self.file.write(line + '\n')
236+ # Sometimes line is bytes, and sometimes it is a str.
237+ # Let's assume that if it's a string, it's ASCII compatible.
238+ if not isinstance(line, bytes):
239+ line = line.encode('ascii')
240+ self.file.write(line + b'\n')
241
242 def eomReceived(self):
243 self.file.seek(0)
244@@ -44,9 +50,9 @@
245 self.file.close()
246
247
248+@implementer(smtp.IMessageDelivery)
249 class MemoryDelivery(object):
250 """Null-validator for email address - always delivers succesfully"""
251- implements(smtp.IMessageDelivery)
252
253 def validateTo(self, user):
254 return MemoryMessage
255@@ -62,8 +68,8 @@
256 """Factort for SMTP connections that authenticates any user"""
257 protocol = smtp.ESMTP
258 challengers = {
259- "LOGIN": LOGINCredentials,
260- "PLAIN": PLAINCredentials
261+ b"LOGIN": LOGINCredentials,
262+ b"PLAIN": PLAINCredentials
263 }
264 noisy = False
265
266
267=== modified file 'tests/helpers.py'
268--- tests/helpers.py 2014-09-09 20:50:23 +0000
269+++ tests/helpers.py 2018-05-01 09:16:46 +0000
270@@ -1,6 +1,9 @@
271 import smtplib
272 import imaplib
273-from email import message_from_string
274+try:
275+ from email import message_from_bytes as message_from_string
276+except ImportError:
277+ from email import message_from_string
278
279
280 class ContextHelper(object):
281@@ -34,7 +37,7 @@
282
283 def start(self):
284 self.client = smtplib.SMTP(self.host, self.port)
285- #self.client.set_debuglevel(1)
286+ # self.client.set_debuglevel(1)
287 self.client.login(self.user, self.password)
288 return self
289
290@@ -99,5 +102,5 @@
291 return None
292 msg_set = msgs[seq - 1]
293 else:
294- msg_set = str(seq)
295+ msg_set = str(seq).encode('ascii')
296 return msg_set
297
298=== modified file 'tests/spam/check.py'
299--- tests/spam/check.py 2014-09-09 11:49:58 +0000
300+++ tests/spam/check.py 2018-05-01 09:16:46 +0000
301@@ -1,19 +1,20 @@
302+from __future__ import print_function
303 from email.parser import Parser
304 import glob
305
306 for file in glob.glob('*.txt'):
307- print file
308+ print(file)
309 msg = Parser().parse(open(file, 'rb'))
310- print "Type: ", msg.get_content_type()
311+ print("Type: ", msg.get_content_type())
312 for k, v in msg.items():
313 print("%s: %s" % (k, v))
314 for part in msg.walk():
315 if part.get_content_maintype() == 'multipart':
316 continue
317- print "PART:"
318- print part.get_content_type()
319- print part.get_payload()[:150]
320- print
321- print
322- print "-------------------------------------------------"
323- print
324+ print("PART:")
325+ print(part.get_content_type())
326+ print(part.get_payload()[:150])
327+ print()
328+ print()
329+ print("-------------------------------------------------")
330+ print()
331
332=== modified file 'tests/test_localmail.py'
333--- tests/test_localmail.py 2014-09-09 16:41:59 +0000
334+++ tests/test_localmail.py 2018-05-01 09:16:46 +0000
335@@ -1,11 +1,20 @@
336+# coding: utf-8
337+
338 import os
339 import time
340 import threading
341 import imaplib
342 import smtplib
343+from io import BytesIO
344+from email.charset import Charset, BASE64, QP
345+from email.message import Message
346 from email.mime.text import MIMEText
347 from email.mime.multipart import MIMEMultipart
348-
349+from email.header import decode_header
350+try:
351+ from email.generator import BytesGenerator as Generator
352+except ImportError:
353+ from email.generator import Generator
354 try:
355 import unittest2 as unittest
356 except ImportError:
357@@ -13,7 +22,7 @@
358
359 import localmail
360
361-from helpers import (
362+from .helpers import (
363 SMTPClient,
364 IMAPClient,
365 clean_inbox,
366@@ -150,21 +159,21 @@
367
368 def test_delete_single_message(self):
369 self.smtp.send(self._testmsg(1))
370- self.imap.store(1, '(\Deleted)')
371+ self.imap.store(1, r'(\Deleted)')
372 self.imap.client.expunge()
373 self.assertEqual(self.imap.search('ALL'), [])
374
375 def test_delete_with_multiple(self):
376 self.smtp.send(self._testmsg(1))
377 self.smtp.send(self._testmsg(2))
378- self.imap.store(1, '(\Deleted)')
379+ self.imap.store(1, r'(\Deleted)')
380 self.imap.client.expunge()
381 self.assertEqual(self.imap.search('ALL'), [self.imap.msgid(1)])
382
383 def test_search_deleted(self):
384 self.smtp.send(self._testmsg(1))
385 self.smtp.send(self._testmsg(2))
386- self.imap.store(1, '(\Deleted)')
387+ self.imap.store(1, r'(\Deleted)')
388 self.assertEqual(
389 self.imap.search('(DELETED)'),
390 [self.imap.msgid(1)]
391@@ -191,3 +200,118 @@
392 msg.attach(html)
393 msg.attach(text)
394 return msg
395+
396+
397+class EncodingTestCase(BaseLocalmailTestcase):
398+
399+ # These characters are one byte in latin-1 but two in utf-8
400+ difficult_chars = u"£ë"
401+ # These characters are two bytes in either encoding
402+ difficult_chars += u"筷子"
403+ # Unicode snowman for good measure
404+ difficult_chars += u"☃"
405+ # These characters might trip up Base64 encoding...
406+ difficult_chars += u"=+/"
407+ # ... and these characters might trip up quoted printable
408+ difficult_chars += u"=3D" # QP encoded
409+
410+ difficult_chars_latin1_compatible = difficult_chars\
411+ .encode('latin-1', 'ignore')\
412+ .decode('latin-1')
413+
414+ uid = False
415+
416+ def setUp(self):
417+ super(EncodingTestCase, self).setUp()
418+ self.smtp = SMTPClient(HOST, SMTP_PORT)
419+ self.smtp.start()
420+ self.imap = IMAPClient(HOST, IMAP_PORT, uid=self.uid)
421+ self.imap.start()
422+ msgs = self.imap.search('ALL')
423+ self.assertEqual(msgs, [])
424+ self.addCleanup(self.smtp.stop)
425+ self.addCleanup(self.imap.stop)
426+
427+ def _encode_message(self, msg):
428+ with BytesIO() as fp:
429+ generator = Generator(fp)
430+ generator.flatten(msg)
431+ return fp.getvalue()
432+
433+ def _make_message(self, text, charset, cte):
434+ msg = Message()
435+ ctes = {'8bit': None, 'base64': BASE64, 'quoted-printable': QP}
436+ cs = Charset(charset)
437+ cs.body_encoding = ctes[cte]
438+ msg.set_payload(text, charset=cs)
439+
440+ # Should always be encoded correctly.
441+ msg['Subject'] = self.difficult_chars
442+ msg['From'] = 'from@example.com'
443+ msg['To'] = 'to@example.com'
444+ self.assertEqual(msg['Content-Transfer-Encoding'], cte)
445+ return msg
446+
447+ def _fetch_and_delete_sole_message(self):
448+ message_number = None
449+ for _ in range(5):
450+ try:
451+ message_number, = self.imap.search('ALL')
452+ break
453+ except ValueError:
454+ time.sleep(0.5)
455+ else:
456+ raise AssertionError("Single Message not found")
457+ msg = self.imap.fetch(message_number)
458+ self.imap.store(message_number, r'(\Deleted)')
459+ self.imap.client.expunge()
460+ return msg
461+
462+ def _do_test(self, payload, charset, cte):
463+ # Arrange
464+ msg = self._make_message(payload, charset, cte)
465+ encoded = self._encode_message(msg)
466+
467+ # Act
468+ self.smtp.client.sendmail(msg['From'], msg['To'], encoded)
469+ received = self._fetch_and_delete_sole_message()
470+
471+ # Assert
472+ payload_bytes = received.get_payload(decode=True)
473+ payload_text = payload_bytes.decode(received.get_content_charset())
474+ self.assertEqual(received['Content-Transfer-Encoding'], cte)
475+ self.assertEqual(received.get_content_charset(), charset.lower())
476+ (subject_bytes, subject_encoding), = decode_header(received['Subject'])
477+ self.assertEqual(
478+ subject_bytes.decode(subject_encoding),
479+ self.difficult_chars)
480+ self.assertEqual(payload_text.strip(), payload)
481+
482+ def test_roundtrip_latin_1_mail(self):
483+ """
484+ Mail with only latin-1 chars can be sent in latin-1
485+
486+ (8-bit MIME)
487+ """
488+ self._do_test(self.difficult_chars_latin1_compatible,
489+ 'iso-8859-1', '8bit')
490+
491+ def test_roundtrip_utf8_mail(self):
492+ """
493+ Mail can be sent in utf-8 without encoding
494+
495+ (8-bit MIME)
496+ """
497+ self._do_test(self.difficult_chars, 'utf-8', '8bit')
498+
499+ def test_roundtrip_utf8_qp_mail(self):
500+ """
501+ Mail can be sent in utf-8 in quoted printable format
502+ """
503+ self._do_test(self.difficult_chars, 'utf-8', 'quoted-printable')
504+
505+ def test_roundtrip_utf8_base64_mail(self):
506+ """
507+ Mail can be sent in utf-8 in quoted printable format
508+ """
509+ self._do_test(self.difficult_chars, 'utf-8', 'base64')
510
511=== modified file 'tox.ini'
512--- tox.ini 2013-05-24 17:03:36 +0000
513+++ tox.ini 2018-05-01 09:16:46 +0000
514@@ -1,4 +1,5 @@
515 [tox]
516-envlist = py26,py27,pypy
517+envlist = py26,py27,pypy,py35
518 [testenv]
519 commands = python setup.py test
520+passenv = LOCALMAIL
521
522=== modified file 'twisted/plugins/localmail_tap.py'
523--- twisted/plugins/localmail_tap.py 2015-10-14 09:00:55 +0000
524+++ twisted/plugins/localmail_tap.py 2018-05-01 09:16:46 +0000
525@@ -13,7 +13,7 @@
526 # You should have received a copy of the GNU General Public License
527 # along with this program; if not, write to the Free Software
528 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
529-from zope.interface import implements
530+from zope.interface import implementer
531
532 from twisted.application import service
533 from twisted import plugin
534@@ -34,8 +34,8 @@
535 ]
536
537
538+@implementer(service.IServiceMaker, plugin.IPlugin)
539 class LocalmailServiceMaker(object):
540- implements(service.IServiceMaker, plugin.IPlugin)
541 tapname = "localmail"
542 description = "A test SMTP/IMAP server"
543 options = Options

Subscribers

People subscribed via source and target branches

to all changes: