├── src └── smtpproto │ ├── py.typed │ ├── __init__.py │ ├── auth.py │ ├── client.py │ └── protocol.py ├── .gitignore ├── .readthedocs.yml ├── docs ├── api.rst ├── index.rst ├── conf.py ├── userguide.rst ├── versionhistory.rst └── devguide.rst ├── tests ├── conftest.py ├── test_client.py └── test_protocol.py ├── examples ├── local.py └── gmail.py ├── LICENSE ├── .pre-commit-config.yaml ├── README.rst ├── .github └── workflows │ ├── publish.yml │ └── test.yml └── pyproject.toml /src/smtpproto/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/smtpproto/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.dist-info 3 | *.pyc 4 | build 5 | dist 6 | docs/_build 7 | __pycache__ 8 | .coverage 9 | .pytest_cache/ 10 | .mypy_cache/ 11 | .ruff_cache/ 12 | .eggs/ 13 | .tox 14 | .idea 15 | .cache 16 | .local 17 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.8" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | fail_on_warning: true 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | extra_requirements: [ doc ] 17 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============== 3 | 4 | Protocol 5 | -------- 6 | 7 | .. automodule:: smtpproto.protocol 8 | :member-order: bysource 9 | 10 | Authentication 11 | -------------- 12 | 13 | .. automodule:: smtpproto.auth 14 | 15 | Concrete client implementation 16 | ------------------------------ 17 | 18 | .. automodule:: smtpproto.client 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | SMTP state-machine protocol (smtpproto) 2 | ======================================= 3 | 4 | .. include:: ../README.rst 5 | :start-after: Documentation 6 | 7 | Table of Contents 8 | ----------------- 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | 13 | devguide 14 | userguide 15 | api 16 | versionhistory 17 | 18 | Indices and tables 19 | ------------------ 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ssl 4 | 5 | import pytest 6 | import trustme 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def ca() -> trustme.CA: 11 | return trustme.CA() 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def server_context(ca: trustme.CA) -> ssl.SSLContext: 16 | server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 17 | ca.issue_cert("localhost").configure_cert(server_context) 18 | return server_context 19 | 20 | 21 | @pytest.fixture(scope="session") 22 | def client_context(ca: trustme.CA) -> ssl.SSLContext: 23 | client_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 24 | ca.configure_trust(client_context) 25 | return client_context 26 | -------------------------------------------------------------------------------- /examples/local.py: -------------------------------------------------------------------------------- 1 | from email.message import EmailMessage 2 | 3 | import anyio 4 | 5 | from smtpproto.auth import PlainAuthenticator 6 | from smtpproto.client import AsyncSMTPClient 7 | 8 | 9 | async def main() -> None: 10 | client = AsyncSMTPClient(host="localhost", port=25, authenticator=authenticator) 11 | await client.send_message(message) 12 | 13 | 14 | # If your SMTP server requires basic authentication, this is where you enter that 15 | # info 16 | authenticator = PlainAuthenticator(username="myuser", password="mypassword") 17 | 18 | # The message you want to send 19 | message = EmailMessage() 20 | message["From"] = "my.name@mydomain.com" 21 | message["To"] = "somebody@somewhere" 22 | message["Subject"] = "Test from smtpproto" 23 | message.set_content("This is a test.") 24 | 25 | # Actually sends the message by running main() 26 | anyio.run(main) 27 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | from importlib.metadata import version as get_version 5 | 6 | from packaging.version import parse 7 | 8 | extensions = [ 9 | "sphinx.ext.autodoc", 10 | "sphinx.ext.intersphinx", 11 | "sphinx_autodoc_typehints", 12 | ] 13 | 14 | templates_path = ["_templates"] 15 | source_suffix = ".rst" 16 | master_doc = "index" 17 | project = "smtpproto" 18 | author = "Alex Grönholm" 19 | copyright = "2020, " + author 20 | 21 | v = parse(get_version("smtpproto")) 22 | version = v.base_version 23 | release = v.public 24 | 25 | language = "en" 26 | 27 | exclude_patterns = ["_build"] 28 | pygments_style = "sphinx" 29 | autodoc_default_options = {"members": True, "show-inheritance": True} 30 | todo_include_todos = False 31 | 32 | html_theme = "sphinx_rtd_theme" 33 | htmlhelp_basename = project + "doc" 34 | 35 | intersphinx_mapping = { 36 | "python": ("https://docs.python.org/3/", None), 37 | "anyio": ("https://anyio.readthedocs.io/en/stable/", None), 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Alex Grönholm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # This is the configuration file for pre-commit (https://pre-commit.com/). 2 | # To use: 3 | # * Install pre-commit (https://pre-commit.com/#installation) 4 | # * Copy this file as ".pre-commit-config.yaml" 5 | # * Run "pre-commit install". 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v6.0.0 9 | hooks: 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | args: [ "--fix=lf" ] 16 | - id: trailing-whitespace 17 | 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.13.3 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --show-fixes] 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.18.2 27 | hooks: 28 | - id: mypy 29 | additional_dependencies: 30 | - aiosmtpd 31 | - anyio 32 | - pytest 33 | - trustme 34 | 35 | - repo: https://github.com/pre-commit/pygrep-hooks 36 | rev: v1.10.0 37 | hooks: 38 | - id: rst-backticks 39 | - id: rst-directive-colons 40 | - id: rst-inline-touching-normal 41 | 42 | ci: 43 | autoupdate_schedule: quarterly 44 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/agronholm/smtpproto/actions/workflows/test.yml/badge.svg 2 | :target: https://github.com/agronholm/smtpproto/actions/workflows/test.yml 3 | :alt: Build Status 4 | .. image:: https://coveralls.io/repos/github/agronholm/smtpproto/badge.svg?branch=master 5 | :target: https://coveralls.io/github/agronholm/smtpproto?branch=master 6 | :alt: Code Coverage 7 | .. image:: https://readthedocs.org/projects/smtpproto/badge/ 8 | :target: https://smtpproto.readthedocs.org/ 9 | :alt: Documentation 10 | 11 | This library contains a (client-side) sans-io_ implementation of the ESMTP_ protocol. 12 | A concrete, asynchronous I/O implementation is also provided, via the AnyIO_ library. 13 | 14 | The following SMTP extensions are supported: 15 | 16 | * 8BITMIME_ 17 | * AUTH_ 18 | * SIZE_ (max message size reporting only) 19 | * SMTPUTF8_ 20 | * STARTTLS_ 21 | 22 | You can find the documentation `here `_. 23 | 24 | .. _sans-io: https://sans-io.readthedocs.io/ 25 | .. _ESMTP: https://tools.ietf.org/html/rfc5321 26 | .. _AnyIO: https://pypi.org/project/anyio/ 27 | .. _8BITMIME: https://tools.ietf.org/html/rfc1652 28 | .. _AUTH: https://tools.ietf.org/html/rfc4954 29 | .. _SMTPUTF8: https://tools.ietf.org/html/rfc6531 30 | .. _SIZE: https://tools.ietf.org/html/rfc1870 31 | .. _STARTTLS: https://tools.ietf.org/html/rfc3207 32 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish packages to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+.post[0-9]+" 8 | - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" 9 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | environment: release 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - name: Install dependencies 22 | run: pip install build 23 | - name: Create packages 24 | run: python -m build 25 | - name: Archive packages 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: dist 29 | path: dist 30 | 31 | publish: 32 | needs: build 33 | runs-on: ubuntu-latest 34 | environment: release 35 | permissions: 36 | id-token: write 37 | steps: 38 | - name: Retrieve packages 39 | uses: actions/download-artifact@v4 40 | - name: Upload packages 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | 43 | release: 44 | name: Create a GitHub release 45 | needs: build 46 | runs-on: ubuntu-latest 47 | permissions: 48 | contents: write 49 | steps: 50 | - uses: actions/checkout@v4 51 | - id: changelog 52 | uses: agronholm/release-notes@v1 53 | with: 54 | path: docs/versionhistory.rst 55 | - uses: ncipollo/release-action@v1 56 | with: 57 | body: ${{ steps.changelog.outputs.changelog }} 58 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test suite 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | pyright: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.x 18 | - uses: actions/cache@v4 19 | with: 20 | path: ~/.cache/pip 21 | key: pip-pyright 22 | - name: Install dependencies 23 | run: pip install -e . pyright pytest 24 | - name: Run pyright 25 | run: pyright --verifytypes smtpproto 26 | 27 | test: 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | allow-prereleases: true 40 | cache: pip 41 | cache-dependency-path: pyproject.toml 42 | - name: Install dependencies 43 | run: pip install .[test] 44 | - name: Test with pytest 45 | run: coverage run -m pytest 46 | - name: Upload Coverage 47 | uses: coverallsapp/github-action@v2 48 | with: 49 | parallel: true 50 | 51 | coveralls: 52 | name: Finish Coveralls 53 | needs: test 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Finished 57 | uses: coverallsapp/github-action@v2 58 | with: 59 | parallel-finished: true 60 | -------------------------------------------------------------------------------- /docs/userguide.rst: -------------------------------------------------------------------------------- 1 | Using the concrete I/O implementations 2 | ====================================== 3 | 4 | .. py:currentmodule:: smtpproto.client 5 | 6 | In addition to the sans-io protocol implementation, this library also provides both an 7 | asynchronous and a synchronous SMTP client class (:class:`~AsyncSMTPClient` and 8 | :class:`~SyncSMTPClient`, respectively). 9 | 10 | Most SMTP servers, however, require some form of authentication. While it would be 11 | unfeasible to provide solutions for every possible situation, the examples below should 12 | cover some very common cases and should give you a general idea of how to work with SMTP 13 | authentication. 14 | 15 | For the OAuth2 examples (further below), you need to install a couple dependencies: 16 | 17 | * httpx_ 18 | * PyJWT_ (Gmail only) 19 | 20 | .. _httpx: https://pypi.org/project/httpx/ 21 | .. _PyJWT: https://pypi.org/project/pyjwt/ 22 | 23 | 24 | Sending mail via a local SMTP server 25 | ------------------------------------ 26 | 27 | .. literalinclude:: ../examples/local.py 28 | :language: python 29 | 30 | 31 | Sending mail via Gmail 32 | ---------------------- 33 | 34 | The `developer documentation`_ for the G Suite describes how to use the XOAUTH2 35 | mechanism for authenticating against the Gmail SMTP server. The following is a practical 36 | example of how to extend the :class:`~.auth.OAuth2Authenticator` class to obtain an 37 | access token and use it to send an email via Gmail. 38 | 39 | The following example assumes the presence of an existing `G Suite service account`_ 40 | authorized to send email via SMTP (using the ``https://mail.google.com/`` scope). 41 | 42 | .. literalinclude:: ../examples/gmail.py 43 | :language: python 44 | 45 | .. _developer documentation: https://developers.google.com/gmail/imap/xoauth2-protocol 46 | .. _G Suite service account: https://support.google.com/a/answer/7378726?hl=en 47 | -------------------------------------------------------------------------------- /docs/versionhistory.rst: -------------------------------------------------------------------------------- 1 | Version history 2 | =============== 3 | 4 | This library adheres to `Semantic Versioning 2.0 `_. 5 | 6 | **UNRELEASED** 7 | 8 | - Upgraded minimum AnyIO version to 4.4.0 9 | 10 | **2.0.0** 11 | 12 | - **BACKWARDS INCOMPATIBLE** The concrete client implementations were refactored: 13 | 14 | * ``AsyncSMTPClient`` and ``SyncSMTPClient`` were refactored into "session factories", 15 | and thus are no longer used as context managers 16 | * The ``send_message()`` method is now reentrant, as it now creates (and closes) an 17 | ad-hoc session with the SMTP server 18 | * The ``connect()`` method now returns a context manager that yields an SMTP session 19 | - **BACKWARDS INCOMPATIBLE** The ``OAuth2Authenticator`` class was refactored: 20 | 21 | * The return type of ``get_token()`` was changed to a (decoded) JSON web token – 22 | a dict containing the ``access_token`` and ``expires_in`` fields 23 | * The result of ``get_token()`` method is now automatically cached until the token's 24 | expiration time nears (configurable via the ``grace_period`` parameter in 25 | ``OAuth2Authenticator``) 26 | * Added the ``clear_cached_token()`` method 27 | - Dropped support for Python 3.7 28 | - Upgraded minimum AnyIO version to 4.2+ 29 | - The ``Bcc`` and ``Resent-Bcc`` are now properly added to the recipients list by the 30 | concrete client implementation 31 | - The ``Bcc`` and ``Resent-Bcc`` headers are now automatically left out of the data in 32 | ``SMTPClientProtocol.data()`` to simplify client implementations 33 | 34 | **1.2.1** 35 | 36 | - Fixed ``LoginAuthenticator`` expecting the wrong questions (there should be a ``:`` at 37 | the end) 38 | - Fixed compatibility with AnyIO 4 39 | 40 | **1.2.0** 41 | 42 | - Dropped support for Python 3.6 43 | - Added support for Python 3.10 44 | - Upgraded minimum AnyIO version to 3.0+ 45 | - Changed ``SMTPClientProtocol`` to only use ``SMTPUTF8`` if necessary (PR by 46 | Cole Maclean) 47 | 48 | **1.1.0** 49 | 50 | - Added missing ``authorization_id`` parameter to ``PlainAuthenticator`` (also fixes 51 | ``PLAIN`` authentication not working since this field was missing from the encoded 52 | output) 53 | - Fixed sender/recipient addresses (in ``MAIL``/``RCPT`` commands) not being UTF-8 54 | encoded in the presence of the ``SMTPUTF8`` extension 55 | 56 | **1.0.0** 57 | 58 | - Initial release 59 | -------------------------------------------------------------------------------- /examples/gmail.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from email.message import EmailMessage 3 | from typing import cast 4 | 5 | import anyio 6 | import httpx 7 | import jwt 8 | 9 | from smtpproto.auth import JSONWebToken, OAuth2Authenticator 10 | from smtpproto.client import AsyncSMTPClient 11 | 12 | 13 | class GMailAuthenticator(OAuth2Authenticator): 14 | def __init__(self, username: str, client_id: str, private_key: str): 15 | super().__init__(username) 16 | self.client_id = client_id 17 | self.private_key = private_key 18 | 19 | async def get_token(self) -> JSONWebToken: 20 | webtoken = jwt.encode( 21 | { 22 | "iss": self.client_id, 23 | "scope": "https://mail.google.com/", 24 | "aud": "https://oauth2.googleapis.com/token", 25 | "exp": datetime.utcnow() + timedelta(minutes=1), 26 | "iat": datetime.utcnow(), 27 | "sub": self.username, 28 | }, 29 | self.private_key, 30 | algorithm="RS256", 31 | ) 32 | 33 | async with httpx.AsyncClient() as http: 34 | response = await http.post( 35 | "https://oauth2.googleapis.com/token", 36 | data={ 37 | "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 38 | "assertion": webtoken.decode("ascii"), 39 | }, 40 | ) 41 | response.raise_for_status() 42 | return cast(JSONWebToken, await response.json()) 43 | 44 | 45 | async def main() -> None: 46 | client = AsyncSMTPClient(host="smtp.gmail.com", authenticator=authenticator) 47 | await client.send_message(message) 48 | 49 | 50 | authenticator = GMailAuthenticator( 51 | # Your gmail user name 52 | username="my.name@gmail.com", 53 | # Service account ID and private key – these have to be obtained from Gmail 54 | client_id="yourserviceaccount@yourdomain.iam.gserviceaccount.com", 55 | private_key="-----BEGIN PRIVATE KEY-----\n...-----END PRIVATE KEY-----\n", 56 | ) 57 | 58 | # The message you want to send 59 | message = EmailMessage() 60 | message["From"] = "my.name@gmail.com" 61 | message["To"] = "somebody@somewhere" 62 | message["Subject"] = "Test from smtpproto" 63 | message.set_content("This is a test.") 64 | 65 | # Actually sends the message by running main() 66 | anyio.run(main) 67 | -------------------------------------------------------------------------------- /docs/devguide.rst: -------------------------------------------------------------------------------- 1 | Developing new I/O implementations 2 | ================================== 3 | 4 | .. py:currentmodule:: smtpproto.protocol 5 | 6 | The procedure to using the SMTP client protocol state machine to communicate with an 7 | SMTP server is as follows: 8 | 9 | #. Create the state machine (:class:`~SMTPClientProtocol`) 10 | #. Connect to the SMTP server using your chosen I/O backend 11 | 12 | Sending commands and receiving responses: 13 | 14 | #. Call the appropriate method on the state machine 15 | #. Retrieve the outgoing data with :meth:`~SMTPClientProtocol.get_outgoing_data` 16 | #. Use your I/O backend to send that data to the server 17 | #. Use your I/O backend to receive the response data 18 | #. Feed the response data to the state machine using 19 | :meth:`~SMTPClientProtocol.feed_bytes` 20 | #. If the return value is an :class:`~SMTPResponse` (and not ``None``), process the 21 | response as appropriate. You can use :meth:`~SMTPResponse.is_error` as a convenience 22 | to check if the response code means there was an error. 23 | 24 | Establishing a TLS session after connection (optional): 25 | 26 | #. Check if the feature is supported by the server (``STARTTLS`` is in 27 | :attr:`~SMTPClientProtocol.extensions`) 28 | #. Send the ``STARTTLS`` command using :meth:`~SMTPClientProtocol.start_tls` 29 | #. Use your I/O backend to do the TLS handshake in client mode 30 | (:meth:`~ssl.SSLContext.wrap_socket` or whatever you prefer) 31 | #. Proceed with the session as usual 32 | 33 | Developing new authenticators 34 | ============================= 35 | 36 | .. py:currentmodule:: smtpproto.auth 37 | 38 | To add support for a new authentication mechanism, you can create a new class that 39 | inherits from either :class:`~SMTPAuthenticator` or one of its subclasses. This subclass 40 | needs to implement: 41 | 42 | * The :attr:`~SMTPAuthenticator.mechanism` property 43 | * The :meth:`~SMTPAuthenticator.authenticate` method 44 | 45 | The ``mechanism`` property should return the name of the authentication mechanism (in 46 | upper case letters). It is used to send the initial ``AUTH`` command. If ``mechanism`` 47 | returns ``FOOBAR``, the client would send the command ``AUTH FOOBAR``. 48 | 49 | The ``authenticate`` method should return an asynchronous generator that yields strings. 50 | If the generator yields a nonempty string on the first call, it is added to the ``AUTH`` 51 | command. For example, given the following code, the client would authenticate with the 52 | command ``AUTH FOOBAR mysecret``:: 53 | 54 | from smtpproto.auth import SMTPAuthenticator 55 | 56 | class MyAuthenticator(SMTPAuthenticator): 57 | @property 58 | def mechanism(self) -> str: 59 | return 'FOOBAR' 60 | 61 | async def authenticate(self) -> AsyncGenerator[str, str]: 62 | yield 'mysecret' 63 | 64 | For mechanisms such as ``LOGIN`` that involve more rounds of information exchange, the 65 | generator typically yields an empty string first. It will then be sent back the server 66 | response text as the ``yield`` result. The authenticator will then yield its own 67 | response, and so forth. See the source code of the :class:`~LoginAuthenticator` class 68 | for an example. 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 64", 4 | "setuptools_scm >= 6.4" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "smtpproto" 10 | description = "Sans-io SMTP client with an AnyIO based async I/O implementation" 11 | readme = "README.rst" 12 | authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] 13 | license = {text = "MIT"} 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Typing :: Typed", 19 | "Framework :: AnyIO", 20 | "Topic :: Communications :: Email", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | ] 29 | requires-python = ">= 3.8" 30 | dependencies = [ "anyio ~= 4.4" ] 31 | dynamic = [ "version" ] 32 | 33 | [project.urls] 34 | Documentation = "https://smtpproto.readthedocs.io/en/latest/" 35 | "Source code" = "https://github.com/agronholm/smtpproto" 36 | "Issue tracker" = "https://github.com/agronholm/smtpproto/issues" 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "anyio[trio] >= 3.0", 41 | "aiosmtpd >= 1.4.4", 42 | "coverage >= 7", 43 | "pytest >= 6.0", 44 | "trustme", 45 | ] 46 | doc = [ 47 | "packaging", 48 | "sphinx_rtd_theme >= 1.3.0", 49 | "sphinx-autodoc-typehints >= 1.2.0", 50 | ] 51 | 52 | [tool.setuptools_scm] 53 | version_scheme = "post-release" 54 | local_scheme = "dirty-tag" 55 | 56 | [tool.ruff.lint] 57 | extend-select = [ 58 | "B0", # flake8-bugbear 59 | "G", # flake8-logging-format 60 | "I", # isort 61 | "ISC", # flake8-implicit-str-concat 62 | "PGH", # pygrep-hooks 63 | "RUF100", # unused noqa (yesqa) 64 | "UP", # pyupgrade 65 | "W", # pycodestyle warnings 66 | ] 67 | 68 | [tool.mypy] 69 | python_version = "3.8" 70 | strict = true 71 | 72 | [tool.pytest.ini_options] 73 | addopts = "-rsx --tb=short" 74 | testpaths = "tests" 75 | filterwarnings = "always" 76 | 77 | [tool.coverage.run] 78 | source = ["smtpproto"] 79 | 80 | [tool.coverage.report] 81 | show_missing = true 82 | 83 | [tool.tox] 84 | legacy_tox_ini = """ 85 | [tox] 86 | envlist = lint, py38, py39, py310, py311, py312, pypy3 87 | skip_missing_interpreters = true 88 | minversion = 4.0 89 | 90 | [testenv] 91 | depends = pre-commit 92 | package = editable 93 | commands = pytest {posargs} 94 | extras = test 95 | 96 | [testenv:pre-commit] 97 | depends = 98 | package = skip 99 | deps = pre-commit 100 | commands = pre-commit run --all-files --show-diff-on-failure 101 | 102 | [testenv:pyright] 103 | deps = pyright 104 | commands = pyright --verifytypes smtpproto 105 | 106 | [testenv:docs] 107 | extras = doc 108 | commands = sphinx-build -W -n docs build/sphinx 109 | """ 110 | -------------------------------------------------------------------------------- /src/smtpproto/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from abc import ABCMeta, abstractmethod 5 | from base64 import b64decode, b64encode 6 | from collections.abc import AsyncGenerator 7 | from dataclasses import dataclass 8 | from typing import TypedDict 9 | 10 | 11 | class SMTPAuthenticator(metaclass=ABCMeta): 12 | """Interface for providing credentials for authenticating against SMTP servers.""" 13 | 14 | @property 15 | @abstractmethod 16 | def mechanism(self) -> str: 17 | """The name of the authentication mechanism (e.g. ``PLAIN`` or ``GSSAPI``).""" 18 | 19 | @abstractmethod 20 | def authenticate(self) -> AsyncGenerator[str, str]: 21 | """ 22 | Performs authentication against the SMTP server. 23 | 24 | This method must return an async generator. Any non-empty values the generator 25 | yields are sent to the server as authentication data. The response messages from 26 | any 334 responses are sent to the generator. 27 | """ 28 | 29 | 30 | @dataclass 31 | class PlainAuthenticator(SMTPAuthenticator): 32 | """ 33 | Authenticates against the server with a username/password combination using the 34 | PLAIN method. 35 | 36 | :param username: user name to authenticate as 37 | :param password: password to authenticate with 38 | :param authorization_id: optional authorization ID 39 | """ 40 | 41 | username: str 42 | password: str 43 | authorization_id: str = "" 44 | 45 | @property 46 | def mechanism(self) -> str: 47 | return "PLAIN" 48 | 49 | async def authenticate(self) -> AsyncGenerator[str, str]: 50 | joined = ( 51 | self.authorization_id + "\x00" + self.username + "\x00" + self.password 52 | ).encode("utf-8") 53 | yield b64encode(joined).decode("ascii") 54 | 55 | 56 | @dataclass 57 | class LoginAuthenticator(SMTPAuthenticator): 58 | """ 59 | Authenticates against the server with a username/password combination using the 60 | LOGIN method. 61 | 62 | :param username: user name to authenticate as 63 | :param password: password to authenticate with 64 | """ 65 | 66 | username: str 67 | password: str 68 | 69 | @property 70 | def mechanism(self) -> str: 71 | return "LOGIN" 72 | 73 | async def authenticate(self) -> AsyncGenerator[str, str]: 74 | for _ in range(2): 75 | raw_question = yield "" 76 | question = b64decode(raw_question.encode("ascii")).lower() 77 | if question == b"username:": 78 | yield b64encode(self.username.encode("utf-8")).decode("ascii") 79 | elif question == b"password:": 80 | yield b64encode(self.password.encode("utf-8")).decode("ascii") 81 | else: 82 | raise ValueError(f"Unhandled question: {raw_question}") 83 | 84 | 85 | class JSONWebToken(TypedDict): 86 | """ 87 | :ivar str access_token: the access token 88 | :ivar int expires_in: seconds after which the access token expires 89 | 90 | """ 91 | 92 | access_token: str 93 | expires_in: int 94 | 95 | 96 | class OAuth2Authenticator(SMTPAuthenticator): 97 | """ 98 | Authenticates against the server using OAUTH2. 99 | 100 | In order to use this authenticator, you must subclass it and implement the 101 | :meth:`get_token` method. 102 | 103 | :param username: the user name to authenticate as 104 | :param grace_period: number of seconds prior to token expiration to get a new one 105 | """ 106 | 107 | _stored_token: str | None = None 108 | _expires_at: float | None = None 109 | 110 | def __init__(self, username: str, *, grace_period: float = 600): 111 | self.username: str = username 112 | self.grace_period = grace_period 113 | 114 | @property 115 | def mechanism(self) -> str: 116 | return "XOAUTH2" 117 | 118 | async def authenticate(self) -> AsyncGenerator[str, str]: 119 | # Don't request a new token unless it has expired or is close to expiring 120 | if ( 121 | self._stored_token 122 | and self._expires_at 123 | and time.monotonic() - self._expires_at 124 | ): 125 | token = self._stored_token 126 | else: 127 | jwt = await self.get_token() 128 | self._stored_token = token = jwt["access_token"] 129 | self._expires_at = time.monotonic() + jwt["expires_in"] - self.grace_period 130 | 131 | auth_string = f"user={self.username}\x01auth=Bearer {token}\x01\x01" 132 | yield b64encode(auth_string.encode("utf-8")).decode("ascii") 133 | 134 | @abstractmethod 135 | async def get_token(self) -> JSONWebToken: 136 | """ 137 | Obtain a new access token. 138 | 139 | This method will be called only when there either is no cached token, or the 140 | cached token is expired or nearing expiration. You can also use 141 | :meth:`clear_cached_token` to manually erase the cached token. The 142 | ``expires_in`` field in the returned dict is the number of seconds after which 143 | the token will expire. 144 | 145 | .. note:: If the backing server does not provide a value for ``expires_in``, 146 | the implementor must fill in the value by other means. 147 | 148 | :return: a dict containing the ``access_token`` and ``expires_in`` fields 149 | """ 150 | 151 | def clear_cached_token(self) -> None: 152 | """Clear the previously stored token, if any.""" 153 | self._stored_token = self._expires_at = None 154 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ssl 4 | from collections import defaultdict 5 | from collections.abc import Callable, Generator 6 | from concurrent.futures import ThreadPoolExecutor 7 | from contextlib import ExitStack, closing, contextmanager 8 | from email.headerregistry import Address 9 | from email.message import EmailMessage 10 | from socket import socket 11 | from typing import Any 12 | 13 | import pytest 14 | from aiosmtpd.controller import Controller 15 | from aiosmtpd.handlers import Sink 16 | from aiosmtpd.smtp import SMTP, AuthResult, Envelope, Session 17 | from anyio import create_task_group 18 | 19 | from smtpproto.auth import PlainAuthenticator 20 | from smtpproto.client import AsyncSMTPClient, SyncSMTPClient 21 | from smtpproto.protocol import SMTPException 22 | 23 | pytestmark = pytest.mark.anyio 24 | 25 | 26 | class DummyController(Controller): 27 | def __init__( 28 | self, 29 | handler: Any, 30 | factory: Callable[..., SMTP] = SMTP, 31 | hostname: str | None = None, 32 | port: int = 0, 33 | *, 34 | ready_timeout: float = 1.0, 35 | ssl_context: ssl.SSLContext | None = None, 36 | ): 37 | super().__init__( 38 | handler, 39 | hostname=hostname, 40 | port=port, 41 | ready_timeout=ready_timeout, 42 | ssl_context=None, 43 | ) 44 | self.__factory = factory 45 | self.__ssl_context = ssl_context 46 | 47 | def factory(self) -> SMTP: 48 | return self.__factory( 49 | self.handler, hostname=self.hostname, tls_context=self.__ssl_context 50 | ) 51 | 52 | 53 | @contextmanager 54 | def start_server( 55 | *, 56 | ssl_context: ssl.SSLContext | None = None, 57 | factory: Callable[..., SMTP] = SMTP, 58 | handler: Any = Sink, 59 | ) -> Generator[tuple[str, int], Any, None]: 60 | with closing(socket()) as sock: 61 | sock.bind(("localhost", 0)) 62 | port = sock.getsockname()[1] 63 | 64 | controller = DummyController( 65 | handler, 66 | factory=factory, 67 | hostname="localhost", 68 | port=port, 69 | ssl_context=ssl_context, 70 | ) 71 | controller.start() 72 | yield controller.hostname, port 73 | controller.stop() 74 | 75 | 76 | @pytest.fixture 77 | def message() -> EmailMessage: 78 | message = EmailMessage() 79 | message["From"] = Address("Foo Bar", "foo.bar", "baz.com") 80 | message["To"] = ["test1@example.org"] 81 | message["Cc"] = ["test2@example.org"] 82 | message["Bcc"] = ["test3@example.org"] 83 | message["Resent-To"] = ["test4@example.org"] 84 | message["Resent-Cc"] = ["test5@example.org"] 85 | message["Resent-Bcc"] = ["test6@example.org"] 86 | message["Subject"] = "Unicöde string" 87 | return message 88 | 89 | 90 | class TestAsyncClient: 91 | @pytest.mark.parametrize("use_tls", [False, True], ids=["notls", "tls"]) 92 | async def test_send_mail( 93 | self, 94 | client_context: ssl.SSLContext, 95 | server_context: ssl.SSLContext, 96 | use_tls: bool, 97 | message: EmailMessage, 98 | ) -> None: 99 | received_recipients = [] 100 | received_content = b"" 101 | 102 | class Handler: 103 | async def handle_RCPT( 104 | self, 105 | server: SMTP, 106 | session: Session, 107 | envelope: Envelope, 108 | address: str, 109 | rcpt_options: list[str], 110 | ) -> str: 111 | received_recipients.append(address) 112 | envelope.rcpt_tos.append(address) 113 | envelope.rcpt_options.extend(rcpt_options) 114 | return "250 OK" 115 | 116 | async def handle_DATA( 117 | self, server: SMTP, session: Session, envelope: Envelope 118 | ) -> str: 119 | nonlocal received_content 120 | received_content = envelope.original_content or b"" 121 | return "250 OK" 122 | 123 | with start_server( 124 | ssl_context=server_context if use_tls else None, handler=Handler() 125 | ) as (host, port): 126 | client = AsyncSMTPClient(host=host, port=port, ssl_context=client_context) 127 | await client.send_message(message) 128 | 129 | assert received_recipients == [ 130 | f"test{index}@example.org" for index in range(1, 7) 131 | ] 132 | assert b"To: test1@example.org" in received_content 133 | assert b"Cc: test2@example.org" in received_content 134 | assert b"Resent-To: test4@example.org" in received_content 135 | assert b"Resent-Cc: test5@example.org" in received_content 136 | assert b"Bcc:" not in received_content 137 | assert b"Resent-Bcc:" not in received_content 138 | 139 | async def test_concurrency(self, message: EmailMessage) -> None: 140 | received_recipients: dict[str, int] = defaultdict(lambda: 0) 141 | received_contents: list[bytes] = [] 142 | 143 | class Handler: 144 | async def handle_RCPT( 145 | self, 146 | server: SMTP, 147 | session: Session, 148 | envelope: Envelope, 149 | address: str, 150 | rcpt_options: list[str], 151 | ) -> str: 152 | received_recipients[address] += 1 153 | envelope.rcpt_tos.append(address) 154 | envelope.rcpt_options.extend(rcpt_options) 155 | return "250 OK" 156 | 157 | async def handle_DATA( 158 | self, server: SMTP, session: Session, envelope: Envelope 159 | ) -> str: 160 | received_contents.append(envelope.original_content or b"") 161 | return "250 OK" 162 | 163 | with start_server(handler=Handler()) as (host, port): 164 | client = AsyncSMTPClient(host=host, port=port) 165 | 166 | async def send_multiple_messages(count: int) -> None: 167 | async with client.connect() as session: 168 | for _ in range(count): 169 | await session.send_message(message) 170 | 171 | async with create_task_group() as tg: 172 | for _ in range(10): 173 | tg.start_soon(send_multiple_messages, 10) 174 | 175 | for index in range(1, 7): 176 | assert received_recipients[f"test{index}@example.org"] == 100 177 | 178 | assert len(received_contents) == 100 179 | 180 | async def test_no_esmtp_support(self) -> None: 181 | class NoESMTP(SMTP): 182 | async def smtp_EHLO(self, hostname: str) -> None: 183 | await self.push("500 Unknown command") 184 | 185 | with start_server(factory=NoESMTP) as (host, port): 186 | client = AsyncSMTPClient(host=host, port=port) 187 | async with client.connect(): 188 | pass 189 | 190 | @pytest.mark.parametrize("success", [True, False], ids=["success", "failure"]) 191 | async def test_auth_plain( 192 | self, 193 | client_context: ssl.SSLContext, 194 | server_context: ssl.SSLContext, 195 | success: bool, 196 | ) -> None: 197 | class AuthCapableSMTP(SMTP): 198 | async def auth_PLAIN(self, _: Any, args: list[str]) -> AuthResult: 199 | expected = "AHVzZXJuYW1lAHBhc3N3b3Jk" 200 | if args[1] == expected and success: 201 | return AuthResult(success=True) 202 | else: 203 | return AuthResult(success=False, handled=False) 204 | 205 | with ExitStack() as stack: 206 | host, port = stack.enter_context( 207 | start_server(ssl_context=server_context, factory=AuthCapableSMTP) 208 | ) 209 | if not success: 210 | stack.enter_context(pytest.raises(SMTPException)) 211 | 212 | authenticator = PlainAuthenticator("username", "password") 213 | client = AsyncSMTPClient( 214 | host=host, 215 | port=port, 216 | ssl_context=client_context, 217 | authenticator=authenticator, 218 | ) 219 | async with client.connect(): 220 | pass 221 | 222 | 223 | class TestSyncClient: 224 | @pytest.mark.parametrize("use_tls", [False, True], ids=["notls", "tls"]) 225 | def test_send_mail( 226 | self, 227 | client_context: ssl.SSLContext, 228 | server_context: ssl.SSLContext, 229 | use_tls: bool, 230 | ) -> None: 231 | message = EmailMessage() 232 | message["From"] = Address("Foo Bar", "foo.bar", "baz.com") 233 | message["To"] = ["test@example.org"] 234 | message["Cc"] = ["test2@example.org"] 235 | message["Subject"] = "Unicöde string" 236 | with start_server(ssl_context=server_context if use_tls else None) as ( 237 | host, 238 | port, 239 | ): 240 | client = SyncSMTPClient(host=host, port=port, ssl_context=client_context) 241 | client.send_message(message) 242 | 243 | def test_concurrency(self, message: EmailMessage) -> None: 244 | received_recipients: dict[str, int] = defaultdict(lambda: 0) 245 | received_contents: list[bytes] = [] 246 | 247 | class Handler: 248 | async def handle_RCPT( 249 | self, 250 | server: SMTP, 251 | session: Session, 252 | envelope: Envelope, 253 | address: str, 254 | rcpt_options: list[str], 255 | ) -> str: 256 | received_recipients[address] += 1 257 | envelope.rcpt_tos.append(address) 258 | envelope.rcpt_options.extend(rcpt_options) 259 | return "250 OK" 260 | 261 | async def handle_DATA( 262 | self, server: SMTP, session: Session, envelope: Envelope 263 | ) -> str: 264 | received_contents.append(envelope.original_content or b"") 265 | return "250 OK" 266 | 267 | with start_server(handler=Handler()) as (host, port): 268 | client = SyncSMTPClient(host=host, port=port) 269 | 270 | def send_multiple_messages(count: int) -> None: 271 | with client.connect() as session: 272 | for _ in range(count): 273 | session.send_message(message) 274 | 275 | with ThreadPoolExecutor() as executor: 276 | for _ in range(10): 277 | executor.submit(send_multiple_messages, 10) 278 | 279 | for index in range(1, 7): 280 | assert received_recipients[f"test{index}@example.org"] == 100 281 | 282 | assert len(received_contents) == 100 283 | 284 | def test_no_esmtp_support(self) -> None: 285 | class NoESMTP(SMTP): 286 | async def smtp_EHLO(self, hostname: str) -> None: 287 | await self.push("500 Unknown command") 288 | 289 | with start_server(factory=NoESMTP) as (host, port): 290 | client = SyncSMTPClient(host=host, port=port) 291 | with client.connect(): 292 | pass 293 | 294 | @pytest.mark.parametrize("success", [True, False], ids=["success", "failure"]) 295 | def test_auth_plain( 296 | self, 297 | client_context: ssl.SSLContext, 298 | server_context: ssl.SSLContext, 299 | success: bool, 300 | ) -> None: 301 | class AuthCapableSMTP(SMTP): 302 | async def auth_PLAIN(self, _: Any, args: list[str]) -> AuthResult: 303 | expected = "AHVzZXJuYW1lAHBhc3N3b3Jk" 304 | if args[1] == expected and success: 305 | return AuthResult(success=True) 306 | else: 307 | return AuthResult(success=False, handled=False) 308 | 309 | with ExitStack() as stack: 310 | host, port = stack.enter_context( 311 | start_server(ssl_context=server_context, factory=AuthCapableSMTP) 312 | ) 313 | if not success: 314 | stack.enter_context(pytest.raises(SMTPException)) 315 | 316 | authenticator = PlainAuthenticator("username", "password") 317 | client = SyncSMTPClient( 318 | host=host, 319 | port=port, 320 | ssl_context=client_context, 321 | authenticator=authenticator, 322 | ) 323 | with client.connect(): 324 | pass 325 | -------------------------------------------------------------------------------- /src/smtpproto/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import socket 5 | import sys 6 | from collections.abc import AsyncGenerator, Callable, Generator, Iterable 7 | from contextlib import AsyncExitStack, asynccontextmanager, contextmanager 8 | from dataclasses import dataclass, field 9 | from email.headerregistry import Address 10 | from email.message import EmailMessage 11 | from email.utils import getaddresses, parseaddr 12 | from ssl import SSLContext 13 | from types import TracebackType 14 | from typing import Any, TypeVar, cast 15 | from warnings import warn 16 | 17 | from anyio import ( 18 | BrokenResourceError, 19 | Semaphore, 20 | aclose_forcefully, 21 | connect_tcp, 22 | fail_after, 23 | move_on_after, 24 | ) 25 | from anyio.abc import BlockingPortal, SocketStream 26 | from anyio.from_thread import BlockingPortalProvider 27 | from anyio.streams.tls import TLSStream 28 | 29 | from .auth import SMTPAuthenticator 30 | from .protocol import ClientState, SMTPClientProtocol, SMTPResponse 31 | 32 | if sys.version_info >= (3, 10): 33 | from typing import ParamSpec 34 | else: 35 | from typing_extensions import ParamSpec 36 | 37 | logger: logging.Logger = logging.getLogger(__name__) 38 | P = ParamSpec("P") 39 | TAsync = TypeVar("TAsync", bound="AsyncSMTPClient") 40 | TSync = TypeVar("TSync", bound="SyncSMTPClient") 41 | 42 | 43 | @dataclass 44 | class AsyncSMTPSession: 45 | """ 46 | Encapsulates a live connection to an SMTP server. 47 | 48 | :ivar SMTPClientProtocol protocol: the protocol state machine 49 | """ 50 | 51 | host: str 52 | port: int 53 | connect_timeout: float 54 | timeout: float 55 | domain: str 56 | authenticator: SMTPAuthenticator | None 57 | ssl_context: SSLContext | None 58 | protocol: SMTPClientProtocol = field(init=False, default_factory=SMTPClientProtocol) 59 | _stream: TLSStream | SocketStream = field(init=False) 60 | _exit_stack: AsyncExitStack = field(init=False) 61 | 62 | async def send_message( 63 | self, 64 | message: EmailMessage, 65 | *, 66 | sender: str | Address | None = None, 67 | recipients: Iterable[str] | None = None, 68 | ) -> SMTPResponse: 69 | """ 70 | Send an email message. 71 | 72 | :param message: the message to send 73 | :param sender: override the sender address in the ``MAIL FROM`` command 74 | :param recipients: override the destination addresses in the ``RCPT TO`` 75 | commands 76 | :return: the SMTP response 77 | 78 | """ 79 | # type checkers don't handle default typevar values yet, so they see 80 | # message.get() returning Any | None thought it should be str 81 | from_ = cast(str, message.get("From")) 82 | sender = sender or parseaddr(from_)[1] 83 | await self.send_command(self.protocol.mail, sender) 84 | 85 | if not recipients: 86 | tos: list[str] = message.get_all("to", []) 87 | ccs: list[str] = message.get_all("cc", []) 88 | bccs: list[str] = message.get_all("bcc", []) 89 | resent_tos: list[str] = message.get_all("resent-to", []) 90 | resent_ccs: list[str] = message.get_all("resent-cc", []) 91 | resent_bccs: list[str] = message.get_all("resent-bcc", []) 92 | recipients = [ 93 | email 94 | for name, email in getaddresses( 95 | tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs 96 | ) 97 | ] 98 | 99 | for recipient in recipients: 100 | await self.send_command(self.protocol.recipient, recipient) 101 | 102 | await self.send_command(self.protocol.start_data) 103 | return await self.send_command(self.protocol.data, message) 104 | 105 | async def send_command( 106 | self, command: Callable[P, None], /, *args: P.args, **kwargs: P.kwargs 107 | ) -> SMTPResponse: 108 | """ 109 | Send a command to the SMTP server and return the response. 110 | 111 | :param command: a callable from :class:`~.protocol.SMTPClientProtocol` 112 | :param args: positional arguments to ``command`` 113 | :param kwargs: keyword arguments to ``command`` 114 | 115 | """ 116 | command(*args, **kwargs) 117 | await self._flush_output() 118 | return await self._wait_response() 119 | 120 | async def _wait_response(self) -> SMTPResponse: 121 | while True: 122 | if self.protocol.needs_incoming_data: 123 | try: 124 | with fail_after(self.timeout): 125 | data = await self._stream.receive() 126 | except (BrokenResourceError, TimeoutError): 127 | await aclose_forcefully(self._stream) 128 | del self._stream 129 | raise 130 | 131 | logger.debug("Received: %s", data) 132 | response = self.protocol.feed_bytes(data) 133 | if response: 134 | if response.is_error(): 135 | response.raise_as_exception() 136 | else: 137 | return response 138 | 139 | await self._flush_output() 140 | 141 | async def _flush_output(self) -> None: 142 | data = self.protocol.get_outgoing_data() 143 | if data: 144 | logger.debug("Sent: %s", data) 145 | try: 146 | with fail_after(self.timeout): 147 | await self._stream.send(data) 148 | except (BrokenResourceError, TimeoutError): 149 | await aclose_forcefully(self._stream) 150 | del self._stream 151 | raise 152 | 153 | async def aclose(self) -> None: 154 | if hasattr(self, "_stream"): 155 | stream = self._stream 156 | del self._stream 157 | with move_on_after(5, shield=True): 158 | await stream.aclose() 159 | 160 | async def __aenter__(self) -> AsyncSMTPSession: 161 | with fail_after(self.connect_timeout): 162 | self._stream = await connect_tcp(self.host, self.port) 163 | 164 | async with AsyncExitStack() as exit_stack: 165 | exit_stack.push_async_callback(self.aclose) 166 | 167 | await self._wait_response() 168 | await self.send_command(self.protocol.send_greeting, self.domain) 169 | 170 | # Do the TLS handshake if supported by the server 171 | if "STARTTLS" in self.protocol.extensions: 172 | await self.send_command(self.protocol.start_tls) 173 | self._stream = await TLSStream.wrap( 174 | self._stream, 175 | hostname=self.host, 176 | ssl_context=self.ssl_context, 177 | standard_compatible=False, 178 | ) 179 | 180 | # Send a new EHLO command to determine new capabilities 181 | await self.send_command(self.protocol.send_greeting, self.domain) 182 | 183 | # Use the authenticator if one was provided 184 | if self.authenticator: 185 | auth_gen = self.authenticator.authenticate() 186 | try: 187 | auth_data = await auth_gen.__anext__() 188 | response = await self.send_command( 189 | self.protocol.authenticate, 190 | self.authenticator.mechanism, 191 | auth_data, 192 | ) 193 | while self.protocol.state is ClientState.authenticating: 194 | auth_data = await auth_gen.asend(response.message) 195 | self.protocol.send_authentication_data(auth_data) 196 | await self._flush_output() 197 | except StopAsyncIteration: 198 | pass 199 | finally: 200 | await auth_gen.aclose() 201 | 202 | self._exit_stack = exit_stack.pop_all() 203 | 204 | return self 205 | 206 | async def __aexit__( 207 | self, 208 | exc_type: type[BaseException] | None, 209 | exc_val: BaseException | None, 210 | exc_tb: TracebackType | None, 211 | ) -> None: 212 | try: 213 | if self.protocol.state is not ClientState.finished: 214 | await self.send_command(self.protocol.quit) 215 | finally: 216 | await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) 217 | 218 | def __del__(self) -> None: 219 | if hasattr(self, "_stream"): 220 | warn( 221 | f"unclosed {self.__class__.__name__}", 222 | ResourceWarning, 223 | stacklevel=1, 224 | source=self, 225 | ) 226 | 227 | 228 | class AsyncSMTPClient: 229 | """ 230 | An asynchronous SMTP client. 231 | 232 | This runs on asyncio or any other backend supported by AnyIO. 233 | 234 | :param host: host name or IP address of the SMTP server 235 | :param port: port on the SMTP server to connect to 236 | :param connect_timeout: connection timeout (in seconds) 237 | :param timeout: timeout for sending requests and reading responses (in seconds) 238 | :param domain: domain name to send to the server as part of the greeting message 239 | :param ssl_context: SSL context to use for establishing TLS encrypted sessions 240 | :param authenticator: authenticator to use for authenticating with the SMTP server 241 | :param max_concurrent_connections: maximum number of connections to allows to the 242 | SMTP server before blocking 243 | """ 244 | 245 | def __init__( 246 | self, 247 | host: str, 248 | port: int = 587, 249 | connect_timeout: float = 30, 250 | timeout: float = 60, 251 | domain: str | None = None, 252 | ssl_context: SSLContext | None = None, 253 | authenticator: SMTPAuthenticator | None = None, 254 | max_concurrent_connections: int = 50, 255 | ): 256 | self.host = host 257 | self.port = port 258 | self.connect_timeout = connect_timeout 259 | self.timeout = timeout 260 | self.domain: str = domain or socket.gethostname() 261 | self.ssl_context = ssl_context 262 | self.authenticator = authenticator 263 | self._semaphore = Semaphore(max_concurrent_connections) 264 | 265 | @asynccontextmanager 266 | async def connect(self) -> AsyncGenerator[AsyncSMTPSession, Any]: 267 | """ 268 | Establish a session with the SMTP server. 269 | 270 | The returned async context manager connects to the SMTP server and performs the 271 | protocol handshake. After that, it optionally establishes an encrypted session 272 | with ``STARTTLS``, and then logs in (if an authenticator was provided). 273 | 274 | :return: a context manager yielding an :class:`AsyncSMTPSession` 275 | 276 | """ 277 | async with self._semaphore: 278 | session = AsyncSMTPSession( 279 | self.host, 280 | self.port, 281 | self.connect_timeout, 282 | self.timeout, 283 | self.domain, 284 | self.authenticator, 285 | self.ssl_context, 286 | ) 287 | async with session: 288 | yield session 289 | 290 | async def send_message( 291 | self, 292 | message: EmailMessage, 293 | *, 294 | sender: str | Address | None = None, 295 | recipients: Iterable[str] | None = None, 296 | ) -> SMTPResponse: 297 | """ 298 | Open a session with the SMTP server, send an email and then close the session. 299 | 300 | This is a convenience method for the following:: 301 | 302 | async with client.connect() as session: 303 | return await session.send_message(message, sender=sender, \ 304 | recipients=recipients) 305 | 306 | :param message: the message to send 307 | :param sender: override the sender address in the ``MAIL FROM`` command 308 | :param recipients: override the destination addresses in the ``RCPT TO`` 309 | commands 310 | :return: the SMTP response 311 | 312 | """ 313 | async with self.connect() as session: 314 | return await session.send_message( 315 | message, sender=sender, recipients=recipients 316 | ) 317 | 318 | 319 | @dataclass 320 | class SyncSMTPSession: 321 | portal: BlockingPortal 322 | async_session: AsyncSMTPSession 323 | 324 | def send_command( 325 | self, command: Callable[P, None], /, *args: P.args, **kwargs: P.kwargs 326 | ) -> SMTPResponse: 327 | """ 328 | Send a command to the SMTP server and return the response. 329 | 330 | :param command: a callable from :class:`~.protocol.SMTPClientProtocol` 331 | :param args: positional arguments to ``command`` 332 | :param kwargs: keyword arguments to ``command`` 333 | 334 | """ 335 | return self.portal.call( 336 | lambda: self.async_session.send_command(command, *args, **kwargs) 337 | ) 338 | 339 | def send_message( 340 | self, 341 | message: EmailMessage, 342 | *, 343 | sender: str | Address | None = None, 344 | recipients: Iterable[str] | None = None, 345 | ) -> SMTPResponse: 346 | """ 347 | Send an email message. 348 | 349 | :param message: the message to send 350 | :param sender: override the sender address in the ``MAIL FROM`` command 351 | :param recipients: override the destination addresses in the ``RCPT TO`` 352 | commands 353 | :return: the SMTP response 354 | 355 | """ 356 | return self.portal.call( 357 | lambda: self.async_session.send_message( 358 | message, sender=sender, recipients=recipients 359 | ) 360 | ) 361 | 362 | 363 | class SyncSMTPClient: 364 | """ 365 | A synchronous (blocking) SMTP client. 366 | 367 | :param host: host name or IP address of the SMTP server 368 | :param port: port on the SMTP server to connect to 369 | :param connect_timeout: connection timeout (in seconds) 370 | :param timeout: timeout for sending requests and reading responses (in seconds) 371 | :param domain: domain name to send to the server as part of the greeting message 372 | :param ssl_context: SSL context to use for establishing TLS encrypted sessions 373 | :param authenticator: authenticator to use for authenticating with the SMTP server 374 | :param max_concurrent_connections: maximum number of connections to allows to the 375 | SMTP server before blocking 376 | :param async_backend: name of the AnyIO-supported asynchronous backend 377 | :param async_backend_options: dictionary of keyword arguments passed to 378 | :func:`anyio.from_thread.start_blocking_portal` 379 | """ 380 | 381 | def __init__( 382 | self, 383 | host: str, 384 | port: int = 587, 385 | connect_timeout: float = 30, 386 | timeout: float = 60, 387 | domain: str | None = None, 388 | ssl_context: SSLContext | None = None, 389 | authenticator: SMTPAuthenticator | None = None, 390 | max_concurrent_connections: int = 50, 391 | async_backend: str = "asyncio", 392 | async_backend_options: dict[str, Any] | None = None, 393 | ): 394 | self._async_client = AsyncSMTPClient( 395 | host=host, 396 | port=port, 397 | connect_timeout=connect_timeout, 398 | timeout=timeout, 399 | domain=domain or socket.gethostname(), 400 | ssl_context=ssl_context, 401 | authenticator=authenticator, 402 | max_concurrent_connections=max_concurrent_connections, 403 | ) 404 | self._portal_provider = BlockingPortalProvider( 405 | async_backend, async_backend_options 406 | ) 407 | 408 | @contextmanager 409 | def connect(self) -> Generator[SyncSMTPSession, Any, None]: 410 | """ 411 | Establish a session with the SMTP server. 412 | 413 | The returned context manager connects to the SMTP server and performs the 414 | protocol handshake. After that, it optionally establishes an encrypted session 415 | with ``STARTTLS``, and then logs in (if an authenticator was provided). 416 | 417 | :return: a context manager yielding a :class:`SyncSMTPSession` 418 | 419 | """ 420 | with self._portal_provider as portal: 421 | async_session_cm = portal.call(self._async_client.connect) 422 | with portal.wrap_async_context_manager(async_session_cm) as async_session: 423 | yield SyncSMTPSession(portal, async_session) 424 | 425 | def send_message( 426 | self, 427 | message: EmailMessage, 428 | *, 429 | sender: str | Address | None = None, 430 | recipients: Iterable[str] | None = None, 431 | ) -> SMTPResponse: 432 | """ 433 | Open a session with the SMTP server, send an email and then close the session. 434 | 435 | This is a convenience method for the following:: 436 | 437 | with client.connect() as session: 438 | return session.send_message(message, sender=sender, \ 439 | recipients=recipients) 440 | 441 | :param message: the message to send 442 | :param sender: override the sender address in the ``MAIL FROM`` command 443 | :param recipients: override the destination addresses in the ``RCPT TO`` 444 | commands 445 | :return: the SMTP response 446 | 447 | """ 448 | with self.connect() as session: 449 | return session.send_message(message, sender=sender, recipients=recipients) 450 | -------------------------------------------------------------------------------- /src/smtpproto/protocol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from collections.abc import Iterable, Sequence 5 | from copy import copy 6 | from dataclasses import dataclass, field 7 | from email.headerregistry import Address 8 | from email.message import EmailMessage 9 | from email.policy import SMTP, SMTPUTF8 10 | from enum import Enum, auto 11 | from re import Pattern 12 | from typing import NoReturn 13 | 14 | response_re: Pattern[str] = re.compile("(\\d+)([- ])(.*)$") 15 | 16 | 17 | class ClientState(Enum): 18 | """Enumerates all possible protocol states.""" 19 | 20 | greeting_expected = auto() #: expecting a greeting from the server 21 | greeting_received = ( 22 | auto() 23 | ) #: received a greeting from the server, ready to authenticate 24 | authenticating = auto() #: authentication in progress 25 | authenticated = auto() #: authentication done 26 | ready = auto() #: ready to send commands 27 | mailtx = auto() #: in a mail transaction 28 | recipient_sent = auto() #: sent at least one recipient 29 | send_data = auto() #: ready to send the message data 30 | data_sent = auto() #: message data sent 31 | finished = auto() #: session finished 32 | 33 | 34 | class SMTPException(Exception): 35 | """Base class for SMTP exceptions.""" 36 | 37 | 38 | class SMTPMissingExtension(SMTPException): 39 | """Raised when a required SMTP extension is not present on the server.""" 40 | 41 | 42 | class SMTPUnsupportedAuthMechanism(SMTPException): 43 | """ 44 | Raised when trying to authenticate using a mechanism not supported by the server. 45 | """ 46 | 47 | 48 | class SMTPProtocolViolation(SMTPException): 49 | """Raised when there has been a violation of the (E)SMTP protocol by either side.""" 50 | 51 | 52 | @dataclass(frozen=True) 53 | class SMTPResponse: 54 | """Represents a response from the server.""" 55 | 56 | code: int #: response status code (between 100 and 599) 57 | message: str #: response message 58 | 59 | def is_error(self) -> bool: 60 | """Return ``True`` if this is an error response, ``False`` if not.""" 61 | return self.code >= 400 62 | 63 | def raise_as_exception(self) -> NoReturn: 64 | """Raise an :class:`SMTPException` from this response.""" 65 | raise SMTPException(f"{self.code} {self.message}") 66 | 67 | 68 | @dataclass 69 | class SMTPClientProtocol: 70 | """The (E)SMTP protocol state machine.""" 71 | 72 | _state: ClientState = field(init=False, default=ClientState.greeting_expected) 73 | _smtputf8_message: bool = field(init=False, default=True) 74 | _out_buffer: bytes = field(init=False, default=b"") 75 | _in_buffer: bytes = field(init=False, default=b"") 76 | _response_code: int | None = field(init=False, default=None) 77 | _response_lines: list[str] = field(init=False, default_factory=list) 78 | _command_sent: str | None = field(init=False, default=None) 79 | _args_sent: tuple[bytes, ...] | None = field(init=False, default=None) 80 | _extensions: frozenset[str] = field(init=False, default_factory=frozenset) 81 | _auth_mechanisms: frozenset[str] = field(init=False, default_factory=frozenset) 82 | _max_message_size: int | None = field(init=False, default=None) 83 | 84 | def _require_state(self, *states: ClientState) -> None: 85 | if self._state not in states: 86 | allowed_states = ", ".join(state.name for state in states) 87 | raise SMTPProtocolViolation( 88 | f"Required state: one of: {allowed_states}; " 89 | f"current state: {self._state.name}" 90 | ) 91 | 92 | def _require_extension(self, extension: str) -> None: 93 | if extension not in self._extensions: 94 | raise SMTPMissingExtension( 95 | f"This operation requires the {extension} extension but " 96 | f"the server does not support it" 97 | ) 98 | 99 | def _require_auth_mechanism(self, mechanism: str) -> None: 100 | if mechanism not in self._auth_mechanisms: 101 | raise SMTPUnsupportedAuthMechanism( 102 | f"{mechanism} is not a supported authentication mechanism on this " 103 | f"server" 104 | ) 105 | 106 | def _encode_address(self, address: str | Address) -> bytes: 107 | if isinstance(address, Address): 108 | address_str = f"{address.username}@{address.domain}" 109 | else: 110 | address_str = address 111 | 112 | if self._smtputf8_message: 113 | return address_str.encode("utf-8") 114 | 115 | # If SMPTUTF8 is not supported, the address must be ASCII compatible 116 | try: 117 | return address_str.encode("ascii") 118 | except UnicodeEncodeError: 119 | if "SMTPUTF8" in self._extensions: 120 | raise SMTPProtocolViolation( 121 | f"The address {address_str!r} requires UTF-8 encoding but " 122 | f"`smtputf8` was not specified in the mail command" 123 | ) 124 | else: 125 | raise SMTPProtocolViolation( 126 | f"The address {address_str!r} requires UTF-8 encoding but the " 127 | f"server does not support the SMTPUTF8 extension" 128 | ) 129 | 130 | def _send_command(self, command: str, *args: str | bytes) -> None: 131 | if self._command_sent is not None: 132 | raise SMTPProtocolViolation( 133 | "Tried to send a command before the previous one received a response" 134 | ) 135 | 136 | line = command.encode("ascii") 137 | args_encoded = tuple( 138 | arg.encode("ascii") if isinstance(arg, str) else arg for arg in args 139 | ) 140 | if args_encoded: 141 | line += b" " + b" ".join(args_encoded) 142 | 143 | self._out_buffer += line + b"\r\n" 144 | self._command_sent = command 145 | self._args_sent = args_encoded 146 | 147 | def _parse_extensions(self, lines: Iterable[str]) -> None: 148 | auth_mechanisms: list[str] = [] 149 | extensions = [] 150 | for line in lines: 151 | extension, *params = line.split(" ") 152 | extension = extension.upper() 153 | if extension == "AUTH": 154 | auth_mechanisms = params 155 | elif extension == "SIZE": 156 | if params and params[0].isdigit(): 157 | self._max_message_size = int(params[0]) 158 | 159 | extensions.append(extension) 160 | 161 | self._extensions = frozenset(extensions) 162 | self._auth_mechanisms = frozenset(auth_mechanisms) 163 | 164 | def _parse_response(self, code: int, lines: Sequence[str]) -> SMTPResponse | None: 165 | command, args = self._command_sent, self._args_sent or () 166 | self._command_sent = self._args_sent = None 167 | 168 | if self._state is ClientState.authenticating or command == "AUTH": 169 | if code == 334: 170 | self._state = ClientState.authenticating 171 | return SMTPResponse(code, "\n".join(lines)) 172 | elif code == 235: 173 | self._state = ClientState.authenticated 174 | return SMTPResponse(code, "\n".join(lines)) 175 | elif code in (432, 454, 500, 534, 535, 538): 176 | self._state = ClientState.ready 177 | return SMTPResponse(code, "\n".join(lines)) 178 | 179 | if code == 530 and "AUTH" in self._extensions: 180 | # As per RFC 4954, authentication cannot be required for some commands 181 | if command not in ("AUTH", "EHLO", "HELO", "NOOP", "RSET", "QUIT"): 182 | return SMTPResponse(code, "\n".join(lines)) 183 | 184 | if command is None: 185 | if self._state is ClientState.data_sent: 186 | if code == 250: 187 | self._state = ClientState.ready 188 | return SMTPResponse(code, "\n".join(lines)) 189 | elif self._state is ClientState.greeting_expected: 190 | if code == 220: 191 | self._state = ClientState.greeting_received 192 | return SMTPResponse(code, "\n".join(lines)) 193 | elif code == 554: 194 | self._state = ClientState.finished 195 | return SMTPResponse(code, "\n".join(lines)) 196 | elif command == "EHLO": 197 | if code == 250: 198 | self._state = ClientState.ready 199 | self._parse_extensions(lines[1:]) 200 | return SMTPResponse(code, "\n".join(lines)) 201 | elif code == 500: # old SMTP server; try the RFC 821 HELO instead 202 | self._send_command("HELO", *args) 203 | return None 204 | elif code in (504, 550): 205 | return SMTPResponse(code, "\n".join(lines)) 206 | elif command == "HELO": 207 | if code == 250: 208 | self._state = ClientState.ready 209 | return SMTPResponse(code, "\n".join(lines)) 210 | elif code in (502, 504, 550): 211 | return SMTPResponse(code, "\n".join(lines)) 212 | elif command == "NOOP": 213 | if code == 250: 214 | return SMTPResponse(code, "\n".join(lines)) 215 | elif command == "QUIT": 216 | if code == 221: 217 | self._state = ClientState.finished 218 | return SMTPResponse(code, "\n".join(lines)) 219 | elif command == "MAIL": 220 | if code == 250: 221 | self._state = ClientState.mailtx 222 | return SMTPResponse(code, "\n".join(lines)) 223 | elif code in (451, 452, 455, 503, 550, 553, 552, 555): 224 | return SMTPResponse(code, "\n".join(lines)) 225 | elif command == "RCPT": 226 | if code in (250, 251): 227 | self._state = ClientState.recipient_sent 228 | return SMTPResponse(code, "\n".join(lines)) 229 | elif code in (450, 451, 452, 455, 503, 550, 551, 552, 553, 555): 230 | return SMTPResponse(code, "\n".join(lines)) 231 | elif command == "DATA": 232 | if code == 354: 233 | self._state = ClientState.send_data 234 | return SMTPResponse(code, "\n".join(lines)) 235 | elif code in (450, 451, 452, 503, 550, 552, 554): 236 | return SMTPResponse(code, "\n".join(lines)) 237 | elif command == "RSET": 238 | if code == 250: 239 | self._state = ClientState.ready 240 | return SMTPResponse(code, "\n".join(lines)) 241 | elif command == "STARTTLS": 242 | if code == 220: 243 | self._state = ClientState.greeting_received 244 | return SMTPResponse(code, "\n".join(lines)) 245 | 246 | self._state = ClientState.finished 247 | raise SMTPProtocolViolation(f"Unexpected response: {code} " + "\n".join(lines)) 248 | 249 | @property 250 | def state(self) -> ClientState: 251 | """The current state of the protocol.""" 252 | return self._state 253 | 254 | @property 255 | def needs_incoming_data(self) -> bool: 256 | """``True`` if the state machine requires more data, ``False`` if not.""" 257 | return ( 258 | self._state in (ClientState.greeting_expected, ClientState.data_sent) 259 | or self._command_sent is not None 260 | ) 261 | 262 | def get_outgoing_data(self) -> bytes: 263 | """Retrieve any bytes to be sent to the server.""" 264 | buffer = self._out_buffer 265 | self._out_buffer = b"" 266 | return buffer 267 | 268 | @property 269 | def max_message_size(self) -> int | None: 270 | """The maximum size of the email message (in bytes) accepted by the server.""" 271 | return self._max_message_size 272 | 273 | @property 274 | def auth_mechanisms(self) -> frozenset[str]: 275 | """The set of authentication mechanisms supported on the server.""" 276 | return self._auth_mechanisms 277 | 278 | @property 279 | def extensions(self) -> frozenset[str]: 280 | """The set of extensions advertised by the server.""" 281 | return self._extensions 282 | 283 | def authenticate(self, mechanism: str, secret: str | None = None) -> None: 284 | """ 285 | Authenticate to the server using the given mechanism and an accompanying secret. 286 | 287 | :param mechanism: the authentication mechanism (e.g. ``PLAIN`` or ``GSSAPI``) 288 | :param secret: an optional string (usually containing the credentials) that is 289 | added as an argument to the ``AUTH XXX`` command 290 | 291 | """ 292 | self._require_state(ClientState.ready) 293 | self._require_extension("AUTH") 294 | self._require_auth_mechanism(mechanism) 295 | if secret: 296 | self._send_command("AUTH", mechanism, secret) 297 | else: 298 | self._send_command("AUTH", mechanism) 299 | 300 | def send_authentication_data(self, data: str) -> None: 301 | """ 302 | Send authentication data to the server. 303 | 304 | This method can be called when the server responds with a 334 to an AUTH 305 | command. 306 | 307 | :param data: authentication data (ASCII compatible; usually base64 encoded) 308 | 309 | """ 310 | self._require_state(ClientState.authenticating) 311 | self._send_command(data) 312 | 313 | def send_greeting(self, domain: str) -> None: 314 | """ 315 | Send the initial greeting (EHLO or HELO). 316 | 317 | :param domain: the required domain name that represents the client side 318 | 319 | """ 320 | self._require_state(ClientState.greeting_received) 321 | self._send_command("EHLO", domain) 322 | 323 | def noop(self) -> None: 324 | """Send the NOOP command (No Operation).""" 325 | self._send_command("NOOP") 326 | 327 | def quit(self) -> None: 328 | """Send the QUIT command (required to cleanly shut down the session).""" 329 | self._send_command("QUIT") 330 | 331 | def mail(self, sender: str | Address, *, smtputf8: bool = True) -> None: 332 | """ 333 | Send the MAIL FROM command (starts a mail transaction). 334 | 335 | :param sender: the sender's email address 336 | :param smtputf8: send the SMTPUTF8 option, if available on the server 337 | 338 | """ 339 | self._require_state(ClientState.ready, ClientState.authenticated) 340 | 341 | args = [] 342 | if "8BITMIME" in self._extensions: 343 | args.append("BODY=8BITMIME") 344 | 345 | if smtputf8 and "SMTPUTF8" in self._extensions: 346 | self._smtputf8_message = True 347 | args.append("SMTPUTF8") 348 | else: 349 | self._smtputf8_message = False 350 | 351 | self._send_command( 352 | "MAIL", b"FROM:<" + self._encode_address(sender) + b">", *args 353 | ) 354 | 355 | def recipient(self, recipient: str | Address) -> None: 356 | """ 357 | Send the RCPT TO command (declare an intended recipient). 358 | 359 | Requires an active mail transaction. 360 | 361 | :param recipient: the recipient's email address 362 | 363 | """ 364 | self._require_state(ClientState.mailtx, ClientState.recipient_sent) 365 | self._send_command("RCPT", b"TO:<" + self._encode_address(recipient) + b">") 366 | 367 | def start_data(self) -> None: 368 | """ 369 | Send the DATA command (prepare for sending the email payload). 370 | 371 | Requires an active mail transaction, and that at least one recipient has been 372 | declared. 373 | 374 | """ 375 | self._require_state(ClientState.recipient_sent) 376 | self._send_command("DATA") 377 | 378 | def data(self, message: EmailMessage) -> None: 379 | """ 380 | Send the actual email payload. 381 | 382 | Requires that the DATA command has been sent first. 383 | 384 | :param message: the email message 385 | 386 | """ 387 | self._require_state(ClientState.send_data) 388 | policy = SMTPUTF8 if self._smtputf8_message else SMTP 389 | policy = ( 390 | policy.clone(cte_type="7bit") 391 | if "8BITMIME" not in self._extensions 392 | else policy 393 | ) 394 | 395 | if "bcc" in message or "resent-bcc" in message: 396 | message = copy(message) 397 | del message["bcc"] 398 | del message["resent-bcc"] 399 | 400 | self._out_buffer += message.as_bytes(policy=policy).replace(b"\r\n.", b"\r\n..") 401 | self._out_buffer += b".\r\n" 402 | self._state = ClientState.data_sent 403 | 404 | def reset(self) -> None: 405 | """Send the RSET command (cancel the active mail transaction).""" 406 | self._require_state( 407 | ClientState.mailtx, ClientState.recipient_sent, ClientState.send_data 408 | ) 409 | self._send_command("RSET") 410 | 411 | def start_tls(self) -> None: 412 | """Send the STARTTLS command (signal the server to initiate a TLS handshake).""" 413 | self._require_state(ClientState.ready) 414 | self._require_extension("STARTTLS") 415 | self._send_command("STARTTLS") 416 | 417 | def feed_bytes(self, data: bytes) -> SMTPResponse | None: 418 | """ 419 | Feed received bytes from the transport into the state machine. 420 | 421 | if this method raises :exc:`SMTPProtocolViolation`, the state machine is 422 | transitioned to the ``finished`` state, and the connection should be closed. 423 | 424 | :param data: received bytes 425 | :return: a response object if a complete response was received, ``None`` 426 | otherwise 427 | :raises SMTPProtocolViolation: if the server sent an invalid response 428 | 429 | """ 430 | self._in_buffer += data 431 | start = 0 432 | while True: 433 | end = self._in_buffer.find(b"\r\n", start) 434 | if end < 0: 435 | # If there's an unfinished line, save it in the buffer 436 | self._in_buffer = self._in_buffer[start:] 437 | return None 438 | 439 | # Check that the format of each line matches the expected one 440 | line = self._in_buffer[start:end].decode("ascii") 441 | start = end + 2 442 | match = response_re.match(line) 443 | if not match: 444 | self._state = ClientState.finished 445 | raise SMTPProtocolViolation(f"Invalid input: {line}") 446 | 447 | code = int(match.group(1)) 448 | continues = match.group(2) == "-" 449 | message = match.group(3) 450 | if self._response_code is None: 451 | self._response_code = code 452 | elif self._response_code != code: 453 | self._state = ClientState.finished 454 | raise SMTPProtocolViolation( 455 | f"Expected code {self._response_code}, got {code} instead" 456 | ) 457 | 458 | self._response_lines.append(message) 459 | if not continues: 460 | response_code = self._response_code 461 | response_lines = self._response_lines 462 | self._response_code = None 463 | self._response_lines = [] 464 | self._in_buffer = self._in_buffer[start:] 465 | return self._parse_response(response_code, response_lines) 466 | -------------------------------------------------------------------------------- /tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from email.headerregistry import Address 5 | from email.message import EmailMessage 6 | from typing import Any, cast 7 | 8 | import pytest 9 | from _pytest.fixtures import SubRequest 10 | 11 | from smtpproto.protocol import ( 12 | ClientState, 13 | SMTPClientProtocol, 14 | SMTPMissingExtension, 15 | SMTPProtocolViolation, 16 | SMTPUnsupportedAuthMechanism, 17 | ) 18 | 19 | 20 | def call_protocol_method( 21 | protocol: SMTPClientProtocol, func: Callable[[], Any], expected_outgoing_data: bytes 22 | ) -> None: 23 | assert not protocol.needs_incoming_data 24 | func() 25 | assert protocol.get_outgoing_data() == expected_outgoing_data 26 | 27 | 28 | def feed_bytes( 29 | protocol: SMTPClientProtocol, 30 | data: bytes, 31 | expected_code: int | None = None, 32 | expected_message: str | None = None, 33 | expected_state: ClientState | None = None, 34 | ) -> None: 35 | assert protocol.needs_incoming_data 36 | response = protocol.feed_bytes(data) 37 | if expected_code: 38 | assert response 39 | assert response.code == expected_code 40 | assert not protocol.needs_incoming_data 41 | else: 42 | assert response is None 43 | assert protocol.needs_incoming_data 44 | 45 | if expected_message: 46 | assert response 47 | assert response.message == expected_message 48 | if expected_state: 49 | assert protocol.state is expected_state 50 | 51 | 52 | def exchange_greetings(protocol: SMTPClientProtocol, esmtp: bool = True) -> None: 53 | # Server sends a greeting message 54 | feed_bytes( 55 | protocol, 56 | b"220 foo.bar SMTP service ready\r\n", 57 | 220, 58 | "foo.bar SMTP service ready", 59 | ClientState.greeting_received, 60 | ) 61 | 62 | # Do the ESMTP handshake 63 | call_protocol_method( 64 | protocol, lambda: protocol.send_greeting("foo.bar"), b"EHLO foo.bar\r\n" 65 | ) 66 | if esmtp: 67 | feed_bytes(protocol, b"250-foo.bar ready\r\n") 68 | feed_bytes(protocol, b"250-8BITMIME\r\n") 69 | feed_bytes(protocol, b"250-SMTPUTF8\r\n") 70 | feed_bytes(protocol, b"250-STARTTLS\r\n") 71 | feed_bytes(protocol, b"250-SIZE 10000000\r\n") 72 | feed_bytes( 73 | protocol, 74 | b"250 AUTH PLAIN LOGIN\r\n", 75 | 250, 76 | "foo.bar ready\n8BITMIME\nSMTPUTF8\nSTARTTLS\nSIZE 10000000\nAUTH PLAIN " 77 | "LOGIN", 78 | ClientState.ready, 79 | ) 80 | assert protocol.extensions == { 81 | "8BITMIME", 82 | "SMTPUTF8", 83 | "STARTTLS", 84 | "SIZE", 85 | "AUTH", 86 | } 87 | assert protocol.auth_mechanisms == {"PLAIN", "LOGIN"} 88 | assert protocol.max_message_size == 10000000 89 | else: 90 | # Fall back to HELO 91 | feed_bytes(protocol, b"500 Unknown command\r\n") 92 | assert protocol.get_outgoing_data() == b"HELO foo.bar\r\n" 93 | feed_bytes( 94 | protocol, b"250 foo.bar ready\r\n", 250, "foo.bar ready", ClientState.ready 95 | ) 96 | assert protocol.extensions == frozenset() 97 | assert protocol.auth_mechanisms == frozenset() 98 | assert protocol.max_message_size is None 99 | 100 | 101 | def start_mail_tx(protocol: SMTPClientProtocol, smtputf8: bool = True) -> None: 102 | # Start a mail transaction 103 | extra_args = b"" 104 | if "8BITMIME" in protocol.extensions: 105 | extra_args += b" BODY=8BITMIME" 106 | if smtputf8 and "SMTPUTF8" in protocol.extensions: 107 | extra_args += b" SMTPUTF8" 108 | 109 | call_protocol_method( 110 | protocol, 111 | lambda: protocol.mail("foo@bar.com", smtputf8=smtputf8), 112 | b"MAIL FROM:" + extra_args + b"\r\n", 113 | ) 114 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.mailtx) 115 | 116 | # Declare the first recipient 117 | call_protocol_method( 118 | protocol, 119 | lambda: protocol.recipient("recipient1@domain.com"), 120 | b"RCPT TO:\r\n", 121 | ) 122 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.recipient_sent) 123 | 124 | # Declare the second recipient, this time using an Address 125 | address = Address("Firstname Lastname", "recipient2", "domain.com") 126 | call_protocol_method( 127 | protocol, 128 | lambda: protocol.recipient(address), 129 | b"RCPT TO:\r\n", 130 | ) 131 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.recipient_sent) 132 | 133 | # Declare the start of the message data 134 | call_protocol_method(protocol, protocol.start_data, b"DATA\r\n") 135 | feed_bytes( 136 | protocol, 137 | b"354 Start mail input; end with .\r\n", 138 | 354, 139 | "Start mail input; end with .", 140 | ClientState.send_data, 141 | ) 142 | 143 | 144 | @pytest.fixture( 145 | params=[ 146 | pytest.param("héllö@example.org", id="str"), 147 | pytest.param(Address("Héllö World", "héllö", "example.org"), id="object"), 148 | ], 149 | ) 150 | def unicode_address(request: SubRequest) -> str | Address: 151 | return cast("str | Address", request.param) 152 | 153 | 154 | @pytest.fixture 155 | def protocol() -> SMTPClientProtocol: 156 | proto = SMTPClientProtocol() 157 | assert proto.state is ClientState.greeting_expected 158 | assert proto.needs_incoming_data 159 | return proto 160 | 161 | 162 | @pytest.mark.parametrize( 163 | "esmtp, smtputf8, expected_cte, expected_subject, expected_body", 164 | [ 165 | pytest.param( 166 | True, 167 | True, 168 | "8bit", 169 | "This is a subjëct", 170 | "This is ä test message.", 171 | id="8bit", 172 | ), 173 | pytest.param( 174 | False, 175 | True, 176 | "base64", 177 | "This is a =?utf-8?q?subj=C3=ABct?=", 178 | "VGhpcyBpcyDDpCB0ZXN0IG1lc3NhZ2UuCg==", 179 | id="7bit", 180 | ), 181 | pytest.param( 182 | True, 183 | False, 184 | "8bit", 185 | "This is a =?utf-8?q?subj=C3=ABct?=", 186 | "This is ä test message.", 187 | id="smtputf8_opt_out", 188 | ), 189 | ], 190 | ) 191 | def test_send_mail_utf8_content( 192 | protocol: SMTPClientProtocol, 193 | esmtp: bool, 194 | smtputf8: bool, 195 | expected_cte: str, 196 | expected_subject: str, 197 | expected_body: str, 198 | ) -> None: 199 | exchange_greetings(protocol, esmtp=esmtp) 200 | start_mail_tx(protocol, smtputf8=smtputf8) 201 | 202 | message = EmailMessage() 203 | message["Subject"] = "This is a subjëct" 204 | message.set_content("This is ä test message.") 205 | call_protocol_method( 206 | protocol, 207 | lambda: protocol.data(message), 208 | f"Subject: {expected_subject}\r\n" 209 | f'Content-Type: text/plain; charset="utf-8"\r\n' 210 | f"Content-Transfer-Encoding: {expected_cte}\r\n" 211 | f"MIME-Version: 1.0\r\n\r\n" 212 | f"{expected_body}\r\n.\r\n".encode(), 213 | ) 214 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.ready) 215 | 216 | 217 | def test_send_mail_utf8_addresses( 218 | protocol: SMTPClientProtocol, unicode_address: str | Address 219 | ) -> None: 220 | exchange_greetings(protocol) 221 | protocol.mail(unicode_address) 222 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.mailtx) 223 | protocol.recipient(unicode_address) 224 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.recipient_sent) 225 | 226 | 227 | def test_send_mail_unicode_sender_encoding_error( 228 | protocol: SMTPClientProtocol, unicode_address: str | Address 229 | ) -> None: 230 | exchange_greetings(protocol, esmtp=False) 231 | exc = pytest.raises(SMTPProtocolViolation, protocol.mail, unicode_address) 232 | exc.match( 233 | "^The address 'héllö@example.org' requires UTF-8 encoding but the server does " 234 | "not support the SMTPUTF8 extension" 235 | ) 236 | 237 | 238 | def test_send_mail_unicode_sender_no_smtputf8_encoding_error( 239 | protocol: SMTPClientProtocol, unicode_address: str | Address 240 | ) -> None: 241 | exchange_greetings(protocol, esmtp=True) 242 | exc = pytest.raises( 243 | SMTPProtocolViolation, protocol.mail, unicode_address, smtputf8=False 244 | ) 245 | exc.match( 246 | "^The address 'héllö@example.org' requires UTF-8 encoding but `smtputf8` was " 247 | "not specified" 248 | ) 249 | 250 | 251 | def test_send_mail_unicode_recipient_encoding_error( 252 | protocol: SMTPClientProtocol, unicode_address: str | Address 253 | ) -> None: 254 | exchange_greetings(protocol, esmtp=False) 255 | protocol.mail("hello@example.org") 256 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.mailtx) 257 | 258 | exc = pytest.raises(SMTPProtocolViolation, protocol.recipient, unicode_address) 259 | exc.match("^The address 'héllö@example.org' requires UTF-8") 260 | 261 | 262 | def test_send_mail_unicode_recipient_no_smtputf8_encoding_error( 263 | protocol: SMTPClientProtocol, unicode_address: str | Address 264 | ) -> None: 265 | exchange_greetings(protocol, esmtp=True) 266 | protocol.mail("hello@example.org", smtputf8=False) 267 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.mailtx) 268 | 269 | exc = pytest.raises(SMTPProtocolViolation, protocol.recipient, unicode_address) 270 | exc.match("^The address 'héllö@example.org' requires UTF-8") 271 | 272 | 273 | def test_send_mail_escape_dots(protocol: SMTPClientProtocol) -> None: 274 | exchange_greetings(protocol) 275 | start_mail_tx(protocol) 276 | 277 | message = EmailMessage() 278 | message.set_content("The following lines might trip the protocol:\n.test\n.") 279 | call_protocol_method( 280 | protocol, 281 | lambda: protocol.data(message), 282 | b'Content-Type: text/plain; charset="utf-8"\r\n' 283 | b"Content-Transfer-Encoding: 7bit\r\n" 284 | b"MIME-Version: 1.0\r\n\r\n" 285 | b"The following lines might trip the protocol:\r\n" 286 | b"..test\r\n" 287 | b"..\r\n" 288 | b".\r\n", 289 | ) 290 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.ready) 291 | 292 | 293 | def test_reset_mail_tx(protocol: SMTPClientProtocol) -> None: 294 | exchange_greetings(protocol) 295 | start_mail_tx(protocol) 296 | call_protocol_method(protocol, protocol.reset, b"RSET\r\n") 297 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.ready) 298 | 299 | 300 | def test_bad_greeting(protocol: SMTPClientProtocol) -> None: 301 | feed_bytes(protocol, b"554 Go away\r\n", 554, "Go away") 302 | 303 | 304 | def test_premature_greeting(protocol: SMTPClientProtocol) -> None: 305 | pytest.raises(SMTPProtocolViolation, protocol.send_greeting, "foo.bar").match( 306 | "Required state: one of: greeting_received; current state: greeting_expected" 307 | ) 308 | 309 | 310 | def test_double_command(protocol: SMTPClientProtocol) -> None: 311 | protocol.noop() 312 | pytest.raises(SMTPProtocolViolation, protocol.noop).match( 313 | "Tried to send a command before the previous one received a response" 314 | ) 315 | 316 | 317 | def test_authentication_required(protocol: SMTPClientProtocol) -> None: 318 | exchange_greetings(protocol) 319 | call_protocol_method( 320 | protocol, 321 | lambda: protocol.mail("foo@bar.com"), 322 | b"MAIL FROM: BODY=8BITMIME SMTPUTF8\r\n", 323 | ) 324 | feed_bytes( 325 | protocol, b"530 Authentication required\r\n", 530, "Authentication required" 326 | ) 327 | 328 | 329 | def test_noop(protocol: SMTPClientProtocol) -> None: 330 | exchange_greetings(protocol) 331 | call_protocol_method(protocol, protocol.noop, b"NOOP\r\n") 332 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK", ClientState.ready) 333 | 334 | 335 | def test_start_tls(protocol: SMTPClientProtocol) -> None: 336 | exchange_greetings(protocol) 337 | call_protocol_method(protocol, protocol.start_tls, b"STARTTLS\r\n") 338 | feed_bytes(protocol, b"220 OK\r\n", 220, "OK", ClientState.greeting_received) 339 | 340 | 341 | def test_start_tls_missing_extension(protocol: SMTPClientProtocol) -> None: 342 | exchange_greetings(protocol, esmtp=False) 343 | pytest.raises(SMTPMissingExtension, protocol.start_tls).match( 344 | "This operation requires the STARTTLS extension but the server does not " 345 | "support it" 346 | ) 347 | 348 | 349 | def test_quit(protocol: SMTPClientProtocol) -> None: 350 | exchange_greetings(protocol) 351 | call_protocol_method(protocol, protocol.quit, b"QUIT\r\n") 352 | feed_bytes(protocol, b"221 OK\r\n", 221, "OK", ClientState.finished) 353 | 354 | 355 | def test_auth_with_unsupported_mechanism(protocol: SMTPClientProtocol) -> None: 356 | exchange_greetings(protocol) 357 | pytest.raises( 358 | SMTPUnsupportedAuthMechanism, lambda: protocol.authenticate("XOAUTH2") 359 | ).match("XOAUTH2 is not a supported authentication mechanism on this server") 360 | 361 | 362 | def test_auth_plain(protocol: SMTPClientProtocol) -> None: 363 | exchange_greetings(protocol) 364 | 365 | call_protocol_method( 366 | protocol, 367 | lambda: protocol.authenticate("PLAIN", "AHRlc3QAcGFzcw=="), 368 | b"AUTH PLAIN AHRlc3QAcGFzcw==\r\n", 369 | ) 370 | feed_bytes( 371 | protocol, 372 | b"235 Authentication successful\r\n", 373 | 235, 374 | "Authentication successful", 375 | ClientState.authenticated, 376 | ) 377 | 378 | 379 | @pytest.mark.parametrize("error_code", [432, 454, 500, 534, 535, 538]) 380 | def test_auth_plain_failure(protocol: SMTPClientProtocol, error_code: int) -> None: 381 | exchange_greetings(protocol) 382 | call_protocol_method( 383 | protocol, 384 | lambda: protocol.authenticate("PLAIN", "dummy"), 385 | b"AUTH PLAIN dummy\r\n", 386 | ) 387 | feed_bytes( 388 | protocol, 389 | f"{error_code} Error\r\n".encode(), 390 | error_code, 391 | "Error", 392 | ClientState.ready, 393 | ) 394 | 395 | 396 | def test_auth_login(protocol: SMTPClientProtocol) -> None: 397 | exchange_greetings(protocol) 398 | 399 | call_protocol_method( 400 | protocol, lambda: protocol.authenticate("LOGIN"), b"AUTH LOGIN\r\n" 401 | ) 402 | feed_bytes( 403 | protocol, 404 | b"334 VXNlcm5hbWU=\r\n", 405 | 334, 406 | "VXNlcm5hbWU=", 407 | ClientState.authenticating, 408 | ) 409 | call_protocol_method( 410 | protocol, lambda: protocol.send_authentication_data("dXNlcg=="), b"dXNlcg==\r\n" 411 | ) 412 | feed_bytes( 413 | protocol, 414 | b"334 cGFzc3dvcmQ=\r\n", 415 | 334, 416 | "cGFzc3dvcmQ=", 417 | ClientState.authenticating, 418 | ) 419 | call_protocol_method( 420 | protocol, lambda: protocol.send_authentication_data("cGFzcw=="), b"cGFzcw==\r\n" 421 | ) 422 | feed_bytes( 423 | protocol, 424 | b"235 Authentication successful\r\n", 425 | 235, 426 | "Authentication successful", 427 | ClientState.authenticated, 428 | ) 429 | 430 | 431 | @pytest.mark.parametrize("error_code", [432, 454, 500, 534, 535, 538]) 432 | def test_auth_login_failure(protocol: SMTPClientProtocol, error_code: int) -> None: 433 | exchange_greetings(protocol) 434 | 435 | call_protocol_method( 436 | protocol, lambda: protocol.authenticate("LOGIN"), b"AUTH LOGIN\r\n" 437 | ) 438 | feed_bytes( 439 | protocol, 440 | b"334 VXNlcm5hbWU=\r\n", 441 | 334, 442 | "VXNlcm5hbWU=", 443 | ClientState.authenticating, 444 | ) 445 | call_protocol_method( 446 | protocol, lambda: protocol.send_authentication_data("dXNlcg=="), b"dXNlcg==\r\n" 447 | ) 448 | feed_bytes( 449 | protocol, 450 | b"334 cGFzc3dvcmQ=\r\n", 451 | 334, 452 | "cGFzc3dvcmQ=", 453 | ClientState.authenticating, 454 | ) 455 | call_protocol_method( 456 | protocol, lambda: protocol.send_authentication_data("cGFzcw=="), b"cGFzcw==\r\n" 457 | ) 458 | feed_bytes( 459 | protocol, 460 | f"{error_code} Error\r\n".encode(), 461 | error_code, 462 | "Error", 463 | ClientState.ready, 464 | ) 465 | 466 | 467 | def test_server_invalid_input(protocol: SMTPClientProtocol) -> None: 468 | exc = pytest.raises(SMTPProtocolViolation, feed_bytes, protocol, b"BLAH foobar\r\n") 469 | exc.match("Invalid input: BLAH foobar") 470 | 471 | 472 | def test_server_invalid_continuation(protocol: SMTPClientProtocol) -> None: 473 | feed_bytes(protocol, b"220-hello\r\n") 474 | exc = pytest.raises(SMTPProtocolViolation, feed_bytes, protocol, b"230 hello\r\n") 475 | exc.match("Expected code 220, got 230 instead") 476 | 477 | 478 | def test_server_invalid_status_code(protocol: SMTPClientProtocol) -> None: 479 | exc = pytest.raises(SMTPProtocolViolation, feed_bytes, protocol, b"600 hello\r\n") 480 | exc.match("Unexpected response: 600 hello") 481 | 482 | 483 | @pytest.mark.parametrize("error_code", [504, 550]) 484 | def test_ehlo_error(protocol: SMTPClientProtocol, error_code: int) -> None: 485 | feed_bytes( 486 | protocol, 487 | b"220 foo.bar SMTP service ready\r\n", 488 | 220, 489 | "foo.bar SMTP service ready", 490 | ClientState.greeting_received, 491 | ) 492 | call_protocol_method( 493 | protocol, lambda: protocol.send_greeting("foo.bar"), b"EHLO foo.bar\r\n" 494 | ) 495 | feed_bytes(protocol, f"{error_code} Error\r\n".encode(), error_code, "Error") 496 | 497 | 498 | @pytest.mark.parametrize("error_code", [502, 504, 550]) 499 | def test_helo_error(protocol: SMTPClientProtocol, error_code: int) -> None: 500 | feed_bytes( 501 | protocol, 502 | b"220 foo.bar SMTP service ready\r\n", 503 | 220, 504 | "foo.bar SMTP service ready", 505 | ClientState.greeting_received, 506 | ) 507 | call_protocol_method( 508 | protocol, lambda: protocol.send_greeting("foo.bar"), b"EHLO foo.bar\r\n" 509 | ) 510 | feed_bytes(protocol, b"500 unrecognized command\r\n") 511 | assert protocol.get_outgoing_data() == b"HELO foo.bar\r\n" 512 | feed_bytes(protocol, f"{error_code} Error\r\n".encode(), error_code, "Error") 513 | 514 | 515 | @pytest.mark.parametrize("error_code", [451, 452, 455, 503, 550, 553, 552, 555]) 516 | def test_mail_error(protocol: SMTPClientProtocol, error_code: int) -> None: 517 | exchange_greetings(protocol) 518 | call_protocol_method( 519 | protocol, 520 | lambda: protocol.mail("foo@bar"), 521 | b"MAIL FROM: BODY=8BITMIME SMTPUTF8\r\n", 522 | ) 523 | feed_bytes(protocol, f"{error_code} Error\r\n".encode(), error_code, "Error") 524 | 525 | 526 | @pytest.mark.parametrize( 527 | "error_code", [450, 451, 452, 455, 503, 550, 551, 552, 553, 555] 528 | ) 529 | def test_rcpt_error(protocol: SMTPClientProtocol, error_code: int) -> None: 530 | exchange_greetings(protocol) 531 | call_protocol_method( 532 | protocol, 533 | lambda: protocol.mail("foo@bar"), 534 | b"MAIL FROM: BODY=8BITMIME SMTPUTF8\r\n", 535 | ) 536 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK") 537 | call_protocol_method( 538 | protocol, lambda: protocol.recipient("foo@bar"), b"RCPT TO:\r\n" 539 | ) 540 | feed_bytes(protocol, f"{error_code} Error\r\n".encode(), error_code, "Error") 541 | 542 | 543 | @pytest.mark.parametrize("error_code", [450, 451, 452, 503, 550, 552, 554]) 544 | def test_start_data_error(protocol: SMTPClientProtocol, error_code: int) -> None: 545 | exchange_greetings(protocol) 546 | call_protocol_method( 547 | protocol, 548 | lambda: protocol.mail("foo@bar"), 549 | b"MAIL FROM: BODY=8BITMIME SMTPUTF8\r\n", 550 | ) 551 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK") 552 | call_protocol_method( 553 | protocol, lambda: protocol.recipient("foo@bar"), b"RCPT TO:\r\n" 554 | ) 555 | feed_bytes(protocol, b"250 OK\r\n", 250, "OK") 556 | call_protocol_method(protocol, protocol.start_data, b"DATA\r\n") 557 | feed_bytes(protocol, f"{error_code} Error\r\n".encode(), error_code, "Error") 558 | --------------------------------------------------------------------------------