aiosmtplib-4.0.0/.pre-commit-config.yaml0000644000000000000000000000125613615410400015063 0ustar00repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-ast - id: check-added-large-files - id: fix-byte-order-marker - id: check-toml - id: check-yaml - id: debug-statements - id: check-merge-conflict - id: trailing-whitespace - id: end-of-file-fixer - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.9.3' hooks: - id: ruff - id: ruff-format - repo: local hooks: - id: pyright name: pyright entry: pyright language: node args: [] files: '\.py$' additional_dependencies: ["pyright@1.1.383"] aiosmtplib-4.0.0/.readthedocs.yml0000644000000000000000000000057013615410400013666 0ustar00# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: python: "3.11" python: install: - requirements: docs/requirements.txt - requirements: requirements-dev.txt - method: pip path: ./ sphinx: configuration: docs/conf.py fail_on_warning: true aiosmtplib-4.0.0/CHANGELOG.rst0000644000000000000000000001652613615410400012631 0ustar00Changelog ========= 4.0.0 ----- - **BREAKING**: Drop Python 3.8 support - Bugfix: Run `socket.getfqdn` in thread to avoid blocking event loop if `local_hostname` not provided (thanks @Raidzin) - Bugfix: Clear connect lock on connection lost, allowing client reconnect - Bugfix: Allow socket connections to use TLS by providing `hostname` and `use_tls=True` 3.0.2 ----- - Bugfix: Type of "send" is partially unknown with pyright - Bugfix: Fix asyncio deadlock trying to reconnect after error (thanks @Voldemat) - Change: Switched from Poetry to build/hatch/twine for packaging. 3.0.1 ----- - Bugfix: 'Future exception was never retrieved' warning in SMTPProtocol after successful connection close and garbage collection. - Cleanup: Updated FlowControlMixin logic from stdlib 3.0.0 ----- - **BREAKING**: Drop Python 3.7 support. - **BREAKING**: Positional arguments are now positional only, and keyword arguments are keyword only. - **BREAKING**: Passing ``source_address`` as a string argument (deprecated in 2.0) is now an error. ``source_address`` takes a (addr, port) tuple that is used as the ``local_addr`` param of ``asyncio.create_connection``, allowing for binding to a specific IP. The ``local_hostname`` argument takes the value to be sent to the server with the EHLO/HELO message (which is what ``source_address`` was used for prior to 2.0). - Change: don't use timeout value passed to ``connect`` everywhere, only for the initial connection (credit @wombatonfire) - Change: removed unnecessary connection lost callback - Change: revised handling for 'Future exception was never retrieved' warnings in protocol 2.0.2 ----- - Bugfix: don't send extra EHLO/HELO before QUIT (credit @ikrivosheev) - Change: added SMTPConnectionResponseError for invalid response on connect only (credit @ikrivosheev) 2.0.1 ----- - Bugfix: "tests" and "docs" in the sdist should be includes, not packages, so that they do not get put in ``site-packages``. 2.0.0 ----- - **BREAKING**: Drop Python 3.5 and 3.6 support. - **BREAKING**: On connect, if the server supports STARTTLS, automatically try to upgrade the connection. STARTTLS after connect can be turned on or off explicitly by passing ``start_tls=True`` or ``start_tls=False`` respectively. - **BREAKING**: Remove deprecated ``loop`` keyword argument for the SMTP class. - Change: The ``source_address`` argument now takes a (addr, port) tuple that is passed as the ``local_addr`` param to ``asyncio.create_connection``, allowing for binding to a specific IP. The new ``local_hostname`` argument that takes the value to be sent to the server with the EHLO/HELO message. This behaviour more closely matches ``smtplib``. In order to not break existing usage, passing a string instead of a tuple to ``source_address`` will give a DeprecationWarning, and use the value as it if had been passed for ``local_hostname``. Thanks @rafaelrds and @davidmcnabnz for raising and contributing work on this issue. - Bugfix: the ``mail_options`` and ``rcpt_options`` arguments to the ``send`` coroutine no longer cause errors - Cleanup: Refactored ``SMTP`` parent classes to remove complex inheritance structure. - Cleanup: Switched to ``asyncio.run`` for sync client methods. - Cleanup: Don't use private email.message.Message policy attribute (instead, set an appropriate policy based on message class) 1.1.7 ----- - Security: Fix a possible injection vulnerability (a variant of https://consensys.net/diligence/vulnerabilities/python-smtplib-multiple-crlf-injection/) Note that in order to exploit this vulnerability in aiosmtplib, the attacker would need control of the ``hostname`` or ``source_address`` parameters. Thanks Sam Sanoop @ Snyk for bringing this to my attention. - Bugfix: include CHANGLOG in sdist release - Type hints: fix type hints for async context exit (credit @JelleZijlstra) 1.1.6 ----- - Bugfix: fix authenticated test failures (credit @P-EB) 1.1.5 ----- - Bugfix: avoid raising ``asyncio.CancelledError`` on connection lost - Bugfix: allow UTF-8 chars in usernames and password strings - Feature: allow bytes type args for login usernames and passwords 1.1.4 ----- - Bugfix: parsing comma separated addresses in to header (credit @gjcarneiro) - Feature: add py.typed file (PEP 561, credit @retnikt) 1.1.3 ----- - Feature: add pause and resume writing methods to ``SMTPProcotol``, via ``asyncio.streams.FlowControlMixin`` (thanks @ikrivosheev). - Bugfix: allow an empty sender (credit @ikrivosheev) - Cleanup: more useful error message when login called without TLS 1.1.2 ----- - Bugfix: removed ``docs`` and ``tests`` from wheel, they should only be in the source distribution. 1.1.1 ----- - Bugfix: Fix handling of sending legacy email API (Message) objects. - Bugfix: Fix SMTPNotSupported error with UTF8 sender/recipient names on servers that don't support SMTPUTF8. 1.1.0 ----- - Feature: Added send coroutine api. - Feature: Added SMTPUTF8 support for UTF8 chars in addresses. - Feature: Added connected socket and Unix socket path connection options. - Feature: Wait until the connect coroutine is awaited to get the event loop. Passing an explicit event loop via the loop keyword argument is deprecated and will be removed in version 2.0. - Cleanup: Set context for timeout and connection exceptions properly. - Cleanup: Use built in start_tls method on Python 3.7+. - Cleanup: Timeout correctly if TLS handshake takes too long on Python 3.7+. - Cleanup: Updated SMTPProcotol class and removed StreamReader/StreamWriter usage to remove deprecation warnings in 3.8. - Bugfix: EHLO/HELO if required before any command, not just when using higher level commands. - Cleanup: Replaced asserts in functions with more useful errors (e.g. RuntimeError). - Cleanup: More useful error messages for timeouts (thanks ikrivosheev!), including two new exception classes, ``SMTPConnectTimeoutError`` and ``SMTPReadTimeoutError`` 1.0.6 ----- - Bugfix: Set default timeout to 60 seconds as per documentation (previously it was unlimited). 1.0.5 ----- - Bugfix: Connection is now closed if an error response is received immediately after connecting. 1.0.4 ----- - Bugfix: Badly encoded server response messages are now decoded to utf-8, with error chars escaped. - Cleanup: Removed handling for exceptions not raised by asyncio (in SMTPProtocol._readline) 1.0.3 ----- - Bugfix: Removed buggy close connection on __del__ - Bugfix: Fixed old style auth method parsing in ESMTP response. - Bugfix: Cleanup transport on exception in connect method. - Cleanup: Simplified SMTPProtocol.connection_made, __main__ 1.0.2 ----- - Bugfix: Close connection lock on on SMTPServerDisconnected - Feature: Added cert_bundle argument to connection init, connect and starttls methods - Bugfix: Disconnected clients would raise SMTPResponseException: (-1 ...) instead of SMTPServerDisconnected 1.0.1 ----- - Bugfix: Commands were getting out of order when using the client as a context manager within a task - Bugfix: multiple tasks calling connect would get confused - Bugfix: EHLO/HELO responses were being saved even after disconnect - Bugfix: RuntimeError on client cleanup if event loop was closed - Bugfix: CRAM-MD5 auth was not working - Bugfix: AttributeError on STARTTLS under uvloop 1.0.0 ----- Initial feature complete release with stable API; future changes will be documented here. aiosmtplib-4.0.0/CODE_OF_CONDUCT.md0000644000000000000000000001214413615410400013377 0ustar00# Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hi@colemaclean.dev. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. aiosmtplib-4.0.0/mypy.ini0000644000000000000000000000075513615410400012304 0ustar00[mypy] python_version = 3.9 # --strict warn_unused_configs = True disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_defs = True disallow_incomplete_defs = True check_untyped_defs = True disallow_untyped_decorators = True no_implicit_optional = True warn_redundant_casts = True warn_unused_ignores = True warn_return_any = True no_implicit_reexport = True strict_equality = True [mypy-tests.*] disallow_subclassing_any = False disallow_untyped_decorators = False aiosmtplib-4.0.0/requirements-dev.txt0000644000000000000000000000021113615410400014630 0ustar00pytest>=7.2 pytest-asyncio>=0.20.1 pytest-cov>=4.0 pytest-xdist>=3.0 coverage[toml]>=6.5 hypothesis>=6.56 aiosmtpd>=1.4.2 trustme>=0.9.0 aiosmtplib-4.0.0/.circleci/config.yml0000644000000000000000000001621213615410400014423 0ustar00version: 2.1 orbs: codecov: codecov/codecov@4.1.0 executors: cpython_executor: parameters: version: default: "3.12" type: enum enum: ["3.9", "3.10", "3.11", "3.12", "3.13"] docker: - image: "ghcr.io/astral-sh/uv:python<>-bookworm" resource_class: small environment: FORCE_COLOR: "1" pypy_executor: docker: - image: "pypy:3.10-bookworm" auth: username: $DOCKERHUB_USERNAME password: $DOCKERHUB_PASSWORD resource_class: small environment: FORCE_COLOR: "1" PIP_ROOT_USER_ACTION: "ignore" jobs: build: executor: name: cpython_executor steps: - checkout - run: uv build - persist_to_workspace: root: dist paths: - aiosmtplib-*.tar.gz - aiosmtplib-*.whl - store_artifacts: path: dist/ buildcheck: executor: name: cpython_executor steps: - attach_workspace: at: dist - run: uv tool run check-wheel-contents dist/*.whl - run: uv tool run twine check --strict dist/* typecheck: executor: name: cpython_executor steps: - checkout - run: uv tool run mypy src/aiosmtplib security: executor: name: cpython_executor steps: - checkout - run: uv tool run bandit -n 10 -x tests -r src/aiosmtplib docs: executor: name: cpython_executor steps: - checkout - attach_workspace: at: dist - run: uv venv - run: uv pip install -r docs/requirements.txt - run: uv pip install -r requirements-dev.txt - run: uv pip install dist/*.whl - run: uv run python -m sphinx -nWT -b doctest -d ./docs/build/doctrees ./docs ./docs/build/html - run: uv run python -m sphinx -nWT -b dummy -d ./docs/build/doctrees ./docs ./docs/build/html test: executor: name: cpython_executor version: <> parameters: python_version: type: enum description: "python version" enum: ["3.9", "3.10", "3.11", "3.12", "3.13"] event_loop: type: enum description: "event loop type" enum: ["asyncio", "uvloop"] environment: COVERAGE_FILE: "coverage-results/.coverage.cpython<>-<>" HYPOTHESIS_PROFILE: "ci" BIND_ADDR: "127.0.0.1" steps: - checkout - attach_workspace: at: dist - run: uv venv - run: uv pip install -r requirements-dev.txt - when: condition: equal: ["uvloop", <>] steps: - run: uv pip install $(find dist -name aiosmtplib-*.whl)[uvloop] - unless: condition: equal: ["uvloop", <>] steps: - run: uv pip install $(find dist -name aiosmtplib-*.whl) - run: | uv run --with aiosmtplib --no-project -- python -m pytest \ --cov \ --cov-report= \ --cov-config=pyproject.toml \ --junitxml=test-results/$CIRCLE_JOB/results.xml \ --override-ini=pythonpath= \ --event-loop=<> \ --bind-addr=$BIND_ADDR \ --hypothesis-profile $HYPOTHESIS_PROFILE - store_artifacts: path: test-results - store_test_results: path: test-results - persist_to_workspace: root: coverage-results paths: - .coverage.* test-pypy: executor: name: pypy_executor environment: COVERAGE_FILE: "coverage-results/.coverage.pypyn3.10-asyncio" HYPOTHESIS_PROFILE: "ci" BIND_ADDR: "127.0.0.1" steps: - checkout - attach_workspace: at: dist - run: python -m pip install -r requirements-dev.txt - run: python -m pip install dist/*.whl - run: | python -m pytest --cov --cov-report= \ --cov-config=pyproject.toml \ --junitxml=test-results/$CIRCLE_JOB/results.xml \ --override-ini=pythonpath= \ --event-loop=asyncio \ --bind-addr=$BIND_ADDR \ --hypothesis-profile $HYPOTHESIS_PROFILE - store_artifacts: path: test-results - store_test_results: path: test-results - persist_to_workspace: root: coverage-results paths: - .coverage.* coverage: executor: name: cpython_executor environment: COVERAGE_FILE: .coverage steps: - checkout - attach_workspace: at: coverage-results - run: cp coverage-results/.coverage.* ./ - run: uv tool run coverage combine - run: uv tool run coverage xml - run: uv tool run coverage html - run: uv tool run coverage report --fail-under=90 - store_artifacts: path: coverage.xml - store_artifacts: path: htmlcov - codecov/upload: file: coverage.xml deploy: executor: name: cpython_executor steps: - attach_workspace: at: dist - run: uv tool run twine upload -r testpypi --username $TESTPYPI_USERNAME --password $TESTPYPI_PASSWORD dist/* - run: uv tool run twine upload -r pypi --username $PYPI_USERNAME --password $PYPI_PASSWORD dist/* workflows: build_test_deploy: jobs: - build: &base-job context: - docker-hub-credentials filters: tags: only: /.*/ - buildcheck: <<: *base-job requires: - build - docs: <<: *base-job requires: - build - test-pypy: <<: *base-job name: "test-asyncio-pypy3.10" requires: - build - test: <<: *base-job name: "test-asyncio-<< matrix.python_version >>" matrix: alias: "test-cpython-asyncio" parameters: event_loop: ["asyncio"] python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"] requires: - build - test: <<: *base-job name: "test-uvloop-<< matrix.python_version >>" matrix: alias: "test-cpython-uvloop" parameters: event_loop: ["uvloop"] python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"] requires: - build - "test-asyncio-<< matrix.python_version >>" - coverage: <<: *base-job requires: - test-cpython-asyncio - test-cpython-uvloop - test-asyncio-pypy3.10 - deploy: requires: - build - buildcheck - docs - test-cpython-asyncio - test-cpython-uvloop - test-asyncio-pypy3.10 context: - docker-hub-credentials filters: branches: ignore: /.*/ tags: only: /^v.*/ lint: jobs: - typecheck: <<: *base-job - security: <<: *base-job # VS Code Extension Version: 1.5.1 aiosmtplib-4.0.0/.github/ISSUE_TEMPLATE/bug_report.md0000644000000000000000000000071313615410400017014 0ustar00--- name: Bug report about: "Something is broken \U0001F631" title: '' labels: '🐛 bug' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behaviour: 1. Call ``aiosmtplib.send`` with the following params: ... 2. ... **Expected behaviour** A clear and concise description of what you expected to happen. **Additional context** Add any other context about the problem here. aiosmtplib-4.0.0/.github/ISSUE_TEMPLATE/feature_request.md0000644000000000000000000000111613615410400020045 0ustar00--- name: Feature request about: "Suggest a feature \U0001F680" title: '' labels: '🚀 feature' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context about the feature request here. aiosmtplib-4.0.0/docs/Makefile0000644000000000000000000000114013615410400013162 0ustar00# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = aiosmtplib SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) aiosmtplib-4.0.0/docs/bug-reporting.rst0000644000000000000000000000020113615410400015035 0ustar00Bug Reporting ============= .. include:: ../README.rst :start-after: start bug-reporting :end-before: end bug-reporting aiosmtplib-4.0.0/docs/changelog.rst0000644000000000000000000000003613615410400014206 0ustar00.. include:: ../CHANGELOG.rst aiosmtplib-4.0.0/docs/client.rst0000644000000000000000000001301613615410400013537 0ustar00 .. py:currentmodule:: aiosmtplib The SMTP Client Class ===================== Use the :class:`SMTP` class as a client directly when you want more control over the email sending process than the :func:`send` async function provides. Connecting to an SMTP Server ---------------------------- Initialize a new :class:`SMTP` instance, then await its :meth:`SMTP.connect` coroutine. Initializing an instance does not automatically connect to the server, as that is a blocking operation. .. testcode:: import asyncio from aiosmtplib import SMTP client = SMTP() asyncio.run(client.connect(hostname="127.0.0.1", port=1025)) Connecting over TLS/SSL ~~~~~~~~~~~~~~~~~~~~~~~ .. seealso:: For details on different connection types, see :ref:`connection-types`. If an SMTP server supports direct connection via TLS/SSL, pass ``use_tls=True`` when initializing the SMTP instance (or when calling :meth:`SMTP.connect`). .. code-block:: python smtp_client = aiosmtplib.SMTP(hostname="smtp.gmail.com", port=465, use_tls=True) await smtp_client.connect() STARTTLS connections ~~~~~~~~~~~~~~~~~~~~ .. seealso:: For details on different connection types, see :ref:`connection-types`. By default, if the server advertises STARTTLS support, aiosmtplib will upgrade the connection automatically. Setting ``use_tls=True`` for STARTTLS servers will typically result in a connection error. To opt out of STARTTLS on connect, pass ``start_tls=False``. You may then manually call :meth:`SMTP.starttls` if needed. .. code-block:: python smtp_client = aiosmtplib.SMTP( hostname="smtp.gmail.com", port=587, start_tls=False, use_tls=False, ) await smtp_client.connect() await smtp_client.starttls() Connecting via Async Context Manager ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Instances of the :class:`SMTP` class can also be used as an async context manager, which will automatically connect/disconnect on entry/exit. .. testcode:: import asyncio from email.message import EmailMessage from aiosmtplib import SMTP async def say_hello(): message = EmailMessage() message["From"] = "root@localhost" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.set_content("Sent via aiosmtplib") smtp_client = SMTP(hostname="127.0.0.1", port=1025) async with smtp_client: await smtp_client.send_message(message) asyncio.run(say_hello()) Sending Messages ---------------- :meth:`SMTP.send_message` ~~~~~~~~~~~~~~~~~~~~~~~~~ Use this method to send :py:class:`email.message.EmailMessage` objects, including :py:mod:`email.mime` subclasses such as :py:class:`email.mime.text.MIMEText`. For details on creating :py:class:`email.message.EmailMessage` objects, see `the stdlib documentation examples `_. .. testcode:: import asyncio from email.mime.text import MIMEText from aiosmtplib import SMTP mime_message = MIMEText("Sent via aiosmtplib") mime_message["From"] = "root@localhost" mime_message["To"] = "somebody@example.com" mime_message["Subject"] = "Hello World!" async def send_with_send_message(message): smtp_client = SMTP(hostname="127.0.0.1", port=1025) await smtp_client.connect() await smtp_client.send_message(message) await smtp_client.quit() asyncio.run(send_with_send_message(mime_message)) Pass :py:class:`email.mime.multipart.MIMEMultipart` objects to :meth:`SMTP.send_message` to send messages with both HTML text and plain text alternatives. .. testcode:: from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText message = MIMEMultipart("alternative") message["From"] = "root@localhost" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.attach(MIMEText("hello", "plain", "utf-8")) message.attach(MIMEText("

Hello

", "html", "utf-8")) async def send_multipart_message(message): smtp_client = SMTP(hostname="127.0.0.1", port=1025) await smtp_client.connect() await smtp_client.send_message(message) await smtp_client.quit() asyncio.run(send_multipart_message(message)) :meth:`SMTP.sendmail` ~~~~~~~~~~~~~~~~~~~~~ Use :meth:`SMTP.sendmail` to send raw messages. Note that when using this method, you must format the message headers yourself. .. testcode:: import asyncio from aiosmtplib import SMTP sender = "root@localhost" recipients = ["somebody@example.com"] message = """To: somebody@example.com From: root@localhost Subject: Hello World! Sent via aiosmtplib """ async def send_with_sendmail(): smtp_client = SMTP(hostname="127.0.0.1", port=1025) await smtp_client.connect() await smtp_client.sendmail(sender, recipients, message) await smtp_client.quit() asyncio.run(send_with_sendmail()) Parallel Execution ------------------ SMTP is a sequential protocol. Multiple commands must be sent to send an email, and they must be sent in the correct sequence. As a consequence of this, executing multiple :meth:`SMTP.send_message` tasks in parallel (i.e. with :py:func:`asyncio.gather`) is not any more efficient than executing in sequence, as the client must wait until one mail is sent before beginning the next. If you have a lot of emails to send, consider creating multiple connections (:class:`SMTP` instances) and splitting the work between them. aiosmtplib-4.0.0/docs/conf.py0000644000000000000000000001331413615410400013027 0ustar00#!/usr/bin/env python3 # # aiosmtplib documentation build configuration file, created by # sphinx-quickstart on Wed Dec 7 11:17:39 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. import datetime import pathlib import re VERSION_REGEX = r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]' # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.viewcode", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinx_copybutton", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # General information about the project. project = "aiosmtplib" author = "Cole Maclean" year = datetime.date.today().year copyright = f"{year}, {author}" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. init = pathlib.Path("../src/aiosmtplib/__init__.py") version_match = re.search(VERSION_REGEX, init.read_text("utf8"), re.MULTILINE) if not version_match: raise RuntimeError("Cannot find version information") # The short X.Y version. version = version_match.group(1) # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "furo" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { "navigation_with_keys": True, } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path: list[str] = [] # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = "aiosmtplibdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements: dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "aiosmtplib.tex", "aiosmtplib Documentation", "Cole Maclean", "manual") ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "aiosmtplib", "aiosmtplib Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "aiosmtplib", "aiosmtplib Documentation", author, "aiosmtplib", "asyncio SMTP client", "Miscellaneous", ) ] intersphinx_mapping = {"python": ("https://docs.python.org/3.10", None)} html_sidebars = {} nitpick_ignore = [ ("py:class", "asyncio.exceptions.TimeoutError"), ("py:class", "socket.socket"), ] doctest_global_setup = """ import asyncio import logging from aiosmtpd.controller import Controller aiosmtpd_logger = logging.getLogger("mail.log") aiosmtpd_logger.setLevel(logging.ERROR) controller = Controller(object(), hostname="127.0.0.1", port=1025) controller.start() """ doctest_global_cleanup = """ controller.stop() """ aiosmtplib-4.0.0/docs/encryption.rst0000644000000000000000000000221313615410400014450 0ustar00.. _connection-types: TLS, SSL & STARTTLS =================== aiosmtplib supports the three main types of encryption used by SMTP servers: 1. Plaintext (default port 25). The connection is entirely unencrypted. Most authentication methods will not be supported by servers when using an unencrypted connection. Best suited for connecting to a server running locally or for testing. 2. TLS/SSL encrypted (default port 465). In this case the TLS handshake occurs when the connection is established, and all traffic is encrypted. This type of connection should generally be used where available. 3. STARTTLS (default port 587). When using STARTTLS, an initial unencrypted connection is made, EHLO/HELO greetings are exchanged, and the connection is upgraded in place once the client requests it by sending the STARTTLS command. Most servers require an upgrade before allowing AUTH commands. .. tip:: By default, if aiosmtplib will connect in plaintext and upgrade the connetion using STARTTLS if the server supports it. If you want to opt out of upgrades even if the server supports them, pass a ``start_tls`` value of ``False``. aiosmtplib-4.0.0/docs/index.rst0000644000000000000000000000070313615410400013367 0ustar00.. module:: aiosmtplib aiosmtplib ========== aiosmtplib is an asynchronous SMTP client for use with :py:mod:`asyncio`. It is an async version of the :py:mod:`smtplib` module, with similar APIs. Table of Contents ----------------- .. toctree:: :maxdepth: 2 quickstart usage client encryption timeouts proxies trio reference bug-reporting Release History --------------- .. toctree:: :maxdepth: 2 changelog aiosmtplib-4.0.0/docs/proxies.rst0000644000000000000000000000220613615410400013751 0ustar00.. py:currentmodule:: aiosmtplib Proxy Support ============= SOCKS Proxies ~~~~~~~~~~~~~ You can use the `python-socks`_ library to connect to a SOCKS proxy. Create a socket using the ``proxy.connect`` method, and pass it as the ``sock`` argument to the :func:`send` coroutine or :py:class:`SMTP` class. .. code-block:: python import ssl import asyncio import aiosmtplib from python_socks.async_.asyncio import Proxy hello_message = """To: somebody@example.com From: root@localhost Subject: Hello World! Sent via aiosmtplib """ async def send_via_proxy(message): proxy = Proxy.from_url('socks5://user:password@127.0.0.1:1080') # `proxy.connect` returns a socket in non-blocking mode sock = await proxy.connect(dest_host='example.com', dest_port=443) # Use the socket with aiosmtplib await aiosmtplib.send( message, sender="root@localhost", recipients=["somebody@example.com"], sock=sock, ) asyncio.run(send_via_proxy(hello_message)) .. _python-socks: https://pypi.org/project/python-socks/ aiosmtplib-4.0.0/docs/quickstart.rst0000644000000000000000000000042413615410400014452 0ustar00Getting Started =============== Requirements ~~~~~~~~~~~~ .. include:: ../README.rst :start-after: start requirements :end-before: end requirements Quickstart ~~~~~~~~~~ .. include:: ../README.rst :start-after: start quickstart :end-before: end quickstart aiosmtplib-4.0.0/docs/reference.rst0000644000000000000000000000146313615410400014222 0ustar00API Reference ============= .. testsetup:: import aiosmtplib from aiosmtplib import SMTPResponse The send Coroutine ------------------ Use the :func:`aiosmtplib.send` coroutine in most cases when you want to send a message. .. autofunction:: aiosmtplib.send The SMTP Class -------------- The lower level :class:`aiosmtplib.SMTP` class gives you more control over the SMTP connection. .. autoclass:: aiosmtplib.SMTP :members: :inherited-members: .. automethod:: aiosmtplib.SMTP.__init__ Server Responses ---------------- .. autoclass:: aiosmtplib.response.SMTPResponse :members: Status Codes ------------ .. autoclass:: aiosmtplib.typing.SMTPStatus :members: :undoc-members: Exceptions ---------- .. automodule:: aiosmtplib.errors :members: :show-inheritance: aiosmtplib-4.0.0/docs/requirements.txt0000644000000000000000000000013013615410400015004 0ustar00sphinx>=7.0.0 sphinx_autodoc_typehints>=1.24.0 sphinx-copybutton>=0.5.0 furo>=2023.9.10 aiosmtplib-4.0.0/docs/timeouts.rst0000644000000000000000000000173013615410400014132 0ustar00 .. py:currentmodule:: aiosmtplib Timeouts ======== The :func:`send` coroutine and most :class:`SMTP` operations ( :meth:`SMTP.__init__`, :meth:`SMTP.connect`, and most command operations, e.g. :meth:`SMTP.ehlo`) accept a ``timeout`` keyword argument of a numerical value in seconds. This value is used for all socket operations (initial connection, STARTTLS, each command/response, etc), and will raise :exc:`.SMTPTimeoutError` if exceeded. .. warning:: Note that because the timeout is on socket operations, as long as there is no period of inactivity that exceeds it (meaning no individual bytes sent or received), the timeout will not be triggered. This means that if you set the timeout to 1 second (for example), sending an entire message might take much longer than that *without* a timeout occurring. Timeout values passed directly to :class:`SMTP` command methods will override the default passed in on initialization. The default timeout is 60 seconds. aiosmtplib-4.0.0/docs/trio.rst0000644000000000000000000000042513615410400013236 0ustar00Trio Support ============ aiosmtplib does not support the (excellent) `Trio`_ library. For Trio support, try the concrete implementation provided by the `smtpproto`_ library. .. _Trio: https://github.com/python-trio/trio .. _smtpproto: https://github.com/agronholm/smtpproto aiosmtplib-4.0.0/docs/usage.rst0000644000000000000000000000772413615410400013376 0ustar00 .. py:currentmodule:: aiosmtplib The send Coroutine ================== The :func:`send` coroutine is the main entry point for sending email, and is recommended for most use cases. If you need more direct control over connection, disconnection, etc, consider instantiating an :class:`SMTP` object directly. Sending Messages ---------------- To send a message, create an :py:class:`email.message.EmailMessage` object, set appropriate headers (``From``, and one of ``To``, ``Cc`` or ``Bcc``, at minimum), then pass it to :func:`send` with the hostname and port of an SMTP server. For details on creating :py:class:`email.message.EmailMessage` objects, see `the stdlib documentation examples `_. .. caution:: Confusingly, :py:class:`email.message.Message` objects are part of the legacy email API (prior to Python 3.3), while :py:class:`email.message.EmailMessage` objects support email policies other than the older :py:class:`email.policy.Compat32`. Use :py:class:`email.message.EmailMessage` where possible; it makes headers easier to work with. .. testcode:: import asyncio from email.message import EmailMessage import aiosmtplib async def send_hello_world(): message = EmailMessage() message["From"] = "root@localhost" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.set_content("Sent via aiosmtplib") await aiosmtplib.send(message, hostname="127.0.0.1", port=1025) asyncio.run(send_hello_world()) Multipart Messages ------------------ Pass :py:class:`email.mime.multipart.MIMEMultipart` objects to :func:`send` to send messages with both HTML text and plain text alternatives. .. testcode:: from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText message = MIMEMultipart("alternative") message["From"] = "root@localhost" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" plain_text_message = MIMEText("Sent via aiosmtplib", "plain", "utf-8") html_message = MIMEText( "

Sent via aiosmtplib

", "html", "utf-8" ) message.attach(plain_text_message) message.attach(html_message) Sending Raw Messages -------------------- You can also send a ``str`` or ``bytes`` message, by providing the ``sender`` and ``recipients`` keyword arguments. Note that you must provide any headers as part of the message text. .. testcode:: import asyncio import aiosmtplib async def send_hello_world(): message = """To: somebody@example.com From: root@localhost Subject: Hello World! Sent via aiosmtplib """ await aiosmtplib.send( message, sender="root@localhost", recipients=["somebody@example.com"], hostname="127.0.0.1", port=1025 ) asyncio.run(send_hello_world()) Connecting Over TLS/SSL ----------------------- .. seealso:: For details on different connection types, see :ref:`connection-types`. If an SMTP server supports direct connection via TLS/SSL, pass ``use_tls=True``. .. code-block:: python await send(message, hostname="smtp.gmail.com", port=465, use_tls=True) STARTTLS connections ~~~~~~~~~~~~~~~~~~~~ By default, if the server advertises STARTTLS support, aiosmtplib will upgrade the connection automatically. Setting ``use_tls=True`` for STARTTLS servers will typically result in a connection error. To opt out of STARTTLS on connect, pass ``start_tls=False``. .. code-block:: python await send(message, hostname="smtp.gmail.com", port=587, start_tls=False) Authentication -------------- To authenticate, pass the ``username`` and ``password`` keyword arguments to :func:`send`. .. code-block:: python await send( message, hostname="smtp.gmail.com", port=587, username="test@gmail.com", password="test" ) aiosmtplib-4.0.0/src/aiosmtplib/__init__.py0000644000000000000000000000246513615410400015650 0ustar00""" aiosmtplib ========== An asyncio SMTP client. Originally based on smtplib from the Python 3 standard library by: The Dragon De Monsyne Author: Cole Maclean """ from .api import send from .errors import ( SMTPAuthenticationError, SMTPConnectError, SMTPConnectTimeoutError, SMTPDataError, SMTPException, SMTPHeloError, SMTPNotSupported, SMTPReadTimeoutError, SMTPRecipientRefused, SMTPRecipientsRefused, SMTPResponseException, SMTPSenderRefused, SMTPServerDisconnected, SMTPTimeoutError, SMTPConnectResponseError, ) from .response import SMTPResponse from .smtp import SMTP from .typing import SMTPStatus __title__ = "aiosmtplib" __version__ = "4.0.0" __author__ = "Cole Maclean" __license__ = "MIT" __copyright__ = "Copyright 2022 Cole Maclean" __all__ = ( "send", "SMTP", "SMTPResponse", "SMTPStatus", "SMTPAuthenticationError", "SMTPConnectError", "SMTPDataError", "SMTPException", "SMTPHeloError", "SMTPNotSupported", "SMTPRecipientRefused", "SMTPRecipientsRefused", "SMTPResponseException", "SMTPSenderRefused", "SMTPServerDisconnected", "SMTPTimeoutError", "SMTPConnectTimeoutError", "SMTPReadTimeoutError", "SMTPConnectResponseError", ) aiosmtplib-4.0.0/src/aiosmtplib/__main__.py0000644000000000000000000000157013615410400015625 0ustar00from aiosmtplib.smtp import SMTP, SMTP_PORT raw_hostname = input("SMTP server hostname [localhost]: ") # nosec raw_port = input(f"SMTP server port [{SMTP_PORT}]: ") # nosec raw_sender = input("From: ") # nosec raw_recipients = input("To: ") # nosec hostname = raw_hostname or "localhost" port = int(raw_port) if raw_port else SMTP_PORT recipients = raw_recipients.split(",") lines: list[str] = [] print("Enter message, end with ^D:") while True: try: lines.append(input()) # nosec except EOFError: break message = "\n".join(lines) message_len = len(message.encode("utf-8")) print(f"Message length (bytes): {message_len}") smtp_client = SMTP(hostname=hostname or "localhost", port=port, start_tls=False) sendmail_errors, sendmail_response = smtp_client.sendmail_sync( raw_sender, recipients, message ) print(f"Server response: {sendmail_response}") aiosmtplib-4.0.0/src/aiosmtplib/api.py0000644000000000000000000001347713615410400014667 0ustar00""" Main public API. """ import email.message import socket import ssl from collections.abc import Sequence from typing import Optional, Union, cast from .response import SMTPResponse from .smtp import DEFAULT_TIMEOUT, SMTP from .typing import SocketPathType __all__ = ("send",) async def send( message: Union[email.message.EmailMessage, email.message.Message, str, bytes], /, *, sender: Optional[str] = None, recipients: Optional[Union[str, Sequence[str]]] = None, mail_options: Optional[Sequence[str]] = None, rcpt_options: Optional[Sequence[str]] = None, hostname: Optional[str] = "localhost", port: Optional[int] = None, username: Optional[Union[str, bytes]] = None, password: Optional[Union[str, bytes]] = None, local_hostname: Optional[str] = None, source_address: Optional[tuple[str, int]] = None, timeout: Optional[float] = DEFAULT_TIMEOUT, use_tls: bool = False, start_tls: Optional[bool] = None, validate_certs: bool = True, client_cert: Optional[str] = None, client_key: Optional[str] = None, tls_context: Optional[ssl.SSLContext] = None, cert_bundle: Optional[str] = None, socket_path: Optional[SocketPathType] = None, sock: Optional[socket.socket] = None, ) -> tuple[dict[str, SMTPResponse], str]: """ Send an email message. On await, connects to the SMTP server using the details provided, sends the message, then disconnects. :param message: Message text. Either an :py:class:`email.message.EmailMessage` object, ``str`` or ``bytes``. If an :py:class:`email.message.EmailMessage` object is provided, sender and recipients set in the message headers will be used, unless overridden by the respective keyword arguments. :keyword sender: From email address. Not required if an :py:class:`email.message.EmailMessage` object is provided for the `message` argument. :keyword recipients: Recipient email addresses. Not required if an :py:class:`email.message.EmailMessage` object is provided for the `message` argument. :keyword hostname: Server name (or IP) to connect to. Defaults to "localhost". :keyword port: Server port. Defaults ``465`` if ``use_tls`` is ``True``, ``587`` if ``start_tls`` is ``True``, or ``25`` otherwise. :keyword username: Username to login as after connect. :keyword password: Password for login after connect. :keyword local_hostname: The hostname of the client. If specified, used as the FQDN of the local host in the HELO/EHLO command. Otherwise, the result of :func:`socket.getfqdn`. :keyword source_address: Takes a 2-tuple (host, port) for the socket to bind to as its source address before connecting. If the host is '' and port is 0, the OS default behavior will be used. :keyword timeout: Default timeout value for the connection, in seconds. Defaults to 60. :keyword use_tls: If True, make the initial connection to the server over TLS/SSL. Mutually exclusive with ``start_tls``; if the server uses STARTTLS, ``use_tls`` should be ``False``. :keyword start_tls: Flag to initiate a STARTTLS upgrade on connect. If ``None`` (the default), upgrade will be initiated if supported by the server. If ``True``, and upgrade will be initiated regardless of server support. If ``False``, no upgrade will occur. Mutually exclusive with ``use_tls``. :keyword validate_certs: Determines if server certificates are validated. Defaults to ``True``. :keyword client_cert: Path to client side certificate, for TLS. :keyword client_key: Path to client side key, for TLS. :keyword tls_context: An existing :py:class:`ssl.SSLContext`, for TLS. Mutually exclusive with ``client_cert``/``client_key``. :keyword cert_bundle: Path to certificate bundle, for TLS verification. :keyword socket_path: Path to a Unix domain socket. Not compatible with `port` or `sock`. Accepts str, bytes, or a pathlike object. :keyword sock: An existing, connected socket object. Not compatible with `port`, or `socket_path`. Passing a socket object will transfer control of it to the asyncio connection, and it will be closed when the client disconnects. :raises ValueError: required arguments missing or mutually exclusive options provided """ if not isinstance(message, (email.message.EmailMessage, email.message.Message)): if not recipients: raise ValueError("Recipients must be provided with raw messages.") if not sender: raise ValueError("Sender must be provided with raw messages.") sender = cast(str, sender) recipients = cast(Union[str, Sequence[str]], recipients) client = SMTP( hostname=hostname, port=port, local_hostname=local_hostname, source_address=source_address, timeout=timeout, use_tls=use_tls, start_tls=start_tls, validate_certs=validate_certs, client_cert=client_cert, client_key=client_key, tls_context=tls_context, cert_bundle=cert_bundle, socket_path=socket_path, sock=sock, username=username, password=password, ) async with client: if isinstance(message, (email.message.EmailMessage, email.message.Message)): result = await client.send_message( message, sender=sender, recipients=recipients, mail_options=mail_options, rcpt_options=rcpt_options, ) else: result = await client.sendmail( sender, recipients, message, mail_options=mail_options, rcpt_options=rcpt_options, ) return result aiosmtplib-4.0.0/src/aiosmtplib/auth.py0000644000000000000000000000364713615410400015055 0ustar00""" Authentication related methods. """ import base64 import hmac from typing import Union __all__ = ("auth_crammd5_verify", "auth_plain_encode", "auth_login_encode") def _ensure_bytes(value: Union[str, bytes]) -> bytes: if isinstance(value, (bytes, bytearray, memoryview)): return value return value.encode("utf-8") def auth_crammd5_verify( username: Union[str, bytes], password: Union[str, bytes], challenge: Union[str, bytes], /, ) -> bytes: """ CRAM-MD5 auth uses the password as a shared secret to MD5 the server's response, and sends the username combined with that (base64 encoded). """ username_bytes = _ensure_bytes(username) password_bytes = _ensure_bytes(password) decoded_challenge = base64.b64decode(challenge) md5_digest = hmac.new(password_bytes, msg=decoded_challenge, digestmod="md5") verification = username_bytes + b" " + md5_digest.hexdigest().encode("ascii") encoded_verification = base64.b64encode(verification) return encoded_verification def auth_plain_encode( username: Union[str, bytes], password: Union[str, bytes], /, ) -> bytes: """ PLAIN auth base64 encodes the username and password together. """ username_bytes = _ensure_bytes(username) password_bytes = _ensure_bytes(password) username_and_password = b"\0" + username_bytes + b"\0" + password_bytes encoded = base64.b64encode(username_and_password) return encoded def auth_login_encode( username: Union[str, bytes], password: Union[str, bytes], /, ) -> tuple[bytes, bytes]: """ LOGIN auth base64 encodes the username and password and sends them in sequence. """ username_bytes = _ensure_bytes(username) password_bytes = _ensure_bytes(password) encoded_username = base64.b64encode(username_bytes) encoded_password = base64.b64encode(password_bytes) return encoded_username, encoded_password aiosmtplib-4.0.0/src/aiosmtplib/email.py0000644000000000000000000001172513615410400015177 0ustar00""" Email message and address formatting/parsing functions. """ import copy import email.charset import email.generator import email.header import email.headerregistry import email.message import email.policy import email.utils import io import re from collections.abc import Iterable from typing import Optional, Union, cast __all__ = ( "extract_recipients", "extract_sender", "flatten_message", "parse_address", "quote_address", ) SPECIALS_REGEX = re.compile(r'[][\\()<>@,:;".]') ESCAPES_REGEX = re.compile(r'[\\"]') UTF8_CHARSET = email.charset.Charset("utf-8") def parse_address(address: str) -> str: """ Parse an email address, falling back to the raw string given. """ _, parsed_address = email.utils.parseaddr(address) return parsed_address or address.strip() def quote_address(address: str) -> str: """ Quote a subset of the email addresses defined by RFC 821. """ parsed_address = parse_address(address) return f"<{parsed_address}>" def flatten_message( message: Union[email.message.EmailMessage, email.message.Message], /, *, utf8: bool = False, cte_type: str = "8bit", ) -> bytes: # Make a local copy so we can delete the bcc headers. message_copy = copy.copy(message) del message_copy["Bcc"] del message_copy["Resent-Bcc"] with io.BytesIO() as messageio: if isinstance(message_copy, email.message.EmailMessage): policy = email.policy.SMTPUTF8 if utf8 else email.policy.SMTP if cte_type != "8bit": policy = policy.clone(cte_type=cte_type) generator = email.generator.BytesGenerator(messageio, policy=policy) generator.flatten(message_copy) else: # Old message class, Compat32 policy. Compat32 cannot use UTF8 # Mypy can't handle message unions, so just use different vars compat_policy = email.policy.compat32 if cte_type != "8bit": compat_policy = compat_policy.clone(cte_type=cte_type) compat_generator = email.generator.BytesGenerator( messageio, policy=compat_policy ) compat_generator.flatten(message_copy) flat_message = messageio.getvalue() return flat_message def extract_addresses( header: Union[str, email.headerregistry.AddressHeader, email.header.Header], /, ) -> list[str]: """ Convert address headers into raw email addresses, suitable for use in low level SMTP commands. """ addresses: list[str] = [] if isinstance(header, email.headerregistry.AddressHeader): # If the object has been assigned an iterable, it's possible to get a string here. header_addresses = cast( Iterable[Union[str, email.headerregistry.Address]], header.addresses ) for address in header_addresses: if isinstance(address, email.headerregistry.Address): addresses.append(address.addr_spec) else: addresses.append(parse_address(address)) elif isinstance(header, email.header.Header): for address_bytes, charset in email.header.decode_header(header): address_str = str(address_bytes, encoding=charset or "ascii") addresses.append(parse_address(address_str)) else: addresses.extend(addr for _, addr in email.utils.getaddresses([header])) return addresses def extract_sender( message: Union[email.message.EmailMessage, email.message.Message], /, ) -> Optional[str]: """ Extract the sender from the message object given. """ resent_dates = message.get_all("Resent-Date") if resent_dates is not None and len(resent_dates) > 1: raise ValueError("Message has more than one 'Resent-' header block") elif resent_dates: sender_header_name = "Resent-Sender" from_header_name = "Resent-From" else: sender_header_name = "Sender" from_header_name = "From" # Prefer the sender field per RFC 2822:3.6.2. if sender_header_name in message: sender_header = message[sender_header_name] else: sender_header = message[from_header_name] if sender_header is None: return None return extract_addresses(sender_header)[0] def extract_recipients( message: Union[email.message.EmailMessage, email.message.Message], /, ) -> list[str]: """ Extract the recipients from the message object given. """ recipients: list[str] = [] resent_dates = message.get_all("Resent-Date") if resent_dates is not None and len(resent_dates) > 1: raise ValueError("Message has more than one 'Resent-' header block") elif resent_dates: recipient_headers = ("Resent-To", "Resent-Cc", "Resent-Bcc") else: recipient_headers = ("To", "Cc", "Bcc") for header in recipient_headers: for recipient in message.get_all(header, failobj=[]): recipients.extend(extract_addresses(recipient)) return recipients aiosmtplib-4.0.0/src/aiosmtplib/errors.py0000644000000000000000000000613613615410400015424 0ustar00from asyncio import TimeoutError __all__ = ( "SMTPAuthenticationError", "SMTPConnectError", "SMTPDataError", "SMTPException", "SMTPHeloError", "SMTPNotSupported", "SMTPRecipientRefused", "SMTPRecipientsRefused", "SMTPResponseException", "SMTPSenderRefused", "SMTPServerDisconnected", "SMTPTimeoutError", "SMTPConnectTimeoutError", "SMTPReadTimeoutError", "SMTPConnectResponseError", ) class SMTPException(Exception): """ Base class for all SMTP exceptions. """ def __init__(self, message: str, /) -> None: self.message = message self.args = (message,) class SMTPServerDisconnected(SMTPException, ConnectionError): """ The connection was lost unexpectedly, or a command was run that requires a connection. """ class SMTPConnectError(SMTPException, ConnectionError): """ An error occurred while connecting to the SMTP server. """ class SMTPTimeoutError(SMTPException, TimeoutError): """ A timeout occurred while performing a network operation. """ class SMTPConnectTimeoutError(SMTPTimeoutError, SMTPConnectError): """ A timeout occurred while connecting to the SMTP server. """ class SMTPReadTimeoutError(SMTPTimeoutError): """ A timeout occurred while waiting for a response from the SMTP server. """ class SMTPNotSupported(SMTPException): """ A command or argument sent to the SMTP server is not supported. """ class SMTPResponseException(SMTPException): """ Base class for all server responses with error codes. """ def __init__(self, code: int, message: str, /) -> None: self.code = code self.message = message self.args = (code, message) class SMTPConnectResponseError(SMTPResponseException, SMTPConnectError): """ The SMTP server returned an invalid response code after connecting. """ class SMTPHeloError(SMTPResponseException): """ Server refused HELO or EHLO. """ class SMTPDataError(SMTPResponseException): """ Server refused DATA content. """ class SMTPAuthenticationError(SMTPResponseException): """ Server refused our AUTH request; may be caused by invalid credentials. """ class SMTPSenderRefused(SMTPResponseException): """ SMTP server refused the message sender. """ def __init__(self, code: int, message: str, sender: str, /) -> None: self.code = code self.message = message self.sender = sender self.args = (code, message, sender) class SMTPRecipientRefused(SMTPResponseException): """ SMTP server refused a message recipient. """ def __init__(self, code: int, message: str, recipient: str, /) -> None: self.code = code self.message = message self.recipient = recipient self.args = (code, message, recipient) class SMTPRecipientsRefused(SMTPException): """ SMTP server refused multiple recipients. """ def __init__(self, recipients: list[SMTPRecipientRefused], /) -> None: self.recipients = recipients self.args = (recipients,) aiosmtplib-4.0.0/src/aiosmtplib/esmtp.py0000644000000000000000000000445513615410400015242 0ustar00""" ESMTP utils """ import re __all__ = ("parse_esmtp_extensions",) OLDSTYLE_AUTH_REGEX = re.compile(r"auth=(?P.*)", flags=re.I) EXTENSIONS_REGEX = re.compile(r"(?P[A-Za-z0-9][A-Za-z0-9\-]*) ?") def parse_esmtp_extensions(message: str) -> tuple[dict[str, str], list[str]]: """ Parse an EHLO response from the server into a dict of {extension: params} and a list of auth method names. It might look something like: 220 size.does.matter.af.MIL (More ESMTP than Crappysoft!) EHLO heaven.af.mil 250-size.does.matter.af.MIL offers FIFTEEN extensions: 250-8BITMIME 250-PIPELINING 250-DSN 250-ENHANCEDSTATUSCODES 250-EXPN 250-HELP 250-SAML 250-SEND 250-SOML 250-TURN 250-XADR 250-XSTA 250-ETRN 250-XGEN 250 SIZE 51200000 """ esmtp_extensions: dict[str, str] = {} auth_types: list[str] = [] response_lines = message.split("\n") # ignore the first line for line in response_lines[1:]: # To be able to communicate with as many SMTP servers as possible, # we have to take the old-style auth advertisement into account, # because: # 1) Else our SMTP feature parser gets confused. # 2) There are some servers that only advertise the auth methods we # support using the old style. auth_match = OLDSTYLE_AUTH_REGEX.match(line) if auth_match is not None: auth_type = auth_match.group("auth") auth_types.append(auth_type.lower().strip()) # RFC 1869 requires a space between ehlo keyword and parameters. # It's actually stricter, in that only spaces are allowed between # parameters, but were not going to check for that here. Note # that the space isn't present if there are no parameters. extensions = EXTENSIONS_REGEX.match(line) if extensions is not None: extension = extensions.group("ext").lower() params = extensions.string[extensions.end("ext") :].strip() esmtp_extensions[extension] = params if extension == "auth": auth_types.extend([param.strip().lower() for param in params.split()]) return esmtp_extensions, auth_types aiosmtplib-4.0.0/src/aiosmtplib/protocol.py0000644000000000000000000003161713615410400015753 0ustar00""" An ``asyncio.Protocol`` subclass for lower level IO handling. """ import asyncio import collections import re import ssl from typing import Optional, cast from .errors import ( SMTPDataError, SMTPReadTimeoutError, SMTPResponseException, SMTPServerDisconnected, SMTPTimeoutError, ) from .response import SMTPResponse from .typing import SMTPStatus __all__ = ("SMTPProtocol",) MAX_LINE_LENGTH = 8192 LINE_ENDINGS_REGEX = re.compile(rb"(?:\r\n|\n|\r(?!\n))") PERIOD_REGEX = re.compile(rb"(?m)^\.") class FlowControlMixin(asyncio.Protocol): """ Reusable flow control logic for StreamWriter.drain(). This implements the protocol methods pause_writing(), resume_writing() and connection_lost(). If the subclass overrides these it must call the super methods. StreamWriter.drain() must wait for _drain_helper() coroutine. Copied from stdlib as per recommendation: https://bugs.python.org/msg343685. Logging and asserts removed, type annotations added. """ def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None): if loop is None: self._loop = asyncio.get_event_loop() else: self._loop = loop self._paused = False self._drain_waiters: collections.deque[asyncio.Future[None]] = ( collections.deque() ) self._connection_lost = False def pause_writing(self) -> None: self._paused = True def resume_writing(self) -> None: self._paused = False for waiter in self._drain_waiters: if not waiter.done(): waiter.set_result(None) def connection_lost(self, exc: Optional[Exception]) -> None: self._connection_lost = True # Wake up the writer(s) if currently paused. if not self._paused: return for waiter in self._drain_waiters: if not waiter.done(): if exc is None: waiter.set_result(None) else: waiter.set_exception(exc) async def _drain_helper(self) -> None: if self._connection_lost: raise ConnectionResetError("Connection lost") if not self._paused: return waiter = self._loop.create_future() self._drain_waiters.append(waiter) try: await waiter finally: self._drain_waiters.remove(waiter) def _get_close_waiter(self, stream: asyncio.StreamWriter) -> "asyncio.Future[None]": raise NotImplementedError class SMTPProtocol(FlowControlMixin, asyncio.BaseProtocol): def __init__( self, loop: Optional[asyncio.AbstractEventLoop] = None, ) -> None: super().__init__(loop=loop) self._over_ssl = False self._buffer = bytearray() self._response_waiter: Optional[asyncio.Future[SMTPResponse]] = None self.transport: Optional[asyncio.BaseTransport] = None self._command_lock: Optional[asyncio.Lock] = None self._closed_future: "asyncio.Future[None]" = self._loop.create_future() self._quit_sent = False def _get_close_waiter(self, stream: asyncio.StreamWriter) -> "asyncio.Future[None]": return self._closed_future def __del__(self) -> None: # Avoid 'Future exception was never retrieved' warnings # Some unknown race conditions can sometimes trigger these :( self._retrieve_response_exception() @property def is_connected(self) -> bool: """ Check if our transport is still connected. """ return bool(self.transport is not None and not self.transport.is_closing()) def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.Transport, transport) self._over_ssl = transport.get_extra_info("sslcontext") is not None self._response_waiter = self._loop.create_future() self._command_lock = asyncio.Lock() self._quit_sent = False def connection_lost(self, exc: Optional[Exception]) -> None: super().connection_lost(exc) if not self._quit_sent: smtp_exc = SMTPServerDisconnected("Connection lost") if exc: smtp_exc.__cause__ = exc if self._response_waiter and not self._response_waiter.done(): self._response_waiter.set_exception(smtp_exc) self.transport = None self._command_lock = None def data_received(self, data: bytes) -> None: if self._response_waiter is None: raise RuntimeError( f"data_received called without a response waiter set: {data!r}" ) elif self._response_waiter.done(): # We got a response without issuing a command; ignore it. return self._buffer.extend(data) # If we got an obvious partial message, don't try to parse the buffer last_linebreak = data.rfind(b"\n") if ( last_linebreak == -1 or data[last_linebreak + 3 : last_linebreak + 4] == b"-" ): return try: response = self._read_response_from_buffer() except Exception as exc: self._response_waiter.set_exception(exc) else: if response is not None: self._response_waiter.set_result(response) def eof_received(self) -> bool: exc = SMTPServerDisconnected("Unexpected EOF received") if self._response_waiter and not self._response_waiter.done(): self._response_waiter.set_exception(exc) # Returning false closes the transport return False def _retrieve_response_exception(self) -> Optional[BaseException]: """ Return any exception that has been set on the response waiter. Used to avoid 'Future exception was never retrieved' warnings """ if ( self._response_waiter and self._response_waiter.done() and not self._response_waiter.cancelled() ): return self._response_waiter.exception() return None def _read_response_from_buffer(self) -> Optional[SMTPResponse]: """Parse the actual response (if any) from the data buffer""" code = -1 message = bytearray() offset = 0 message_complete = False while True: line_end_index = self._buffer.find(b"\n", offset) if line_end_index == -1: break line = bytes(self._buffer[offset : line_end_index + 1]) if len(line) > MAX_LINE_LENGTH: raise SMTPResponseException( SMTPStatus.unrecognized_command, "Response too long" ) try: code = int(line[:3]) except ValueError: error_text = line.decode("utf-8", errors="ignore") raise SMTPResponseException( SMTPStatus.invalid_response.value, f"Malformed SMTP response line: {error_text}", ) from None offset += len(line) if len(message): message.extend(b"\n") message.extend(line[4:].strip(b" \t\r\n")) if line[3:4] != b"-": message_complete = True break if message_complete: response = SMTPResponse( code, bytes(message).decode("utf-8", "surrogateescape") ) del self._buffer[:offset] return response else: return None async def read_response(self, timeout: Optional[float] = None) -> SMTPResponse: """ Get a status response from the server. This method must be awaited once per command sent; if multiple commands are written to the transport without awaiting, response data will be lost. Returns an :class:`.response.SMTPResponse` namedtuple consisting of: - server response code (e.g. 250, or such, if all goes well) - server response string (multiline responses are converted to a single, multiline string). """ if self._response_waiter is None: raise SMTPServerDisconnected("Connection lost") try: result = await asyncio.wait_for(self._response_waiter, timeout) except (TimeoutError, asyncio.TimeoutError) as exc: raise SMTPReadTimeoutError("Timed out waiting for server response") from exc finally: # If we were disconnected, don't create a new waiter if self.transport is None: self._response_waiter = None else: self._response_waiter = self._loop.create_future() return result def write(self, data: bytes) -> None: if self.transport is None or self.transport.is_closing(): raise SMTPServerDisconnected("Connection lost") try: cast(asyncio.WriteTransport, self.transport).write(data) # uvloop raises NotImplementedError, asyncio doesn't have a write method except (AttributeError, NotImplementedError): raise RuntimeError( f"Transport {self.transport!r} does not support writing." ) from None async def execute_command( self, *args: bytes, timeout: Optional[float] = None ) -> SMTPResponse: """ Sends an SMTP command along with any args to the server, and returns a response. """ if self._command_lock is None: raise SMTPServerDisconnected("Server not connected") command = b" ".join(args) + b"\r\n" async with self._command_lock: self.write(command) if command == b"QUIT\r\n": self._quit_sent = True response = await self.read_response(timeout=timeout) return response async def execute_data_command( self, message: bytes, timeout: Optional[float] = None ) -> SMTPResponse: """ Sends an SMTP DATA command to the server, followed by encoded message content. Automatically quotes lines beginning with a period per RFC821. Lone \\\\r and \\\\n characters are converted to \\\\r\\\\n characters. """ if self._command_lock is None: raise SMTPServerDisconnected("Server not connected") message = LINE_ENDINGS_REGEX.sub(b"\r\n", message) message = PERIOD_REGEX.sub(b"..", message) if not message.endswith(b"\r\n"): message += b"\r\n" message += b".\r\n" async with self._command_lock: self.write(b"DATA\r\n") start_response = await self.read_response(timeout=timeout) if start_response.code != SMTPStatus.start_input: raise SMTPDataError(start_response.code, start_response.message) self.write(message) response = await self.read_response(timeout=timeout) if response.code != SMTPStatus.completed: raise SMTPDataError(response.code, response.message) return response async def start_tls( self, tls_context: ssl.SSLContext, server_hostname: Optional[str] = None, timeout: Optional[float] = None, ) -> SMTPResponse: """ Puts the connection to the SMTP server into TLS mode. """ if self._over_ssl: raise RuntimeError("Already using TLS.") if self._command_lock is None: raise SMTPServerDisconnected("Server not connected") async with self._command_lock: self.write(b"STARTTLS\r\n") response = await self.read_response(timeout=timeout) if response.code != SMTPStatus.ready: raise SMTPResponseException(response.code, response.message) # Check for disconnect after response if self.transport is None or self.transport.is_closing(): raise SMTPServerDisconnected("Connection lost") try: tls_transport = await self._loop.start_tls( cast(asyncio.WriteTransport, self.transport), self, tls_context, server_side=False, server_hostname=server_hostname, ssl_handshake_timeout=timeout, ) except (TimeoutError, asyncio.TimeoutError) as exc: raise SMTPTimeoutError("Timed out while upgrading transport") from exc # SSLProtocol only raises ConnectionAbortedError on timeout except ConnectionAbortedError as exc: raise SMTPTimeoutError( "Connection aborted while upgrading transport" ) from exc except ConnectionError as exc: raise SMTPServerDisconnected( "Connection reset while upgrading transport" ) from exc self.transport = tls_transport return response aiosmtplib-4.0.0/src/aiosmtplib/py.typed0000644000000000000000000000014113615410400015223 0ustar00This file exists to help mypy (and other tools) find inline type hints. See PR #141 and PEP 561. aiosmtplib-4.0.0/src/aiosmtplib/response.py0000644000000000000000000000124113615410400015736 0ustar00""" SMTPResponse class, a simple namedtuple of (code, message). """ from typing import NamedTuple __all__ = ("SMTPResponse",) class SMTPResponse(NamedTuple): """ NamedTuple of server response code and server response message. ``code`` and ``message`` can be accessed via attributes or indexes: >>> response = SMTPResponse(200, "OK") >>> response.message 'OK' >>> response[0] 200 >>> response.code 200 """ code: int message: str def __repr__(self) -> str: return f"({self.code}, {self.message})" def __str__(self) -> str: return f"{self.code} {self.message}" aiosmtplib-4.0.0/src/aiosmtplib/smtp.py0000644000000000000000000015520413615410400015074 0ustar00""" Main SMTP client class. Implements SMTP, ESMTP & Auth methods. """ import asyncio import email.message import socket import ssl from collections.abc import Iterable, Sequence from typing import ( Any, Literal, Optional, Union, ) from .auth import auth_crammd5_verify, auth_login_encode, auth_plain_encode from .email import ( extract_recipients, extract_sender, flatten_message, parse_address, quote_address, ) from .errors import ( SMTPAuthenticationError, SMTPConnectError, SMTPConnectTimeoutError, SMTPException, SMTPHeloError, SMTPNotSupported, SMTPRecipientRefused, SMTPRecipientsRefused, SMTPResponseException, SMTPSenderRefused, SMTPServerDisconnected, SMTPTimeoutError, SMTPConnectResponseError, ) from .esmtp import parse_esmtp_extensions from .protocol import SMTPProtocol from .response import SMTPResponse from .typing import Default, SMTPStatus, SocketPathType __all__ = ("SMTP", "SMTP_PORT", "SMTP_TLS_PORT", "SMTP_STARTTLS_PORT") SMTP_PORT = 25 SMTP_TLS_PORT = 465 SMTP_STARTTLS_PORT = 587 DEFAULT_TIMEOUT = 60 class SMTP: """ Main SMTP client class. Basic usage: >>> smtp = aiosmtplib.SMTP(hostname="127.0.0.1", port=1025) >>> sender = "root@localhost" >>> recipients = ["somebody@localhost"] >>> async def connect_and_send(): ... await smtp.connect() ... return await smtp.sendmail(sender, recipients, "Hello") >>> asyncio.run(connect_and_send()) ({}, 'OK') Keyword arguments can be provided either on :meth:`__init__` or when calling the :meth:`connect` method. Note that in both cases these options, except for ``timeout``, are saved for later use; subsequent calls to :meth:`connect` will use the same options, unless new ones are provided. ``timeout`` is saved for later use when provided on :meth:`__init__`, but not when calling the :meth:`connect` method. """ # Preferred methods first AUTH_METHODS: tuple[str, ...] = ( "cram-md5", "plain", "login", ) def __init__( self, *, hostname: Optional[str] = None, port: Optional[int] = None, username: Optional[Union[str, bytes]] = None, password: Optional[Union[str, bytes]] = None, local_hostname: Optional[str] = None, source_address: Optional[tuple[str, int]] = None, timeout: Optional[float] = DEFAULT_TIMEOUT, use_tls: bool = False, start_tls: Optional[bool] = None, validate_certs: bool = True, client_cert: Optional[str] = None, client_key: Optional[str] = None, tls_context: Optional[ssl.SSLContext] = None, cert_bundle: Optional[str] = None, socket_path: Optional[SocketPathType] = None, sock: Optional[socket.socket] = None, ) -> None: """ :keyword hostname: Server name (or IP) to connect to. defaults to "localhost". :keyword port: Server port. defaults ``465`` if ``use_tls`` is ``True``, ``587`` if ``start_tls`` is ``True``, or ``25`` otherwise. :keyword username: Username to login as after connect. :keyword password: Password for login after connect. :keyword local_hostname: The hostname of the client. If specified, used as the FQDN of the local host in the HELO/EHLO command. Otherwise, the result of :func:`socket.getfqdn`. :keyword source_address: Takes a 2-tuple (host, port) for the socket to bind to as its source address before connecting. If the host is '' and port is 0, the OS default behavior will be used. :keyword timeout: Default timeout value for the connection, in seconds. defaults to 60. :keyword use_tls: If True, make the initial connection to the server over TLS/SSL. Mutually exclusive with ``start_tls``; if the server uses STARTTLS, ``use_tls`` should be ``False``. :keyword start_tls: Flag to initiate a STARTTLS upgrade on connect. If ``None`` (the default), upgrade will be initiated if supported by the server. If ``True``, and upgrade will be initiated regardless of server support. If ``False``, no upgrade will occur. Mutually exclusive with ``use_tls``. :keyword validate_certs: Determines if server certificates are validated. defaults to ``True``. :keyword client_cert: Path to client side certificate, for TLS. :keyword client_key: Path to client side key, for TLS. :keyword tls_context: An existing :py:class:`ssl.SSLContext`, for TLS. Mutually exclusive with ``client_cert``/``client_key``. :keyword cert_bundle: Path to certificate bundle, for TLS verification. :keyword socket_path: Path to a Unix domain socket. Not compatible with hostname or port. Accepts str, bytes, or a pathlike object. :keyword sock: An existing, connected socket object. If given, none of hostname, port, or socket_path should be provided. :raises ValueError: mutually exclusive options provided """ self.protocol: Optional[SMTPProtocol] = None self.transport: Optional[asyncio.BaseTransport] = None # Kwarg defaults are provided here, and saved for connect. self.hostname = hostname self.port = port self._login_username = username self._login_password = password self.local_hostname = local_hostname self.timeout = timeout self.use_tls = use_tls self._start_tls_on_connect = start_tls self.validate_certs = validate_certs self.client_cert = client_cert self.client_key = client_key self.tls_context = tls_context self.cert_bundle = cert_bundle self.socket_path = socket_path self.sock = sock self.source_address = source_address self.loop: Optional[asyncio.AbstractEventLoop] = None self._connect_lock: Optional[asyncio.Lock] = None self.last_helo_response: Optional[SMTPResponse] = None self._last_ehlo_response: Optional[SMTPResponse] = None self.esmtp_extensions: dict[str, str] = {} self.supports_esmtp = False self.server_auth_methods: list[str] = [] self._sendmail_lock: Optional[asyncio.Lock] = None self._validate_config() async def __aenter__(self) -> "SMTP": if not self.is_connected: await self.connect() return self async def __aexit__( self, exc_type: type[BaseException], exc: BaseException, traceback: Any ) -> None: if isinstance(exc, (ConnectionError, TimeoutError)): self.close() return try: await self.quit() except (SMTPServerDisconnected, SMTPResponseException, SMTPTimeoutError): pass @property def is_connected(self) -> bool: """ Check if our transport is still connected. """ return bool(self.protocol is not None and self.protocol.is_connected) @property def last_ehlo_response(self) -> Union[SMTPResponse, None]: return self._last_ehlo_response @last_ehlo_response.setter def last_ehlo_response(self, response: SMTPResponse) -> None: """ When setting the last EHLO response, parse the message for supported extensions and auth methods. """ extensions, auth_methods = parse_esmtp_extensions(response.message) self._last_ehlo_response = response self.esmtp_extensions = extensions self.server_auth_methods = auth_methods self.supports_esmtp = True @property def is_ehlo_or_helo_needed(self) -> bool: """ Check if we've already received a response to an EHLO or HELO command. """ return self.last_ehlo_response is None and self.last_helo_response is None @property def supported_auth_methods(self) -> list[str]: """ Get all AUTH methods supported by the both server and by us. """ return [auth for auth in self.AUTH_METHODS if auth in self.server_auth_methods] def _update_settings_from_kwargs( self, hostname: Optional[Union[str, Literal[Default.token]]] = Default.token, port: Optional[Union[int, Literal[Default.token]]] = Default.token, username: Optional[Union[str, bytes, Literal[Default.token]]] = Default.token, password: Optional[Union[str, bytes, Literal[Default.token]]] = Default.token, local_hostname: Optional[Union[str, Literal[Default.token]]] = Default.token, source_address: Optional[ Union[tuple[str, int], Literal[Default.token]] ] = Default.token, use_tls: Optional[bool] = None, start_tls: Optional[Union[bool, Literal[Default.token]]] = Default.token, validate_certs: Optional[bool] = None, client_cert: Optional[Union[str, Literal[Default.token]]] = Default.token, client_key: Optional[Union[str, Literal[Default.token]]] = Default.token, tls_context: Optional[ Union[ssl.SSLContext, Literal[Default.token]] ] = Default.token, cert_bundle: Optional[Union[str, Literal[Default.token]]] = Default.token, socket_path: Optional[ Union[SocketPathType, Literal[Default.token]] ] = Default.token, sock: Optional[Union[socket.socket, Literal[Default.token]]] = Default.token, ) -> None: """Update our configuration from the kwargs provided. This method can be called multiple times. """ if hostname is not Default.token: self.hostname = hostname if use_tls is not None: self.use_tls = use_tls if start_tls is not Default.token: self._start_tls_on_connect = start_tls if validate_certs is not None: self.validate_certs = validate_certs if port is not Default.token: self.port = port if username is not Default.token: self._login_username = username if password is not Default.token: self._login_password = password if local_hostname is not Default.token: self.local_hostname = local_hostname if source_address is not Default.token: self.source_address = source_address if client_cert is not Default.token: self.client_cert = client_cert if client_key is not Default.token: self.client_key = client_key if tls_context is not Default.token: self.tls_context = tls_context if cert_bundle is not Default.token: self.cert_bundle = cert_bundle if socket_path is not Default.token: self.socket_path = socket_path if sock is not Default.token: self.sock = sock def _validate_config(self) -> None: if self._start_tls_on_connect and self.use_tls: raise ValueError("The start_tls and use_tls options are not compatible.") if self.tls_context is not None and self.client_cert is not None: raise ValueError( "Either a TLS context or a certificate/key must be provided" ) if self.sock is not None and self.socket_path is not None: raise ValueError( "Either a socket or a socket path must be provided, not both" ) if (self.sock or self.socket_path) and self.port is not None: raise ValueError("If using a socket, port is not required") if (self.sock or self.socket_path) and self.use_tls and self.hostname is None: raise ValueError("If using a socket with TLS, hostname is required") if self.local_hostname is not None and ( "\r" in self.local_hostname or "\n" in self.local_hostname ): raise ValueError( "The local_hostname param contains prohibited newline characters" ) if self.hostname is not None and ( "\r" in self.hostname or "\n" in self.hostname ): raise ValueError( "The hostname param contains prohibited newline characters" ) def _get_default_port(self) -> int: """ Return an appropriate default port, based on options selected. """ if self.use_tls: return SMTP_TLS_PORT elif self._start_tls_on_connect: return SMTP_STARTTLS_PORT return SMTP_PORT async def _get_default_local_hostname(self) -> str: return await asyncio.to_thread(socket.getfqdn) async def connect( self, *, hostname: Optional[Union[str, Literal[Default.token]]] = Default.token, port: Optional[Union[int, Literal[Default.token]]] = Default.token, username: Optional[Union[str, bytes, Literal[Default.token]]] = Default.token, password: Optional[Union[str, bytes, Literal[Default.token]]] = Default.token, local_hostname: Optional[Union[str, Literal[Default.token]]] = Default.token, source_address: Optional[ Union[tuple[str, int], Literal[Default.token]] ] = Default.token, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, use_tls: Optional[bool] = None, start_tls: Optional[Union[bool, Literal[Default.token]]] = Default.token, validate_certs: Optional[bool] = None, client_cert: Optional[Union[str, Literal[Default.token]]] = Default.token, client_key: Optional[Union[str, Literal[Default.token]]] = Default.token, tls_context: Optional[ Union[ssl.SSLContext, Literal[Default.token]] ] = Default.token, cert_bundle: Optional[Union[str, Literal[Default.token]]] = Default.token, socket_path: Optional[ Union[SocketPathType, Literal[Default.token]] ] = Default.token, sock: Optional[Union[socket.socket, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Initialize a connection to the server. Options provided to :meth:`.connect` take precedence over those used to initialize the class. :keyword hostname: Server name (or IP) to connect to. defaults to "localhost". :keyword port: Server port. defaults ``465`` if ``use_tls`` is ``True``, ``587`` if ``start_tls`` is ``True``, or ``25`` otherwise. :keyword username: Username to login as after connect. :keyword password: Password for login after connect. :keyword local_hostname: The hostname of the client. If specified, used as the FQDN of the local host in the HELO/EHLO command. Otherwise, the result of :func:`socket.getfqdn`. :keyword source_address: Takes a 2-tuple (host, port) for the socket to bind to as its source address before connecting. If the host is '' and port is 0, the OS default behavior will be used. :keyword timeout: timeout value for the connection, in seconds. Defaults to 60. :keyword use_tls: If True, make the initial connection to the server over TLS/SSL. Mutually exclusive with ``start_tls``; if the server uses STARTTLS, ``use_tls`` should be ``False``. :keyword start_tls: Flag to initiate a STARTTLS upgrade on connect. If ``None`` (the default), upgrade will be initiated if supported by the server. If ``True``, and upgrade will be initiated regardless of server support. If ``False``, no upgrade will occur. Mutually exclusive with ``use_tls``. :keyword validate_certs: Determines if server certificates are validated. defaults to ``True``. :keyword client_cert: Path to client side certificate, for TLS. :keyword client_key: Path to client side key, for TLS. :keyword tls_context: An existing :py:class:`ssl.SSLContext`, for TLS. Mutually exclusive with ``client_cert``/``client_key``. :keyword cert_bundle: Path to certificate bundle, for TLS verification. :keyword socket_path: Path to a Unix domain socket. Not compatible with `port` or `sock`. Accepts str, bytes, or a pathlike object. :keyword sock: An existing, connected socket object. Not compatible with `port`, or `socket_path`. Passing a socket object will transfer control of it to the asyncio connection, and it will be closed when the client disconnects. :raises ValueError: mutually exclusive options provided """ self._update_settings_from_kwargs( hostname=hostname, port=port, local_hostname=local_hostname, source_address=source_address, use_tls=use_tls, start_tls=start_tls, validate_certs=validate_certs, client_cert=client_cert, client_key=client_key, tls_context=tls_context, cert_bundle=cert_bundle, socket_path=socket_path, sock=sock, username=username, password=password, ) self._validate_config() self.loop = asyncio.get_running_loop() if self._connect_lock is None: self._connect_lock = asyncio.Lock() await self._connect_lock.acquire() # If we're not using a socket, default to port and hostname if self.sock is None and self.socket_path is None: if self.hostname is None: self.hostname = "localhost" if self.port is None and self.sock is None and self.socket_path is None: self.port = self._get_default_port() if self.local_hostname is None: self.local_hostname = await self._get_default_local_hostname() try: response = await self._create_connection( timeout=self.timeout if timeout is Default.token else timeout ) await self._maybe_start_tls_on_connect() await self._maybe_login_on_connect() except Exception as exc: self.close() # Reset our state to disconnected raise exc return response async def _create_connection(self, timeout: Optional[float]) -> SMTPResponse: if self.loop is None: raise RuntimeError("No event loop set") protocol = SMTPProtocol(loop=self.loop) tls_context: Optional[ssl.SSLContext] = None ssl_handshake_timeout: Optional[float] = None server_hostname: Optional[str] = None if self.use_tls: tls_context = self._get_tls_context() ssl_handshake_timeout = timeout server_hostname = self.hostname if self.sock is not None: connect_coro = self.loop.create_connection( lambda: protocol, sock=self.sock, ssl=tls_context, server_hostname=server_hostname, ssl_handshake_timeout=ssl_handshake_timeout, ) elif self.socket_path is not None: connect_coro = self.loop.create_unix_connection( lambda: protocol, path=self.socket_path, # type: ignore ssl=tls_context, server_hostname=server_hostname, ssl_handshake_timeout=ssl_handshake_timeout, ) else: if self.hostname is None: raise RuntimeError("No hostname provided; default should have been set") if self.port is None: raise RuntimeError("No port provided; default should have been set") connect_coro = self.loop.create_connection( lambda: protocol, host=self.hostname, port=self.port, ssl=tls_context, ssl_handshake_timeout=ssl_handshake_timeout, local_addr=self.source_address, ) try: transport, _ = await asyncio.wait_for(connect_coro, timeout=timeout) except (TimeoutError, asyncio.TimeoutError) as exc: raise SMTPConnectTimeoutError( f"Timed out connecting to {self.hostname} on port {self.port}" ) from exc except OSError as exc: raise SMTPConnectError( f"Error connecting to {self.hostname} on port {self.port}: {exc}" ) from exc self.protocol = protocol self.transport = transport try: response = await protocol.read_response(timeout=timeout) except SMTPServerDisconnected as exc: raise SMTPConnectError( f"Error connecting to {self.hostname} on port {self.port}: {exc}" ) from exc except SMTPTimeoutError as exc: raise SMTPConnectTimeoutError( "Timed out waiting for server ready message" ) from exc if response.code != SMTPStatus.ready: raise SMTPConnectResponseError(response.code, response.message) return response async def _maybe_start_tls_on_connect(self) -> None: """ Depending on config, upgrade the connection via STARTTLS. """ if self._start_tls_on_connect is True: await self.starttls() # If _start_tls_on_connect hasn't been set either way, # try to STARTTLS if supported, with graceful failure handling elif self._start_tls_on_connect is None: already_using_tls = self.get_transport_info("sslcontext") is not None if not (self.use_tls or already_using_tls): await self._ehlo_or_helo_if_needed() if self.supports_extension("starttls"): await self.starttls() async def _maybe_login_on_connect(self) -> None: """ Depending on config, login after connecting. """ if self._login_username is not None: login_password = ( self._login_password if self._login_password is not None else "" ) await self.login(self._login_username, login_password) async def execute_command( self, *args: bytes, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Check that we're connected, if we got a timeout value, and then pass the command to the protocol. :raises SMTPServerDisconnected: connection lost """ if self.protocol is None: raise SMTPServerDisconnected("Server not connected") try: response = await self.protocol.execute_command( *args, timeout=self.timeout if timeout is Default.token else timeout ) except SMTPServerDisconnected: self.close() raise # If the server is unavailable, be nice and close the connection if response.code == SMTPStatus.domain_unavailable: self.close() return response def _get_tls_context(self) -> ssl.SSLContext: """ Build an SSLContext object from the options we've been given. """ if self.tls_context is not None: context = self.tls_context else: # SERVER_AUTH is what we want for a client side socket context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) context.check_hostname = bool(self.validate_certs) if self.validate_certs: context.verify_mode = ssl.CERT_REQUIRED else: context.verify_mode = ssl.CERT_NONE if self.cert_bundle is not None: context.load_verify_locations(cafile=self.cert_bundle) if self.client_cert is not None: context.load_cert_chain(self.client_cert, keyfile=self.client_key) return context def close(self) -> None: """ Closes the connection. """ if self.transport is not None and not self.transport.is_closing(): self.transport.close() if self._connect_lock is not None and self._connect_lock.locked(): self._connect_lock.release() self.protocol = None self.transport = None # Reset ESMTP state self._reset_server_state() def get_transport_info(self, key: str) -> Any: """ Get extra info from the transport. Supported keys: - ``peername`` - ``socket`` - ``sockname`` - ``compression`` - ``cipher`` - ``peercert`` - ``sslcontext`` - ``sslobject`` :raises SMTPServerDisconnected: connection lost """ if not (self.is_connected and self.transport): raise SMTPServerDisconnected("Server not connected") return self.transport.get_extra_info(key) # Base SMTP commands # async def helo( self, *, hostname: Optional[str] = None, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send the SMTP HELO command. Hostname to send for this command defaults to the FQDN of the local host. :raises SMTPHeloError: on unexpected server response code """ if hostname is None: # Should already be set on connect if self.local_hostname is None: self.local_hostname = await self._get_default_local_hostname() hostname = self.local_hostname response = self.last_helo_response = await self.execute_command( b"HELO", hostname.encode("ascii"), timeout=timeout ) if response.code != SMTPStatus.completed: raise SMTPHeloError(response.code, response.message) return response async def help( self, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token ) -> str: """ Send the SMTP HELP command, which responds with help text. :raises SMTPResponseException: on unexpected server response code """ await self._ehlo_or_helo_if_needed() response = await self.execute_command(b"HELP", timeout=timeout) if response.code not in ( SMTPStatus.system_status_ok, SMTPStatus.help_message, SMTPStatus.completed, ): raise SMTPResponseException(response.code, response.message) return response.message async def rset( self, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token ) -> SMTPResponse: """ Send an SMTP RSET command, which resets the server's envelope (the envelope contains the sender, recipient, and mail data). :raises SMTPResponseException: on unexpected server response code """ await self._ehlo_or_helo_if_needed() response = await self.execute_command(b"RSET", timeout=timeout) if response.code != SMTPStatus.completed: raise SMTPResponseException(response.code, response.message) return response async def noop( self, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token ) -> SMTPResponse: """ Send an SMTP NOOP command, which does nothing. :raises SMTPResponseException: on unexpected server response code """ await self._ehlo_or_helo_if_needed() response = await self.execute_command(b"NOOP", timeout=timeout) if response.code != SMTPStatus.completed: raise SMTPResponseException(response.code, response.message) return response async def vrfy( self, address: str, /, *, options: Optional[Iterable[str]] = None, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send an SMTP VRFY command, which tests an address for validity. Not many servers support this command. :raises SMTPResponseException: on unexpected server response code """ await self._ehlo_or_helo_if_needed() if options is None: options = [] parsed_address = parse_address(address) if any(option.lower() == "smtputf8" for option in options): if not self.supports_extension("smtputf8"): raise SMTPNotSupported("SMTPUTF8 is not supported by this server") addr_bytes = parsed_address.encode("utf-8") else: addr_bytes = parsed_address.encode("ascii") options_bytes = [option.encode("ascii") for option in options] response = await self.execute_command( b"VRFY", addr_bytes, *options_bytes, timeout=timeout ) if response.code not in ( SMTPStatus.completed, SMTPStatus.will_forward, SMTPStatus.cannot_vrfy, ): raise SMTPResponseException(response.code, response.message) return response async def expn( self, address: str, /, *, options: Optional[Iterable[str]] = None, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send an SMTP EXPN command, which expands a mailing list. Not many servers support this command. :raises SMTPResponseException: on unexpected server response code """ await self._ehlo_or_helo_if_needed() if options is None: options = [] parsed_address = parse_address(address) if any(option.lower() == "smtputf8" for option in options): if not self.supports_extension("smtputf8"): raise SMTPNotSupported("SMTPUTF8 is not supported by this server") addr_bytes = parsed_address.encode("utf-8") else: addr_bytes = parsed_address.encode("ascii") options_bytes = [option.encode("ascii") for option in options] response = await self.execute_command( b"EXPN", addr_bytes, *options_bytes, timeout=timeout ) if response.code != SMTPStatus.completed: raise SMTPResponseException(response.code, response.message) return response async def quit( self, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token ) -> SMTPResponse: """ Send the SMTP QUIT command, which closes the connection. Also closes the connection from our side after a response is received. :raises SMTPResponseException: on unexpected server response code """ response = await self.execute_command(b"QUIT", timeout=timeout) if response.code != SMTPStatus.closing: raise SMTPResponseException(response.code, response.message) self.close() return response async def mail( self, sender: str, /, *, options: Optional[Iterable[str]] = None, encoding: str = "ascii", timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send an SMTP MAIL command, which specifies the message sender and begins a new mail transfer session ("envelope"). :raises SMTPSenderRefused: on unexpected server response code """ await self._ehlo_or_helo_if_needed() if options is None: options = [] quoted_sender = quote_address(sender) addr_bytes = quoted_sender.encode(encoding) options_bytes = [option.encode("ascii") for option in options] response = await self.execute_command( b"MAIL", b"FROM:" + addr_bytes, *options_bytes, timeout=timeout ) if response.code != SMTPStatus.completed: raise SMTPSenderRefused(response.code, response.message, sender) return response async def rcpt( self, recipient: str, /, *, options: Optional[Iterable[str]] = None, encoding: str = "ascii", timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send an SMTP RCPT command, which specifies a single recipient for the message. This command is sent once per recipient and must be preceded by 'MAIL'. :raises SMTPRecipientRefused: on unexpected server response code """ await self._ehlo_or_helo_if_needed() if options is None: options = [] quoted_recipient = quote_address(recipient) addr_bytes = quoted_recipient.encode(encoding) options_bytes = [option.encode("ascii") for option in options] response = await self.execute_command( b"RCPT", b"TO:" + addr_bytes, *options_bytes, timeout=timeout ) if response.code not in (SMTPStatus.completed, SMTPStatus.will_forward): raise SMTPRecipientRefused(response.code, response.message, recipient) return response async def data( self, message: Union[str, bytes], /, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send an SMTP DATA command, followed by the message given. This method transfers the actual email content to the server. :raises SMTPDataError: on unexpected server response code :raises SMTPServerDisconnected: connection lost """ if self.protocol is None: raise SMTPServerDisconnected("Connection lost") await self._ehlo_or_helo_if_needed() if timeout is Default.token: timeout = self.timeout if isinstance(message, str): message = message.encode("ascii") try: return await self.protocol.execute_data_command(message, timeout=timeout) except SMTPServerDisconnected: self.close() raise # ESMTP commands # async def ehlo( self, *, hostname: Optional[str] = None, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Send the SMTP EHLO command. Hostname to send for this command defaults to the FQDN of the local host. :raises SMTPHeloError: on unexpected server response code """ if hostname is None: # Should already be set on connect if self.local_hostname is None: self.local_hostname = await self._get_default_local_hostname() hostname = self.local_hostname response = await self.execute_command( b"EHLO", hostname.encode("ascii"), timeout=timeout ) self.last_ehlo_response = response if response.code != SMTPStatus.completed: raise SMTPHeloError(response.code, response.message) return response def supports_extension(self, extension: str, /) -> bool: """ Tests if the server supports the ESMTP service extension given. """ return extension.lower() in self.esmtp_extensions async def _ehlo_or_helo_if_needed(self) -> None: """ Call self.ehlo() and/or self.helo() if needed. If there has been no previous EHLO or HELO command this session, this method tries ESMTP EHLO first. """ if self.is_ehlo_or_helo_needed: try: await self.ehlo() except SMTPHeloError as exc: if self.is_connected: await self.helo() else: raise exc def _reset_server_state(self) -> None: """ Clear stored information about the server. """ self.last_helo_response = None self._last_ehlo_response = None self.esmtp_extensions = {} self.supports_esmtp = False self.server_auth_methods = [] async def starttls( self, *, server_hostname: Optional[str] = None, validate_certs: Optional[bool] = None, client_cert: Optional[Union[str, Literal[Default.token]]] = Default.token, client_key: Optional[Union[str, Literal[Default.token]]] = Default.token, cert_bundle: Optional[Union[str, Literal[Default.token]]] = Default.token, tls_context: Optional[ Union[ssl.SSLContext, Literal[Default.token]] ] = Default.token, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Puts the connection to the SMTP server into TLS mode. If there has been no previous EHLO or HELO command this session, this method tries ESMTP EHLO first. If the server supports TLS, this will encrypt the rest of the SMTP session. If you provide the keyfile and certfile parameters, the identity of the SMTP server and client can be checked (if validate_certs is True). You can also provide a custom SSLContext object. If no certs or SSLContext is given, and TLS config was provided when initializing the class, STARTTLS will use to that, otherwise it will use the Python defaults. :raises SMTPException: server does not support STARTTLS :raises SMTPServerDisconnected: connection lost :raises ValueError: invalid options provided """ if self.protocol is None: raise SMTPServerDisconnected("Server not connected") if self.get_transport_info("sslcontext") is not None: raise SMTPException("Connection already using TLS") await self._ehlo_or_helo_if_needed() self._update_settings_from_kwargs( validate_certs=validate_certs, client_cert=client_cert, client_key=client_key, cert_bundle=cert_bundle, tls_context=tls_context, ) self._validate_config() if server_hostname is None: server_hostname = self.hostname if timeout is Default.token: timeout = self.timeout tls_context = self._get_tls_context() if not self.supports_extension("starttls"): raise SMTPException("SMTP STARTTLS extension not supported by server.") try: response = await self.protocol.start_tls( tls_context, server_hostname=server_hostname, timeout=timeout ) except SMTPServerDisconnected: self.close() raise # Update our transport reference self.transport = self.protocol.transport # RFC 3207 part 4.2: # The client MUST discard any knowledge obtained from the server, such # as the list of SMTP service extensions, which was not obtained from # the TLS negotiation itself. self._reset_server_state() return response # Auth commands async def login( self, username: Union[str, bytes], password: Union[str, bytes], /, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ Tries to login with supported auth methods. Some servers advertise authentication methods they don't really support, so if authentication fails, we continue until we've tried all methods. """ await self._ehlo_or_helo_if_needed() if not self.supports_extension("auth"): if self.is_connected and self.get_transport_info("sslcontext") is None: raise SMTPException( "The SMTP AUTH extension is not supported by this server. Try " "connecting via TLS (or STARTTLS)." ) raise SMTPException( "The SMTP AUTH extension is not supported by this server." ) response: Optional[SMTPResponse] = None exception: Optional[SMTPAuthenticationError] = None for auth_name in self.supported_auth_methods: method_name = f"auth_{auth_name.replace('-', '')}" try: auth_method = getattr(self, method_name) except AttributeError as err: raise RuntimeError( f"Missing handler for auth method {auth_name}" ) from err try: response = await auth_method(username, password, timeout=timeout) except SMTPAuthenticationError as exc: exception = exc else: # No exception means we're good break if response is None: raise exception or SMTPException("No suitable authentication method found.") return response async def auth_crammd5( self, username: Union[str, bytes], password: Union[str, bytes], /, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ CRAM-MD5 auth uses the password as a shared secret to MD5 the server's response. Example:: 250 AUTH CRAM-MD5 auth cram-md5 334 PDI0NjA5LjEwNDc5MTQwNDZAcG9wbWFpbC5TcGFjZS5OZXQ+ dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw """ initial_response = await self.execute_command( b"AUTH", b"CRAM-MD5", timeout=timeout ) if initial_response.code != SMTPStatus.auth_continue: raise SMTPAuthenticationError( initial_response.code, initial_response.message ) verification_bytes = auth_crammd5_verify( username, password, initial_response.message ) response = await self.execute_command(verification_bytes) if response.code != SMTPStatus.auth_successful: raise SMTPAuthenticationError(response.code, response.message) return response async def auth_plain( self, username: Union[str, bytes], password: Union[str, bytes], /, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ PLAIN auth encodes the username and password in one Base64 encoded string. No verification message is required. Example:: 220-esmtp.example.com AUTH PLAIN dGVzdAB0ZXN0AHRlc3RwYXNz 235 ok, go ahead (#2.0.0) """ encoded = auth_plain_encode(username, password) response = await self.execute_command( b"AUTH", b"PLAIN", encoded, timeout=timeout ) if response.code != SMTPStatus.auth_successful: raise SMTPAuthenticationError(response.code, response.message) return response async def auth_login( self, username: Union[str, bytes], password: Union[str, bytes], /, *, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> SMTPResponse: """ LOGIN auth sends the Base64 encoded username and password in sequence. Example:: 250 AUTH LOGIN PLAIN CRAM-MD5 auth login avlsdkfj 334 UGFzc3dvcmQ6 avlsdkfj Note that there is an alternate version sends the username as a separate command:: 250 AUTH LOGIN PLAIN CRAM-MD5 auth login 334 VXNlcm5hbWU6 avlsdkfj 334 UGFzc3dvcmQ6 avlsdkfj However, since most servers seem to support both, we send the username with the initial request. """ encoded_username, encoded_password = auth_login_encode(username, password) initial_response = await self.execute_command( b"AUTH", b"LOGIN", encoded_username, timeout=timeout ) if initial_response.code != SMTPStatus.auth_continue: raise SMTPAuthenticationError( initial_response.code, initial_response.message ) response = await self.execute_command(encoded_password, timeout=timeout) if response.code != SMTPStatus.auth_successful: raise SMTPAuthenticationError(response.code, response.message) return response async def sendmail( self, sender: str, recipients: Union[str, Sequence[str]], message: Union[str, bytes], /, *, mail_options: Optional[Iterable[str]] = None, rcpt_options: Optional[Iterable[str]] = None, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> tuple[dict[str, SMTPResponse], str]: """ This command performs an entire mail transaction. The arguments are: - sender: The address sending this mail. - recipients: A list of addresses to send this mail to. A bare string will be treated as a list with 1 address. - message: The message string to send. - mail_options: List of options (such as ESMTP 8bitmime) for the MAIL command. - rcpt_options: List of options (such as DSN commands) for all the RCPT commands. message must be a string containing characters in the ASCII range. The string is encoded to bytes using the ascii codec, and lone \\\\r and \\\\n characters are converted to \\\\r\\\\n characters. If there has been no previous HELO or EHLO command this session, this method tries EHLO first. This method will return normally if the mail is accepted for at least one recipient. It returns a tuple consisting of: - an error dictionary, with one entry for each recipient that was refused. Each entry contains a tuple of the SMTP error code and the accompanying error message sent by the server. - the message sent by the server in response to the DATA command (often containing a message id) Example: >>> smtp = aiosmtplib.SMTP(hostname="127.0.0.1", port=1025) >>> recipients = ["one@one.org", "two@two.org", "3@three.org"] >>> message = "From: Me@my.org\\nSubject: testing\\nHello World" >>> async def connect_and_send(): ... await smtp.connect() ... await smtp.sendmail("me@my.org", recipients, message) ... return await smtp.quit() >>> asyncio.run(connect_and_send()) (221, Bye) In the above example, the message was accepted for delivery for all three addresses. If delivery had been only successful to two of the three addresses, and one was rejected, the response would look something like:: ( {"nobody@three.org": (550, "User unknown")}, "Written safely to disk. #902487694.289148.12219.", ) If delivery is not successful to any addresses, :exc:`.SMTPRecipientsRefused` is raised. If :exc:`.SMTPResponseException` is raised by this method, we try to send an RSET command to reset the server envelope automatically for the next attempt. :raises SMTPRecipientsRefused: delivery to all recipients failed :raises SMTPResponseException: on invalid response """ if isinstance(recipients, str): recipients = [recipients] if mail_options is None: mail_options = [] else: mail_options = list(mail_options) if rcpt_options is None: rcpt_options = [] else: rcpt_options = list(rcpt_options) if any(option.lower() == "smtputf8" for option in mail_options): mailbox_encoding = "utf-8" else: mailbox_encoding = "ascii" if self._sendmail_lock is None: self._sendmail_lock = asyncio.Lock() async with self._sendmail_lock: # Make sure we've done an EHLO for extension checks await self._ehlo_or_helo_if_needed() if mailbox_encoding == "utf-8" and not self.supports_extension("smtputf8"): raise SMTPNotSupported("SMTPUTF8 is not supported by this server") if self.supports_extension("size"): message_len = len(message) size_option = f"size={message_len}" mail_options.insert(0, size_option) try: await self.mail( sender, options=mail_options, encoding=mailbox_encoding, timeout=timeout, ) recipient_errors = await self._send_recipients( recipients, rcpt_options, encoding=mailbox_encoding, timeout=timeout ) response = await self.data(message, timeout=timeout) except (SMTPResponseException, SMTPRecipientsRefused) as exc: # If we got an error, reset the envelope. try: await self.rset(timeout=timeout) except (ConnectionError, SMTPResponseException): # If we're disconnected on the reset, or we get a bad # status, don't raise that as it's confusing pass raise exc return recipient_errors, response.message async def _send_recipients( self, recipients: Sequence[str], options: Iterable[str], encoding: str = "ascii", timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> dict[str, SMTPResponse]: """ Send the recipients given to the server. Used as part of :meth:`.sendmail`. """ recipient_errors: list[SMTPRecipientRefused] = [] for address in recipients: try: await self.rcpt( address, options=options, encoding=encoding, timeout=timeout ) except SMTPRecipientRefused as exc: recipient_errors.append(exc) if len(recipient_errors) == len(recipients): raise SMTPRecipientsRefused(recipient_errors) formatted_errors = { err.recipient: SMTPResponse(err.code, err.message) for err in recipient_errors } return formatted_errors async def send_message( self, message: Union[email.message.EmailMessage, email.message.Message], /, *, sender: Optional[str] = None, recipients: Optional[Union[str, Sequence[str]]] = None, mail_options: Optional[Iterable[str]] = None, rcpt_options: Optional[Iterable[str]] = None, timeout: Optional[Union[float, Literal[Default.token]]] = Default.token, ) -> tuple[dict[str, SMTPResponse], str]: r""" Sends an :py:class:`email.message.EmailMessage` object. Arguments are as for :meth:`.sendmail`, except that message is an :py:class:`email.message.EmailMessage` object. If sender is None or recipients is None, these arguments are taken from the headers of the EmailMessage as described in RFC 2822. Regardless of the values of sender and recipients, any Bcc field (or Resent-Bcc field, when the message is a resent) of the EmailMessage object will not be transmitted. The EmailMessage object is then serialized using :py:class:`email.generator.Generator` and :meth:`.sendmail` is called to transmit the message. 'Resent-Date' is a mandatory field if the message is resent (RFC 2822 Section 3.6.6). In such a case, we use the 'Resent-\*' fields. However, if there is more than one 'Resent-' block there's no way to unambiguously determine which one is the most recent in all cases, so rather than guess we raise a ``ValueError`` in that case. :raises ValueError: on more than one Resent header block on no sender kwarg or From header in message on no recipients kwarg or To, Cc or Bcc header in message :raises SMTPRecipientsRefused: delivery to all recipients failed :raises SMTPResponseException: on invalid response """ if mail_options is None: mail_options = [] else: mail_options = list(mail_options) if sender is None: sender = extract_sender(message) if sender is None: raise ValueError("No From header provided in message") if isinstance(recipients, str): recipients = [recipients] elif recipients is None: recipients = extract_recipients(message) if not recipients: raise ValueError("No recipient headers provided in message") # Make sure we've done an EHLO for extension checks await self._ehlo_or_helo_if_needed() try: sender.encode("ascii") "".join(recipients).encode("ascii") except UnicodeEncodeError: utf8_required = True else: utf8_required = False if utf8_required: if not self.supports_extension("smtputf8"): raise SMTPNotSupported( "An address containing non-ASCII characters was provided, but " "SMTPUTF8 is not supported by this server" ) elif "smtputf8" not in [option.lower() for option in mail_options]: mail_options.append("SMTPUTF8") if self.supports_extension("8BITMIME"): if "body=8bitmime" not in [option.lower() for option in mail_options]: mail_options.append("BODY=8BITMIME") cte_type = "8bit" else: cte_type = "7bit" flat_message = flatten_message(message, utf8=utf8_required, cte_type=cte_type) return await self.sendmail( sender, recipients, flat_message, mail_options=mail_options, rcpt_options=rcpt_options, timeout=timeout, ) def sendmail_sync( self, *args: Any, **kwargs: Any ) -> tuple[dict[str, SMTPResponse], str]: """ Synchronous version of :meth:`.sendmail`. This method starts an event loop to connect, send the message, and disconnect. """ async def sendmail_coroutine() -> tuple[dict[str, SMTPResponse], str]: async with self: return await self.sendmail(*args, **kwargs) return asyncio.run(sendmail_coroutine()) def send_message_sync( self, *args: Any, **kwargs: Any ) -> tuple[dict[str, SMTPResponse], str]: """ Synchronous version of :meth:`.send_message`. This method starts an event loop to connect, send the message, and disconnect. """ async def send_message_coroutine() -> tuple[dict[str, SMTPResponse], str]: async with self: return await self.send_message(*args, **kwargs) return asyncio.run(send_message_coroutine()) aiosmtplib-4.0.0/src/aiosmtplib/status.py0000644000000000000000000000015213615410400015423 0ustar00from .typing import SMTPStatus # alias SMTPStatus for backwards compatibility __all__ = ("SMTPStatus",) aiosmtplib-4.0.0/src/aiosmtplib/typing.py0000644000000000000000000000243413615410400015417 0ustar00import enum import os from typing import Union __all__ = ("Default", "SMTPStatus", "SocketPathType", "_default") SocketPathType = Union[str, bytes, os.PathLike[str]] class Default(enum.Enum): """ Used for type hinting kwarg defaults. """ token = 0 _default = Default.token @enum.unique class SMTPStatus(enum.IntEnum): """ Defines SMTP statuses for code readability. See also: http://www.greenend.org.uk/rjk/tech/smtpreplies.html """ invalid_response = -1 system_status_ok = 211 help_message = 214 ready = 220 closing = 221 auth_successful = 235 completed = 250 will_forward = 251 cannot_vrfy = 252 auth_continue = 334 start_input = 354 domain_unavailable = 421 mailbox_unavailable = 450 error_processing = 451 insufficient_storage = 452 tls_not_available = 454 unrecognized_command = 500 unrecognized_parameters = 501 command_not_implemented = 502 bad_command_sequence = 503 parameter_not_implemented = 504 domain_does_not_accept_mail = 521 access_denied = 530 # Sendmail specific auth_failed = 535 mailbox_does_not_exist = 550 user_not_local = 551 storage_exceeded = 552 mailbox_name_invalid = 553 transaction_failed = 554 syntax_error = 555 aiosmtplib-4.0.0/tests/__init__.py0000644000000000000000000000000013615410400014037 0ustar00aiosmtplib-4.0.0/tests/auth.py0000644000000000000000000000147013615410400013255 0ustar00from collections import deque from typing import Any from aiosmtplib.response import SMTPResponse from aiosmtplib.smtp import SMTP class DummySMTPAuth(SMTP): transport = None def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.received_commands: list[bytes] = [] self.responses: deque[tuple[int, str]] = deque() self.esmtp_extensions = {"auth": ""} self.server_auth_methods = ["cram-md5", "login", "plain"] self.supports_esmtp = True async def execute_command(self, *args: Any, **kwargs: Any) -> SMTPResponse: self.received_commands.append(b" ".join(args)) response = self.responses.popleft() return SMTPResponse(*response) async def _ehlo_or_helo_if_needed(self) -> None: return None aiosmtplib-4.0.0/tests/compat.py0000644000000000000000000000030413615410400013572 0ustar00import asyncio async def cleanup_server(server: asyncio.AbstractServer) -> None: try: await asyncio.wait_for(server.wait_closed(), 0.1) except asyncio.TimeoutError: pass aiosmtplib-4.0.0/tests/conftest.py0000644000000000000000000003763013615410400014150 0ustar00""" Pytest fixtures and config. """ import asyncio import email.header import email.message import email.mime.multipart import email.mime.text import socket import ssl import sys from collections.abc import Callable, Generator from pathlib import Path from typing import Any, Optional, Union import hypothesis import pytest import pytest_asyncio import trustme from aiosmtpd.controller import Controller as SMTPDController from aiosmtpd.smtp import SMTP as SMTPD from aiosmtplib import SMTP from .auth import DummySMTPAuth from .compat import cleanup_server from .smtpd import RecordingHandler, TestSMTPD try: import uvloop except ImportError: HAS_UVLOOP = False else: HAS_UVLOOP = True BASE_CERT_PATH = Path("tests/certs/") IS_PYPY = hasattr(sys, "pypy_version_info") # pypy can take a while to generate data, so don't fail the test due to health checks. if IS_PYPY: base_settings = hypothesis.settings( suppress_health_check=(hypothesis.HealthCheck.too_slow,) ) else: base_settings = hypothesis.settings() hypothesis.settings.register_profile("dev", parent=base_settings, max_examples=10) hypothesis.settings.register_profile("ci", parent=base_settings, max_examples=100) class ParamFixtureRequest(pytest.FixtureRequest): param: Any class EchoServerProtocol(asyncio.Protocol): def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = transport def data_received(self, data: bytes) -> None: self.transport.write(data) # type: ignore def pytest_addoption(parser: Any) -> None: parser.addoption( "--event-loop", action="store", default="asyncio", choices=["asyncio", "uvloop"], help="event loop to run tests on", ) parser.addoption( "--bind-addr", action="store", default="127.0.0.1", help="address to bind on for network tests", ) original_event_loop_policy = None def pytest_sessionstart(session: pytest.Session) -> None: # Install the uvloop event loop policy globally, per session loop_type = session.config.getoption("--event-loop") if loop_type == "uvloop": if not HAS_UVLOOP: raise RuntimeError("uvloop not installed.") uvloop.install() # type: ignore @pytest_asyncio.fixture def debug_event_loop( event_loop: asyncio.AbstractEventLoop, ) -> Generator[asyncio.AbstractEventLoop, None, None]: previous_debug = event_loop.get_debug() event_loop.set_debug(True) yield event_loop event_loop.set_debug(previous_debug) # Session scoped static values # @pytest.fixture(scope="session") def bind_address(request: pytest.FixtureRequest) -> str: """Server side address for socket binding""" return str(request.config.getoption("--bind-addr")) @pytest.fixture(scope="session") def hostname(bind_address: str) -> str: return bind_address @pytest.fixture(scope="session") def recipient_str() -> str: return "recipient@example.com" @pytest.fixture(scope="session") def sender_str() -> str: return "sender@example.com" @pytest.fixture(scope="session") def message_str(recipient_str: str, sender_str: str) -> str: return ( "Content-Type: multipart/mixed; " 'boundary="===============6842273139637972052=="\n' "MIME-Version: 1.0\n" f"To: {recipient_str}\n" f"From: {sender_str}\n" "Subject: A message\n\n" "--===============6842273139637972052==\n" 'Content-Type: text/plain; charset="us-ascii"\n' "MIME-Version: 1.0\n" "Content-Transfer-Encoding: 7bit\n\n" "Hello World\n" "--===============6842273139637972052==--\n" ) @pytest.fixture(scope="session") def smtpd_class() -> type[SMTPD]: return TestSMTPD @pytest.fixture(scope="session") def cert_authority() -> trustme.CA: return trustme.CA() @pytest.fixture(scope="session") def unknown_cert_authority() -> trustme.CA: return trustme.CA() @pytest.fixture(scope="session") def valid_server_cert(cert_authority: trustme.CA, hostname: str) -> trustme.LeafCert: return cert_authority.issue_cert(hostname) @pytest.fixture(scope="session") def valid_client_cert(cert_authority: trustme.CA, hostname: str) -> trustme.LeafCert: return cert_authority.issue_cert(f"user@{hostname}") @pytest.fixture(scope="session") def unknown_client_cert( unknown_cert_authority: trustme.CA, hostname: str ) -> trustme.LeafCert: return unknown_cert_authority.issue_cert(f"user@{hostname}") @pytest.fixture(scope="session") def client_tls_context( cert_authority: trustme.CA, valid_client_cert: trustme.LeafCert ) -> ssl.SSLContext: tls_context = ssl.create_default_context() cert_authority.configure_trust(tls_context) valid_client_cert.configure_cert(tls_context) return tls_context @pytest.fixture(scope="session") def unknown_client_tls_context( unknown_cert_authority: trustme.CA, unknown_client_cert: trustme.LeafCert ) -> ssl.SSLContext: tls_context = ssl.create_default_context() unknown_cert_authority.configure_trust(tls_context) unknown_client_cert.configure_cert(tls_context) return tls_context @pytest.fixture(scope="session") def server_tls_context( cert_authority: trustme.CA, valid_server_cert: trustme.LeafCert ) -> ssl.SSLContext: tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) cert_authority.configure_trust(tls_context) valid_server_cert.configure_cert(tls_context) tls_context.verify_mode = ssl.CERT_OPTIONAL return tls_context @pytest.fixture(scope="session") def ca_cert_path( tmp_path_factory: pytest.TempPathFactory, cert_authority: trustme.CA ) -> str: tmp_path = tmp_path_factory.mktemp("cacert") cert_authority.cert_pem.write_to_path(tmp_path / "ca.pem") return str(tmp_path / "ca.pem") @pytest.fixture(scope="session") def valid_cert_path( tmp_path_factory: pytest.TempPathFactory, valid_client_cert: trustme.LeafCert ) -> str: tmp_path = tmp_path_factory.mktemp("cert-valid-pem") for pem in valid_client_cert.cert_chain_pems: pem.write_to_path(tmp_path / "valid.pem", append=True) return str(tmp_path / "valid.pem") @pytest.fixture(scope="session") def valid_key_path( tmp_path_factory: pytest.TempPathFactory, valid_client_cert: trustme.LeafCert ) -> str: tmp_path = tmp_path_factory.mktemp("cert-valid-key") valid_client_cert.private_key_pem.write_to_path(tmp_path / "valid.key") return str(tmp_path / "valid.key") @pytest.fixture(scope="session") def invalid_cert_path( tmp_path_factory: pytest.TempPathFactory, unknown_client_cert: trustme.LeafCert ) -> str: tmp_path = tmp_path_factory.mktemp("cert-invalid-pem") for pem in unknown_client_cert.cert_chain_pems: pem.write_to_path(tmp_path / "invalid.pem", append=True) return str(tmp_path / "invalid.pem") @pytest.fixture(scope="session") def invalid_key_path( tmp_path_factory: pytest.TempPathFactory, unknown_client_cert: trustme.LeafCert ) -> str: tmp_path = tmp_path_factory.mktemp("cert-invalid-key") unknown_client_cert.private_key_pem.write_to_path(tmp_path / "invalid.key") return str(tmp_path / "invalid.key") @pytest.fixture(scope="session") def auth_username() -> str: return "test" @pytest.fixture(scope="session") def auth_password() -> str: return "test" # Auth # @pytest.fixture(scope="function") def mock_auth() -> DummySMTPAuth: return DummySMTPAuth() # Messages # @pytest.fixture(scope="function") def compat32_message(recipient_str: str, sender_str: str) -> email.message.Message: message = email.message.Message() message["To"] = email.header.Header(recipient_str) message["From"] = email.header.Header(sender_str) message["Subject"] = "A message" message.set_payload("Hello World") return message @pytest.fixture(scope="function") def mime_message( recipient_str: str, sender_str: str ) -> email.mime.multipart.MIMEMultipart: message = email.mime.multipart.MIMEMultipart() message["To"] = recipient_str message["From"] = sender_str message["Subject"] = "A message" message.attach(email.mime.text.MIMEText("Hello World")) return message @pytest.fixture(scope="function", params=["mime_multipart", "compat32"]) def message( request: ParamFixtureRequest, compat32_message: email.message.Message, mime_message: email.message.EmailMessage, ) -> Union[email.message.Message, email.message.EmailMessage]: if request.param == "compat32": return compat32_message else: return mime_message # Server helpers and factories # @pytest.fixture(scope="function") def received_messages() -> list[email.message.EmailMessage]: return [] @pytest.fixture(scope="function") def received_commands() -> list[tuple[str, tuple[Any, ...]]]: return [] @pytest.fixture(scope="function") def smtpd_responses() -> list[str]: return [] @pytest.fixture(scope="function") def smtpd_handler( received_messages: list[email.message.EmailMessage], received_commands: list[tuple[str, tuple[Any, ...]]], smtpd_responses: list[str], ) -> RecordingHandler: return RecordingHandler(received_messages, received_commands, smtpd_responses) @pytest.fixture(scope="session") def smtpd_auth_callback( auth_username: str, auth_password: str ) -> Callable[[str, bytes, bytes], bool]: def auth_callback(mechanism: str, username: bytes, password: bytes) -> bool: return bool( username.decode("utf-8") == auth_username and password.decode("utf-8") == auth_password ) return auth_callback @pytest.fixture( scope="function", params=(str, bytes, Path), ids=("str", "bytes", "pathlike"), ) def socket_path( request: ParamFixtureRequest, tmp_path: Path ) -> Union[str, bytes, Path]: if sys.platform.startswith("darwin"): # Work around OSError: AF_UNIX path too long tmp_dir = Path("/tmp") # nosec else: tmp_dir = tmp_path index = 0 socket_path = tmp_dir / f"aiosmtplib-test{index}" while socket_path.exists(): index += 1 socket_path = tmp_dir / f"aiosmtplib-test{index}" typed_socket_path: Union[str, bytes, Path] = request.param(socket_path) return typed_socket_path # Servers # @pytest.fixture(scope="function") def smtpd_factory( request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, hostname: str, smtpd_handler: RecordingHandler, server_tls_context: ssl.SSLContext, smtpd_auth_callback: Callable[[str, bytes, bytes], bool], ) -> Callable[[], SMTPD]: smtpd_options_marker = request.node.get_closest_marker("smtpd_options") if smtpd_options_marker is None: smtpd_options = {} else: smtpd_options = smtpd_options_marker.kwargs smtpd_mocks_marker = request.node.get_closest_marker("smtpd_mocks") if smtpd_mocks_marker is None: smtpd_mocks = {} else: smtpd_mocks = smtpd_mocks_marker.kwargs for attr, mock_fn in smtpd_mocks.items(): monkeypatch.setattr(TestSMTPD, attr, mock_fn) smtpd_tls_context = ( server_tls_context if smtpd_options.get("starttls", True) or smtpd_options.get("tls", False) else None ) def factory() -> SMTPD: return TestSMTPD( smtpd_handler, hostname=hostname, enable_SMTPUTF8=smtpd_options.get("smtputf8", False), decode_data=smtpd_options.get("7bit", False), tls_context=smtpd_tls_context, auth_callback=smtpd_auth_callback, ) return factory @pytest.fixture(scope="function") def smtpd_server( request: pytest.FixtureRequest, event_loop: asyncio.AbstractEventLoop, bind_address: str, server_tls_context: ssl.SSLContext, smtpd_factory: Callable[[], SMTPD], ) -> Generator[asyncio.AbstractServer, None, None]: smtpd_options_marker = request.node.get_closest_marker("smtpd_options") if smtpd_options_marker is None: smtpd_options = {} else: smtpd_options = smtpd_options_marker.kwargs create_server_kwargs = { "host": bind_address, "port": 0, "family": socket.AF_INET, } if smtpd_options.get("tls", False): create_server_kwargs["ssl"] = server_tls_context server_coro = event_loop.create_server(smtpd_factory, **create_server_kwargs) server = event_loop.run_until_complete(server_coro) yield server server.close() try: event_loop.run_until_complete(cleanup_server(server)) except RuntimeError: pass @pytest.fixture(scope="function") def echo_server( event_loop: asyncio.AbstractEventLoop, bind_address: str ) -> Generator[asyncio.AbstractServer, None, None]: server_coro = event_loop.create_server( EchoServerProtocol, host=bind_address, port=0, family=socket.AF_INET ) server = event_loop.run_until_complete(server_coro) yield server server.close() try: event_loop.run_until_complete(cleanup_server(server)) except RuntimeError: pass @pytest.fixture(scope="function") def smtpd_server_socket_path( request: pytest.FixtureRequest, event_loop: asyncio.AbstractEventLoop, socket_path: Union[str, bytes, Path], server_tls_context: ssl.SSLContext, smtpd_factory: Callable[[], SMTPD], ) -> Generator[asyncio.AbstractServer, None, None]: smtpd_options_marker = request.node.get_closest_marker("smtpd_options") if smtpd_options_marker is None: smtpd_options = {} else: smtpd_options = smtpd_options_marker.kwargs create_server_coro = event_loop.create_unix_server( smtpd_factory, path=socket_path, # type: ignore ssl=server_tls_context if smtpd_options.get("tls", False) else None, ) server = event_loop.run_until_complete(create_server_coro) yield server server.close() try: event_loop.run_until_complete(cleanup_server(server)) except RuntimeError: pass @pytest.fixture(scope="function") def smtpd_controller( bind_address: str, unused_tcp_port: int, smtpd_handler: RecordingHandler, ) -> Generator[SMTPDController, None, None]: port = unused_tcp_port controller: Optional[SMTPDController] controller = SMTPDController(smtpd_handler, hostname=bind_address, port=port) controller.start() yield controller controller.stop() @pytest.fixture(scope="function") def smtpd_server_threaded(smtpd_controller: SMTPDController) -> asyncio.AbstractServer: server: asyncio.AbstractServer = smtpd_controller.server return server # Running server ports # @pytest.fixture(scope="function") def smtpd_server_port(smtpd_server: asyncio.Server) -> int: return int(smtpd_server.sockets[0].getsockname()[1]) @pytest.fixture(scope="function") def echo_server_port(echo_server: asyncio.Server) -> int: return int(echo_server.sockets[0].getsockname()[1]) @pytest.fixture(scope="function") def smtpd_server_threaded_port(smtpd_controller: SMTPDController) -> int: port: int = smtpd_controller.port return port # SMTP Clients # @pytest.fixture(scope="function") def smtp_client( request: pytest.FixtureRequest, hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, ) -> SMTP: smtp_client_options_marker = request.node.get_closest_marker("smtp_client_options") if smtp_client_options_marker is None: smtp_client_options = {} else: smtp_client_options = smtp_client_options_marker.kwargs smtp_client_options.setdefault("tls_context", client_tls_context) smtp_client_options.setdefault("start_tls", False) return SMTP( hostname=hostname, port=smtpd_server_port, timeout=1.0, **smtp_client_options, ) @pytest.fixture(scope="function") def smtp_client_threaded( hostname: str, smtpd_server_threaded_port: int, client_tls_context: ssl.SSLContext ) -> SMTP: return SMTP( hostname=hostname, port=smtpd_server_threaded_port, timeout=1.0, start_tls=False, tls_context=client_tls_context, ) aiosmtplib-4.0.0/tests/pyright_usage.py0000644000000000000000000000173113615410400015166 0ustar00# pyright: strict import email.message import pathlib import socket import ssl import aiosmtplib async def send_str(): await aiosmtplib.send("test") async def send_message(): message = email.message.EmailMessage() await aiosmtplib.send(message) async def send_all_options(): await aiosmtplib.send( "test", sender="test@example.com", recipients=["user1@example.com"], mail_options=["FOO"], rcpt_options=["BAR"], hostname="smtp.example.com", port=1234, username="root@example.com", password="changeme", local_hostname="test", source_address=("foo", 123), timeout=124.34, use_tls=True, start_tls=False, validate_certs=False, client_cert="test", client_key="test", tls_context=ssl.create_default_context(), cert_bundle="test", socket_path=pathlib.PurePath("/tmp/foo"), sock=socket.socket(), ) aiosmtplib-4.0.0/tests/smtpd.py0000644000000000000000000001551513615410400013450 0ustar00""" Implements handlers required on top of aiosmtpd for testing. """ import asyncio import logging from email.errors import HeaderParseError from email.message import EmailMessage, Message from typing import Any, AnyStr, Optional, Union from aiosmtpd.handlers import Message as MessageHandler from aiosmtpd.smtp import MISSING from aiosmtpd.smtp import SMTP as SMTPD from aiosmtpd.smtp import Envelope, Session, _Missing from aiosmtplib import SMTPStatus log = logging.getLogger("mail.log") class RecordingHandler(MessageHandler): def __init__( self, messages_list: list[Union[EmailMessage, Message]], commands_list: list[tuple[str, tuple[Any, ...]]], responses_list: list[str], ): self.messages = messages_list self.commands = commands_list self.responses = responses_list super().__init__(message_class=EmailMessage) def record_command(self, command: str, *args: Any) -> None: self.commands.append((command, tuple(args))) def record_server_response(self, status: str) -> None: self.responses.append(status) def handle_message(self, message: Union[EmailMessage, Message]) -> None: self.messages.append(message) async def handle_EHLO( self, server: SMTPD, session: Session, envelope: Envelope, hostname: str, responses: list[str], ) -> list[str]: """Advertise auth login support.""" session.host_name = hostname # type: ignore if server._tls_protocol: return ["250-AUTH LOGIN"] + responses else: return responses class TestSMTPD(SMTPD): transport: Optional[asyncio.BaseTransport] # type: ignore def _getaddr(self, arg: str) -> tuple[Optional[str], Optional[str]]: """ Don't raise an exception on unparsable email address """ address: Optional[str] = None rest: Optional[str] = "" try: address, rest = super()._getaddr(arg) except HeaderParseError: pass return address, rest async def _call_handler_hook(self, command: str, *args: Any) -> Any: self.event_handler.record_command(command, *args) return await super()._call_handler_hook(command, *args) async def push(self, status: AnyStr) -> None: await super().push(status) self.event_handler.record_server_response(status) async def smtp_EXPN(self, arg: str) -> None: """ Pass EXPN to handler hook. """ status = await self._call_handler_hook("EXPN") await self.push( "502 EXPN not implemented" if isinstance(status, _Missing) else status ) async def smtp_HELP(self, arg: str) -> None: """ Override help to pass to handler hook. """ status = await self._call_handler_hook("HELP") if status is MISSING: await super().smtp_HELP(arg) else: await self.push(status) async def smtp_STARTTLS(self, arg: str) -> None: await super().smtp_STARTTLS(arg) self.event_handler.record_command("STARTTLS", arg) async def mock_response_delayed_ok(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: await asyncio.sleep(1.0) await smtpd.push("250 all done") async def mock_response_delayed_read(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: await smtpd.push("220-hi") await asyncio.sleep(1.0) async def mock_response_done(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: if args and args[0]: smtpd.session.host_name = args[0] await smtpd.push("250 done") async def mock_response_done_then_close( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: if args and args[0]: smtpd.session.host_name = args[0] await smtpd.push("250 done") await smtpd.push("221 bye now") smtpd.transport.close() async def mock_response_error_disconnect( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push("501 error") smtpd.transport.close() async def mock_response_bad_data(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: smtpd._writer.write(b"250 \xff\xff\xff\xff\r\n") await smtpd._writer.drain() async def mock_response_gibberish(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: smtpd._writer.write("wefpPSwrsfa2sdfsdf") await smtpd._writer.drain() async def mock_response_expn(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: await smtpd.push( """250-Joseph Blow 250 Alice Smith """ ) async def mock_response_ehlo_minimal(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: if args and args[0]: smtpd.session.host_name = args[0] await smtpd.push("250 HELP") async def mock_response_ehlo_full(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: if args and args[0]: smtpd.session.host_name = args[0] await smtpd.push( """250-localhost 250-PIPELINING 250-8BITMIME 250-SIZE 512000 250-DSN 250-ENHANCEDSTATUSCODES 250-EXPN 250-HELP 250-SAML 250-SEND 250-SOML 250-TURN 250-XADR 250-XSTA 250-ETRN 250 XGEN""" ) async def mock_response_unavailable(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: await smtpd.push("421 retry in 5 minutes") smtpd.transport.close() async def mock_response_tls_not_available( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push("454 please login") async def mock_response_tls_ready_disconnect( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push("220 go for it") smtpd.transport.close() async def mock_response_start_data_disconnect( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push("354 ok") smtpd.transport.close() async def mock_response_disconnect(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: smtpd.transport.close() async def mock_response_eof(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: smtpd.transport.write_eof() async def mock_response_mailbox_unavailable( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push(f"{SMTPStatus.mailbox_unavailable} error") async def mock_response_unrecognized_command( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push(f"{SMTPStatus.unrecognized_command} error") async def mock_response_bad_command_sequence( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push(f"{SMTPStatus.bad_command_sequence} error") async def mock_response_syntax_error(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: await smtpd.push(f"{SMTPStatus.syntax_error} error") async def mock_response_syntax_error_and_cleanup( smtpd: SMTPD, *args: Any, **kwargs: Any ) -> None: await smtpd.push(f"{SMTPStatus.syntax_error} error") if smtpd._handler_coroutine: smtpd._handler_coroutine.cancel() if smtpd.transport: smtpd.transport.close() aiosmtplib-4.0.0/tests/test_api.py0000644000000000000000000001624213615410400014127 0ustar00""" send coroutine testing. """ import asyncio import email import email.message import pathlib import socket import ssl from typing import Any, Union import pytest from aiosmtplib import send async def test_send( hostname: str, smtpd_server_port: int, message: email.message.Message, received_messages: list[email.message.EmailMessage], client_tls_context: ssl.SSLContext, ) -> None: errors, response = await send( message, hostname=hostname, port=smtpd_server_port, tls_context=client_tls_context, ) assert not errors assert len(received_messages) == 1 async def test_send_with_str( hostname: str, smtpd_server_port: int, recipient_str: str, sender_str: str, message_str: str, received_messages: list[email.message.EmailMessage], ) -> None: errors, response = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[recipient_str], start_tls=False, ) assert not errors assert len(received_messages) == 1 async def test_send_with_bytes( hostname: str, smtpd_server_port: int, recipient_str: str, sender_str: str, message_str: str, received_messages: list[email.message.EmailMessage], ) -> None: errors, response = await send( bytes(message_str, "ascii"), hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[recipient_str], start_tls=False, ) assert not errors assert len(received_messages) == 1 async def test_send_without_sender( hostname: str, smtpd_server_port: int, recipient_str: str, message_str: str, received_messages: list[email.message.EmailMessage], ) -> None: with pytest.raises(ValueError): errors, response = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=None, recipients=[recipient_str], start_tls=False, ) async def test_send_without_recipients( hostname: str, smtpd_server_port: int, sender_str: str, message_str: str, received_messages: list[email.message.EmailMessage], ) -> None: with pytest.raises(ValueError): errors, response = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[], start_tls=False, ) async def test_send_with_start_tls( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, message: email.message.Message, received_messages: list[email.message.EmailMessage], received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: errors, response = await send( message, hostname=hostname, port=smtpd_server_port, start_tls=True, tls_context=client_tls_context, ) assert not errors assert "STARTTLS" in [command[0] for command in received_commands] assert len(received_messages) == 1 async def test_send_with_login( hostname: str, smtpd_server_port: int, message: email.message.Message, received_messages: list[email.message.EmailMessage], received_commands: list[tuple[str, tuple[Any, ...]]], auth_username: str, auth_password: str, client_tls_context: ssl.SSLContext, ) -> None: errors, response = await send( message, hostname=hostname, port=smtpd_server_port, start_tls=True, tls_context=client_tls_context, username=auth_username, password=auth_password, ) assert not errors assert "AUTH" in [command[0] for command in received_commands] assert len(received_messages) == 1 async def test_send_via_socket( hostname: str, smtpd_server_port: int, mime_message: email.message.EmailMessage, received_messages: list[email.message.EmailMessage], ) -> None: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect((hostname, smtpd_server_port)) errors, _ = await send( mime_message, hostname=None, port=None, sock=sock, start_tls=False, ) assert not errors assert len(received_messages) == 1 @pytest.mark.smtpd_options(tls=True) async def test_send_via_socket_tls_and_hostname( hostname: str, client_tls_context: ssl.SSLContext, smtpd_server_port: int, mime_message: email.message.EmailMessage, received_messages: list[email.message.EmailMessage], ) -> None: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect((hostname, smtpd_server_port)) errors, _ = await send( mime_message, hostname=hostname, port=None, sock=sock, tls_context=client_tls_context, use_tls=True, ) assert not errors assert len(received_messages) == 1 async def test_send_via_socket_path( smtpd_server_socket_path: asyncio.AbstractServer, socket_path: Union[pathlib.Path, str, bytes], mime_message: email.message.EmailMessage, received_messages: list[email.message.EmailMessage], ) -> None: errors, _ = await send( mime_message, hostname=None, port=None, socket_path=socket_path, start_tls=False, ) assert not errors assert len(received_messages) == 1 @pytest.mark.smtpd_options(tls=True) async def test_send_via_socket_path_with_tls( smtpd_server_socket_path: asyncio.AbstractServer, socket_path: Union[pathlib.Path, str, bytes], hostname: str, client_tls_context: ssl.SSLContext, mime_message: email.message.EmailMessage, received_messages: list[email.message.EmailMessage], ) -> None: errors, _ = await send( mime_message, hostname=hostname, port=None, socket_path=socket_path, use_tls=True, tls_context=client_tls_context, ) assert not errors assert len(received_messages) == 1 async def test_send_with_mail_options( hostname: str, smtpd_server_port: int, recipient_str: str, sender_str: str, message_str: str, received_messages: list[email.message.EmailMessage], ) -> None: errors, _ = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[recipient_str], mail_options=["BODY=8BITMIME"], start_tls=False, ) assert not errors assert len(received_messages) == 1 async def test_send_with_rcpt_options( hostname: str, smtpd_server_port: int, recipient_str: str, sender_str: str, message_str: str, received_messages: list[email.message.EmailMessage], ) -> None: errors, _ = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[recipient_str], # RCPT params are not supported by the server; just check that the kwarg works rcpt_options=[], start_tls=False, ) assert not errors assert len(received_messages) == 1 aiosmtplib-4.0.0/tests/test_asyncio.py0000644000000000000000000001331713615410400015023 0ustar00""" Tests that cover asyncio usage. """ import asyncio import ssl from collections.abc import Awaitable from typing import Any import pytest from aiosmtplib import SMTP from aiosmtplib.response import SMTPResponse from .smtpd import mock_response_expn RECIPIENTS = [ "recipient1@example.com", "recipient2@example.com", "recipient3@example.com", ] async def test_sendmail_multiple_times_in_sequence( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, sender_str: str, message_str: str, ) -> None: async with smtp_client: for recipient in RECIPIENTS: errors, response = await smtp_client.sendmail( sender_str, [recipient], message_str ) assert not errors assert isinstance(errors, dict) assert response != "" async def test_sendmail_multiple_times_with_gather( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, sender_str: str, message_str: str, ) -> None: async with smtp_client: tasks = [ smtp_client.sendmail(sender_str, [recipient], message_str) for recipient in RECIPIENTS ] results = await asyncio.gather(*tasks) for errors, message in results: assert not errors assert isinstance(errors, dict) assert message != "" async def test_connect_and_sendmail_multiple_times_with_gather( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, sender_str: str, message_str: str, ) -> None: async def connect_and_send( *args: Any, **kwargs: Any ) -> tuple[dict[str, SMTPResponse], str]: async with SMTP( hostname=hostname, port=smtpd_server_port, tls_context=client_tls_context ) as client: response = await client.sendmail(*args, **kwargs) return response tasks = [ connect_and_send(sender_str, [recipient], message_str) for recipient in RECIPIENTS ] results = await asyncio.gather(*tasks) for errors, message in results: assert not errors assert isinstance(errors, dict) assert message != "" async def test_multiple_clients_with_gather( hostname: str, smtpd_server: asyncio.AbstractServer, smtpd_server_port: int, client_tls_context: ssl.SSLContext, sender_str: str, message_str: str, ) -> None: async def connect_and_send( *args: Any, **kwargs: Any ) -> tuple[dict[str, SMTPResponse], str]: client = SMTP( hostname=hostname, port=smtpd_server_port, tls_context=client_tls_context ) async with client: response = await client.sendmail(*args, **kwargs) return response tasks = [ connect_and_send(sender_str, [recipient], message_str) for recipient in RECIPIENTS ] results = await asyncio.gather(*tasks) for errors, message in results: assert not errors assert isinstance(errors, dict) assert message != "" async def test_multiple_actions_in_context_manager_with_gather( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, sender_str: str, message_str: str, ) -> None: async def connect_and_run_commands(*args: Any, **kwargs: Any) -> SMTPResponse: async with SMTP( hostname=hostname, port=smtpd_server_port, tls_context=client_tls_context ) as client: await client.ehlo() await client.help() response = await client.noop() return response tasks = [ connect_and_run_commands(sender_str, [recipient], message_str) for recipient in RECIPIENTS ] responses = await asyncio.gather(*tasks) for response in responses: assert 200 <= response.code < 300 @pytest.mark.smtpd_mocks(smtp_EXPN=mock_response_expn) async def test_many_commands_with_gather(smtp_client: SMTP) -> None: """ Tests that appropriate locks are in place to prevent commands confusing each other. """ async with smtp_client: tasks: list[Awaitable] = [ smtp_client.ehlo(), smtp_client.helo(), smtp_client.noop(), smtp_client.vrfy("foo@bar.com"), smtp_client.expn("users@example.com"), smtp_client.mail("alice@example.com"), smtp_client.help(), ] results = await asyncio.gather(*tasks) for result in results[:-1]: assert 200 <= result.code < 300 # Help text is returned as a string, not a result tuple assert "Supported commands" in results[-1] async def test_close_works_on_stopped_loop( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, ) -> None: event_loop = asyncio.get_running_loop() client = SMTP( hostname=hostname, port=smtpd_server_port, tls_context=client_tls_context ) await client.connect() assert client.is_connected assert client.transport is not None event_loop.stop() client.close() assert not client.is_connected async def test_context_manager_entry_multiple_times_with_gather( smtp_client: SMTP, sender_str: str, message_str: str ) -> None: async def connect_and_send( *args: Any, **kwargs: Any ) -> tuple[dict[str, SMTPResponse], str]: async with smtp_client: response = await smtp_client.sendmail(*args, **kwargs) return response tasks = [ asyncio.wait_for(connect_and_send(sender_str, [recipient], message_str), 2.0) for recipient in RECIPIENTS ] results = await asyncio.gather(*tasks) for errors, message in results: assert not errors assert isinstance(errors, dict) assert message != "" aiosmtplib-4.0.0/tests/test_auth_methods.py0000644000000000000000000001665713615410400016054 0ustar00""" Tests for auth methods on the SMTP class. """ import asyncio import base64 import pytest from aiosmtplib import SMTP from aiosmtplib.auth import auth_crammd5_verify, auth_login_encode, auth_plain_encode from aiosmtplib.errors import SMTPAuthenticationError, SMTPException from aiosmtplib.response import SMTPResponse from aiosmtplib.typing import SMTPStatus from .auth import DummySMTPAuth SUCCESS_RESPONSE = SMTPResponse(SMTPStatus.auth_successful, "OK") FAILURE_RESPONSE = SMTPResponse(SMTPStatus.auth_failed, "Nope") async def test_login_without_extension_raises_error(mock_auth: DummySMTPAuth) -> None: mock_auth.esmtp_extensions = {} with pytest.raises(SMTPException) as excinfo: await mock_auth.login("username", "bogus") assert "Try connecting via TLS" not in excinfo.value.args[0] async def test_login_unknown_method_raises_error(mock_auth: DummySMTPAuth) -> None: mock_auth.AUTH_METHODS = ("fakeauth",) mock_auth.server_auth_methods = ["fakeauth"] with pytest.raises(RuntimeError): await mock_auth.login("username", "bogus") async def test_login_without_method_raises_error(mock_auth: DummySMTPAuth) -> None: mock_auth.server_auth_methods = [] with pytest.raises(SMTPException): await mock_auth.login("username", "bogus") async def test_login_tries_all_methods(mock_auth: DummySMTPAuth) -> None: responses = [ FAILURE_RESPONSE, # CRAM-MD5 FAILURE_RESPONSE, # PLAIN (SMTPStatus.auth_continue, "VXNlcm5hbWU6"), # LOGIN continue SUCCESS_RESPONSE, # LOGIN success ] mock_auth.responses.extend(responses) await mock_auth.login("username", "thirdtimelucky") async def test_login_all_methods_fail_raises_error(mock_auth: DummySMTPAuth) -> None: responses = [ FAILURE_RESPONSE, # CRAM-MD5 FAILURE_RESPONSE, # PLAIN FAILURE_RESPONSE, # LOGIN ] mock_auth.responses.extend(responses) with pytest.raises(SMTPAuthenticationError): await mock_auth.login("username", "bogus") @pytest.mark.parametrize( "username,password", [("test", "test"), ("admin124", "$3cr3t$"), ("føø", "bär€")], ids=["test user", "admin user", "utf-8 user"], ) async def test_auth_plain_success( mock_auth: DummySMTPAuth, username: str, password: str ) -> None: """ Check that auth_plain base64 encodes the username/password given. """ mock_auth.responses.append(SUCCESS_RESPONSE) await mock_auth.auth_plain(username, password) encoded = auth_plain_encode(username, password) assert mock_auth.received_commands == [b"AUTH PLAIN " + encoded] async def test_auth_plain_success_bytes(mock_auth: DummySMTPAuth) -> None: """ Check that auth_plain base64 encodes the username/password when given as bytes. """ username = "ภาษา".encode("tis-620") password = "ไทย".encode("tis-620") mock_auth.responses.append(SUCCESS_RESPONSE) await mock_auth.auth_plain(username, password) encoded = auth_plain_encode(username, password) assert mock_auth.received_commands == [b"AUTH PLAIN " + encoded] async def test_auth_plain_error(mock_auth: DummySMTPAuth) -> None: mock_auth.responses.append(FAILURE_RESPONSE) with pytest.raises(SMTPAuthenticationError): await mock_auth.auth_plain("username", "bogus") @pytest.mark.parametrize( "username,password", [("test", "test"), ("admin124", "$3cr3t$"), ("føø", "bär€")], ids=["test user", "admin user", "utf-8 user"], ) async def test_auth_login_success( mock_auth: DummySMTPAuth, username: str, password: str ) -> None: continue_response = (SMTPStatus.auth_continue, "VXNlcm5hbWU6") mock_auth.responses.extend([continue_response, SUCCESS_RESPONSE]) await mock_auth.auth_login(username, password) encoded_username, encoded_password = auth_login_encode(username, password) assert mock_auth.received_commands == [ b"AUTH LOGIN " + encoded_username, encoded_password, ] async def test_auth_login_success_bytes(mock_auth: DummySMTPAuth) -> None: continue_response = (SMTPStatus.auth_continue, "VXNlcm5hbWU6") mock_auth.responses.extend([continue_response, SUCCESS_RESPONSE]) username = "ภาษา".encode("tis-620") password = "ไทย".encode("tis-620") await mock_auth.auth_login(username, password) encoded_username, encoded_password = auth_login_encode(username, password) assert mock_auth.received_commands == [ b"AUTH LOGIN " + encoded_username, encoded_password, ] async def test_auth_login_error(mock_auth: DummySMTPAuth) -> None: mock_auth.responses.append(FAILURE_RESPONSE) with pytest.raises(SMTPAuthenticationError): await mock_auth.auth_login("username", "bogus") async def test_auth_plain_continue_error(mock_auth: DummySMTPAuth) -> None: continue_response = (SMTPStatus.auth_continue, "VXNlcm5hbWU6") mock_auth.responses.extend([continue_response, FAILURE_RESPONSE]) with pytest.raises(SMTPAuthenticationError): await mock_auth.auth_login("username", "bogus") @pytest.mark.parametrize( "username,password", [("test", "test"), ("admin124", "$3cr3t$"), ("føø", "bär€")], ids=["test user", "admin user", "utf-8 user"], ) async def test_auth_crammd5_success( mock_auth: DummySMTPAuth, username: str, password: str ) -> None: continue_response = ( SMTPStatus.auth_continue, base64.b64encode(b"secretteststring").decode("utf-8"), ) mock_auth.responses.extend([continue_response, SUCCESS_RESPONSE]) await mock_auth.auth_crammd5(username, password) password_bytes = password.encode("utf-8") username_bytes = username.encode("utf-8") response_bytes = continue_response[1].encode("utf-8") expected_command = auth_crammd5_verify( username_bytes, password_bytes, response_bytes ) assert mock_auth.received_commands == [b"AUTH CRAM-MD5", expected_command] async def test_auth_crammd5_success_bytes(mock_auth: DummySMTPAuth) -> None: continue_response = ( SMTPStatus.auth_continue, base64.b64encode(b"secretteststring").decode("utf-8"), ) mock_auth.responses.extend([continue_response, SUCCESS_RESPONSE]) username = "ภาษา".encode("tis-620") password = "ไทย".encode("tis-620") await mock_auth.auth_crammd5(username, password) response_bytes = continue_response[1].encode("utf-8") expected_command = auth_crammd5_verify(username, password, response_bytes) assert mock_auth.received_commands == [b"AUTH CRAM-MD5", expected_command] async def test_auth_crammd5_initial_error(mock_auth: DummySMTPAuth) -> None: mock_auth.responses.append(FAILURE_RESPONSE) with pytest.raises(SMTPAuthenticationError): await mock_auth.auth_crammd5("username", "bogus") async def test_auth_crammd5_continue_error(mock_auth: DummySMTPAuth) -> None: continue_response = (SMTPStatus.auth_continue, "VXNlcm5hbWU6") mock_auth.responses.extend([continue_response, FAILURE_RESPONSE]) with pytest.raises(SMTPAuthenticationError): await mock_auth.auth_crammd5("username", "bogus") async def test_login_without_starttls_exception( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, auth_username: str, auth_password: str, ) -> None: async with smtp_client: with pytest.raises(SMTPException) as excinfo: await smtp_client.login(auth_username, auth_password) assert "Try connecting via TLS" in excinfo.value.args[0] aiosmtplib-4.0.0/tests/test_auth_utils.py0000644000000000000000000000524613615410400015541 0ustar00""" Tests for authentication encoding utils. """ import base64 import hmac from hypothesis import given from hypothesis.strategies import binary, text from aiosmtplib.auth import auth_crammd5_verify, auth_login_encode, auth_plain_encode @given(binary(), binary(), binary()) def test_auth_crammd5_verify_bytes( username: bytes, password: bytes, challenge: bytes, ) -> None: encoded_challenge = base64.b64encode(challenge) # Basically a re-implementation of the function being tested :( md5_digest = hmac.new(password, msg=challenge, digestmod="md5") verification = username + b" " + md5_digest.hexdigest().encode("ascii") encoded_verification = base64.b64encode(verification) result = auth_crammd5_verify(username, password, encoded_challenge) assert result == encoded_verification @given(text(), text(), text()) def test_auth_crammd5_verify_str( username: str, password: str, challenge: str, ) -> None: username_bytes = username.encode("utf-8") password_bytes = password.encode("utf-8") challenge_bytes = challenge.encode("utf-8") encoded_challenge = base64.b64encode(challenge_bytes) # Basically a re-implementation of the function being tested :( md5_digest = hmac.new(password_bytes, msg=challenge_bytes, digestmod="md5") verification = username_bytes + b" " + md5_digest.hexdigest().encode("ascii") encoded_verification = base64.b64encode(verification) result = auth_crammd5_verify(username, password, encoded_challenge) assert result == encoded_verification @given(binary(), binary()) def test_auth_plain_encode_bytes( username: bytes, password: bytes, ) -> None: assert auth_plain_encode(username, password) == base64.b64encode( b"\0" + username + b"\0" + password ) @given(text(), text()) def test_auth_plain_encode_str( username: str, password: str, ) -> None: username_bytes = username.encode("utf-8") password_bytes = password.encode("utf-8") assert auth_plain_encode(username, password) == base64.b64encode( b"\0" + username_bytes + b"\0" + password_bytes ) @given(binary(), binary()) def test_auth_login_encode_bytes( username: bytes, password: bytes, ) -> None: assert auth_login_encode(username, password) == ( base64.b64encode(username), base64.b64encode(password), ) @given(text(), text()) def test_auth_login_encode_str( username: str, password: str, ) -> None: username_bytes = username.encode("utf-8") password_bytes = password.encode("utf-8") assert auth_login_encode(username, password) == ( base64.b64encode(username_bytes), base64.b64encode(password_bytes), ) aiosmtplib-4.0.0/tests/test_commands.py0000644000000000000000000003522513615410400015161 0ustar00""" Lower level SMTP command tests. """ from typing import Any import pytest from aiosmtplib import ( SMTP, SMTPDataError, SMTPHeloError, SMTPNotSupported, SMTPResponseException, SMTPServerDisconnected, SMTPStatus, ) from .smtpd import ( RecordingHandler, mock_response_done, mock_response_bad_data, mock_response_ehlo_full, mock_response_expn, mock_response_gibberish, mock_response_unavailable, mock_response_unrecognized_command, mock_response_bad_command_sequence, mock_response_syntax_error, mock_response_syntax_error_and_cleanup, ) async def test_helo_ok(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.helo() assert response.code == SMTPStatus.completed async def test_helo_with_hostname(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.helo(hostname="example.com") assert response.code == SMTPStatus.completed async def test_helo_with_hostname_unset_after_connect(smtp_client: SMTP) -> None: async with smtp_client: smtp_client.local_hostname = None response = await smtp_client.helo() assert response.code == SMTPStatus.completed @pytest.mark.smtpd_mocks(smtp_HELO=mock_response_unrecognized_command) async def test_helo_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPHeloError) as exception_info: await smtp_client.helo() assert exception_info.value.code == SMTPStatus.unrecognized_command async def test_ehlo_ok(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed async def test_ehlo_with_hostname(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.ehlo(hostname="example.com") assert response.code == SMTPStatus.completed async def test_ehlo_with_hostname_unset_after_connect(smtp_client: SMTP) -> None: async with smtp_client: smtp_client.local_hostname = None response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_unrecognized_command) async def test_ehlo_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPHeloError) as exception_info: await smtp_client.ehlo() assert exception_info.value.code == SMTPStatus.unrecognized_command @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_ehlo_full) async def test_ehlo_parses_esmtp_extensions(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.ehlo() assert smtp_client.supports_extension("8bitmime") assert smtp_client.supports_extension("size") assert smtp_client.supports_extension("pipelining") assert smtp_client.supports_extension("ENHANCEDSTATUSCODES") assert not smtp_client.supports_extension("notreal") @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_done) async def test_ehlo_with_no_extensions(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.ehlo() assert not smtp_client.supports_extension("size") async def test_ehlo_or_helo_if_needed_ehlo_success(smtp_client: SMTP) -> None: async with smtp_client: assert smtp_client.is_ehlo_or_helo_needed is True await smtp_client._ehlo_or_helo_if_needed() assert smtp_client.is_ehlo_or_helo_needed is False @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_unrecognized_command) async def test_ehlo_or_helo_if_needed_helo_success(smtp_client: SMTP) -> None: async with smtp_client: assert smtp_client.is_ehlo_or_helo_needed is True await smtp_client._ehlo_or_helo_if_needed() assert smtp_client.is_ehlo_or_helo_needed is False @pytest.mark.smtpd_mocks( smtp_HELO=mock_response_unrecognized_command, smtp_EHLO=mock_response_unrecognized_command, ) async def test_ehlo_or_helo_if_needed_neither_succeeds(smtp_client: SMTP) -> None: async with smtp_client: assert smtp_client.is_ehlo_or_helo_needed is True with pytest.raises(SMTPHeloError) as exception_info: await smtp_client._ehlo_or_helo_if_needed() assert exception_info.value.code == SMTPStatus.unrecognized_command @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_unavailable) async def test_ehlo_or_helo_if_needed_disconnect_after_ehlo(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPHeloError): await smtp_client._ehlo_or_helo_if_needed() async def test_rset_ok(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.rset() assert response.code == SMTPStatus.completed assert response.message == "OK" @pytest.mark.smtpd_mocks(smtp_RSET=mock_response_bad_command_sequence) async def test_rset_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.rset() assert exception_info.value.code == SMTPStatus.bad_command_sequence async def test_noop_ok(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.noop() assert response.code == SMTPStatus.completed assert response.message == "OK" @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_syntax_error) async def test_noop_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.noop() assert exception_info.value.code == SMTPStatus.syntax_error async def test_vrfy_ok(smtp_client: SMTP) -> None: nice_address = "test@example.com" async with smtp_client: response = await smtp_client.vrfy(nice_address) assert response.code == SMTPStatus.cannot_vrfy async def test_vrfy_with_blank_address(smtp_client: SMTP) -> None: bad_address = "" async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.vrfy(bad_address) @pytest.mark.smtpd_options(smtputf8=True) async def test_vrfy_smtputf8_supported(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.vrfy("tést@exåmple.com", options=["SMTPUTF8"]) assert response.code == SMTPStatus.cannot_vrfy @pytest.mark.smtpd_options(smtputf8=False) async def test_vrfy_smtputf8_not_supported(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPNotSupported): await smtp_client.vrfy("tést@exåmple.com", options=["SMTPUTF8"]) @pytest.mark.smtpd_mocks(smtp_EXPN=mock_response_expn) async def test_expn_ok(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.expn("listserv-members") assert response.code == SMTPStatus.completed async def test_expn_error(smtp_client: SMTP) -> None: """ Since EXPN isn't implemented by aiosmtpd, it raises an exception by default. """ async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.expn("a-list") @pytest.mark.smtpd_options(smtputf8=True) @pytest.mark.smtpd_mocks(smtp_EXPN=mock_response_expn) async def test_expn_smtputf8_supported(smtp_client: SMTP) -> None: utf8_list = "tést-lïst" async with smtp_client: response = await smtp_client.expn(utf8_list, options=["SMTPUTF8"]) assert response.code == SMTPStatus.completed @pytest.mark.smtpd_options(smtputf8=False) async def test_expn_smtputf8_not_supported(smtp_client: SMTP) -> None: utf8_list = "tést-lïst" async with smtp_client: with pytest.raises(SMTPNotSupported): await smtp_client.expn(utf8_list, options=["SMTPUTF8"]) async def test_help_ok(smtp_client: SMTP) -> None: async with smtp_client: help_message = await smtp_client.help() assert "Supported commands" in help_message @pytest.mark.smtpd_mocks(smtp_HELP=mock_response_syntax_error) async def test_help_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.help() assert exception_info.value.code == SMTPStatus.syntax_error @pytest.mark.smtpd_mocks(smtp_QUIT=mock_response_syntax_error_and_cleanup) async def test_quit_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.quit() assert exception_info.value.code == SMTPStatus.syntax_error async def test_supported_methods(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed assert smtp_client.supports_extension("size") assert smtp_client.supports_extension("help") assert not smtp_client.supports_extension("bogus") async def test_mail_ok(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.mail("j@example.com") assert response.code == SMTPStatus.completed assert response.message == "OK" @pytest.mark.smtpd_mocks(smtp_MAIL=mock_response_bad_command_sequence) async def test_mail_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.mail("test@example.com") assert exception_info.value.code == SMTPStatus.bad_command_sequence async def test_mail_options_not_implemented(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.mail("j@example.com", options=["OPT=1"]) @pytest.mark.smtpd_options(smtputf8=True) async def test_mail_smtputf8(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.mail( "tést@exåmple.com", options=["SMTPUTF8"], encoding="utf-8" ) assert response.code == SMTPStatus.completed async def test_mail_default_encoding_utf8_encode_error(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(UnicodeEncodeError): await smtp_client.mail("tést@exåmple.com", options=["SMTPUTF8"]) async def test_rcpt_ok(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("j@example.com") response = await smtp_client.rcpt("test@example.com") assert response.code == SMTPStatus.completed assert response.message == "OK" @pytest.mark.smtpd_mocks(smtp_RCPT=mock_response_done) async def test_rcpt_options_ok(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("j@example.com") response = await smtp_client.rcpt( "test@example.com", options=["NOTIFY=FAILURE,DELAY"] ) assert response.code == SMTPStatus.completed async def test_rcpt_options_not_implemented(smtp_client: SMTP) -> None: # RCPT options are not implemented in aiosmtpd, so any option will return 555 async with smtp_client: await smtp_client.mail("j@example.com") with pytest.raises(SMTPResponseException) as err: await smtp_client.rcpt("test@example.com", options=["OPT=1"]) assert err.value.code == SMTPStatus.syntax_error @pytest.mark.smtpd_mocks(smtp_RCPT=mock_response_syntax_error) async def test_rcpt_error(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("j@example.com") with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.rcpt("test@example.com") assert exception_info.value.code == SMTPStatus.syntax_error @pytest.mark.smtpd_options(smtputf8=True) async def test_rcpt_smtputf8(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("j@example.com", options=["SMTPUTF8"]) response = await smtp_client.rcpt("tést@exåmple.com", encoding="utf-8") assert response.code == SMTPStatus.completed async def test_rcpt_default_encoding_utf8_encode_error(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("j@example.com") with pytest.raises(UnicodeEncodeError): await smtp_client.rcpt("tést@exåmple.com", options=["SMTPUTF8"]) async def test_data_ok(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("j@example.com") await smtp_client.rcpt("test@example.com") response = await smtp_client.data("HELLO WORLD") assert response.code == SMTPStatus.completed assert response.message == "OK" @pytest.mark.smtpd_mocks(smtp_DATA=mock_response_bad_command_sequence) async def test_data_error_on_start_input(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.mail("admin@example.com") await smtp_client.rcpt("test@example.com") with pytest.raises(SMTPDataError) as exception_info: await smtp_client.data("TEST MESSAGE") assert exception_info.value.code == SMTPStatus.bad_command_sequence async def test_data_complete_error( smtp_client: SMTP, smtpd_handler: RecordingHandler, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(smtpd_handler, "handle_DATA", mock_response_syntax_error) async with smtp_client: await smtp_client.mail("admin@example.com") await smtp_client.rcpt("test@example.com") with pytest.raises(SMTPDataError) as exception_info: await smtp_client.data("TEST MESSAGE") assert exception_info.value.code == SMTPStatus.syntax_error async def test_data_error_when_disconnected() -> None: client = SMTP() with pytest.raises(SMTPServerDisconnected): await client.data("HELLO WORLD") @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_gibberish) async def test_gibberish_raises_exception(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.noop() @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_bad_data) async def test_badly_encoded_text_response(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.noop() assert response.code == SMTPStatus.completed async def test_header_injection( smtp_client: SMTP, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: async with smtp_client: await smtp_client.mail("test@example.com\r\nX-Malicious-Header: bad stuff") assert len(received_commands) > 0 for command in received_commands: for arg in command: assert "bad stuff" not in arg aiosmtplib-4.0.0/tests/test_config.py0000644000000000000000000002042513615410400014621 0ustar00""" Tests covering SMTP configuration options. """ import asyncio import socket import ssl import pytest from aiosmtplib import SMTP async def test_tls_context_and_cert_raises(client_tls_context: ssl.SSLContext) -> None: with pytest.raises(ValueError): SMTP(use_tls=True, client_cert="foo.crt", tls_context=client_tls_context) async def test_tls_context_and_cert_to_connect_raises( client_tls_context: ssl.SSLContext, ) -> None: client = SMTP(use_tls=True, tls_context=client_tls_context) with pytest.raises(ValueError): await client.connect(client_cert="foo.crt") async def test_tls_context_and_cert_to_starttls_raises( smtp_client: SMTP, client_tls_context: ssl.SSLContext ) -> None: async with smtp_client: with pytest.raises(ValueError): await smtp_client.starttls( client_cert="test.cert", tls_context=client_tls_context ) async def test_use_tls_and_start_tls_raises() -> None: with pytest.raises(ValueError): SMTP(use_tls=True, start_tls=True) async def test_use_tls_and_start_tls_to_connect_raises() -> None: client = SMTP(use_tls=True) with pytest.raises(ValueError): await client.connect(start_tls=True) async def test_socket_and_port_raises() -> None: with pytest.raises(ValueError): SMTP(port=1, sock=socket.socket(socket.AF_INET)) async def test_socket_and_socket_path_raises() -> None: with pytest.raises(ValueError): SMTP(socket_path="/tmp/test", sock=socket.socket(socket.AF_INET)) # nosec async def test_tls_socket_with_no_hostname_raises() -> None: with pytest.raises(ValueError): SMTP(hostname=None, sock=socket.socket(socket.AF_INET), use_tls=True) # nosec async def test_tls_socket_path_with_no_hostname_raises() -> None: with pytest.raises(ValueError): SMTP(hostname=None, socket_path="/tmp/test", use_tls=True) # nosec async def test_port_and_socket_path_raises() -> None: with pytest.raises(ValueError): SMTP(port=1, socket_path="/tmp/test") # nosec async def test_config_via_connect_kwargs( bind_address: str, unused_tcp_port: int, hostname: str, smtpd_server_port: int ) -> None: client = SMTP( hostname="", use_tls=True, start_tls=None, port=smtpd_server_port + 1, local_hostname="example.com", ) local_hostname = "smtp.example.com" source_address = (bind_address, unused_tcp_port) await client.connect( hostname=hostname, port=smtpd_server_port, use_tls=False, start_tls=False, local_hostname=local_hostname, source_address=source_address, ) assert client.is_connected assert client.hostname == hostname assert client.port == smtpd_server_port assert client.use_tls is False assert client.local_hostname == local_hostname assert client.source_address == source_address assert client._start_tls_on_connect is False await client.quit() @pytest.mark.parametrize( "use_tls,start_tls,expected_port", [(False, False, 25), (True, False, 465), (False, True, 587)], ids=["plaintext", "tls", "starttls"], ) async def test_default_port_on_connect( bind_address: str, use_tls: bool, start_tls: bool, expected_port: int, ) -> None: client = SMTP() try: await client.connect( hostname=bind_address, use_tls=use_tls, start_tls=start_tls, timeout=0.001 ) except (asyncio.TimeoutError, OSError): pass assert client.port == expected_port client.close() async def test_connect_hostname_takes_precedence( hostname: str, smtpd_server_port: int ) -> None: client = SMTP(hostname="example.com", port=smtpd_server_port, start_tls=False) await client.connect(hostname=hostname) assert client.hostname == hostname await client.quit() async def test_connect_port_takes_precedence( hostname: str, smtpd_server_port: int ) -> None: client = SMTP(hostname=hostname, port=17, start_tls=False) await client.connect(port=smtpd_server_port) assert client.port == smtpd_server_port await client.quit() async def test_connect_timeout_is_reverted( hostname: str, smtpd_server_port: int ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, timeout=0.66, start_tls=False ) await client.connect(timeout=0.99) assert client.timeout == 0.66 await client.quit() async def test_connect_source_address_takes_precedence( bind_address: str, unused_tcp_port: int, hostname: str, smtpd_server_port: int, ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, source_address=("example.com", 444), ) await client.connect(source_address=(bind_address, unused_tcp_port)) assert client.source_address == (bind_address, unused_tcp_port) await client.quit() async def test_connect_local_hostname_takes_precedence( hostname: str, smtpd_server_port: int, ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, local_hostname="foo.com", ) await client.connect(local_hostname="example.com") assert client.local_hostname == "example.com" await client.quit() async def test_connect_use_tls_takes_precedence( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, use_tls=True, tls_context=client_tls_context, ) await client.connect(use_tls=False) assert client.use_tls is False await client.quit() async def test_connect_validate_certs_takes_precedence( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, validate_certs=False, tls_context=client_tls_context, ) await client.connect(validate_certs=True) assert client.validate_certs is True await client.quit() async def test_connect_certificate_options_take_precedence( hostname: str, smtpd_server_port: int ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, client_cert="test", client_key="test", cert_bundle="test", start_tls=False, ) await client.connect(client_cert=None, client_key=None, cert_bundle=None) assert client.client_cert is None assert client.client_key is None assert client.cert_bundle is None await client.quit() async def test_connect_tls_context_option_takes_precedence( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, server_tls_context: ssl.SSLContext, ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, tls_context=server_tls_context ) await client.connect(tls_context=client_tls_context) assert client.tls_context is client_tls_context await client.quit() async def test_starttls_certificate_options_take_precedence( hostname: str, smtpd_server_port: int, ca_cert_path: str, valid_cert_path: str, valid_key_path: str, ) -> None: client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, validate_certs=False, client_cert="test1", client_key="test1", cert_bundle="test1", ) await client.connect( validate_certs=False, client_cert="test2", client_key="test2", cert_bundle="test2", ) await client.starttls( client_cert=valid_cert_path, client_key=valid_key_path, cert_bundle=ca_cert_path, validate_certs=True, ) assert client.client_cert == valid_cert_path assert client.client_key == valid_key_path assert client.cert_bundle == ca_cert_path assert client.validate_certs is True await client.quit() async def test_hostname_newline_raises_error() -> None: with pytest.raises(ValueError): SMTP(hostname="localhost\r\n") async def test_local_hostname_newline_raises_error() -> None: with pytest.raises(ValueError): SMTP( hostname="localhost", local_hostname="localhost\r\nRCPT TO: ", ) aiosmtplib-4.0.0/tests/test_connect.py0000644000000000000000000003076113615410400015011 0ustar00""" Connectivity tests. """ import asyncio import pathlib import socket from typing import Any, Union import pytest from aiosmtpd.smtp import SMTP as SMTPD from aiosmtplib import ( SMTP, SMTPConnectError, SMTPResponseException, SMTPServerDisconnected, SMTPStatus, ) from .smtpd import ( mock_response_done_then_close, mock_response_unavailable, mock_response_disconnect, mock_response_eof, mock_response_start_data_disconnect, mock_response_tls_ready_disconnect, ) async def close_during_read_response(smtpd: SMTPD, *args: Any, **kwargs: Any) -> None: # Read one line of data, then cut the connection. await smtpd.push(f"{SMTPStatus.start_input} End data with .") await smtpd._reader.readline() smtpd.transport.close() async def test_plain_smtp_connect( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer ) -> None: """ Use an explicit connect/quit here, as other tests use the context manager. """ await smtp_client.connect() assert smtp_client.is_connected await smtp_client.quit() assert not smtp_client.is_connected async def test_quit_then_connect_ok( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer ) -> None: async with smtp_client: response = await smtp_client.quit() assert response.code == SMTPStatus.closing # Next command should fail with pytest.raises(SMTPServerDisconnected): response = await smtp_client.noop() await smtp_client.connect() # after reconnect, it should work again response = await smtp_client.noop() assert response.code == SMTPStatus.completed @pytest.mark.smtpd_mocks(_handle_client=mock_response_unavailable) async def test_bad_connect_response_raises_error(smtp_client: SMTP) -> None: with pytest.raises(SMTPConnectError): await smtp_client.connect() assert smtp_client.transport is None assert smtp_client.protocol is None @pytest.mark.smtpd_mocks(_handle_client=mock_response_eof) async def test_eof_on_connect_raises_connect_error(smtp_client: SMTP) -> None: with pytest.raises(SMTPConnectError): await smtp_client.connect() assert smtp_client.transport is None assert smtp_client.protocol is None @pytest.mark.smtpd_mocks(_handle_client=mock_response_disconnect) async def test_close_on_connect_raises_connect_error(smtp_client: SMTP) -> None: with pytest.raises(SMTPConnectError): await smtp_client.connect() assert not smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_unavailable) async def test_421_closes_connection(smtp_client: SMTP) -> None: await smtp_client.connect() with pytest.raises(SMTPResponseException): await smtp_client.noop() assert not smtp_client.is_connected async def test_connect_error_with_no_server( hostname: str, unused_tcp_port: int ) -> None: client = SMTP(hostname=hostname, port=unused_tcp_port, timeout=1.0) with pytest.raises(SMTPConnectError): # SMTPConnectTimeoutError vs SMTPConnectError here depends on # processing time. await client.connect() @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_disconnect) async def test_disconnected_server_raises_on_client_read(smtp_client: SMTP) -> None: await smtp_client.connect() with pytest.raises(SMTPServerDisconnected): await smtp_client.execute_command(b"NOOP") assert not smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_eof) async def test_disconnected_server_raises_on_client_write(smtp_client: SMTP) -> None: await smtp_client.connect() with pytest.raises(SMTPServerDisconnected): await smtp_client.execute_command(b"NOOP") assert not smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_DATA=mock_response_disconnect) async def test_disconnected_server_raises_on_data_read(smtp_client: SMTP) -> None: await smtp_client.connect() await smtp_client.ehlo() await smtp_client.mail("sender@example.com") await smtp_client.rcpt("recipient@example.com") with pytest.raises(SMTPServerDisconnected): await smtp_client.data("A MESSAGE") assert not smtp_client.is_connected async def test_disconnected_server_raises_on_data_write( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, smtpd_class: type[SMTPD], monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(smtpd_class, "smtp_DATA", close_during_read_response) await smtp_client.connect() await smtp_client.ehlo() await smtp_client.mail("sender@example.com") await smtp_client.rcpt("recipient@example.com") with pytest.raises(SMTPServerDisconnected): await smtp_client.data("A MESSAGE\nLINE2") assert not smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_STARTTLS=mock_response_disconnect) async def test_disconnected_server_raises_on_starttls(smtp_client: SMTP) -> None: await smtp_client.connect() await smtp_client.ehlo() async def mock_ehlo_or_helo_if_needed() -> None: pass smtp_client._ehlo_or_helo_if_needed = mock_ehlo_or_helo_if_needed with pytest.raises(SMTPServerDisconnected): await smtp_client.starttls(timeout=1.0) assert not smtp_client.is_connected async def test_context_manager( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer ) -> None: async with smtp_client: assert smtp_client.is_connected response = await smtp_client.noop() assert response.code == SMTPStatus.completed assert not smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_disconnect) async def test_context_manager_disconnect_handling(smtp_client: SMTP) -> None: """ Exceptions can be raised, but the context manager should handle disconnection. """ async with smtp_client: assert smtp_client.is_connected try: await smtp_client.noop() except SMTPServerDisconnected: pass assert not smtp_client.is_connected async def test_context_manager_exception_quits( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: with pytest.raises(ZeroDivisionError): async with smtp_client: 1 / 0 # noqa assert received_commands[-1][0] == "QUIT" async def test_context_manager_connect_exception_closes( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: with pytest.raises(ConnectionError): async with smtp_client: raise ConnectionError("Failed!") assert len(received_commands) == 0 async def test_context_manager_with_manual_connection( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer ) -> None: await smtp_client.connect() assert smtp_client.is_connected async with smtp_client: assert smtp_client.is_connected await smtp_client.quit() assert not smtp_client.is_connected assert not smtp_client.is_connected async def test_context_manager_double_entry( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer ) -> None: async with smtp_client: async with smtp_client: assert smtp_client.is_connected response = await smtp_client.noop() assert response.code == SMTPStatus.completed # The first exit should disconnect us assert not smtp_client.is_connected assert not smtp_client.is_connected async def test_connect_error_second_attempt( hostname: str, unused_tcp_port: int ) -> None: client = SMTP(hostname=hostname, port=unused_tcp_port, timeout=1.0) with pytest.raises(SMTPConnectError): await client.connect() with pytest.raises(SMTPConnectError): await client.connect() @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_done_then_close) async def test_server_unexpected_disconnect_on_command_then_reconnect( smtp_client: SMTP, ) -> None: await smtp_client.connect() await smtp_client.ehlo() with pytest.raises(SMTPServerDisconnected): await smtp_client.noop() assert not smtp_client.is_connected assert not smtp_client._connect_lock.locked() await asyncio.wait_for(smtp_client.connect(), 1.0) assert smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_STARTTLS=mock_response_tls_ready_disconnect) async def test_server_unexpected_disconnect_on_starttls_then_reconnect( smtp_client: SMTP, ) -> None: await smtp_client.connect() await smtp_client.ehlo() with pytest.raises(SMTPServerDisconnected): await smtp_client.starttls() assert not smtp_client.is_connected assert not smtp_client._connect_lock.locked() await asyncio.wait_for(smtp_client.connect(), 1.0) assert smtp_client.is_connected @pytest.mark.smtpd_mocks(smtp_DATA=mock_response_start_data_disconnect) async def test_server_unexpected_disconnect_on_data_then_reconnect( smtp_client: SMTP, ) -> None: await smtp_client.connect() await smtp_client.ehlo() await smtp_client.mail("j@example.com") await smtp_client.rcpt("test@example.com") with pytest.raises(SMTPServerDisconnected): await smtp_client.data(b"Test message") assert not smtp_client.is_connected assert not smtp_client._connect_lock.locked() await asyncio.wait_for(smtp_client.connect(), 1.0) assert smtp_client.is_connected async def test_connect_with_login( smtp_client: SMTP, smtpd_server: asyncio.AbstractServer, received_commands: list[tuple[str, tuple[Any, ...]]], auth_username: str, auth_password: str, ) -> None: # STARTTLS is required for login await smtp_client.connect( start_tls=True, username=auth_username, password=auth_password, ) assert "AUTH" in [command[0] for command in received_commands] await smtp_client.quit() @pytest.mark.smtpd_options(starttls=False) async def test_connect_with_no_starttls_support(smtp_client: SMTP) -> None: await smtp_client.connect() assert smtp_client.is_connected assert not smtp_client.protocol._over_ssl await smtp_client.quit() async def test_connect_via_socket( smtp_client: SMTP, hostname: str, smtpd_server_port: int ) -> None: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect((hostname, smtpd_server_port)) await smtp_client.connect(hostname=None, port=None, sock=sock) response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed async def test_connect_via_socket_path( smtp_client: SMTP, smtpd_server_socket_path: asyncio.AbstractServer, socket_path: Union[pathlib.Path, str, bytes], ) -> None: await smtp_client.connect(hostname=None, port=None, socket_path=socket_path) response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_eof) async def test_disconnected_server_get_transport_info(smtp_client: SMTP) -> None: await smtp_client.connect() with pytest.raises(SMTPServerDisconnected): await smtp_client.execute_command(b"NOOP") with pytest.raises(SMTPServerDisconnected, match="Server not connected"): smtp_client.get_transport_info("sslcontext") @pytest.mark.smtpd_mocks(smtp_NOOP=mock_response_eof) async def test_disconnected_server_data(smtp_client: SMTP) -> None: await smtp_client.connect() with pytest.raises(SMTPServerDisconnected): await smtp_client.execute_command(b"NOOP") async def mock_ehlo_or_helo_if_needed() -> None: pass smtp_client._ehlo_or_helo_if_needed = mock_ehlo_or_helo_if_needed with pytest.raises(SMTPServerDisconnected): await smtp_client.data("123") async def test_create_connection_runtime_error_on_missing_loop( smtp_client: SMTP, ) -> None: client = SMTP(timeout=1.0) with pytest.raises(RuntimeError, match="No event loop set"): await client._create_connection(1.0) async def test_create_connection_runtime_error_on_missing_hostname() -> None: client = SMTP(hostname=None, port=None, timeout=1.0) client.loop = asyncio.get_running_loop() with pytest.raises(RuntimeError, match="No hostname provided"): await client._create_connection(1.0) async def test_create_connection_runtime_error_on_missing_port() -> None: client = SMTP(hostname="localhost", port=None, timeout=1.0) client.loop = asyncio.get_running_loop() with pytest.raises(RuntimeError, match="No port provided"): await client._create_connection(1.0) aiosmtplib-4.0.0/tests/test_email_utils.py0000644000000000000000000002650013615410400015663 0ustar00""" Test message and address parsing/formatting functions. """ from email.header import Header from email.headerregistry import Address from email.message import EmailMessage, Message from typing import Union import pytest from hypothesis import example, given from hypothesis.strategies import emails from aiosmtplib.email import ( extract_recipients, extract_sender, flatten_message, parse_address, quote_address, ) @pytest.mark.parametrize( "address, expected_address", ( ('"A.Smith" ', "asmith+foo@example.com"), ("Pepé Le Pew ", "pépe@example.com"), ("", "a@new.topleveldomain"), ("B. Smith None: parsed_address = parse_address(address) assert parsed_address == expected_address @given(emails()) @example("email@[123.123.123.123]") @example("_______@example.com") def test_parse_address(email: str) -> None: assert parse_address(email) == email @pytest.mark.parametrize( "address, expected_address", ( ('"A.Smith" ', ""), ("Pepé Le Pew ", ""), ("", ""), ("email@[123.123.123.123]", ""), ("_______@example.com", "<_______@example.com>"), ("B. Smith "), ), ids=("quotes", "nonascii", "newtld", "ipaddr", "underscores", "missing_end_quote"), ) def test_quote_address_with_display_names(address: str, expected_address: str) -> None: quoted_address = quote_address(address) assert quoted_address == expected_address @given(emails()) @example("email@[123.123.123.123]") @example("_______@example.com") def test_quote_address(email: str) -> None: assert quote_address(email) == f"<{email}>" def test_flatten_message() -> None: message = EmailMessage() message["To"] = "bob@example.com" message["Subject"] = "Hello, World." message["From"] = "alice@example.com" message.set_content("This is a test") flat_message = flatten_message(message) expected_message = b"""To: bob@example.com\r Subject: Hello, World.\r From: alice@example.com\r Content-Type: text/plain; charset="utf-8"\r Content-Transfer-Encoding: 7bit\r MIME-Version: 1.0\r \r This is a test\r """ assert flat_message == expected_message @pytest.mark.parametrize( "message_class, utf8, cte_type, expected_chunk", ( (Message, False, "7bit", b"=?utf-8?b?w6VsaWNlQGV4YW1wbGUuY29t?="), (Message, True, "7bit", b"=?utf-8?b?w6VsaWNlQGV4YW1wbGUuY29t?="), (Message, False, "8bit", b"=?utf-8?b?w6VsaWNlQGV4YW1wbGUuY29t?="), (Message, True, "8bit", b"=?utf-8?b?w6VsaWNlQGV4YW1wbGUuY29t?="), (EmailMessage, False, "7bit", b"=?utf-8?q?=C3=A5lice?="), (EmailMessage, True, "7bit", b"\xc3\xa5lice@example.com"), (EmailMessage, False, "8bit", b"=?utf-8?q?=C3=A5lice?="), (EmailMessage, True, "8bit", b"\xc3\xa5lice@example.com"), ), ids=( "compat32-ascii-7bit", "compat32-utf8-7bit", "compat32-ascii-8bit", "compat32-utf8-8bit", "mime-ascii-7bit", "mime-utf8-7bit", "mime-ascii-8bit", "mime-utf8-8bit", ), ) def test_flatten_message_utf8_options( message_class: Union[type[EmailMessage], type[Message]], utf8: bool, cte_type: str, expected_chunk: bytes, ) -> None: message = message_class() message["From"] = "ålice@example.com" flat_message = flatten_message(message, utf8=utf8, cte_type=cte_type) assert expected_chunk in flat_message def test_flatten_message_removes_bcc_from_message_text() -> None: message = EmailMessage() message["Bcc"] = "alice@example.com" flat_message = flatten_message(message) assert flat_message == b"\r\n" # empty message def test_flatten_resent_message() -> None: message = EmailMessage() message["To"] = "bob@example.com" message["Cc"] = "claire@example.com" message["Bcc"] = "dustin@example.com" message["Subject"] = "Hello, World." message["From"] = "alice@example.com" message.set_content("This is a test") message["Resent-Date"] = "Mon, 20 Nov 2017 21:04:27 -0000" message["Resent-To"] = "eliza@example.com" message["Resent-Cc"] = "fred@example.com" message["Resent-Bcc"] = "gina@example.com" message["Resent-Subject"] = "Fwd: Hello, World." message["Resent-From"] = "hubert@example.com" flat_message = flatten_message(message) expected_message = b"""To: bob@example.com\r Cc: claire@example.com\r Subject: Hello, World.\r From: alice@example.com\r Content-Type: text/plain; charset="utf-8"\r Content-Transfer-Encoding: 7bit\r MIME-Version: 1.0\r Resent-Date: Mon, 20 Nov 2017 21:04:27 -0000\r Resent-To: eliza@example.com\r Resent-Cc: fred@example.com\r Resent-Subject: Fwd: Hello, World.\r Resent-From: hubert@example.com\r \r This is a test\r """ assert flat_message == expected_message @pytest.mark.parametrize( "mime_to_header,mime_cc_header,compat32_to_header," "compat32_cc_header,expected_recipients", ( ( "Alice Smith , hackerman@email.com", "Bob ", "Alice Smith , hackerman@email.com", "Bob ", ["alice@example.com", "hackerman@email.com", "Bob@example.com"], ), ( Address(display_name="Alice Smith", username="alice", domain="example.com"), Address(display_name="Bob", username="Bob", domain="example.com"), Header("Alice Smith "), Header("Bob "), ["alice@example.com", "Bob@example.com"], ), ( Address(display_name="ålice Smith", username="ålice", domain="example.com"), Address(display_name="Bøb", username="Bøb", domain="example.com"), Header("ålice Smith <ålice@example.com>"), Header("Bøb "), ["ålice@example.com", "Bøb@example.com"], ), ( Address(display_name="ålice Smith", username="alice", domain="example.com"), Address(display_name="Bøb", username="Bob", domain="example.com"), Header("ålice Smith "), Header("Bøb "), ["alice@example.com", "Bob@example.com"], ), ), ids=("str", "ascii", "utf8_address", "utf8_display_name"), ) def test_extract_recipients( mime_to_header: Union[str, Address], mime_cc_header: Union[str, Address], compat32_to_header: Union[str, Header], compat32_cc_header: Union[str, Header], expected_recipients: list[str], ) -> None: mime_message = EmailMessage() mime_message["To"] = mime_to_header mime_message["Cc"] = mime_cc_header mime_recipients = extract_recipients(mime_message) assert mime_recipients == expected_recipients compat32_message = Message() compat32_message["To"] = compat32_to_header compat32_message["Cc"] = compat32_cc_header compat32_recipients = extract_recipients(compat32_message) assert compat32_recipients == expected_recipients def test_extract_recipients_includes_bcc() -> None: message = EmailMessage() message["Bcc"] = "alice@example.com" recipients = extract_recipients(message) assert recipients == [message["Bcc"]] def test_extract_recipients_invalid_email() -> None: message = EmailMessage() message["Cc"] = "me" recipients = extract_recipients(message) assert recipients == ["me"] def test_extract_recipients_with_iterable_of_strings() -> None: message = EmailMessage() message["To"] = ("me@example.com", "you") recipients = extract_recipients(message) assert recipients == ["me@example.com", "you"] def test_extract_recipients_resent_message() -> None: message = EmailMessage() message["To"] = "bob@example.com" message["Cc"] = "claire@example.com" message["Bcc"] = "dustin@example.com" message["Resent-Date"] = "Mon, 20 Nov 2017 21:04:27 -0000" message["Resent-To"] = "eliza@example.com" message["Resent-Cc"] = "fred@example.com" message["Resent-Bcc"] = "gina@example.com" recipients = extract_recipients(message) assert message["Resent-To"] in recipients assert message["Resent-Cc"] in recipients assert message["Resent-Bcc"] in recipients assert message["To"] not in recipients assert message["Cc"] not in recipients assert message["Bcc"] not in recipients def test_extract_recipients_valueerror_on_multiple_resent_message() -> None: message = EmailMessage() message["Resent-Date"] = "Mon, 20 Nov 2016 21:04:27 -0000" message["Resent-Date"] = "Mon, 20 Nov 2017 21:04:27 -0000" with pytest.raises(ValueError): extract_recipients(message) @pytest.mark.parametrize( "mime_header,compat32_header,expected_sender", ( ( "Alice Smith ", "Alice Smith ", "alice@example.com", ), ( Address(display_name="Alice Smith", username="alice", domain="example.com"), Header("Alice Smith "), "alice@example.com", ), ( Address(display_name="ålice Smith", username="ålice", domain="example.com"), Header("ålice Smith <ålice@example.com>", "utf-8"), "ålice@example.com", ), ( Address(display_name="ålice Smith", username="alice", domain="example.com"), Header("ålice Smith ", "utf-8"), "alice@example.com", ), ), ids=("str", "ascii", "utf8_address", "utf8_display_name"), ) def test_extract_sender( mime_header: Union[str, Address], compat32_header: Union[str, Header], expected_sender: str, ) -> None: mime_message = EmailMessage() mime_message["From"] = mime_header mime_sender = extract_sender(mime_message) assert mime_sender == expected_sender compat32_message = Message() compat32_message["From"] = compat32_header compat32_sender = extract_sender(compat32_message) assert compat32_sender == expected_sender def test_extract_sender_prefers_sender_header() -> None: message = EmailMessage() message["From"] = "bob@example.com" message["Sender"] = "alice@example.com" sender = extract_sender(message) assert sender != message["From"] assert sender == message["Sender"] def test_extract_sender_resent_message() -> None: message = EmailMessage() message["From"] = "alice@example.com" message["Resent-Date"] = "Mon, 20 Nov 2017 21:04:27 -0000" message["Resent-From"] = "hubert@example.com" sender = extract_sender(message) assert sender == message["Resent-From"] assert sender != message["From"] def test_extract_sender_valueerror_on_multiple_resent_message() -> None: message = EmailMessage() message["Resent-Date"] = "Mon, 20 Nov 2016 21:04:27 -0000" message["Resent-Date"] = "Mon, 20 Nov 2017 21:04:27 -0000" with pytest.raises(ValueError): extract_sender(message) aiosmtplib-4.0.0/tests/test_errors.py0000644000000000000000000001045313615410400014670 0ustar00""" Test error class imports, arguments, and inheritance. """ import asyncio from typing import Union import pytest from hypothesis import given from hypothesis.strategies import integers, lists, text, tuples from aiosmtplib import ( SMTPAuthenticationError, SMTPConnectError, SMTPConnectTimeoutError, SMTPDataError, SMTPException, SMTPHeloError, SMTPNotSupported, SMTPReadTimeoutError, SMTPRecipientRefused, SMTPRecipientsRefused, SMTPResponseException, SMTPSenderRefused, SMTPServerDisconnected, SMTPTimeoutError, ) @given(error_message=text()) def test_raise_smtp_exception(error_message: str) -> None: with pytest.raises(SMTPException) as excinfo: raise SMTPException(error_message) assert excinfo.value.message == error_message @given(code=integers(), error_message=text()) def test_raise_smtp_response_exception(code: int, error_message: str) -> None: with pytest.raises(SMTPResponseException) as excinfo: raise SMTPResponseException(code, error_message) assert issubclass(excinfo.type, SMTPException) assert excinfo.value.code == code assert excinfo.value.message == error_message @pytest.mark.parametrize( "error_class", (SMTPServerDisconnected, SMTPConnectError, SMTPConnectTimeoutError) ) @given(error_message=text()) def test_connection_exceptions( error_message: str, error_class: type[SMTPException] ) -> None: with pytest.raises(error_class) as excinfo: raise error_class(error_message) assert issubclass(excinfo.type, SMTPException) assert issubclass(excinfo.type, ConnectionError) assert excinfo.value.message == error_message @pytest.mark.parametrize( "error_class", (SMTPTimeoutError, SMTPConnectTimeoutError, SMTPReadTimeoutError) ) @given(error_message=text()) def test_timeout_exceptions( error_message: str, error_class: type[SMTPException] ) -> None: with pytest.raises(error_class) as excinfo: raise error_class(error_message) assert issubclass(excinfo.type, SMTPException) assert issubclass(excinfo.type, asyncio.TimeoutError) assert excinfo.value.message == error_message @pytest.mark.parametrize( "error_class", (SMTPHeloError, SMTPDataError, SMTPAuthenticationError) ) @given(code=integers(), error_message=text()) def test_simple_response_exceptions( code: int, error_message: str, error_class: type[Union[SMTPHeloError, SMTPDataError, SMTPAuthenticationError]], ) -> None: with pytest.raises(error_class) as excinfo: raise error_class(code, error_message) assert issubclass(excinfo.type, SMTPResponseException) assert excinfo.value.code == code assert excinfo.value.message == error_message @given(code=integers(), error_message=text(), sender=text()) def test_raise_smtp_sender_refused(code: int, error_message: str, sender: str) -> None: with pytest.raises(SMTPSenderRefused) as excinfo: raise SMTPSenderRefused(code, error_message, sender) assert issubclass(excinfo.type, SMTPResponseException) assert excinfo.value.code == code assert excinfo.value.message == error_message assert excinfo.value.sender == sender @given(code=integers(), error_message=text(), recipient=text()) def test_raise_smtp_recipient_refused( code: int, error_message: str, recipient: str ) -> None: with pytest.raises(SMTPRecipientRefused) as excinfo: raise SMTPRecipientRefused(code, error_message, recipient) assert issubclass(excinfo.type, SMTPResponseException) assert excinfo.value.code == code assert excinfo.value.message == error_message assert excinfo.value.recipient == recipient @given(lists(elements=tuples(integers(), text(), text()))) def test_raise_smtp_recipients_refused(addresses: list[tuple[int, str, str]]) -> None: errors = [SMTPRecipientRefused(*address) for address in addresses] with pytest.raises(SMTPRecipientsRefused) as excinfo: raise SMTPRecipientsRefused(errors) assert issubclass(excinfo.type, SMTPException) assert excinfo.value.recipients == errors @given(error_message=text()) def test_raise_smtp_not_supported(error_message: str) -> None: with pytest.raises(SMTPNotSupported) as excinfo: raise SMTPNotSupported(error_message) assert issubclass(excinfo.type, SMTPException) assert excinfo.value.message == error_message aiosmtplib-4.0.0/tests/test_esmtp_utils.py0000644000000000000000000000302413615410400015720 0ustar00""" Tests for ESMTP extension parsing. """ from aiosmtplib.esmtp import parse_esmtp_extensions def test_basic_extension_parsing() -> None: response = """size.does.matter.af.MIL offers FIFTEEN extensions: 8BITMIME PIPELINING DSN ENHANCEDSTATUSCODES EXPN HELP SAML SEND SOML TURN XADR XSTA ETRN XGEN SIZE 51200000 """ extensions, auth_types = parse_esmtp_extensions(response) assert "size" in extensions assert extensions["size"] == "51200000" assert "saml" in extensions assert "size.does.matter.af.mil" not in extensions assert auth_types == [] def test_no_extension_parsing() -> None: response = """size.does.matter.af.MIL offers ZERO extensions: """ extensions, auth_types = parse_esmtp_extensions(response) assert extensions == {} assert auth_types == [] def test_auth_type_parsing() -> None: response = """blah blah blah AUTH FOO BAR """ extensions, auth_types = parse_esmtp_extensions(response) assert "foo" in auth_types assert "bar" in auth_types assert "bogus" not in auth_types def test_old_school_auth_type_parsing() -> None: response = """blah blah blah AUTH=PLAIN """ extensions, auth_types = parse_esmtp_extensions(response) assert "plain" in auth_types assert "cram-md5" not in auth_types def test_mixed_auth_type_parsing() -> None: response = """blah blah blah AUTH=PLAIN AUTH CRAM-MD5 """ extensions, auth_types = parse_esmtp_extensions(response) assert "plain" in auth_types assert "cram-md5" in auth_types aiosmtplib-4.0.0/tests/test_main.py0000644000000000000000000000157313615410400014303 0ustar00import asyncio import sys async def test_command_line_send(hostname: str, smtpd_server_port: int) -> None: proc = await asyncio.create_subprocess_exec( sys.executable, b"-m", b"aiosmtplib", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, ) inputs = ( bytes(hostname, "ascii"), bytes(str(smtpd_server_port), "ascii"), b"sender@example.com", b"recipient@example.com", b"Subject: Hello World\n\nHi there.", ) messages = ( b"SMTP server hostname [localhost]:", b"SMTP server port [25]:", b"From:", b"To:", b"Enter message, end with ^D:", ) output, errors = await proc.communicate(input=b"\n".join(inputs)) assert errors is None for message in messages: assert message in output assert proc.returncode == 0 aiosmtplib-4.0.0/tests/test_protocol.py0000644000000000000000000003373013615410400015220 0ustar00""" Protocol level tests. """ import asyncio import gc import os import socket import ssl import pytest from aiosmtplib import SMTPResponseException, SMTPServerDisconnected, SMTPTimeoutError from aiosmtplib.protocol import FlowControlMixin, SMTPProtocol from .compat import cleanup_server async def test_protocol_connect(hostname: str, echo_server_port: int) -> None: event_loop = asyncio.get_running_loop() connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=echo_server_port ) transport, protocol = await asyncio.wait_for(connect_future, timeout=1.0) assert getattr(protocol, "transport", None) is transport assert not transport.is_closing() transport.close() async def test_protocol_read_limit_overrun( bind_address: str, hostname: str, monkeypatch: pytest.MonkeyPatch, ) -> None: event_loop = asyncio.get_running_loop() async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: await reader.read(1000) long_response = ( b"220 At vero eos et accusamus et iusto odio dignissimos ducimus qui " b"blanditiis praesentium voluptatum deleniti atque corruptis qui " b"blanditiis praesentium voluptatum\n" ) writer.write(long_response) await writer.drain() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) monkeypatch.setattr("aiosmtplib.protocol.MAX_LINE_LENGTH", 128) with pytest.raises(SMTPResponseException) as exc_info: await protocol.execute_command(b"TEST\n", timeout=1.0) # type: ignore assert exc_info.value.code == 500 assert "Response too long" in exc_info.value.message server.close() await cleanup_server(server) async def test_protocol_connected_check_on_read_response( monkeypatch: pytest.MonkeyPatch, ) -> None: protocol = SMTPProtocol() monkeypatch.setattr(protocol, "transport", None) with pytest.raises(SMTPServerDisconnected): await protocol.read_response(timeout=1.0) async def test_protocol_read_only_transport_error() -> None: event_loop = asyncio.get_running_loop() read_descriptor, _ = os.pipe() read_pipe = os.fdopen(read_descriptor, "rb", buffering=0) connect_future = event_loop.connect_read_pipe(SMTPProtocol, read_pipe) transport, protocol = await asyncio.wait_for(connect_future, timeout=1.0) assert getattr(protocol, "transport", None) is transport with pytest.raises(RuntimeError, match="does not support writing"): protocol.write(b"TEST\n") transport.close() async def test_protocol_connected_check_on_start_tls( client_tls_context: ssl.SSLContext, ) -> None: smtp_protocol = SMTPProtocol() with pytest.raises(SMTPServerDisconnected): await smtp_protocol.start_tls(client_tls_context, timeout=1.0) async def test_protocol_already_over_tls_check_on_start_tls( client_tls_context: ssl.SSLContext, ) -> None: smtp_protocol = SMTPProtocol() smtp_protocol._over_ssl = True with pytest.raises(RuntimeError, match="Already using TLS"): await smtp_protocol.start_tls(client_tls_context) async def test_protocol_connection_reset_on_starttls( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, monkeypatch: pytest.MonkeyPatch, ) -> None: event_loop = asyncio.get_running_loop() connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=smtpd_server_port ) transport, protocol = await asyncio.wait_for(connect_future, timeout=1.0) def mock_start_tls(*args, **kwargs) -> None: raise ConnectionResetError("Connection was reset") monkeypatch.setattr(event_loop, "start_tls", mock_start_tls) with pytest.raises(SMTPServerDisconnected): await protocol.start_tls(client_tls_context) transport.close() async def test_protocol_timeout_on_starttls( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, monkeypatch: pytest.MonkeyPatch, ) -> None: event_loop = asyncio.get_running_loop() connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=smtpd_server_port ) transport, protocol = await asyncio.wait_for(connect_future, timeout=1.0) def mock_start_tls(*args, **kwargs) -> None: raise TimeoutError("Timed out") monkeypatch.setattr(event_loop, "start_tls", mock_start_tls) with pytest.raises(SMTPTimeoutError, match="Timed out while upgrading transport"): await protocol.start_tls(client_tls_context) transport.close() async def test_error_on_readline_with_partial_line( bind_address: str, hostname: str ) -> None: event_loop = asyncio.get_running_loop() partial_response = b"499 incomplete response\\" async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: writer.write(partial_response) writer.write_eof() await writer.drain() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) with pytest.raises(SMTPServerDisconnected): await protocol.read_response(timeout=1.0) # type: ignore server.close() await cleanup_server(server) async def test_protocol_error_on_readline_with_malformed_response( bind_address: str, hostname: str ) -> None: event_loop = asyncio.get_running_loop() response = b"ERROR\n" async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: writer.write(response) writer.write_eof() await writer.drain() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) with pytest.raises( SMTPResponseException, match="Malformed SMTP response line: ERROR" ): await protocol.read_response(timeout=1.0) # type: ignore server.close() await cleanup_server(server) async def test_protocol_response_waiter_unset( bind_address: str, hostname: str, monkeypatch: pytest.MonkeyPatch, ) -> None: event_loop = asyncio.get_running_loop() async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: await reader.read(1000) writer.write(b"220 Hi\r\n") await writer.drain() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) monkeypatch.setattr(protocol, "_response_waiter", None) with pytest.raises(SMTPServerDisconnected): await protocol.execute_command(b"TEST\n", timeout=1.0) # type: ignore server.close() await cleanup_server(server) async def test_protocol_data_received_called_twice( bind_address: str, hostname: str, ) -> None: event_loop = asyncio.get_running_loop() async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: await reader.read(1000) writer.write(b"220 Hi\r\n") await writer.drain() await asyncio.sleep(0) writer.write(b"221 Hi again!\r\n") await writer.drain() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) response = await protocol.execute_command(b"TEST\n", timeout=1.0) # type: ignore assert response.code == 220 assert response.message == "Hi" server.close() await cleanup_server(server) async def test_protocol_eof_response(bind_address: str, hostname: str) -> None: event_loop = asyncio.get_running_loop() async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: writer.transport.abort() # type: ignore server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) await asyncio.wait_for(connect_future, timeout=1.0) server.close() await cleanup_server(server) async def test_protocol_exception_cleanup_warning( caplog: pytest.LogCaptureFixture, debug_event_loop: asyncio.AbstractEventLoop, bind_address: str, hostname: str, ) -> None: async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: await reader.read(1000) writer.write(b"220 Hi\r\n") await writer.drain() await reader.read(1000) writer.write(b"221 Bye\r\n") await writer.drain() writer.transport.close() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = debug_event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) await protocol.execute_command(b"HELO\n", timeout=1.0) await protocol.execute_command(b"QUIT\n", timeout=1.0) del protocol # Force garbage collection gc.collect() server.close() await cleanup_server(server) assert "Future exception was never retrieved" not in caplog.text async def test_protocol_get_close_waiter() -> None: event_loop = asyncio.get_running_loop() protocol = SMTPProtocol(event_loop) close_waiter = protocol._get_close_waiter(None) # type: ignore assert close_waiter is not None async def test_protocol_missing_command_lock_disconnected() -> None: event_loop = asyncio.get_running_loop() protocol = SMTPProtocol(event_loop) with pytest.raises(SMTPServerDisconnected): await protocol.execute_command(b"TEST\n") with pytest.raises(SMTPServerDisconnected): await protocol.execute_data_command(b"TEST\n") async def test_flow_control_mixin_drain(): event_loop = asyncio.get_running_loop() # Adapted from stdlib drained = 0 async def drainer(stream): nonlocal drained await stream._drain_helper() drained += 1 stream = FlowControlMixin(event_loop) stream.pause_writing() event_loop.call_later(0.1, stream.resume_writing) await asyncio.gather(*[drainer(stream) for _ in range(10)]) assert drained == 10 async def test_flow_control_mixin_drain_incomplete(): event_loop = asyncio.get_running_loop() flow_control = FlowControlMixin(event_loop) flow_control.pause_writing() waiter = event_loop.create_future() flow_control._drain_waiters.append(waiter) waiter.set_result("test") flow_control.resume_writing() assert waiter.done() assert not waiter.cancelled() def test_flow_control_mixin_connection_lost_exception( event_loop: asyncio.AbstractEventLoop, ) -> None: flow_control = FlowControlMixin(event_loop) flow_control.pause_writing() waiter = event_loop.create_future() flow_control._drain_waiters.append(waiter) exc = ConnectionAbortedError("boom") flow_control.connection_lost(exc) assert waiter.done() assert not waiter.cancelled() assert waiter.exception() is exc def test_flow_control_mixin_connection_lost_no_exception( event_loop: asyncio.AbstractEventLoop, ) -> None: flow_control = FlowControlMixin(event_loop) flow_control.pause_writing() waiter = event_loop.create_future() flow_control._drain_waiters.append(waiter) flow_control.connection_lost(None) assert waiter.done() assert not waiter.cancelled() assert waiter.exception() is None def test_flow_control_mixin_connection_lost_done( event_loop: asyncio.AbstractEventLoop, ) -> None: flow_control = FlowControlMixin(event_loop) flow_control.pause_writing() waiter = event_loop.create_future() flow_control._drain_waiters.append(waiter) exc = ConnectionResetError("boom") waiter.set_exception(exc) flow_control.connection_lost(None) assert waiter.done() assert not waiter.cancelled() assert waiter.exception() is exc async def test_flow_control_mixin_drain_helper() -> None: loop = asyncio.get_running_loop() flow_control = FlowControlMixin(loop) await flow_control._drain_helper() async def test_flow_control_mixin_drain_helper_connection_lost() -> None: loop = asyncio.get_running_loop() flow_control = FlowControlMixin(loop) flow_control.pause_writing() flow_control.connection_lost(None) with pytest.raises(ConnectionResetError): await flow_control._drain_helper() aiosmtplib-4.0.0/tests/test_response.py0000644000000000000000000000100513615410400015203 0ustar00from hypothesis import given from hypothesis.strategies import integers, text from aiosmtplib.response import SMTPResponse @given(integers(), text()) def test_response_repr(code: int, message: str) -> None: response = SMTPResponse(code, message) assert repr(response) == f"({response.code}, {response.message})" @given(integers(), text()) def test_response_str(code: int, message: str) -> None: response = SMTPResponse(code, message) assert str(response) == f"{response.code} {response.message}" aiosmtplib-4.0.0/tests/test_sendmail.py0000644000000000000000000004401313615410400015147 0ustar00""" SMTP.sendmail and SMTP.send_message method testing. """ import copy import email.generator import email.header import email.message from typing import Any, Optional import pytest from aiosmtplib import ( SMTP, SMTPNotSupported, SMTPRecipientsRefused, SMTPResponseException, SMTPStatus, ) from .smtpd import ( mock_response_done, mock_response_error_disconnect, mock_response_bad_command_sequence, ) async def test_sendmail_simple_success( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: errors, response = await smtp_client.sendmail( sender_str, [recipient_str], message_str ) assert not errors assert isinstance(errors, dict) assert response != "" async def test_sendmail_binary_content( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: errors, response = await smtp_client.sendmail( sender_str, [recipient_str], bytes(message_str, "ascii") ) assert not errors assert isinstance(errors, dict) assert response != "" async def test_sendmail_with_recipients_string( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: errors, response = await smtp_client.sendmail( sender_str, recipient_str, message_str ) assert not errors assert response != "" async def test_sendmail_with_mail_option( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: errors, response = await smtp_client.sendmail( sender_str, [recipient_str], message_str, mail_options=["BODY=8BITMIME"] ) assert not errors assert response != "" @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_done) async def test_sendmail_without_size_option( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: errors, response = await smtp_client.sendmail( sender_str, [recipient_str], message_str ) assert not errors assert response != "" async def test_sendmail_with_invalid_mail_option( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: with pytest.raises(SMTPResponseException) as excinfo: await smtp_client.sendmail( sender_str, [recipient_str], message_str, mail_options=["BADDATA=0x00000000"], ) assert excinfo.value.code == SMTPStatus.syntax_error async def test_sendmail_with_rcpt_option( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: with pytest.raises(SMTPRecipientsRefused) as excinfo: await smtp_client.sendmail( sender_str, [recipient_str], message_str, rcpt_options=["NOTIFY=FAILURE,DELAY"], ) recipient_exc = excinfo.value.recipients[0] assert recipient_exc.code == SMTPStatus.syntax_error assert ( recipient_exc.message == "RCPT TO parameters not recognized or not implemented" ) async def test_sendmail_simple_failure(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPRecipientsRefused): # @@ is an invalid recipient. await smtp_client.sendmail("test@example.com", ["@@"], "blah") async def test_sendmail_smtputf8_not_supported(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPNotSupported, match="SMTPUTF8 is not supported"): await smtp_client.sendmail( "test@example.com", ["børk@example.com"], "blah", mail_options=["SMTPUTF8"], ) @pytest.mark.smtpd_mocks(smtp_DATA=mock_response_error_disconnect) async def test_sendmail_error_silent_rset_handles_disconnect( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.sendmail(sender_str, [recipient_str], message_str) async def test_rset_after_sendmail_error_response_to_mail( smtp_client: SMTP, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: """ If an error response is given to the MAIL command in the sendmail method, test that we reset the server session. """ async with smtp_client: response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed with pytest.raises(SMTPResponseException) as excinfo: await smtp_client.sendmail(">foobar<", ["test@example.com"], "Hello World") assert excinfo.value.code == SMTPStatus.unrecognized_parameters assert received_commands[-1][0] == "RSET" async def test_rset_after_sendmail_error_response_to_rcpt( smtp_client: SMTP, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: """ If an error response is given to the RCPT command in the sendmail method, test that we reset the server session. """ async with smtp_client: response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed with pytest.raises(SMTPRecipientsRefused) as excinfo: await smtp_client.sendmail( "test@example.com", [">not an addr<"], "Hello World" ) assert excinfo.value.recipients[0].code == SMTPStatus.unrecognized_parameters assert received_commands[-1][0] == "RSET" @pytest.mark.smtpd_mocks(smtp_DATA=mock_response_bad_command_sequence) async def test_rset_after_sendmail_error_response_to_data( smtp_client: SMTP, sender_str: str, recipient_str: str, message_str: str, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: """ If an error response is given to the DATA command in the sendmail method, test that we reset the server session. """ async with smtp_client: response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed with pytest.raises(SMTPResponseException) as excinfo: await smtp_client.sendmail(sender_str, [recipient_str], message_str) assert excinfo.value.code == SMTPStatus.bad_command_sequence assert received_commands[-1][0] == "RSET" async def test_send_message(smtp_client: SMTP, message: email.message.Message) -> None: async with smtp_client: errors, response = await smtp_client.send_message(message) assert not errors assert isinstance(errors, dict) assert response != "" async def test_send_message_with_sender_and_recipient_args( smtp_client: SMTP, message: email.message.Message, received_messages: list[email.message.EmailMessage], ) -> None: sender = "sender2@example.com" recipients = ["recipient1@example.com", "recipient2@example.com"] async with smtp_client: errors, response = await smtp_client.send_message( message, sender=sender, recipients=recipients ) assert not errors assert isinstance(errors, dict) assert response != "" assert len(received_messages) == 1 assert received_messages[0]["X-MailFrom"] == sender assert received_messages[0]["X-RcptTo"] == ", ".join(recipients) async def test_send_message_with_cc_recipients( smtp_client: SMTP, recipient_str: str, message: email.message.Message, received_messages: list[email.message.EmailMessage], received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: cc_recipients = ["recipient1@example.com", "recipient2@example.com"] message["Cc"] = ", ".join(cc_recipients) async with smtp_client: errors, _ = await smtp_client.send_message(message) assert not errors assert len(received_messages) == 1 assert ( received_messages[0]["X-RcptTo"] == f"{recipient_str}, {', '.join(cc_recipients)}" ) assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == recipient_str assert received_commands[3][0] == "RCPT" assert received_commands[3][1][0] == cc_recipients[0] assert received_commands[4][0] == "RCPT" assert received_commands[4][1][0] == cc_recipients[1] async def test_send_message_with_bcc_recipients( smtp_client: SMTP, recipient_str: str, message: email.message.Message, received_messages: list[email.message.EmailMessage], received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: bcc_recipients = ["recipient1@example.com", "recipient2@example.com"] message["Bcc"] = ", ".join(bcc_recipients) async with smtp_client: errors, _ = await smtp_client.send_message(message) assert not errors assert len(received_messages) == 1 assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == recipient_str assert received_commands[3][0] == "RCPT" assert received_commands[3][1][0] == bcc_recipients[0] assert received_commands[4][0] == "RCPT" assert received_commands[4][1][0] == bcc_recipients[1] async def test_send_message_with_cc_and_bcc_recipients( smtp_client: SMTP, recipient_str: str, message: email.message.Message, received_messages: list[email.message.EmailMessage], received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: cc_recipient = "recipient2@example.com" message["Cc"] = cc_recipient bcc_recipient = "recipient2@example.com" message["Bcc"] = bcc_recipient async with smtp_client: errors, _ = await smtp_client.send_message(message) assert not errors assert len(received_messages) == 1 assert received_messages[0]["To"] == recipient_str assert received_messages[0]["Cc"] == cc_recipient # BCC shouldn't be passed through assert received_messages[0]["Bcc"] is None assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == recipient_str assert received_commands[3][0] == "RCPT" assert received_commands[3][1][0] == cc_recipient assert received_commands[4][0] == "RCPT" assert received_commands[4][1][0] == bcc_recipient async def test_send_message_recipient_str( smtp_client: SMTP, message: email.message.Message, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: recipient_str = "1234@example.org" async with smtp_client: errors, response = await smtp_client.send_message( message, recipients=recipient_str ) assert not errors assert isinstance(errors, dict) assert response != "" assert received_commands[2][1][0] == recipient_str async def test_send_message_mail_options( smtp_client: SMTP, message: email.message.Message, ) -> None: async with smtp_client: errors, response = await smtp_client.send_message( message, mail_options=["BODY=8BITMIME"] ) assert not errors assert isinstance(errors, dict) assert response != "" async def test_send_multiple_messages_in_sequence( smtp_client: SMTP, message: email.message.Message ) -> None: message1 = copy.copy(message) message2 = copy.copy(message) del message2["To"] message2["To"] = "recipient2@example.com" async with smtp_client: errors1, response1 = await smtp_client.send_message(message1) assert not errors1 assert isinstance(errors1, dict) assert response1 != "" errors2, response2 = await smtp_client.send_message(message2) assert not errors2 assert isinstance(errors2, dict) assert response2 != "" async def test_send_message_without_recipients( smtp_client: SMTP, message: email.message.Message ) -> None: del message["To"] async with smtp_client: with pytest.raises(ValueError): await smtp_client.send_message(message) async def test_send_message_without_sender( smtp_client: SMTP, message: email.message.Message ) -> None: del message["From"] async with smtp_client: with pytest.raises(ValueError): await smtp_client.send_message(message) @pytest.mark.smtpd_options(smtputf8=True) async def test_send_message_smtputf8_sender( smtp_client: SMTP, message: email.message.Message, received_commands: list[tuple[str, tuple[Any, ...]]], received_messages: list[email.message.EmailMessage], ) -> None: del message["From"] message["From"] = "séndër@exåmple.com" async with smtp_client: errors, response = await smtp_client.send_message(message) assert not errors assert response != "" assert received_commands[1][0] == "MAIL" assert received_commands[1][1][0] == message["From"] # Size varies depending on the message type assert received_commands[1][1][1][0].startswith("SIZE=") assert received_commands[1][1][1][1:] == ["SMTPUTF8", "BODY=8BITMIME"] assert len(received_messages) == 1 assert received_messages[0]["X-MailFrom"] == message["From"] @pytest.mark.smtpd_options(smtputf8=True) @pytest.mark.parametrize( "mail_options", (None, ["SMTPUTF8"]), ids=("no_mail_options", "smtputf8_option"), ) async def test_send_mime_message_smtputf8_recipient( smtp_client: SMTP, mime_message: email.message.EmailMessage, received_commands: list[tuple[str, tuple[Any, ...]]], received_messages: list[email.message.EmailMessage], mail_options: Optional[list[str]], ) -> None: mime_message["To"] = "reçipïént@exåmple.com" async with smtp_client: errors, response = await smtp_client.send_message( mime_message, mail_options=mail_options ) assert not errors assert response != "" assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == mime_message["To"] assert len(received_messages) == 1 assert received_messages[0]["X-RcptTo"] == ", ".join(mime_message.get_all("To")) @pytest.mark.smtpd_options(smtputf8=True) async def test_send_compat32_message_smtputf8_recipient( smtp_client: SMTP, compat32_message: email.message.Message, received_commands: list[tuple[str, tuple[Any, ...]]], received_messages: list[email.message.EmailMessage], ) -> None: recipient_bytes = bytes("reçipïént@exåmple.com", "utf-8") compat32_message["To"] = email.header.Header(recipient_bytes, "utf-8") async with smtp_client: errors, response = await smtp_client.send_message(compat32_message) assert not errors assert response != "" assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == compat32_message["To"] assert len(received_messages) == 1 assert ( received_messages[0]["X-RcptTo"] == "recipient@example.com, reçipïént@exåmple.com" ) @pytest.mark.smtpd_options(smtputf8=False) async def test_send_message_smtputf8_not_supported( smtp_client: SMTP, message: email.message.Message ) -> None: message["To"] = "reçipïént2@exåmple.com" async with smtp_client: with pytest.raises(SMTPNotSupported): await smtp_client.send_message(message) @pytest.mark.smtpd_options(smtputf8=False) async def test_send_compat32_message_utf8_text_without_smtputf8( smtp_client: SMTP, compat32_message: email.message.Message, received_commands: list[tuple[str, tuple[Any, ...]]], received_messages: list[email.message.EmailMessage], ) -> None: compat32_message["To"] = email.header.Header( "reçipïént ", "utf-8" ) async with smtp_client: errors, response = await smtp_client.send_message(compat32_message) assert not errors assert response != "" assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == compat32_message["To"].encode() assert len(received_messages) == 1 assert ( received_messages[0]["X-RcptTo"] == "recipient@example.com, recipient2@example.com" ) # Name should be encoded assert received_messages[0].get_all("To") == [ "recipient@example.com", "=?utf-8?b?cmXDp2lww6/DqW50IDxyZWNpcGllbnQyQGV4YW1wbGUuY29tPg==?=", ] @pytest.mark.smtpd_options(smtputf8=False) async def test_send_mime_message_utf8_text_without_smtputf8( smtp_client: SMTP, mime_message: email.message.EmailMessage, received_commands: list[tuple[str, tuple[Any, ...]]], received_messages: list[email.message.EmailMessage], ) -> None: mime_message["To"] = "reçipïént " async with smtp_client: errors, response = await smtp_client.send_message(mime_message) assert not errors assert response != "" assert received_commands[2][0] == "RCPT" assert received_commands[2][1][0] == mime_message["To"] assert len(received_messages) == 1 assert ( received_messages[0]["X-RcptTo"] == "recipient@example.com, recipient2@example.com" ) # Name should be encoded assert received_messages[0].get_all("To") == [ "recipient@example.com", "=?utf-8?b?cmXDp2lww6/DqW50IDxyZWNpcGllbnQyQGV4YW1wbGUuY29tPg==?=", ] @pytest.mark.smtpd_options(**{"smtputf8": False, "7bit": True}) async def test_send_message_7bit( smtp_client: SMTP, message: email.message.Message, received_commands: list[tuple[str, tuple[Any, ...]]], ) -> None: async with smtp_client: errors, response = await smtp_client.send_message(message) assert not errors assert response != "" assert "BODY=8BITMIME" not in received_commands[1][1][1] async def test_sendmail_empty_sender( smtp_client: SMTP, recipient_str: str, message_str: str ) -> None: async with smtp_client: errors, response = await smtp_client.sendmail("", [recipient_str], message_str) assert not errors assert isinstance(errors, dict) assert response != "" aiosmtplib-4.0.0/tests/test_status.py0000644000000000000000000000033413615410400014674 0ustar00""" Test status import shim. """ from aiosmtplib.status import SMTPStatus as OldImportSMTPStatus from aiosmtplib.typing import SMTPStatus def test_status_import() -> None: assert OldImportSMTPStatus is SMTPStatus aiosmtplib-4.0.0/tests/test_sync.py0000644000000000000000000000124613615410400014330 0ustar00""" Sync method tests. """ import email.message from aiosmtplib import SMTP def test_sendmail_sync( smtp_client_threaded: SMTP, sender_str: str, recipient_str: str, message_str: str, ) -> None: errors, response = smtp_client_threaded.sendmail_sync( sender_str, [recipient_str], message_str ) assert not errors assert isinstance(errors, dict) assert response != "" def test_send_message_sync( smtp_client_threaded: SMTP, message: email.message.Message, ) -> None: errors, response = smtp_client_threaded.send_message_sync(message) assert not errors assert isinstance(errors, dict) assert response != "" aiosmtplib-4.0.0/tests/test_timeouts.py0000644000000000000000000001162013615410400015222 0ustar00""" Timeout tests. """ import asyncio import socket import ssl import pytest from aiosmtplib import ( SMTP, SMTPConnectTimeoutError, SMTPServerDisconnected, SMTPTimeoutError, ) from aiosmtplib.protocol import SMTPProtocol from .compat import cleanup_server from .smtpd import mock_response_delayed_ok, mock_response_delayed_read @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_delayed_ok) async def test_command_timeout_error(smtp_client: SMTP) -> None: await smtp_client.connect() with pytest.raises(SMTPTimeoutError): await smtp_client.ehlo(hostname="example.com", timeout=0.0) @pytest.mark.smtpd_mocks(smtp_DATA=mock_response_delayed_ok) async def test_data_timeout_error(smtp_client: SMTP) -> None: await smtp_client.connect() await smtp_client.ehlo() await smtp_client.mail("j@example.com") await smtp_client.rcpt("test@example.com") with pytest.raises(SMTPTimeoutError): await smtp_client.data("HELLO WORLD", timeout=0.0) @pytest.mark.smtpd_mocks(_handle_client=mock_response_delayed_ok) async def test_timeout_error_on_connect(smtp_client: SMTP) -> None: with pytest.raises(SMTPTimeoutError): await smtp_client.connect(timeout=0.0) assert smtp_client.transport is None assert smtp_client.protocol is None @pytest.mark.smtpd_mocks(_handle_client=mock_response_delayed_read) async def test_timeout_on_initial_read(smtp_client: SMTP) -> None: with pytest.raises(SMTPTimeoutError): # We need to use a timeout > 0 here to avoid timing out on connect await smtp_client.connect(timeout=0.01) @pytest.mark.smtpd_mocks(smtp_STARTTLS=mock_response_delayed_ok) async def test_timeout_on_starttls(smtp_client: SMTP) -> None: await smtp_client.connect() await smtp_client.ehlo() with pytest.raises(SMTPTimeoutError): await smtp_client.starttls(timeout=0.0) async def test_protocol_read_response_with_timeout_times_out( echo_server: asyncio.AbstractServer, hostname: str, echo_server_port: int, ) -> None: event_loop = asyncio.get_running_loop() connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=echo_server_port ) transport, protocol = await asyncio.wait_for(connect_future, timeout=1.0) with pytest.raises(SMTPTimeoutError) as exc: await protocol.read_response(timeout=0.0) # type: ignore transport.close() assert str(exc.value) == "Timed out waiting for server response" async def test_connect_timeout_error(hostname: str, unused_tcp_port: int) -> None: client = SMTP(hostname=hostname, port=unused_tcp_port, timeout=0.0) with pytest.raises(SMTPConnectTimeoutError) as exc: await client.connect() expected_message = f"Timed out connecting to {hostname} on port {unused_tcp_port}" assert str(exc.value) == expected_message async def test_server_disconnected_error_after_connect_timeout( hostname: str, unused_tcp_port: int, sender_str: str, recipient_str: str, message_str: str, ) -> None: client = SMTP(hostname=hostname, port=unused_tcp_port) with pytest.raises(SMTPConnectTimeoutError): await client.connect(timeout=0.0) with pytest.raises(SMTPServerDisconnected): await client.sendmail(sender_str, [recipient_str], message_str) async def test_protocol_timeout_on_starttls( bind_address: str, hostname: str, client_tls_context: ssl.SSLContext, ) -> None: event_loop = asyncio.get_running_loop() async def client_connected( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: await asyncio.sleep(1.0) server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] if server.sockets else 0 connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) _, protocol = await asyncio.wait_for(connect_future, timeout=1.0) with pytest.raises(SMTPTimeoutError): # STARTTLS timeout must be > 0 await protocol.start_tls(client_tls_context, timeout=0.00001) # type: ignore server.close() await cleanup_server(server) async def test_protocol_connection_aborted_on_starttls( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, monkeypatch: pytest.MonkeyPatch, ) -> None: event_loop = asyncio.get_running_loop() connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=smtpd_server_port ) transport, protocol = await asyncio.wait_for(connect_future, timeout=1.0) def mock_start_tls(*args, **kwargs) -> None: raise ConnectionAbortedError("Connection was aborted") monkeypatch.setattr(event_loop, "start_tls", mock_start_tls) with pytest.raises(SMTPTimeoutError): await protocol.start_tls(client_tls_context) transport.close() aiosmtplib-4.0.0/tests/test_tls.py0000644000000000000000000003056113615410400014160 0ustar00""" TLS and STARTTLS handling. """ import copy import ssl import pytest from aiosmtplib import ( SMTP, SMTPConnectError, SMTPException, SMTPResponseException, SMTPServerDisconnected, SMTPStatus, ) from .smtpd import ( mock_response_ehlo_minimal, mock_response_tls_not_available, mock_response_tls_ready_disconnect, mock_response_unrecognized_command, ) @pytest.mark.smtpd_options(tls=True) async def test_tls_connection(smtp_client: SMTP) -> None: """ Use an explicit connect/quit here, as other tests use the context manager. """ await smtp_client.connect(use_tls=True) assert smtp_client.is_connected await smtp_client.quit() assert not smtp_client.is_connected @pytest.mark.smtpd_options(tls=False) async def test_starttls(smtp_client: SMTP) -> None: async with smtp_client: response = await smtp_client.starttls() assert response.code == SMTPStatus.ready # Make sure our state has been cleared assert not smtp_client.esmtp_extensions assert not smtp_client.supported_auth_methods assert not smtp_client.supports_esmtp # Make sure our connection was actually upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" in type(smtp_client.transport).__name__ response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed async def test_starttls_init_kwarg( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=True, tls_context=client_tls_context, timeout=1.0, ) async with smtp_client: # Make sure our connection was actually upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" in type(smtp_client.transport).__name__ @pytest.mark.smtpd_options(tls=False) async def test_starttls_connect_kwarg(smtp_client: SMTP) -> None: await smtp_client.connect(start_tls=True) # Make sure our connection was actually upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" in type(smtp_client.transport).__name__ await smtp_client.quit() async def test_starttls_auto( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=None, tls_context=client_tls_context, timeout=1.0, ) async with smtp_client: # Make sure our connection was actually upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" in type(smtp_client.transport).__name__ async def test_starttls_auto_connect_kwarg( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, tls_context=client_tls_context, timeout=1.0, ) await smtp_client.connect(start_tls=None) # Make sure our connection was actually upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" in type(smtp_client.transport).__name__ await smtp_client.quit() @pytest.mark.smtp_client_options(start_tls=None) @pytest.mark.smtpd_options(tls=False) @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_ehlo_minimal) async def test_starttls_auto_connect_not_supported(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.ehlo() # Make sure our connection was nul upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" not in type(smtp_client.transport).__name__ @pytest.mark.smtpd_options(tls=False) async def test_starttls_with_explicit_server_hostname( smtp_client: SMTP, hostname: str, ) -> None: async with smtp_client: await smtp_client.ehlo() await smtp_client.starttls(server_hostname=hostname) @pytest.mark.smtpd_options(tls=False) @pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_ehlo_minimal) async def test_starttls_not_supported(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.ehlo() with pytest.raises(SMTPException, match="STARTTLS extension not supported"): await smtp_client.starttls() @pytest.mark.smtpd_options(tls=False) @pytest.mark.smtpd_mocks(smtp_STARTTLS=mock_response_tls_not_available) async def test_starttls_advertised_but_not_supported(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.ehlo() with pytest.raises(SMTPException): await smtp_client.starttls() @pytest.mark.smtpd_options(tls=False) @pytest.mark.smtpd_mocks(smtp_STARTTLS=mock_response_tls_ready_disconnect) async def test_starttls_disconnect_before_upgrade(smtp_client: SMTP) -> None: async with smtp_client: with pytest.raises(SMTPServerDisconnected): await smtp_client.starttls() @pytest.mark.smtpd_options(tls=False) @pytest.mark.smtpd_mocks(smtp_STARTTLS=mock_response_unrecognized_command) async def test_starttls_invalid_responses(smtp_client: SMTP) -> None: async with smtp_client: await smtp_client.ehlo() old_extensions = copy.copy(smtp_client.esmtp_extensions) with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.starttls() assert exception_info.value.code == SMTPStatus.unrecognized_command # Make sure our state has been _not_ been cleared assert smtp_client.esmtp_extensions == old_extensions assert smtp_client.supports_esmtp is True # Make sure our connection was not upgraded. ssl protocol transport is # private in UVloop, so just check the class name. assert "SSL" not in type(smtp_client.transport).__name__ @pytest.mark.smtpd_options(tls=False) async def test_starttls_with_client_cert( hostname: str, smtpd_server_port: int, ca_cert_path: str, valid_cert_path: str, valid_key_path: str, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, timeout=1.0 ) async with smtp_client: response = await smtp_client.starttls( client_cert=valid_cert_path, client_key=valid_key_path, cert_bundle=ca_cert_path, ) assert response.code == SMTPStatus.ready assert smtp_client.client_cert == valid_cert_path assert smtp_client.client_key == valid_key_path assert smtp_client.cert_bundle == ca_cert_path @pytest.mark.smtpd_options(tls=False) async def test_starttls_with_invalid_client_cert( hostname: str, smtpd_server_port: int, invalid_cert_path: str, invalid_key_path: str, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, timeout=1.0 ) async with smtp_client: with pytest.raises(ssl.SSLError): await smtp_client.starttls( client_cert=invalid_cert_path, client_key=invalid_key_path, cert_bundle=invalid_cert_path, ) @pytest.mark.smtpd_options(tls=False) async def test_starttls_with_invalid_client_cert_no_validate( hostname: str, smtpd_server_port: int, invalid_cert_path: str, invalid_key_path: str, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, timeout=1.0 ) async with smtp_client: response = await smtp_client.starttls( client_cert=invalid_cert_path, client_key=invalid_key_path, cert_bundle=invalid_cert_path, validate_certs=False, timeout=1.0, ) assert response.code == SMTPStatus.ready assert smtp_client.client_cert == invalid_cert_path assert smtp_client.client_key == invalid_key_path assert smtp_client.cert_bundle == invalid_cert_path @pytest.mark.smtpd_options(tls=False) async def test_starttls_cert_error( hostname: str, smtpd_server_port: int, unknown_client_tls_context: ssl.SSLContext, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, tls_context=unknown_client_tls_context, timeout=1.0, ) async with smtp_client: with pytest.raises(ssl.SSLError): await smtp_client.starttls() @pytest.mark.smtpd_options(tls=False) async def test_starttls_already_upgraded_error( hostname: str, smtpd_server_port: int, client_tls_context: ssl.SSLContext, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, tls_context=client_tls_context, timeout=1.0, ) async with smtp_client: with pytest.raises(SMTPException, match="Connection already using TLS"): await smtp_client.starttls() @pytest.mark.smtpd_options(tls=False) async def test_starttls_cert_no_validate(hostname: str, smtpd_server_port: int) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=False, validate_certs=False, timeout=1.0, ) async with smtp_client: response = await smtp_client.starttls() assert response.code == SMTPStatus.ready @pytest.mark.smtpd_options(tls=True) @pytest.mark.smtp_client_options(use_tls=True) async def test_tls_get_transport_info( smtp_client: SMTP, smtpd_server_port: int ) -> None: async with smtp_client: compression = smtp_client.get_transport_info("compression") assert compression is None # Compression is not used here peername = smtp_client.get_transport_info("peername") assert peername[0] in ("127.0.0.1", "::1") # IP v4 and 6 assert peername[1] == smtpd_server_port sock = smtp_client.get_transport_info("socket") assert sock is not None sockname = smtp_client.get_transport_info("sockname") assert sockname is not None cipher = smtp_client.get_transport_info("cipher") assert cipher is not None peercert = smtp_client.get_transport_info("peercert") assert peercert is not None sslcontext = smtp_client.get_transport_info("sslcontext") assert sslcontext is not None sslobj = smtp_client.get_transport_info("ssl_object") assert sslobj is not None @pytest.mark.smtpd_options(tls=False) async def test_tls_smtp_connect_to_non_tls_server( smtp_client: SMTP, smtpd_server_port: int, ) -> None: with pytest.raises(SMTPConnectError): await smtp_client.connect(use_tls=True, port=smtpd_server_port) assert not smtp_client.is_connected @pytest.mark.smtpd_options(tls=True) async def test_tls_connection_with_existing_sslcontext( smtp_client: SMTP, client_tls_context: ssl.SSLContext, ) -> None: await smtp_client.connect(use_tls=True, tls_context=client_tls_context) assert smtp_client.is_connected assert smtp_client.tls_context is client_tls_context await smtp_client.quit() assert not smtp_client.is_connected @pytest.mark.smtpd_options(tls=True) async def test_tls_connection_with_client_cert( hostname: str, smtpd_server_port: int, ca_cert_path: str, valid_cert_path: str, valid_key_path: str, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, use_tls=True, timeout=1.0 ) await smtp_client.connect( hostname=hostname, client_cert=valid_cert_path, client_key=valid_key_path, cert_bundle=ca_cert_path, ) assert smtp_client.is_connected await smtp_client.quit() assert not smtp_client.is_connected @pytest.mark.smtpd_options(tls=True) async def test_tls_connection_with_cert_error( hostname: str, smtpd_server_port: int, ) -> None: smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, use_tls=True, timeout=1.0 ) with pytest.raises(SMTPConnectError) as exception_info: await smtp_client.connect() assert "CERTIFICATE" in str(exception_info.value).upper() async def test_starttls_when_disconnected() -> None: client = SMTP(timeout=1.0) with pytest.raises(SMTPServerDisconnected): await client.starttls() aiosmtplib-4.0.0/.gitignore0000644000000000000000000000057413615410400012574 0ustar00.bandit .cache/ .coverage .coverage.* .coveralls.yml .eggs/ .hypothesis/ .idea/ .mypy_cache/ .mutmut-cache .pytest_cache/ .python-version .tox/ .tool-versions .venv .vscode/ aiosmtplib.code-workspace aiosmtplib.egg-info/ aiosmtplib.sublime-project aiosmtplib.sublime-workspace build/ coverage.xml coverage/ dist/ docs/_build/ htmlcov/ test-results/ TODO venv/ poetry.lock uv.lock aiosmtplib-4.0.0/LICENSE.txt0000644000000000000000000000206713615410400012426 0ustar00The MIT License (MIT) Copyright (c) 2022 Cole Maclean Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. aiosmtplib-4.0.0/README.rst0000644000000000000000000000444013615410400012267 0ustar00aiosmtplib ========== |circleci| |precommit.ci| |codecov| |zero-deps| |pypi-version| |downloads| |pypi-license| ------------ aiosmtplib is an asynchronous SMTP client for use with asyncio. For documentation, see `Read The Docs`_. Quickstart ---------- .. start quickstart .. code-block:: python import asyncio from email.message import EmailMessage import aiosmtplib message = EmailMessage() message["From"] = "root@localhost" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.set_content("Sent via aiosmtplib") asyncio.run(aiosmtplib.send(message, hostname="127.0.0.1", port=25)) .. end quickstart Requirements ------------ .. start requirements Python 3.9+ is required. .. end requirements Bug Reporting ------------- .. start bug-reporting Bug reports (and feature requests) are welcome via `Github issues`_. .. _Github issues: https://github.com/cole/aiosmtplib/issues .. end bug-reporting .. |circleci| image:: https://circleci.com/gh/cole/aiosmtplib/tree/main.svg?style=shield :target: https://circleci.com/gh/cole/aiosmtplib/tree/main :alt: "aiosmtplib CircleCI build status" .. |pypi-version| image:: https://img.shields.io/pypi/v/aiosmtplib.svg :target: https://pypi.python.org/pypi/aiosmtplib :alt: "aiosmtplib on the Python Package Index" .. |pypi-status| image:: https://img.shields.io/pypi/status/aiosmtplib.svg .. |pypi-license| image:: https://img.shields.io/pypi/l/aiosmtplib.svg .. |codecov| image:: https://codecov.io/gh/cole/aiosmtplib/branch/main/graph/badge.svg :target: https://codecov.io/gh/cole/aiosmtplib .. |downloads| image:: https://static.pepy.tech/badge/aiosmtplib/month :target: https://pepy.tech/project/aiosmtplib :alt: "aiosmtplib on pypy.tech" .. |precommit.ci| image:: https://results.pre-commit.ci/badge/github/cole/aiosmtplib/main.svg :target: https://results.pre-commit.ci/latest/github/cole/aiosmtplib/main :alt: "pre-commit.ci status" .. |zero-deps| image:: https://0dependencies.dev/0dependencies.svg :target: https://0dependencies.dev :alt: "0 dependencies" .. _Read The Docs: https://aiosmtplib.readthedocs.io/en/stable/ aiosmtplib-4.0.0/pyproject.toml0000644000000000000000000000503413615410400013514 0ustar00[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] dynamic = ["version"] name = "aiosmtplib" description = "asyncio SMTP client" authors = [{ name = "Cole Maclean", email = "hi@colemaclean.dev" }] license = { text = "MIT" } readme = "README.rst" requires-python = ">=3.9" keywords = ["smtp", "email", "asyncio"] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Framework :: AsyncIO", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Communications", "Topic :: Communications :: Email", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] [project.urls] Documentation = "https://aiosmtplib.readthedocs.io/en/stable/" Changelog = "https://github.com/cole/aiosmtplib/blob/main/CHANGELOG.rst" GitHub = "https://github.com/cole/aiosmtplib" [project.optional-dependencies] uvloop = ["uvloop>=0.18"] # Docs extra is planned for removal in 4.x docs = [ "sphinx>=7.0.0", "sphinx_autodoc_typehints>=1.24.0", "sphinx-copybutton>=0.5.0", "furo>=2023.9.10", ] [tool.hatch.version] path = "src/aiosmtplib/__init__.py" [tool.pytest.ini_options] pythonpath = "src" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" minversion = "6.0" junit_family = "xunit2" addopts = ["--import-mode=importlib", "--strict-markers"] testpaths = ["tests"] markers = ["smtpd_options", "smtpd_mocks", "smtp_client_options"] [tool.ruff] target-version = "py38" line-length = 88 [tool.ruff.lint] # Enable flake8-bugbear (`B`) rules. select = ["E", "F", "B"] # Never enforce `E501` (line length violations). ignore = ["E501"] # Avoid trying to fix flake8-bugbear (`B`) violations. unfixable = ["B"] [tool.coverage] [tool.coverage.run] source = ["aiosmtplib"] branch = true parallel = true [tool.coverage.report] show_missing = true exclude_lines = [ "pass", "pragma: no cover", "raise NotImplementedError", "if __name__ == .__main__.:", ] [tool.coverage.paths] source = [ "src/aiosmtplib", "/opt/pypy/lib/pypy3.*/site-packages/aiosmtplib", "/usr/local/lib/python3.*/site-packages/aiosmtplib", "/root/project/.venv/lib/python3.*/site-packages/aiosmtplib", ] [tool.pyright] strict = ["src/aiosmtplib"] # exclude all tests except pyright_usage exclude = [ "tests/test_*.py", "tests/auth.py", "tests/compat.py", "tests/conftest.py", "tests/smtpd.py", ] aiosmtplib-4.0.0/PKG-INFO0000644000000000000000000000705013615410400011675 0ustar00Metadata-Version: 2.4 Name: aiosmtplib Version: 4.0.0 Summary: asyncio SMTP client Project-URL: Documentation, https://aiosmtplib.readthedocs.io/en/stable/ Project-URL: Changelog, https://github.com/cole/aiosmtplib/blob/main/CHANGELOG.rst Project-URL: GitHub, https://github.com/cole/aiosmtplib Author-email: Cole Maclean License: MIT License-File: LICENSE.txt Keywords: asyncio,email,smtp Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: No Input/Output (Daemon) Classifier: Framework :: AsyncIO Classifier: Intended Audience :: Developers Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Topic :: Communications Classifier: Topic :: Communications :: Email Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Typing :: Typed Requires-Python: >=3.9 Provides-Extra: docs Requires-Dist: furo>=2023.9.10; extra == 'docs' Requires-Dist: sphinx-autodoc-typehints>=1.24.0; extra == 'docs' Requires-Dist: sphinx-copybutton>=0.5.0; extra == 'docs' Requires-Dist: sphinx>=7.0.0; extra == 'docs' Provides-Extra: uvloop Requires-Dist: uvloop>=0.18; extra == 'uvloop' Description-Content-Type: text/x-rst aiosmtplib ========== |circleci| |precommit.ci| |codecov| |zero-deps| |pypi-version| |downloads| |pypi-license| ------------ aiosmtplib is an asynchronous SMTP client for use with asyncio. For documentation, see `Read The Docs`_. Quickstart ---------- .. start quickstart .. code-block:: python import asyncio from email.message import EmailMessage import aiosmtplib message = EmailMessage() message["From"] = "root@localhost" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.set_content("Sent via aiosmtplib") asyncio.run(aiosmtplib.send(message, hostname="127.0.0.1", port=25)) .. end quickstart Requirements ------------ .. start requirements Python 3.9+ is required. .. end requirements Bug Reporting ------------- .. start bug-reporting Bug reports (and feature requests) are welcome via `Github issues`_. .. _Github issues: https://github.com/cole/aiosmtplib/issues .. end bug-reporting .. |circleci| image:: https://circleci.com/gh/cole/aiosmtplib/tree/main.svg?style=shield :target: https://circleci.com/gh/cole/aiosmtplib/tree/main :alt: "aiosmtplib CircleCI build status" .. |pypi-version| image:: https://img.shields.io/pypi/v/aiosmtplib.svg :target: https://pypi.python.org/pypi/aiosmtplib :alt: "aiosmtplib on the Python Package Index" .. |pypi-status| image:: https://img.shields.io/pypi/status/aiosmtplib.svg .. |pypi-license| image:: https://img.shields.io/pypi/l/aiosmtplib.svg .. |codecov| image:: https://codecov.io/gh/cole/aiosmtplib/branch/main/graph/badge.svg :target: https://codecov.io/gh/cole/aiosmtplib .. |downloads| image:: https://static.pepy.tech/badge/aiosmtplib/month :target: https://pepy.tech/project/aiosmtplib :alt: "aiosmtplib on pypy.tech" .. |precommit.ci| image:: https://results.pre-commit.ci/badge/github/cole/aiosmtplib/main.svg :target: https://results.pre-commit.ci/latest/github/cole/aiosmtplib/main :alt: "pre-commit.ci status" .. |zero-deps| image:: https://0dependencies.dev/0dependencies.svg :target: https://0dependencies.dev :alt: "0 dependencies" .. _Read The Docs: https://aiosmtplib.readthedocs.io/en/stable/