├── tests ├── __init__.py ├── test_dsmtpd.py └── test_cli.py ├── requirements-dev.txt ├── setup.py ├── dsmtpd ├── __main__.py ├── __init__.py └── _dsmtpd.py ├── pyproject.toml ├── MANIFEST.in ├── Makefile ├── AUTHORS ├── contrib └── dsmtpd.service ├── setup.cfg ├── LICENSE ├── CHANGES.rst ├── README.rst └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | twine 3 | build 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | setup() 3 | -------------------------------------------------------------------------------- /dsmtpd/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from ._dsmtpd import main 4 | 5 | sys.exit(main()) 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "aiosmtpd"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst AUTHORS requirements.txt 2 | recursive-include dsmtpd *.py 3 | recursive-exclude dsmtpd *.pyc 4 | recursive-exclude dsmtpd *.pyo 5 | prune dist 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | build: 4 | python3 setup.py build sdist bdist_wheel 5 | 6 | check-dist: 7 | twine check dist/* 8 | 9 | upload: 10 | twine upload dist/* 11 | 12 | test: 13 | pytest 14 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | dsmtpd is written and maintained by Stephane Wirtel. 2 | 3 | Development Lead 4 | ```````````````` 5 | 6 | - Stephane Wirtel 7 | 8 | Contributors 9 | ```````````` 10 | 11 | - Bernhard E. Reiter 12 | - Sebastian Wagner 13 | -------------------------------------------------------------------------------- /dsmtpd/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | dsmtpd 4 | ~~~~~~ 5 | 6 | :copyright: (c) 2013 by Stephane Wirtel 7 | :license: BSD, see LICENSE for more details 8 | """ 9 | __name__ = "dsmtpd" 10 | __version__ = "1.1" 11 | __author__ = "Stephane Wirtel" 12 | __author_email__ = "stephane@wirtel.be" 13 | -------------------------------------------------------------------------------- /tests/test_dsmtpd.py: -------------------------------------------------------------------------------- 1 | from tempfile import TemporaryDirectory 2 | from os import listdir 3 | 4 | # at least tests the import 5 | from dsmtpd._dsmtpd import DsmtpdHandler, create_maildir 6 | 7 | 8 | def test_create_maildir(): 9 | with TemporaryDirectory() as tempdir: 10 | maildir = f"{tempdir}/Maildir" 11 | with create_maildir(maildir): 12 | assert set(listdir(maildir)) == set(('cur', 'tmp', 'new')) 13 | -------------------------------------------------------------------------------- /contrib/dsmtpd.service: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Institute for Common Good Technology 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | [Unit] 5 | Description=Daemon to run dsmtpd mail debugging service 6 | After=network.target 7 | 8 | [Service] 9 | User=dsmtpd 10 | Group=dsmtpd 11 | WorkingDirectory=/var/lib/dsmtpd/ 12 | ExecStart=/usr/bin/dsmtpd -d Maildir 13 | KillMode=mixed 14 | TimeoutStopSec=2 15 | PrivateTmp=true 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest import mock 3 | from unittest.mock import patch 4 | 5 | from dsmtpd._dsmtpd import parse_args 6 | 7 | def test_directory(): 8 | args = ["dsmtpd", "-d", "maildir"] 9 | with patch.object(sys, 'argv', args): 10 | opts = parse_args() 11 | 12 | assert opts.directory == 'maildir' 13 | 14 | def test_default_port(): 15 | args = ["dsmtpd"] 16 | with patch.object(sys, 'argv', args): 17 | opts = parse_args() 18 | 19 | assert opts.port == 1025 20 | 21 | def test_default_interface(): 22 | args = ["dsmtpd"] 23 | with patch.object(sys, 'argv', args): 24 | opts = parse_args() 25 | 26 | assert opts.interface == "127.0.0.1" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = dsmtpd 3 | version = attr: dsmtpd.__version__ 4 | url = https://github.com/matrixise/dsmtpd 5 | description = Simple SMTP Server for debugging 6 | long_description = file: README.rst, CHANGES.rst 7 | long_description_content_type = text/x-rst 8 | author = Stéphane Wirtel 9 | author_email = stephane@wirtel.be 10 | project_urls = 11 | Source = https://github.com/matrixise/dsmtpd 12 | Tracker = https://github.com/matrixise/dsmtpd/issues 13 | license = BSD 14 | classifiers = 15 | Development Status :: 5 - Production/Stable 16 | Environment :: Console 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: BSD License 19 | Programming Language :: Python :: 3 :: Only 20 | Programming Language :: Python :: 3 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: 3.12 24 | Programming Language :: Python :: 3.13 25 | Topic :: Communications :: Email 26 | 27 | [options] 28 | packages = find: 29 | include_package_data = True 30 | python_requires = >= 3.10 31 | install_requires = 32 | aiosmtpd 33 | 34 | [options.packages.find] 35 | exclude = 36 | tests 37 | 38 | [options.entry_points] 39 | console_scripts = 40 | dsmtpd = dsmtpd.__main__:main 41 | 42 | [bdist_wheel] 43 | universal = 0 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Stephane Wirtel 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, this list 8 | of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | dsmtpd Changelog 2 | ================ 3 | 4 | Here you can see the full list of changes between each dsmtpd release. 5 | 6 | Version 1.1 7 | ----------- 8 | 9 | Released on September 13th 2025. 10 | 11 | - Lower Python version requirement from 3.12 to 3.10 (#17) 12 | - Fix crash when directory exists but is not yet a valid Maildir with proper validation and repair functionality (#18) 13 | - Add exit codes documentation to README 14 | - Code formatting improvements with ruff 15 | 16 | Version 1.0 17 | ----------- 18 | 19 | Release on May 20th 2025. 20 | 21 | - Migration to aiosmtpd to Support Python >= 3.12 (#11, patch by Sebastian Wagner) 22 | - Add minimal tests for maildir check and importability 23 | - Add systemd service file (by Sebastian Wagner) 24 | 25 | Version 0.3 26 | ----------- 27 | 28 | Release on May 26th 2021. 29 | 30 | - Maildir capture: added early check (patch by Bernhard E. Reiter) 31 | - Remove the support of Docopt 32 | - Remove the support of Python 2.x (dead in 2020) 33 | - Support Python 3.6+ 34 | - Improve the classifiers for PyPI 35 | - Migrate to PEP 517 36 | - Fix License into setup.py 37 | - Add tests for the CLI using argparse instead of docopt 38 | 39 | Version 0.2 40 | ----------- 41 | 42 | Release on January 21st 2013. 43 | 44 | - Allow to store the incoming emails in a maildir via the '-d' argument 45 | 46 | Version 0.1 47 | ----------- 48 | 49 | Release on January 14th 2013. 50 | 51 | - Implement a basic server 52 | - Show the message in the log 53 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | dsmptd: A debugger SMTP server for Humans 2 | ========================================= 3 | 4 | dsmtpd is a small tool to help the developer without an smtp server 5 | 6 | Usage 7 | ----- 8 | 9 | :: 10 | 11 | $ dsmtpd -p 1025 -i 127.0.0.1 12 | 2013-01-13 14:00:07,346 INFO: Starting SMTP server at 127.0.0.1:1025 13 | 14 | 15 | Installation 16 | ------------ 17 | 18 | For the installation, we recommend to use a virtualenv, it's the easy way if you want to discover this package:: 19 | 20 | virtualenv ~/.envs/dsmtpd 21 | source ~/.envs/dsmtpd/bin/activate 22 | 23 | pip install dsmtpd 24 | 25 | Documentation 26 | ------------- 27 | 28 | Execute dsmtpd with the --help flag and you will get the usage of this command:: 29 | 30 | dsmtpd --help 31 | 32 | There are three options: 33 | 34 | * -p You specify the port of dsmtpd (default is 1025) 35 | * -i You specify the network interface (default is loopback, 127.0.0.1) 36 | * -d You specify a Maildir directory to save the incoming emails 37 | 38 | Use it 39 | ------ 40 | 41 | Here is a small example:: 42 | 43 | dsmtpd 44 | 45 | swaks --from stephane@wirtel.be --to foo@bar.com --server localhost --port 1025 46 | 47 | Exit Codes 48 | ---------- 49 | 50 | ``dsmtpd`` uses specific exit codes to indicate the result of its execution. 51 | 52 | +------+---------------------------+--------------------------------------------+ 53 | | Code | Meaning | Example | 54 | +======+===========================+============================================+ 55 | | 0 | Success | Normal shutdown (e.g. user pressed | 56 | | | | ``Ctrl+C``) or clean termination. | 57 | +------+---------------------------+--------------------------------------------+ 58 | | 2 | Invalid Maildir directory | The given path exists but does not contain | 59 | | | | the required subfolders: ``tmp``, ``new``, | 60 | | | | and ``cur``. | 61 | +------+---------------------------+--------------------------------------------+ 62 | 63 | Contributing 64 | ------------ 65 | 66 | git clone git://github.com/matrixise/dsmtpd.git 67 | 68 | 69 | Copyright 2013 (c) by Stephane Wirtel 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | .vscode/ 140 | -------------------------------------------------------------------------------- /dsmtpd/_dsmtpd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | dsmtpd/_dsmtpd.py 4 | ~~~~~~~~~~~~~~~~~ 5 | 6 | :copyright: (c) 2013 by Stephane Wirtel 7 | :license: BSD, see LICENSE for more details 8 | """ 9 | 10 | import argparse 11 | import asyncio 12 | import contextlib 13 | import email.parser 14 | import logging 15 | import mailbox 16 | import os 17 | import sys 18 | from email import policy 19 | from aiosmtpd.controller import Controller 20 | from aiosmtpd.handlers import Mailbox 21 | 22 | from dsmtpd import __name__ 23 | from dsmtpd import __version__ 24 | 25 | LOGGERNAME = "dsmtpd" 26 | 27 | DEFAULT_INTERFACE = "127.0.0.1" 28 | DEFAULT_PORT = 1025 29 | 30 | log = logging.getLogger(LOGGERNAME) 31 | 32 | 33 | # the default logging (all in level INFO) is too verbose 34 | logging.getLogger("mail.log").level = logging.WARNING 35 | 36 | 37 | @contextlib.contextmanager 38 | def create_maildir(maildir, create=True): 39 | mbox = mailbox.Maildir(maildir, create=create) 40 | try: 41 | mbox.lock() 42 | yield mbox 43 | 44 | finally: 45 | mbox.unlock() 46 | 47 | 48 | def ensure_maildir(path): 49 | """ 50 | Ensure that *path* is a valid Maildir root. 51 | If *path* does not exist, create a fresh Maildir (tmp/new/cur). 52 | If *path* exists, create any missing subfolders without wiping content. 53 | """ 54 | # If path doesn't exist at all → let mailbox.Maildir create the full layout. 55 | if not os.path.exists(path): 56 | mailbox.Maildir(path, create=True) 57 | return 58 | 59 | for sub in ("tmp", "new", "cur"): 60 | os.makedirs(os.path.join(path, sub), exist_ok=True) 61 | 62 | 63 | def is_maildir(path): 64 | "Quick structural check for a Maildir root." 65 | return all(os.path.isdir(os.path.join(path, sub)) for sub in ("tmp", "new", "cur")) 66 | 67 | 68 | class DsmtpdHandler(Mailbox): 69 | async def handle_DATA(self, server, session, envelope): 70 | if isinstance(envelope.content, bytes): # python 3.13 71 | headers = email.parser.BytesHeaderParser(policy=policy.compat32).parsebytes( 72 | envelope.content 73 | ) 74 | else: 75 | # in python 3.5 instance(envelope.content, str) is True -> we use the old code 76 | headers = email.parser.Parser().parsestr(envelope.content) 77 | 78 | values = { 79 | "peer": ":".join(map(str, session.peer)), 80 | "mail_from": envelope.mail_from, 81 | "rcpttos": ", ".join(envelope.rcpt_tos), 82 | "subject": headers.get("subject"), 83 | } 84 | log.info("%(peer)s: %(mail_from)s -> %(rcpttos)s [%(subject)s]", values) 85 | 86 | return await super().handle_DATA(server, session, envelope) 87 | 88 | 89 | def parse_args(): 90 | parser = argparse.ArgumentParser( 91 | prog=__name__, 92 | description="A small SMTP server for the smart developer", 93 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 94 | ) 95 | parser.add_argument( 96 | "--interface", 97 | "-i", 98 | help="Specify the interface", 99 | default=DEFAULT_INTERFACE, 100 | ) 101 | parser.add_argument( 102 | "--port", "-p", help="Specify the port", default=DEFAULT_PORT, type=int 103 | ) 104 | parser.add_argument( 105 | "--directory", 106 | "-d", 107 | help="Specify a Maildir directory to save the incoming emails", 108 | default=os.getcwd(), 109 | ) 110 | parser.add_argument( 111 | "--max-size", 112 | "-s", 113 | help="Maximum message size (default 32 Mebibyte). 0 means no limit.", 114 | default=33554432, # default of aiosmtpd 115 | type=int, 116 | ) 117 | parser.add_argument("--version", action="version", version=__version__) 118 | 119 | return parser.parse_args() 120 | 121 | 122 | def main(): 123 | logging.basicConfig( 124 | format="%(asctime)-15s %(levelname)s: %(message)s", level=logging.INFO 125 | ) 126 | opts = parse_args() 127 | 128 | try: 129 | log.info( 130 | "Starting {0} {1} at {2}:{3} size limit {4}".format( 131 | __name__, 132 | __version__, 133 | opts.interface, 134 | opts.port, 135 | None if opts.max_size == 0 else opts.max_size, 136 | ) 137 | ) 138 | 139 | if opts.directory: 140 | # Make sure it's a valid Maildir, whether or not the path already exists. 141 | ensure_maildir(opts.directory) 142 | 143 | # Double-check structure (defensive) and log a helpful error if not OK. 144 | if not is_maildir(opts.directory): 145 | log.fatal( 146 | "%s must be either non-existing (so it can be created) or an existing Maildir (tmp/new/cur).", 147 | opts.directory, 148 | ) 149 | return 2 150 | 151 | # Safely open and count messages (no crash if the dir previously lacked subdirs). 152 | with create_maildir(opts.directory, create=False) as maildir: 153 | try: 154 | counter = len(maildir) 155 | except FileNotFoundError as exc: 156 | # Extremely defensive: repair and retry once. 157 | log.warning( 158 | "Repairing Maildir layout after FileNotFoundError: %s", exc 159 | ) 160 | ensure_maildir(opts.directory) 161 | counter = len(maildir) 162 | 163 | if counter > 0: 164 | log.info("Found a Maildir storage with {} mails".format(counter)) 165 | 166 | log.info("Storing the incoming emails into {}".format(opts.directory)) 167 | controller = Controller( 168 | DsmtpdHandler(opts.directory), 169 | hostname=opts.interface, 170 | port=opts.port, 171 | data_size_limit=opts.max_size, 172 | ) 173 | controller.start() 174 | asyncio.get_event_loop().run_forever() 175 | controller.stop() 176 | 177 | except KeyboardInterrupt: 178 | log.info("Cleaning up") 179 | 180 | return 0 181 | 182 | 183 | if __name__ == "__main__": 184 | sys.exit(main()) 185 | --------------------------------------------------------------------------------