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 | 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 | |
321 | |
322 | - print "-------------------------------------------------" |
323 | |
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 |
Thanks! Sorry for the delay, I was on holiday!
I'll merge this and do pypi release on monday.
Thanks again!