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
=== modified file '.bzrignore'
--- .bzrignore 2014-09-09 11:49:58 +0000
+++ .bzrignore 2018-05-01 09:16:46 +0000
@@ -6,3 +6,4 @@
6build6build
7.tox7.tox
8*.egg8*.egg
9.eggs/
910
=== modified file 'localmail/cred.py'
--- localmail/cred.py 2012-11-13 14:25:36 +0000
+++ localmail/cred.py 2018-05-01 09:16:46 +0000
@@ -13,17 +13,17 @@
13# You should have received a copy of the GNU General Public License13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16from zope.interface import implements16from zope.interface import implementer
17from twisted.internet import defer17from twisted.internet import defer
18from twisted.cred import portal, checkers, credentials18from twisted.cred import portal, checkers, credentials
19from twisted.mail import smtp, imap419from twisted.mail import smtp, imap4
2020
21from imap import IMAPUserAccount21from .imap import IMAPUserAccount
22from smtp import MemoryDelivery22from .smtp import MemoryDelivery
2323
2424
25@implementer(portal.IRealm)
25class TestServerRealm(object):26class TestServerRealm(object):
26 implements(portal.IRealm)
27 avatarInterfaces = {27 avatarInterfaces = {
28 imap4.IAccount: IMAPUserAccount,28 imap4.IAccount: IMAPUserAccount,
29 smtp.IMessageDelivery: MemoryDelivery,29 smtp.IMessageDelivery: MemoryDelivery,
@@ -34,16 +34,19 @@
34 if requestedInterface in self.avatarInterfaces:34 if requestedInterface in self.avatarInterfaces:
35 avatarClass = self.avatarInterfaces[requestedInterface]35 avatarClass = self.avatarInterfaces[requestedInterface]
36 avatar = avatarClass()36 avatar = avatarClass()
37
37 # null logout function: take no arguments and do nothing38 # null logout function: take no arguments and do nothing
38 logout = lambda: None39 def logout():
40 pass
41
39 return defer.succeed((requestedInterface, avatar, logout))42 return defer.succeed((requestedInterface, avatar, logout))
4043
41 # none of the requested interfaces was supported44 # none of the requested interfaces was supported
42 raise KeyError("None of the requested interfaces is supported")45 raise KeyError("None of the requested interfaces is supported")
4346
4447
48@implementer(checkers.ICredentialsChecker)
45class CredentialsNonChecker(object):49class CredentialsNonChecker(object):
46 implements(checkers.ICredentialsChecker)
47 credentialInterfaces = (credentials.IUsernamePassword,50 credentialInterfaces = (credentials.IUsernamePassword,
48 credentials.IUsernameHashedPassword)51 credentials.IUsernameHashedPassword)
4952
5053
=== modified file 'localmail/imap.py'
--- localmail/imap.py 2014-09-09 16:41:59 +0000
+++ localmail/imap.py 2018-05-01 09:16:46 +0000
@@ -15,13 +15,13 @@
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16from twisted.internet import protocol16from twisted.internet import protocol
17from twisted.mail import imap417from twisted.mail import imap4
18from zope.interface import implements18from zope.interface import implementer
1919
20from inbox import INBOX20from .inbox import INBOX
2121
2222
23@implementer(imap4.IAccount)
23class IMAPUserAccount(object):24class IMAPUserAccount(object):
24 implements(imap4.IAccount)
2525
26 def listMailboxes(self, ref, wildcard):26 def listMailboxes(self, ref, wildcard):
27 "only support one folder"27 "only support one folder"
2828
=== modified file 'localmail/inbox.py'
--- localmail/inbox.py 2015-10-14 13:24:14 +0000
+++ localmail/inbox.py 2018-05-01 09:16:46 +0000
@@ -1,3 +1,4 @@
1# -*- coding: utf-8 -*-
1# Copyright (C) 2012- Canonical Ltd2# Copyright (C) 2012- Canonical Ltd
2#3#
3# This program is free software; you can redistribute it and/or modify4# This program is free software; you can redistribute it and/or modify
@@ -20,15 +21,18 @@
20import mailbox21import mailbox
21import email.utils22import email.utils
22from itertools import count23from itertools import count
23from cStringIO import StringIO24try:
25 from cStringIO import StringIO
26except ImportError:
27 from io import BytesIO as StringIO
2428
25from zope.interface import implements29from zope.interface import implementer
2630
27from twisted.mail import imap431from twisted.mail import imap4
28from twisted.python import log32from twisted.python import log
2933
30UID_GENERATOR = count()34UID_GENERATOR = count()
31LAST_UID = UID_GENERATOR.next()35LAST_UID = next(UID_GENERATOR)
3236
33SEEN = r'\Seen'37SEEN = r'\Seen'
34UNSEEN = r'\Unseen'38UNSEEN = r'\Unseen'
@@ -40,12 +44,12 @@
4044
41def get_counter():45def get_counter():
42 global LAST_UID46 global LAST_UID
43 LAST_UID = UID_GENERATOR.next()47 LAST_UID = next(UID_GENERATOR)
44 return LAST_UID48 return LAST_UID
4549
4650
51@implementer(imap4.IMailbox)
47class MemoryIMAPMailbox(object):52class MemoryIMAPMailbox(object):
48 implements(imap4.IMailbox)
4953
50 mbox = None54 mbox = None
5155
@@ -117,7 +121,7 @@
117121
118 def fetch(self, msg_set, uid):122 def fetch(self, msg_set, uid):
119 messages = self._get_msgs(msg_set, uid)123 messages = self._get_msgs(msg_set, uid)
120 return messages.items()124 return list(messages.items())
121125
122 def addListener(self, listener):126 def addListener(self, listener):
123 self.listeners.append(listener)127 self.listeners.append(listener)
@@ -165,8 +169,8 @@
165INBOX = MemoryIMAPMailbox()169INBOX = MemoryIMAPMailbox()
166170
167171
172@implementer(imap4.IMessagePart)
168class MessagePart(object):173class MessagePart(object):
169 implements(imap4.IMessagePart)
170174
171 def __init__(self, msg):175 def __init__(self, msg):
172 self.msg = msg176 self.msg = msg
@@ -185,7 +189,16 @@
185 def getBodyFile(self):189 def getBodyFile(self):
186 if self.msg.is_multipart():190 if self.msg.is_multipart():
187 raise TypeError("Requested body file of a multipart message")191 raise TypeError("Requested body file of a multipart message")
188 return StringIO(self.msg.get_payload())192 # On Python 3, the payload may be a string created using
193 # surrogate-escape encoding.
194 # We can't get at this through the public API, without also undoing
195 # any Content-Transfer-Encoding, which would be tedious to recreate
196 # so we access the private field. This may cause issues in future.
197 # ¯\_(ツ)_/¯
198 payload = self.msg._payload
199 if not isinstance(payload, bytes):
200 payload = payload.encode('ascii', 'surrogateescape')
201 return StringIO(payload)
189202
190 def getSize(self):203 def getSize(self):
191 return len(self.msg.as_string())204 return len(self.msg.as_string())
@@ -211,17 +224,24 @@
211 def unicode(self, header):224 def unicode(self, header):
212 """Converts a header to unicode"""225 """Converts a header to unicode"""
213 value = self.msg[header]226 value = self.msg[header]
214 orig, enc = decode_header(value)[0]227 parts = decode_header(value)
215 if enc is None:228 return ''.join(
216 enc = self.parse_charset()229 decoded_part.decode(codec)
217 return orig.decode(enc)230 if codec is not None else decoded_part.decode('ascii')
218231 for decoded_part, codec in parts)
219232
233
234@implementer(imap4.IMessage)
220class Message(MessagePart):235class Message(MessagePart):
221 implements(imap4.IMessage)
222236
223 def __init__(self, fp, flags, date):237 def __init__(self, fp, flags, date):
224 super(Message, self).__init__(email.message_from_file(fp))238 # email.message_from_binary_file is new in Python 3.3,
239 # and we need to use it if we are on Python3.
240 if hasattr(email, 'message_from_binary_file'):
241 parsed_message = email.message_from_binary_file(fp)
242 else:
243 parsed_message = email.message_from_file(fp)
244 super(Message, self).__init__(parsed_message)
225 self.data = str(self.msg)245 self.data = str(self.msg)
226 self.uid = get_counter()246 self.uid = get_counter()
227 self.flags = set(flags)247 self.flags = set(flags)
228248
=== modified file 'localmail/smtp.py'
--- localmail/smtp.py 2014-09-09 16:41:59 +0000
+++ localmail/smtp.py 2018-05-01 09:16:46 +0000
@@ -14,25 +14,31 @@
14# along with this program; if not, write to the Free Software14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
1616
17from cStringIO import StringIO17try:
1818 from cStringIO import StringIO
19except ImportError:
20 from io import BytesIO as StringIO
19from twisted.internet import defer21from twisted.internet import defer
20from twisted.mail import smtp22from twisted.mail import smtp
21from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials23from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials
22from zope.interface import implements24from zope.interface import implementer
2325
24from inbox import INBOX26from .inbox import INBOX
2527
2628
29@implementer(smtp.IMessage)
27class MemoryMessage(object):30class MemoryMessage(object):
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"""
29 implements(smtp.IMessage)
3032
31 def __init__(self):33 def __init__(self):
32 self.file = StringIO()34 self.file = StringIO()
3335
34 def lineReceived(self, line):36 def lineReceived(self, line):
35 self.file.write(line + '\n')37 # Sometimes line is bytes, and sometimes it is a str.
38 # Let's assume that if it's a string, it's ASCII compatible.
39 if not isinstance(line, bytes):
40 line = line.encode('ascii')
41 self.file.write(line + b'\n')
3642
37 def eomReceived(self):43 def eomReceived(self):
38 self.file.seek(0)44 self.file.seek(0)
@@ -44,9 +50,9 @@
44 self.file.close()50 self.file.close()
4551
4652
53@implementer(smtp.IMessageDelivery)
47class MemoryDelivery(object):54class MemoryDelivery(object):
48 """Null-validator for email address - always delivers succesfully"""55 """Null-validator for email address - always delivers succesfully"""
49 implements(smtp.IMessageDelivery)
5056
51 def validateTo(self, user):57 def validateTo(self, user):
52 return MemoryMessage58 return MemoryMessage
@@ -62,8 +68,8 @@
62 """Factort for SMTP connections that authenticates any user"""68 """Factort for SMTP connections that authenticates any user"""
63 protocol = smtp.ESMTP69 protocol = smtp.ESMTP
64 challengers = {70 challengers = {
65 "LOGIN": LOGINCredentials,71 b"LOGIN": LOGINCredentials,
66 "PLAIN": PLAINCredentials72 b"PLAIN": PLAINCredentials
67 }73 }
68 noisy = False74 noisy = False
6975
7076
=== modified file 'tests/helpers.py'
--- tests/helpers.py 2014-09-09 20:50:23 +0000
+++ tests/helpers.py 2018-05-01 09:16:46 +0000
@@ -1,6 +1,9 @@
1import smtplib1import smtplib
2import imaplib2import imaplib
3from email import message_from_string3try:
4 from email import message_from_bytes as message_from_string
5except ImportError:
6 from email import message_from_string
47
58
6class ContextHelper(object):9class ContextHelper(object):
@@ -34,7 +37,7 @@
3437
35 def start(self):38 def start(self):
36 self.client = smtplib.SMTP(self.host, self.port)39 self.client = smtplib.SMTP(self.host, self.port)
37 #self.client.set_debuglevel(1)40 # self.client.set_debuglevel(1)
38 self.client.login(self.user, self.password)41 self.client.login(self.user, self.password)
39 return self42 return self
4043
@@ -99,5 +102,5 @@
99 return None102 return None
100 msg_set = msgs[seq - 1]103 msg_set = msgs[seq - 1]
101 else:104 else:
102 msg_set = str(seq)105 msg_set = str(seq).encode('ascii')
103 return msg_set106 return msg_set
104107
=== modified file 'tests/spam/check.py'
--- tests/spam/check.py 2014-09-09 11:49:58 +0000
+++ tests/spam/check.py 2018-05-01 09:16:46 +0000
@@ -1,19 +1,20 @@
1from __future__ import print_function
1from email.parser import Parser2from email.parser import Parser
2import glob3import glob
34
4for file in glob.glob('*.txt'):5for file in glob.glob('*.txt'):
5 print file6 print(file)
6 msg = Parser().parse(open(file, 'rb'))7 msg = Parser().parse(open(file, 'rb'))
7 print "Type: ", msg.get_content_type()8 print("Type: ", msg.get_content_type())
8 for k, v in msg.items():9 for k, v in msg.items():
9 print("%s: %s" % (k, v))10 print("%s: %s" % (k, v))
10 for part in msg.walk():11 for part in msg.walk():
11 if part.get_content_maintype() == 'multipart':12 if part.get_content_maintype() == 'multipart':
12 continue13 continue
13 print "PART:"14 print("PART:")
14 print part.get_content_type()15 print(part.get_content_type())
15 print part.get_payload()[:150]16 print(part.get_payload()[:150])
16 print17 print()
17 print18 print()
18 print "-------------------------------------------------"19 print("-------------------------------------------------")
19 print20 print()
2021
=== modified file 'tests/test_localmail.py'
--- tests/test_localmail.py 2014-09-09 16:41:59 +0000
+++ tests/test_localmail.py 2018-05-01 09:16:46 +0000
@@ -1,11 +1,20 @@
1# coding: utf-8
2
1import os3import os
2import time4import time
3import threading5import threading
4import imaplib6import imaplib
5import smtplib7import smtplib
8from io import BytesIO
9from email.charset import Charset, BASE64, QP
10from email.message import Message
6from email.mime.text import MIMEText11from email.mime.text import MIMEText
7from email.mime.multipart import MIMEMultipart12from email.mime.multipart import MIMEMultipart
813from email.header import decode_header
14try:
15 from email.generator import BytesGenerator as Generator
16except ImportError:
17 from email.generator import Generator
9try:18try:
10 import unittest2 as unittest19 import unittest2 as unittest
11except ImportError:20except ImportError:
@@ -13,7 +22,7 @@
1322
14import localmail23import localmail
1524
16from helpers import (25from .helpers import (
17 SMTPClient,26 SMTPClient,
18 IMAPClient,27 IMAPClient,
19 clean_inbox,28 clean_inbox,
@@ -150,21 +159,21 @@
150159
151 def test_delete_single_message(self):160 def test_delete_single_message(self):
152 self.smtp.send(self._testmsg(1))161 self.smtp.send(self._testmsg(1))
153 self.imap.store(1, '(\Deleted)')162 self.imap.store(1, r'(\Deleted)')
154 self.imap.client.expunge()163 self.imap.client.expunge()
155 self.assertEqual(self.imap.search('ALL'), [])164 self.assertEqual(self.imap.search('ALL'), [])
156165
157 def test_delete_with_multiple(self):166 def test_delete_with_multiple(self):
158 self.smtp.send(self._testmsg(1))167 self.smtp.send(self._testmsg(1))
159 self.smtp.send(self._testmsg(2))168 self.smtp.send(self._testmsg(2))
160 self.imap.store(1, '(\Deleted)')169 self.imap.store(1, r'(\Deleted)')
161 self.imap.client.expunge()170 self.imap.client.expunge()
162 self.assertEqual(self.imap.search('ALL'), [self.imap.msgid(1)])171 self.assertEqual(self.imap.search('ALL'), [self.imap.msgid(1)])
163172
164 def test_search_deleted(self):173 def test_search_deleted(self):
165 self.smtp.send(self._testmsg(1))174 self.smtp.send(self._testmsg(1))
166 self.smtp.send(self._testmsg(2))175 self.smtp.send(self._testmsg(2))
167 self.imap.store(1, '(\Deleted)')176 self.imap.store(1, r'(\Deleted)')
168 self.assertEqual(177 self.assertEqual(
169 self.imap.search('(DELETED)'),178 self.imap.search('(DELETED)'),
170 [self.imap.msgid(1)]179 [self.imap.msgid(1)]
@@ -191,3 +200,118 @@
191 msg.attach(html)200 msg.attach(html)
192 msg.attach(text)201 msg.attach(text)
193 return msg202 return msg
203
204
205class EncodingTestCase(BaseLocalmailTestcase):
206
207 # These characters are one byte in latin-1 but two in utf-8
208 difficult_chars = u"£ë"
209 # These characters are two bytes in either encoding
210 difficult_chars += u"筷子"
211 # Unicode snowman for good measure
212 difficult_chars += u"☃"
213 # These characters might trip up Base64 encoding...
214 difficult_chars += u"=+/"
215 # ... and these characters might trip up quoted printable
216 difficult_chars += u"=3D" # QP encoded
217
218 difficult_chars_latin1_compatible = difficult_chars\
219 .encode('latin-1', 'ignore')\
220 .decode('latin-1')
221
222 uid = False
223
224 def setUp(self):
225 super(EncodingTestCase, self).setUp()
226 self.smtp = SMTPClient(HOST, SMTP_PORT)
227 self.smtp.start()
228 self.imap = IMAPClient(HOST, IMAP_PORT, uid=self.uid)
229 self.imap.start()
230 msgs = self.imap.search('ALL')
231 self.assertEqual(msgs, [])
232 self.addCleanup(self.smtp.stop)
233 self.addCleanup(self.imap.stop)
234
235 def _encode_message(self, msg):
236 with BytesIO() as fp:
237 generator = Generator(fp)
238 generator.flatten(msg)
239 return fp.getvalue()
240
241 def _make_message(self, text, charset, cte):
242 msg = Message()
243 ctes = {'8bit': None, 'base64': BASE64, 'quoted-printable': QP}
244 cs = Charset(charset)
245 cs.body_encoding = ctes[cte]
246 msg.set_payload(text, charset=cs)
247
248 # Should always be encoded correctly.
249 msg['Subject'] = self.difficult_chars
250 msg['From'] = 'from@example.com'
251 msg['To'] = 'to@example.com'
252 self.assertEqual(msg['Content-Transfer-Encoding'], cte)
253 return msg
254
255 def _fetch_and_delete_sole_message(self):
256 message_number = None
257 for _ in range(5):
258 try:
259 message_number, = self.imap.search('ALL')
260 break
261 except ValueError:
262 time.sleep(0.5)
263 else:
264 raise AssertionError("Single Message not found")
265 msg = self.imap.fetch(message_number)
266 self.imap.store(message_number, r'(\Deleted)')
267 self.imap.client.expunge()
268 return msg
269
270 def _do_test(self, payload, charset, cte):
271 # Arrange
272 msg = self._make_message(payload, charset, cte)
273 encoded = self._encode_message(msg)
274
275 # Act
276 self.smtp.client.sendmail(msg['From'], msg['To'], encoded)
277 received = self._fetch_and_delete_sole_message()
278
279 # Assert
280 payload_bytes = received.get_payload(decode=True)
281 payload_text = payload_bytes.decode(received.get_content_charset())
282 self.assertEqual(received['Content-Transfer-Encoding'], cte)
283 self.assertEqual(received.get_content_charset(), charset.lower())
284 (subject_bytes, subject_encoding), = decode_header(received['Subject'])
285 self.assertEqual(
286 subject_bytes.decode(subject_encoding),
287 self.difficult_chars)
288 self.assertEqual(payload_text.strip(), payload)
289
290 def test_roundtrip_latin_1_mail(self):
291 """
292 Mail with only latin-1 chars can be sent in latin-1
293
294 (8-bit MIME)
295 """
296 self._do_test(self.difficult_chars_latin1_compatible,
297 'iso-8859-1', '8bit')
298
299 def test_roundtrip_utf8_mail(self):
300 """
301 Mail can be sent in utf-8 without encoding
302
303 (8-bit MIME)
304 """
305 self._do_test(self.difficult_chars, 'utf-8', '8bit')
306
307 def test_roundtrip_utf8_qp_mail(self):
308 """
309 Mail can be sent in utf-8 in quoted printable format
310 """
311 self._do_test(self.difficult_chars, 'utf-8', 'quoted-printable')
312
313 def test_roundtrip_utf8_base64_mail(self):
314 """
315 Mail can be sent in utf-8 in quoted printable format
316 """
317 self._do_test(self.difficult_chars, 'utf-8', 'base64')
194318
=== modified file 'tox.ini'
--- tox.ini 2013-05-24 17:03:36 +0000
+++ tox.ini 2018-05-01 09:16:46 +0000
@@ -1,4 +1,5 @@
1[tox]1[tox]
2envlist = py26,py27,pypy2envlist = py26,py27,pypy,py35
3[testenv]3[testenv]
4commands = python setup.py test4commands = python setup.py test
5passenv = LOCALMAIL
56
=== modified file 'twisted/plugins/localmail_tap.py'
--- twisted/plugins/localmail_tap.py 2015-10-14 09:00:55 +0000
+++ twisted/plugins/localmail_tap.py 2018-05-01 09:16:46 +0000
@@ -13,7 +13,7 @@
13# You should have received a copy of the GNU General Public License13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16from zope.interface import implements16from zope.interface import implementer
1717
18from twisted.application import service18from twisted.application import service
19from twisted import plugin19from twisted import plugin
@@ -34,8 +34,8 @@
34 ]34 ]
3535
3636
37@implementer(service.IServiceMaker, plugin.IPlugin)
37class LocalmailServiceMaker(object):38class LocalmailServiceMaker(object):
38 implements(service.IServiceMaker, plugin.IPlugin)
39 tapname = "localmail"39 tapname = "localmail"
40 description = "A test SMTP/IMAP server"40 description = "A test SMTP/IMAP server"
41 options = Options41 options = Options

Subscribers

People subscribed via source and target branches

to all changes: