├── tests
├── __init__.py
├── spam
│ ├── polish-plain.txt
│ ├── russian-html.txt
│ ├── check.py
│ ├── english-plain.txt
│ ├── english-multi.txt
│ └── english-html.txt
├── send-test-emails.py
├── helpers.py
└── test_localmail.py
├── kill_localmail.sh
├── .bumpversion.cfg
├── TODO
├── tox.ini
├── .bzrignore
├── MANIFEST.in
├── setup.cfg
├── localmail.tac
├── AUTHORS.rst
├── muttrc
├── HISTORY.rst
├── COPYRIGHT.txt
├── Makefile
├── localmail
├── http.py
├── cred.py
├── smtp.py
├── imap.py
├── templates
│ └── index.html
├── __init__.py
└── inbox.py
├── CONTRIBUTING.rst
├── twisted
└── plugins
│ └── localmail_tap.py
├── README.rst
├── setup.py
└── LICENSE.txt
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/kill_localmail.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | kill `cat twistd.pid`
3 |
--------------------------------------------------------------------------------
/.bumpversion.cfg:
--------------------------------------------------------------------------------
1 | [bumpversion]
2 | current_version = 0.4.1
3 | files = setup.py
4 |
5 |
--------------------------------------------------------------------------------
/TODO:
--------------------------------------------------------------------------------
1 | improve logging output
2 | Add SSL support back in
3 | better tests using twisted.trial
4 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = py26,py27,pypy
3 | [testenv]
4 | commands = python setup.py test
5 |
--------------------------------------------------------------------------------
/tests/spam/polish-plain.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mistio/localmail/HEAD/tests/spam/polish-plain.txt
--------------------------------------------------------------------------------
/tests/spam/russian-html.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mistio/localmail/HEAD/tests/spam/russian-html.txt
--------------------------------------------------------------------------------
/.bzrignore:
--------------------------------------------------------------------------------
1 | twisted/plugins/*.cache
2 | twistd.*
3 | dist
4 | localmail.egg-info
5 | _trial_temp
6 | build
7 | .tox
8 | *.egg
9 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.tac
2 | include *.txt
3 | include *.rst
4 | include muttrc
5 | include twisted/plugins/*.py
6 | recursive-include localmail/templates *.html
7 |
8 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [wheel]
2 | universal = 1
3 |
4 | [check-manifest]
5 | ignore =
6 | tests*
7 | Makefile
8 | tox.ini
9 | TODO
10 | kill_localmail.sh
11 |
12 |
--------------------------------------------------------------------------------
/localmail.tac:
--------------------------------------------------------------------------------
1 | from twisted.application import service
2 |
3 | import localmail
4 |
5 | application = service.Application("localmail")
6 | smtp, imap = localmail.get_services(2025, 2143)
7 | smtp.setServiceParent(application)
8 | imap.setServiceParent(application)
9 |
--------------------------------------------------------------------------------
/AUTHORS.rst:
--------------------------------------------------------------------------------
1 | Credits
2 | =======
3 |
4 | “localmail” is written and maintained by Simon Davy
5 |
6 |
7 | Contributors
8 | ------------
9 |
10 | The following people contributed directly or indirectly to this project:
11 |
12 | - `Ed Jannoo `
13 |
--------------------------------------------------------------------------------
/muttrc:
--------------------------------------------------------------------------------
1 | set imap_user = "user@anywhere.com"
2 | set imap_pass = "pass"
3 | set folder = "imap://127.0.0.1:2143"
4 |
5 | set smtp_url = "smtp://user@127.0.0.1:2025/"
6 | set smtp_pass = "pass"
7 | set from = "localmail@localmail.com"
8 | set realname = "Localmail Test User"
9 | set spoolfile = "+INBOX"
10 | set move=no
11 |
--------------------------------------------------------------------------------
/tests/send-test-emails.py:
--------------------------------------------------------------------------------
1 | import glob
2 | import sys
3 | from email.parser import Parser
4 | import smtplib
5 |
6 | if __name__ == '__main__':
7 | port = int(sys.argv[1]) if len(sys.argv) > 1 else 2025
8 | smtp = smtplib.SMTP("localhost", port)
9 | for file in glob.glob('spam/*.txt'):
10 | msg = Parser().parse(open(file, 'rb'))
11 | smtp.sendmail('a@b.com', ['a@b.com'], msg.as_string())
12 |
--------------------------------------------------------------------------------
/tests/spam/check.py:
--------------------------------------------------------------------------------
1 | from email.parser import Parser
2 | import glob
3 |
4 | for file in glob.glob('*.txt'):
5 | print file
6 | msg = Parser().parse(open(file, 'rb'))
7 | print "Type: ", msg.get_content_type()
8 | for k, v in msg.items():
9 | print("%s: %s" % (k, v))
10 | for part in msg.walk():
11 | if part.get_content_maintype() == 'multipart':
12 | continue
13 | print "PART:"
14 | print part.get_content_type()
15 | print part.get_payload()[:150]
16 | print
17 | print
18 | print "-------------------------------------------------"
19 | print
20 |
--------------------------------------------------------------------------------
/HISTORY.rst:
--------------------------------------------------------------------------------
1 | .. :changelog:
2 |
3 | History
4 | =======
5 |
6 | 0.4 (2015-08-14)
7 | ----------------
8 |
9 | * support for using random port numbers
10 | * available as a universal wheel, general packaging improvements
11 | * Simple HTTP interface for browsing mail (requires jinja2)
12 | * Support writing to mbox file
13 | * Fixed date to work with mutt, example muttrc included in package.
14 |
15 |
16 | 0.3 (2013-05-24)
17 | ----------------
18 |
19 | * Multipart message support [via Ed Jannoo]
20 | * IMAP UID support
21 | * Support python 2.6, 2.7 and pypy, tested via tox
22 |
23 |
24 | 0.2 (2012-11-13)
25 | ----------------
26 |
27 | * Initial public release
28 | * Basic SMTP/IMAP server
29 |
30 |
--------------------------------------------------------------------------------
/COPYRIGHT.txt:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2012- Canonical Ltd
2 | #
3 | # This program is free software; you can redistribute it and/or modify
4 | # it under the terms of the GNU General Public License as published by
5 | # the Free Software Foundation; either version 2 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU General Public License
14 | # along with this program; if not, write to the Free Software
15 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PUBLISHING_DEPENDENCIES=wheel bumpversion twine
2 | TOX=$(shell which detox || which tox)
3 |
4 |
5 | .PHONY: test
6 | test: lint
7 | $(TOX)
8 |
9 | .PHONY: lint
10 | lint:
11 | flake8 localmail tests twisted
12 |
13 | .PHONY: publishing-dependencies
14 | publishing-dependencies:
15 | pip install -U $(PUBLISHING_DEPENDENCIES)
16 |
17 | .PHONY: bump
18 | bump: publishing-dependencies
19 | $(eval OLD=$(shell python -c "import setup; print setup.__VERSION__"))
20 | bumpversion minor
21 | $(MAKE) __finish_bump OLD=$(OLD)
22 |
23 | __finish_bump:
24 | $(eval NEW=$(shell python -c "import setup; print setup.__VERSION__"))
25 | bzr commit -m "bump version: $(OLD) to $(NEW)"
26 | bzr tag "v$(NEW)"
27 |
28 | .PHONY: update
29 | update:
30 | python setup.py register
31 |
32 | .PHONY: upload
33 | upload: publishing-dependencies
34 | python setup.py sdist bdist_wheel
35 | twine upload dist/*
36 |
37 | .PHONY: release
38 | release: bump upload
39 |
40 |
41 |
--------------------------------------------------------------------------------
/localmail/http.py:
--------------------------------------------------------------------------------
1 | from pkg_resources import resource_string
2 | from twisted.web.server import Site
3 | from twisted.web.resource import Resource
4 |
5 | from localmail.inbox import INBOX
6 |
7 |
8 | class TestServerHTTPFactory(Site):
9 | noisy = False
10 |
11 |
12 | class Index(Resource):
13 | isLeaf = True
14 | index_template = None
15 |
16 | def __init__(self, *args, **kwargs):
17 | Resource.__init__(self, *args, **kwargs)
18 |
19 | # defer import so is optional
20 | try:
21 | from jinja2 import Template
22 | self.index_template = Template(resource_string(
23 | __name__, 'templates/index.html').decode('utf8'))
24 | except ImportError:
25 | pass
26 |
27 | def render_GET(self, request):
28 | if self.index_template is None:
29 | return "Web interface not available: Jinja2 not installed"
30 |
31 | request.setHeader('Content-type', 'text/html; charset=utf-8')
32 | return self.index_template.render(msgs=INBOX.msgs).encode('utf8')
33 |
34 |
35 | index = Index()
36 |
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | Contributing
2 | ============
3 |
4 | To set up a development environment, create a virtualenv and then run the
5 | following in it. The main dependency is twisted, and tox for running tests,
6 | and flake8 for linting. Unittest2 is pulled in of you are on python 2.6.
7 |
8 | ::
9 | python setup.py develop
10 |
11 | Testing
12 | -------
13 |
14 | The test suite is very simple. It starts localmail in a thread listening on
15 | random ports. The tests then run in the main thread using the python stdlib
16 | imaplib and smtplib modules as clients, so it's more integration tests rather
17 | than unit tests.
18 |
19 | I probably should add some proper unit tests and use twisted's SMTP/IMAP
20 | clients as well, but twisted.trial scares me a little.
21 |
22 | To run the full suite, use tox to run on python 2.6, 2.7, and pypy. Works in
23 | parallel with detox too, thanks to using random ports, for faster runs.
24 |
25 | ::
26 |
27 | make test
28 |
29 | Note: this will also run flake8, which is required to pass to merge.
30 |
31 | To run the suite manually, or with specific tests, use:
32 |
33 | ::
34 | python setup.py test [-s tests.test_localmail.SomeTestCase.test_something]
35 |
--------------------------------------------------------------------------------
/localmail/cred.py:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2012- Canonical Ltd
2 | #
3 | # This program is free software; you can redistribute it and/or modify
4 | # it under the terms of the GNU General Public License as published by
5 | # the Free Software Foundation; either version 2 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU General Public License
14 | # along with this program; if not, write to the Free Software
15 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 | from zope.interface import implements
17 | from twisted.internet import defer
18 | from twisted.cred import portal, checkers, credentials
19 | from twisted.mail import smtp, imap4
20 |
21 | from imap import IMAPUserAccount
22 | from smtp import MemoryDelivery
23 |
24 |
25 | class TestServerRealm(object):
26 | implements(portal.IRealm)
27 | avatarInterfaces = {
28 | imap4.IAccount: IMAPUserAccount,
29 | smtp.IMessageDelivery: MemoryDelivery,
30 | }
31 |
32 | def requestAvatar(self, avatarId, mind, *interfaces):
33 | for requestedInterface in interfaces:
34 | if requestedInterface in self.avatarInterfaces:
35 | avatarClass = self.avatarInterfaces[requestedInterface]
36 | avatar = avatarClass()
37 | # null logout function: take no arguments and do nothing
38 | logout = lambda: None
39 | return defer.succeed((requestedInterface, avatar, logout))
40 |
41 | # none of the requested interfaces was supported
42 | raise KeyError("None of the requested interfaces is supported")
43 |
44 |
45 | class CredentialsNonChecker(object):
46 | implements(checkers.ICredentialsChecker)
47 | credentialInterfaces = (credentials.IUsernamePassword,
48 | credentials.IUsernameHashedPassword)
49 |
50 | def requestAvatarId(self, credentials):
51 | """automatically validate *any* user"""
52 | return credentials.username
53 |
--------------------------------------------------------------------------------
/localmail/smtp.py:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2012- Canonical Ltd
2 | #
3 | # This program is free software; you can redistribute it and/or modify
4 | # it under the terms of the GNU General Public License as published by
5 | # the Free Software Foundation; either version 2 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU General Public License
14 | # along with this program; if not, write to the Free Software
15 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 |
17 | from cStringIO import StringIO
18 |
19 | from twisted.internet import defer
20 | from twisted.mail import smtp
21 | from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials
22 | from zope.interface import implements
23 |
24 | from inbox import INBOX
25 |
26 |
27 | class MemoryMessage(object):
28 | """Reads a message into a StringIO, and passes on to global inbox"""
29 | implements(smtp.IMessage)
30 |
31 | def __init__(self):
32 | self.file = StringIO()
33 |
34 | def lineReceived(self, line):
35 | self.file.write(line + '\n')
36 | print(line)
37 |
38 | def eomReceived(self):
39 | self.file.seek(0)
40 | INBOX.addMessage(self.file, [r'\Recent', r'\Unseen'])
41 | self.file.close()
42 | return defer.succeed(None)
43 |
44 | def connectionLost(self):
45 | self.file.close()
46 |
47 |
48 | class MemoryDelivery(object):
49 | """Null-validator for email address - always delivers succesfully"""
50 | implements(smtp.IMessageDelivery)
51 |
52 | def validateTo(self, user):
53 | return MemoryMessage
54 |
55 | def validateFrom(self, helo, origin):
56 | return origin
57 |
58 | def receivedHeader(self, helo, origin, recipients):
59 | return 'Received: Test Server.'
60 |
61 |
62 | class TestServerESMTPFactory(smtp.SMTPFactory):
63 | """Factort for SMTP connections that authenticates any user"""
64 | protocol = smtp.ESMTP
65 | challengers = {
66 | "LOGIN": LOGINCredentials,
67 | "PLAIN": PLAINCredentials
68 | }
69 | noisy = False
70 |
71 | def buildProtocol(self, addr):
72 | p = smtp.SMTPFactory.buildProtocol(self, addr)
73 | p.challengers = self.challengers
74 | return p
75 |
--------------------------------------------------------------------------------
/localmail/imap.py:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2012- Canonical Ltd
2 | #
3 | # This program is free software; you can redistribute it and/or modify
4 | # it under the terms of the GNU General Public License as published by
5 | # the Free Software Foundation; either version 2 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU General Public License
14 | # along with this program; if not, write to the Free Software
15 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 | from twisted.internet import protocol
17 | from twisted.mail import imap4
18 | from zope.interface import implements
19 |
20 | from inbox import INBOX
21 |
22 |
23 | class IMAPUserAccount(object):
24 | implements(imap4.IAccount)
25 |
26 | def listMailboxes(self, ref, wildcard):
27 | "only support one folder"
28 | return [("INBOX", INBOX)]
29 |
30 | def select(self, path, rw=True):
31 | "return the same mailbox for every path"
32 | return INBOX
33 |
34 | def create(self, path):
35 | "nothing to create"
36 | pass
37 |
38 | def delete(self, path):
39 | "delete the mailbox at path"
40 | raise imap4.MailboxException("Permission denied.")
41 |
42 | def rename(self, oldname, newname):
43 | "rename a mailbox"
44 | pass
45 |
46 | def isSubscribed(self, path):
47 | "return a true value if user is subscribed to the mailbox"
48 | return True
49 |
50 | def subscribe(self, path):
51 | return True
52 |
53 | def unsubscribe(self, path):
54 | return True
55 |
56 |
57 | class IMAPServerProtocol(imap4.IMAP4Server):
58 | "Subclass of imap4.IMAP4Server that adds debugging."
59 |
60 | def lineReceived(self, line):
61 | imap4.IMAP4Server.lineReceived(self, line)
62 |
63 | def sendLine(self, line):
64 | imap4.IMAP4Server.sendLine(self, line)
65 |
66 |
67 | class TestServerIMAPFactory(protocol.Factory):
68 | protocol = IMAPServerProtocol
69 | portal = None # placeholder
70 | noisy = False
71 |
72 | def buildProtocol(self, address):
73 | p = self.protocol()
74 | # self.portal will be set up already "magically"
75 | p.portal = self.portal
76 | p.factory = self
77 | return p
78 |
--------------------------------------------------------------------------------
/twisted/plugins/localmail_tap.py:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2012- Canonical Ltd
2 | #
3 | # This program is free software; you can redistribute it and/or modify
4 | # it under the terms of the GNU General Public License as published by
5 | # the Free Software Foundation; either version 2 of the License, or
6 | # (at your option) any later version.
7 | #
8 | # This program is distributed in the hope that it will be useful,
9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | # GNU General Public License for more details.
12 | #
13 | # You should have received a copy of the GNU General Public License
14 | # along with this program; if not, write to the Free Software
15 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 | from zope.interface import implements
17 |
18 | from twisted.application import service
19 | from twisted import plugin
20 | from twisted.python import usage
21 |
22 | import localmail
23 |
24 |
25 | class Options(usage.Options):
26 | optFlags = [
27 | ["random", "r", "Use random ports. Overides any other port options"],
28 | ]
29 | optParameters = [
30 | ["smtp", "s", 2025, "The port number the SMTP server will listen on"],
31 | ["imap", "i", 2143, "The port number the IMAP server will listen on"],
32 | ["http", "h", 8880, "The port number the HTTP server will listen on"],
33 | ["file", "f", None, "File to write messages to"],
34 | ]
35 |
36 |
37 | class LocalmailServiceMaker(object):
38 | implements(service.IServiceMaker, plugin.IPlugin)
39 | tapname = "localmail"
40 | description = "A test SMTP/IMAP server"
41 | options = Options
42 |
43 | def makeService(self, options):
44 | svc = service.MultiService()
45 | svc.setName("localmail")
46 | if options['random']:
47 | smtp_port = imap_port = http_port = 0
48 | else:
49 | smtp_port = int(options['smtp'])
50 | imap_port = int(options['imap'])
51 | http_port = int(options['http'])
52 |
53 | smtp, imap, http = localmail.get_services(
54 | smtp_port, imap_port, http_port
55 | )
56 | if options['file']:
57 | from localmail.inbox import INBOX
58 | INBOX.setFile(options['file'])
59 | imap.setServiceParent(svc)
60 | smtp.setServiceParent(svc)
61 | http.setServiceParent(svc)
62 | return svc
63 |
64 |
65 | # The name of this variable is irrelevant, as long as there is *some*
66 | # name bound to a provider of IPlugin and IServiceMaker.
67 | localmailServiceMaker = LocalmailServiceMaker()
68 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Localmail
2 | =========
3 |
4 | For local people.
5 |
6 | Localmail is an SMTP and IMAP server that stores all messages into a single
7 | in-memory mailbox. It is designed to be used to speed up running test suites on
8 | systems that send email, such as new account sign up emails with confirmation
9 | codes. It can also be used to test SMTP/IMAP client code.
10 |
11 | Features:
12 |
13 | * Fast and robust IMAP/SMTP implementations, including multipart
14 | messages and unicode support.
15 |
16 | * Includes simple HTTP interface for reading messages, which is useful for
17 | checking html emails.
18 |
19 | * Compatible with python's stdlib client, plus clients like mutt and
20 | thunderbird.
21 |
22 | * Authentication is supported but completely ignored, all message go in
23 | single mailbox.
24 |
25 | * Messages not persisted by default, and will be lost on shutdown.
26 | Optionally, you can log messages to disk in mbox format.
27 |
28 | Missing features/TODO:
29 |
30 | * SSL support
31 |
32 | WARNING: not a real SMTP/IMAP server - not for production usage.
33 |
34 |
35 | Running localmail
36 | -----------------
37 |
38 | .. code-block:: bash
39 |
40 | twistd localmail
41 |
42 | This will run localmail in the background, SMTP on port 2025 and IMAP on 2143,
43 | It will log to a file ./twistd.log. Use the -n option if you want to run in
44 | the foreground, like so.
45 |
46 | .. code-block:: bash
47 |
48 | twistd -n localmail
49 |
50 |
51 | You can pass in arguments to control parameters.
52 |
53 | .. code-block:: bash
54 |
55 | twistd localmail --imap --smtp --http --file localmail.mbox
56 |
57 |
58 | You can have localmail use random ports if you like. The port numbers will be logged.
59 | TODO: enable writing random port numbers to a file.
60 |
61 | .. code-block:: bash
62 |
63 | twisted -n localmail --random
64 |
65 |
66 | Embedding
67 | ---------
68 |
69 | If you want to embed localmail in another non-twisted program, such as test
70 | runner, do the following.
71 |
72 | .. code-block:: python
73 |
74 | import threading
75 | import localmail
76 |
77 | thread = threading.Thread(
78 | target=localmail.run,
79 | args=(2025, 2143, 8880, 'localmail.mbox')
80 | )
81 | thread.start()
82 |
83 | ...
84 |
85 | localmail.shutdown_thread(thread)
86 |
87 | This will run the twisted reactor in a separate thread, and shut it down on
88 | exit.
89 |
90 | If you want to use random ports, you can pass a callback that will have the
91 | ports the service is listening on.
92 |
93 | .. code-block:: python
94 |
95 | import threading
96 | import localmail
97 |
98 | def report(smtp, imap, http):
99 | """do stuff with ports"""
100 |
101 | thread = threading.Thread(
102 | target=localmail.run,
103 | args=(0, 0, 0, None, report)
104 | )
105 | thread.start()
106 |
107 |
108 |
--------------------------------------------------------------------------------
/localmail/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | localmail
5 |
6 |
7 |
8 |
9 |
32 |
44 |
45 |
46 |