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