├── 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 |
--------------------------------------------------------------------------------