././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/LICENSE.txt0000644000000000000000000000206700000000000013641 0ustar0000000000000000The MIT License (MIT) Copyright (c) 2020 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. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/README.rst0000644000000000000000000000405100000000000013500 0ustar0000000000000000aiosmtplib ========== |circleci| |codecov| |pypi-version| |pypi-python-versions| |pypi-status| |downloads| |pypi-license| |black| ------------ aiosmtplib is an asynchronous SMTP client for use with asyncio. For documentation, see `Read The Docs`_. 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") loop = asyncio.get_event_loop() loop.run_until_complete(aiosmtplib.send(message, hostname="127.0.0.1", port=25)) Requirements ------------ Python 3.5.2+, compiled with SSL support, is required. Bug reporting ------------- Bug reports (and feature requests) are welcome via Github issues. .. |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-python-versions| image:: https://img.shields.io/pypi/pyversions/aiosmtplib.svg .. |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 .. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/ambv/black :alt: "Code style: black" .. |downloads| image:: https://pepy.tech/badge/aiosmtplib :target: https://pepy.tech/project/aiosmtplib :alt: "aiosmtplib on pypy.tech" .. _Read The Docs: https://aiosmtplib.readthedocs.io/en/stable/overview.html ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/__init__.py0000644000000000000000000000236600000000000016274 0ustar0000000000000000""" 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, ) from .response import SMTPResponse from .smtp import SMTP from .status import SMTPStatus __title__ = "aiosmtplib" __version__ = "1.1.6" __author__ = "Cole Maclean" __license__ = "MIT" __copyright__ = "Copyright 2021 Cole Maclean" __all__ = ( "send", "SMTP", "SMTPResponse", "SMTPStatus", "SMTPAuthenticationError", "SMTPConnectError", "SMTPDataError", "SMTPException", "SMTPHeloError", "SMTPNotSupported", "SMTPRecipientRefused", "SMTPRecipientsRefused", "SMTPResponseException", "SMTPSenderRefused", "SMTPServerDisconnected", "SMTPTimeoutError", "SMTPConnectTimeoutError", "SMTPReadTimeoutError", ) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/__main__.py0000644000000000000000000000157400000000000016255 0ustar0000000000000000from aiosmtplib.connection import SMTP_PORT from aiosmtplib.smtp import SMTP raw_hostname = input("SMTP server hostname [localhost]: ") # nosec raw_port = input("SMTP server port [{}]: ".format(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 = [] print("Enter message, end with ^D:") while True: try: lines.append(input()) # nosec except EOFError: break message = "\n".join(lines) print("Message length (bytes): {}".format(len(message.encode("utf-8")))) smtp_client = SMTP(hostname=hostname or "localhost", port=port) sendmail_errors, sendmail_response = smtp_client.sendmail_sync( raw_sender, recipients, message ) print("Server response: {}".format(sendmail_response)) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/api.py0000644000000000000000000003236100000000000015304 0ustar0000000000000000""" Main public API. """ import email.message import os import socket import ssl import sys from typing import Dict, List, Optional, Sequence, Tuple, Union, overload from .response import SMTPResponse from .smtp import SMTP __all__ = ("send",) if sys.version_info >= (3, 7): SocketPathType = Union[str, bytes, os.PathLike] else: SocketPathType = Union[str, bytes] # flake8: noqa F811 # overloaded matrix is split by: # * message type (Message, str/bytes) # * connection type (hostname/socket/socket path) # * cert info (client_cert/tls_context) @overload async def send( message: Union[email.message.EmailMessage, email.message.Message], sender: Optional[str] = ..., recipients: Optional[Union[str, Sequence[str]]] = ..., hostname: str = ..., port: Optional[int] = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: Optional[str] = ..., client_key: Optional[str] = ..., tls_context: None = ..., cert_bundle: Optional[str] = ..., socket_path: None = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[str, bytes], sender: str = ..., recipients: Union[str, Sequence[str]] = ..., hostname: str = ..., port: Optional[int] = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: Optional[str] = ..., client_key: Optional[str] = ..., tls_context: None = ..., cert_bundle: Optional[str] = ..., socket_path: None = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[email.message.EmailMessage, email.message.Message], sender: Optional[str] = ..., recipients: Optional[Union[str, Sequence[str]]] = ..., hostname: str = ..., port: Optional[int] = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: None = ..., client_key: None = ..., tls_context: Optional[ssl.SSLContext] = ..., cert_bundle: None = ..., socket_path: None = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[str, bytes], sender: str = ..., recipients: Union[str, Sequence[str]] = ..., hostname: str = ..., port: Optional[int] = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: None = ..., client_key: None = ..., tls_context: Optional[ssl.SSLContext] = ..., cert_bundle: None = ..., socket_path: None = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[email.message.EmailMessage, email.message.Message], sender: Optional[str] = ..., recipients: Optional[Union[str, Sequence[str]]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: Optional[str] = ..., client_key: Optional[str] = ..., tls_context: None = ..., cert_bundle: Optional[str] = ..., socket_path: None = ..., sock: socket.socket = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[str, bytes], sender: str = ..., recipients: Union[str, Sequence[str]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: Optional[str] = ..., client_key: Optional[str] = ..., tls_context: None = ..., cert_bundle: Optional[str] = ..., socket_path: None = ..., sock: socket.socket = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[email.message.EmailMessage, email.message.Message], sender: Optional[str] = ..., recipients: Optional[Union[str, Sequence[str]]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: None = ..., client_key: None = ..., tls_context: Optional[ssl.SSLContext] = ..., cert_bundle: None = ..., socket_path: None = ..., sock: socket.socket = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[str, bytes], sender: str = ..., recipients: Union[str, Sequence[str]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: None = ..., client_key: None = ..., tls_context: Optional[ssl.SSLContext] = ..., cert_bundle: None = ..., socket_path: None = ..., sock: socket.socket = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[email.message.EmailMessage, email.message.Message], sender: Optional[str] = ..., recipients: Optional[Union[str, Sequence[str]]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: Optional[str] = ..., client_key: Optional[str] = ..., tls_context: None = ..., cert_bundle: Optional[str] = ..., socket_path: SocketPathType = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[str, bytes], sender: str = ..., recipients: Union[str, Sequence[str]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: Optional[str] = ..., client_key: Optional[str] = ..., tls_context: None = ..., cert_bundle: Optional[str] = ..., socket_path: SocketPathType = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[email.message.EmailMessage, email.message.Message], sender: Optional[str] = ..., recipients: Optional[Union[str, Sequence[str]]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: None = ..., client_key: None = ..., tls_context: Optional[ssl.SSLContext] = ..., cert_bundle: None = ..., socket_path: SocketPathType = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... @overload async def send( message: Union[str, bytes], sender: str = ..., recipients: Union[str, Sequence[str]] = ..., hostname: None = ..., port: None = ..., username: Optional[Union[str, bytes]] = ..., password: Optional[Union[str, bytes]] = ..., mail_options: Optional[List[str]] = ..., rcpt_options: Optional[List[str]] = ..., timeout: Optional[float] = ..., source_address: Optional[str] = ..., use_tls: bool = ..., start_tls: bool = ..., validate_certs: bool = ..., client_cert: None = ..., client_key: None = ..., tls_context: Optional[ssl.SSLContext] = ..., cert_bundle: None = ..., socket_path: SocketPathType = ..., sock: None = ..., ) -> Tuple[Dict[str, SMTPResponse], str]: ... async def send(message, sender=None, recipients=None, **kwargs): """ 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 source_address: The hostname of the client. Defaults to the result of :py:func:`socket.getfqdn`. Note that this call blocks. :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. Note that if the server supports STARTTLS only, this should be False. :keyword start_tls: If True, make the initial connection to the server over plaintext, and then upgrade the connection to TLS/SSL. Not compatible 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 or bytes, or a pathlike object in 3.7+. :keyword sock: An existing, connected socket object. If given, none of hostname, port, or socket_path should be provided. :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.") client = SMTP(**kwargs) async with client: if isinstance(message, (email.message.EmailMessage, email.message.Message)): result = await client.send_message( message, sender=sender, recipients=recipients ) else: result = await client.sendmail(sender, recipients, message) return result ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/auth.py0000644000000000000000000001654700000000000015504 0ustar0000000000000000""" Authentication methods. """ import base64 import hmac from typing import List, Optional, Union from .default import Default, _default from .errors import SMTPAuthenticationError, SMTPException from .esmtp import ESMTP from .response import SMTPResponse from .status import SMTPStatus __all__ = ("SMTPAuth", "crammd5_verify") def crammd5_verify(username: bytes, password: bytes, challenge: bytes) -> bytes: decoded_challenge = base64.b64decode(challenge) md5_digest = hmac.new(password, msg=decoded_challenge, digestmod="md5") verification = username + b" " + md5_digest.hexdigest().encode("utf-8") encoded_verification = base64.b64encode(verification) return encoded_verification class SMTPAuth(ESMTP): """ Handles ESMTP Authentication support. CRAM-MD5, PLAIN, and LOGIN auth methods are supported. """ AUTH_METHODS = ("cram-md5", "plain", "login") # Preferred methods first @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] async def login( self, username: Union[str, bytes], password: Union[str, bytes], timeout: Optional[Union[float, Default]] = _default, ) -> 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 = None # type: Optional[SMTPResponse] exception = None # type: Optional[SMTPAuthenticationError] for auth_name in self.supported_auth_methods: method_name = "auth_{}".format(auth_name.replace("-", "")) try: auth_method = getattr(self, method_name) except AttributeError: raise RuntimeError( "Missing handler for auth method {}".format(auth_name) ) 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, Default]] = _default, ) -> 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 ) if isinstance(password, bytes): password_bytes = password else: password_bytes = password.encode("utf-8") if isinstance(username, bytes): username_bytes = username else: username_bytes = username.encode("utf-8") response_bytes = initial_response.message.encode("utf-8") verification_bytes = crammd5_verify( username_bytes, password_bytes, response_bytes ) 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, Default]] = _default, ) -> 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) """ if isinstance(password, bytes): password_bytes = password else: password_bytes = password.encode("utf-8") if isinstance(username, bytes): username_bytes = username else: username_bytes = username.encode("utf-8") username_and_password = b"\0" + username_bytes + b"\0" + password_bytes encoded = base64.b64encode(username_and_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, Default]] = _default, ) -> 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. """ if isinstance(password, bytes): password_bytes = password else: password_bytes = password.encode("utf-8") if isinstance(username, bytes): username_bytes = username else: username_bytes = username.encode("utf-8") encoded_username = base64.b64encode(username_bytes) encoded_password = base64.b64encode(password_bytes) 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 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/compat.py0000644000000000000000000000534100000000000016014 0ustar0000000000000000""" asyncio compatibility shims. """ import asyncio import ssl import sys from asyncio.sslproto import SSLProtocol # type: ignore from typing import Optional, Union __all__ = ( "PY36_OR_LATER", "PY37_OR_LATER", "all_tasks", "get_running_loop", "start_tls", ) PY36_OR_LATER = sys.version_info[:2] >= (3, 6) PY37_OR_LATER = sys.version_info[:2] >= (3, 7) def get_running_loop() -> asyncio.AbstractEventLoop: if PY37_OR_LATER: return asyncio.get_running_loop() loop = asyncio.get_event_loop() if not loop.is_running(): raise RuntimeError("no running event loop") return loop def all_tasks(loop: asyncio.AbstractEventLoop = None): if PY37_OR_LATER: return asyncio.all_tasks(loop=loop) return asyncio.Task.all_tasks(loop=loop) # type: ignore async def start_tls( loop: asyncio.AbstractEventLoop, transport: asyncio.Transport, protocol: asyncio.Protocol, sslcontext: ssl.SSLContext, server_side: bool = False, server_hostname: Optional[str] = None, ssl_handshake_timeout: Optional[Union[float, int]] = None, ) -> asyncio.Transport: # We use hasattr here, as uvloop also supports start_tls. if hasattr(loop, "start_tls"): return await loop.start_tls( # type: ignore transport, protocol, sslcontext, server_side=server_side, server_hostname=server_hostname, ssl_handshake_timeout=ssl_handshake_timeout, ) waiter = loop.create_future() ssl_protocol = SSLProtocol( loop, protocol, sslcontext, waiter, server_side, server_hostname ) # Pause early so that "ssl_protocol.data_received()" doesn't # have a chance to get called before "ssl_protocol.connection_made()". transport.pause_reading() # Use set_protocol if we can if hasattr(transport, "set_protocol"): transport.set_protocol(ssl_protocol) else: transport._protocol = ssl_protocol # type: ignore conmade_cb = loop.call_soon(ssl_protocol.connection_made, transport) resume_cb = loop.call_soon(transport.resume_reading) try: await asyncio.wait_for(waiter, timeout=ssl_handshake_timeout) except Exception: transport.close() conmade_cb.cancel() resume_cb.cancel() raise return ssl_protocol._app_transport def create_connection(loop: asyncio.AbstractEventLoop, *args, **kwargs): if not PY37_OR_LATER: kwargs.pop("ssl_handshake_timeout") return loop.create_connection(*args, **kwargs) def create_unix_connection(loop: asyncio.AbstractEventLoop, *args, **kwargs): if not PY37_OR_LATER: kwargs.pop("ssl_handshake_timeout") return loop.create_unix_connection(*args, **kwargs) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/connection.py0000644000000000000000000004571400000000000016700 0ustar0000000000000000""" Handles client connection/disconnection. """ import asyncio import os import socket import ssl import sys import warnings from typing import Any, Optional, Type, Union from .compat import create_connection, create_unix_connection, get_running_loop from .default import Default, _default from .errors import ( SMTPConnectError, SMTPConnectTimeoutError, SMTPResponseException, SMTPServerDisconnected, SMTPTimeoutError, ) from .protocol import SMTPProtocol from .response import SMTPResponse from .status import SMTPStatus __all__ = ("SMTPConnection",) SMTP_PORT = 25 SMTP_TLS_PORT = 465 SMTP_STARTTLS_PORT = 587 DEFAULT_TIMEOUT = 60 # Mypy special cases sys.version checks if sys.version_info >= (3, 7): SocketPathType = Union[str, bytes, os.PathLike] else: SocketPathType = Union[str, bytes] class SMTPConnection: """ Handles connection/disconnection from the SMTP server provided. Keyword arguments can be provided either on :meth:`__init__` or when calling the :meth:`connect` method. Note that in both cases these options are saved for later use; subsequent calls to :meth:`connect` will use the same options, unless new ones are provided. """ def __init__( self, hostname: Optional[str] = "localhost", port: Optional[int] = None, username: Optional[Union[str, bytes]] = None, password: Optional[Union[str, bytes]] = None, source_address: Optional[str] = None, timeout: Optional[float] = DEFAULT_TIMEOUT, loop: Optional[asyncio.AbstractEventLoop] = None, use_tls: bool = False, start_tls: bool = False, 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 source_address: The hostname of the client. Defaults to the result of :func:`socket.getfqdn`. Note that this call blocks. :keyword timeout: Default timeout value for the connection, in seconds. Defaults to 60. :keyword loop: event loop to run on. If no loop is passed, the running loop will be used. This option is deprecated, and will be removed in future. :keyword use_tls: If True, make the _initial_ connection to the server over TLS/SSL. Note that if the server supports STARTTLS only, this should be False. :keyword start_tls: If True, make the initial connection to the server over plaintext, and then upgrade the connection to TLS/SSL. Not compatible 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 verification. :keyword client_key: Path to client side key, for TLS verification. :keyword tls_context: An existing :py:class:`ssl.SSLContext`, for TLS verification. 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 or bytes, or a pathlike object in 3.7+. :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 = None # type: Optional[SMTPProtocol] self.transport = None # type: Optional[asyncio.BaseTransport] # Kwarg defaults are provided here, and saved for connect. self.hostname = hostname self.port = port self._login_username = username self._login_password = password self.timeout = timeout self.use_tls = use_tls self._start_tls_on_connect = start_tls self._source_address = source_address 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 if loop: warnings.warn( "Passing an event loop via the loop keyword argument is deprecated. " "It will be removed in version 2.0.", DeprecationWarning, stacklevel=4, ) self.loop = loop self._connect_lock = None # type: Optional[asyncio.Lock] self._validate_config() async def __aenter__(self) -> "SMTPConnection": if not self.is_connected: await self.connect() return self async def __aexit__( self, exc_type: Type[Exception], exc: Exception, 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 source_address(self) -> str: """ Get the system hostname to be sent to the SMTP server. Simply caches the result of :func:`socket.getfqdn`. """ if self._source_address is None: self._source_address = socket.getfqdn() return self._source_address def _update_settings_from_kwargs( self, hostname: Optional[Union[str, Default]] = _default, port: Optional[Union[int, Default]] = _default, username: Optional[Union[str, bytes, Default]] = _default, password: Optional[Union[str, bytes, Default]] = _default, source_address: Optional[Union[str, Default]] = _default, timeout: Optional[Union[float, Default]] = _default, loop: Optional[Union[asyncio.AbstractEventLoop, Default]] = _default, use_tls: Optional[bool] = None, start_tls: Optional[bool] = None, validate_certs: Optional[bool] = None, client_cert: Optional[Union[str, Default]] = _default, client_key: Optional[Union[str, Default]] = _default, tls_context: Optional[Union[ssl.SSLContext, Default]] = _default, cert_bundle: Optional[Union[str, Default]] = _default, socket_path: Optional[Union[SocketPathType, Default]] = _default, sock: Optional[Union[socket.socket, Default]] = _default, ) -> None: """Update our configuration from the kwargs provided. This method can be called multiple times. """ if hostname is not _default: self.hostname = hostname if loop is not _default: if loop is not None: warnings.warn( "Passing an event loop via the loop keyword argument is deprecated." " It will be removed in version 2.0.", DeprecationWarning, stacklevel=3, ) self.loop = loop if use_tls is not None: self.use_tls = use_tls if start_tls is not None: self._start_tls_on_connect = start_tls if validate_certs is not None: self.validate_certs = validate_certs if port is not _default: self.port = port if username is not _default: self._login_username = username if password is not _default: self._login_password = password if timeout is not _default: self.timeout = timeout if source_address is not _default: self._source_address = source_address if client_cert is not _default: self.client_cert = client_cert if client_key is not _default: self.client_key = client_key if tls_context is not _default: self.tls_context = tls_context if cert_bundle is not _default: self.cert_bundle = cert_bundle if socket_path is not _default: self.socket_path = socket_path if sock is not _default: 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 any([self.hostname, self.port, self.socket_path]): raise ValueError( "The socket option is not compatible with hostname, port or socket_path" ) if self.socket_path is not None and any([self.hostname, self.port]): raise ValueError( "The socket_path option is not compatible with hostname/port" ) async def connect(self, **kwargs) -> 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 source_address: The hostname of the client. Defaults to the result of :func:`socket.getfqdn`. Note that this call blocks. :keyword timeout: Default timeout value for the connection, in seconds. Defaults to 60. :keyword loop: event loop to run on. If no loop is passed, the running loop will be used. This option is deprecated, and will be removed in future. :keyword use_tls: If True, make the initial connection to the server over TLS/SSL. Note that if the server supports STARTTLS only, this should be False. :keyword start_tls: If True, make the initial connection to the server over plaintext, and then upgrade the connection to TLS/SSL. Not compatible 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 or bytes, or a pathlike object in 3.7+. :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._update_settings_from_kwargs(**kwargs) self._validate_config() if self.loop is None: self.loop = get_running_loop() if self._connect_lock is None: self._connect_lock = asyncio.Lock() await self._connect_lock.acquire() # Set default port last in case use_tls or start_tls is provided, # and only if we're not using a socket. if self.port is None and self.sock is None and self.socket_path is None: if self.use_tls: self.port = SMTP_TLS_PORT elif self._start_tls_on_connect: self.port = SMTP_STARTTLS_PORT else: self.port = SMTP_PORT try: response = await self._create_connection() except Exception as exc: self.close() # Reset our state to disconnected raise exc if self._start_tls_on_connect: await self.starttls() if self._login_username is not None: password = self._login_password if self._login_password is not None else "" await self.login(self._login_username, password) return response async def _create_connection(self) -> SMTPResponse: if self.loop is None: raise RuntimeError("No event loop set") protocol = SMTPProtocol( loop=self.loop, connection_lost_callback=self._connection_lost ) tls_context = None # type: Optional[ssl.SSLContext] ssl_handshake_timeout = None # type: Optional[float] if self.use_tls: tls_context = self._get_tls_context() ssl_handshake_timeout = self.timeout if self.sock: connect_coro = create_connection( self.loop, lambda: protocol, sock=self.sock, ssl=tls_context, ssl_handshake_timeout=ssl_handshake_timeout, ) elif self.socket_path: connect_coro = create_unix_connection( self.loop, lambda: protocol, path=self.socket_path, ssl=tls_context, ssl_handshake_timeout=ssl_handshake_timeout, ) else: connect_coro = create_connection( self.loop, lambda: protocol, host=self.hostname, port=self.port, ssl=tls_context, ssl_handshake_timeout=ssl_handshake_timeout, ) try: transport, _ = await asyncio.wait_for(connect_coro, timeout=self.timeout) except OSError as exc: raise SMTPConnectError( "Error connecting to {host} on port {port}: {err}".format( host=self.hostname, port=self.port, err=exc ) ) from exc except asyncio.TimeoutError as exc: raise SMTPConnectTimeoutError( "Timed out connecting to {host} on port {port}".format( host=self.hostname, port=self.port ) ) from exc self.protocol = protocol self.transport = transport try: response = await protocol.read_response(timeout=self.timeout) except SMTPServerDisconnected as exc: raise SMTPConnectError( "Error connecting to {host} on port {port}: {err}".format( host=self.hostname, port=self.port, err=exc ) ) from exc except SMTPTimeoutError as exc: raise SMTPConnectTimeoutError( "Timed out waiting for server ready message" ) from exc if response.code != SMTPStatus.ready: raise SMTPConnectError(str(response)) return response def _connection_lost(self, waiter: asyncio.Future) -> None: if waiter.cancelled() or waiter.exception() is not None: self.close() async def execute_command( self, *args: bytes, timeout: Optional[Union[float, Default]] = _default ) -> 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") if timeout is _default: timeout = self.timeout response = await self.protocol.execute_command(*args, timeout=timeout) # If the server is unavailable, be nice and close the connection if response.code == SMTPStatus.domain_unavailable: self.close() return response async def quit( self, timeout: Optional[Union[float, Default]] = _default ) -> SMTPResponse: raise NotImplementedError async def login( self, username: Union[str, bytes], password: Union[str, bytes], timeout: Optional[Union[float, Default]] = _default, ) -> SMTPResponse: raise NotImplementedError async def starttls( self, server_hostname: Optional[str] = None, validate_certs: Optional[bool] = None, client_cert: Optional[Union[str, Default]] = _default, client_key: Optional[Union[str, Default]] = _default, cert_bundle: Optional[Union[str, Default]] = _default, tls_context: Optional[Union[ssl.SSLContext, Default]] = _default, timeout: Optional[Union[float, Default]] = _default, ) -> SMTPResponse: raise NotImplementedError 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 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 self.transport is None: raise SMTPServerDisconnected("Server not connected") return self.transport.get_extra_info(key) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/default.py0000644000000000000000000000030300000000000016146 0ustar0000000000000000""" A default enum, used for kwarg default values. """ import enum class Default(enum.Enum): """ Used for type hinting kwarg defaults. """ token = 0 _default = Default.token ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/email.py0000644000000000000000000001243100000000000015616 0ustar0000000000000000""" 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 typing import List, Optional, Tuple, Union __all__ = ( "extract_recipients", "extract_sender", "flatten_message", "parse_address", "quote_address", ) LINE_SEP = "\r\n" 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. """ display_name, 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 "<{}>".format(parsed_address) def formataddr(pair: Tuple[str, str]) -> str: """ Copied from the standard library, and modified to handle international (UTF-8) email addresses. The inverse of parseaddr(), this takes a 2-tuple of the form (realname, email_address) and returns the string value suitable for an RFC 2822 From, To or Cc header. If the first element of pair is false, then the second element is returned unmodified. """ name, address = pair if name: encoded_name = UTF8_CHARSET.header_encode(name) return "{} <{}>".format(encoded_name, address) else: quotes = "" if SPECIALS_REGEX.search(name): quotes = '"' name = ESCAPES_REGEX.sub(r"\\\g<0>", name) return "{}{}{} <{}>".format(quotes, name, quotes, address) return 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"] if isinstance(message.policy, email.policy.Compat32): # type: ignore # Compat32 cannot use UTF8 policy = message.policy.clone( # type: ignore linesep=LINE_SEP, cte_type=cte_type ) else: policy = message.policy.clone( # type: ignore linesep=LINE_SEP, utf8=utf8, cte_type=cte_type ) with io.BytesIO() as messageio: generator = email.generator.BytesGenerator(messageio, policy=policy) 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 = [] if isinstance(header, email.headerregistry.AddressHeader): for address in header.addresses: # If the object has been assigned an iterable, it's possible to get # a string here 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): if charset is None: charset = "ascii" addresses.append(parse_address(str(address_bytes, encoding=charset))) 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 = [] # type: 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 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/errors.py0000644000000000000000000000564400000000000016053 0ustar0000000000000000from asyncio import TimeoutError from typing import List __all__ = ( "SMTPAuthenticationError", "SMTPConnectError", "SMTPDataError", "SMTPException", "SMTPHeloError", "SMTPNotSupported", "SMTPRecipientRefused", "SMTPRecipientsRefused", "SMTPResponseException", "SMTPSenderRefused", "SMTPServerDisconnected", "SMTPTimeoutError", "SMTPConnectTimeoutError", "SMTPReadTimeoutError", ) 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 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,) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/esmtp.py0000644000000000000000000004215400000000000015664 0ustar0000000000000000""" Low level ESMTP command API. """ import re import ssl from typing import Dict, Iterable, List, Optional, Tuple, Union from .connection import SMTPConnection from .default import Default, _default from .email import parse_address, quote_address from .errors import ( SMTPException, SMTPHeloError, SMTPNotSupported, SMTPRecipientRefused, SMTPResponseException, SMTPSenderRefused, SMTPServerDisconnected, ) from .response import SMTPResponse from .status import SMTPStatus __all__ = ("ESMTP",) 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\-]*) ?") class ESMTP(SMTPConnection): """ Handles individual SMTP and ESMTP commands. """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.last_helo_response = None # type: Optional[SMTPResponse] self._last_ehlo_response = None # type: Optional[SMTPResponse] self.esmtp_extensions = {} # type: Dict[str, str] self.supports_esmtp = False self.server_auth_methods = [] # type: List[str] @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 def close(self) -> None: """ Makes sure we reset ESMTP state on close. """ super().close() self._reset_server_state() # Base SMTP commands # async def helo( self, hostname: str = None, timeout: Optional[Union[float, Default]] = _default ) -> 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: hostname = self.source_address response = await self.execute_command( b"HELO", hostname.encode("ascii"), timeout=timeout ) self.last_helo_response = response if response.code != SMTPStatus.completed: raise SMTPHeloError(response.code, response.message) return response async def help(self, timeout: Optional[Union[float, Default]] = _default) -> 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, Default]] = _default ) -> 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, Default]] = _default ) -> 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, Default]] = _default, ) -> 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 """ if options is None: options = [] await self._ehlo_or_helo_if_needed() 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, Default]] = _default, ) -> SMTPResponse: """ Send an SMTP EXPN command, which expands a mailing list. Not many servers support this command. :raises SMTPResponseException: on unexpected server response code """ if options is None: options = [] await self._ehlo_or_helo_if_needed() 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, Default]] = _default ) -> 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 """ # Can't quit without HELO/EHLO await self._ehlo_or_helo_if_needed() 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, Default]] = _default, ) -> 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 """ if options is None: options = [] await self._ehlo_or_helo_if_needed() 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, Default]] = _default, ) -> 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 """ if options is None: options = [] await self._ehlo_or_helo_if_needed() 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, Default]] = _default, ) -> 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 """ await self._ehlo_or_helo_if_needed() # As data accesses protocol directly, some handling is required if self.protocol is None: raise SMTPServerDisconnected("Connection lost") if timeout is _default: timeout = self.timeout if isinstance(message, str): message = message.encode("ascii") return await self.protocol.execute_data_command(message, timeout=timeout) # ESMTP commands # async def ehlo( self, hostname: Optional[str] = None, timeout: Optional[Union[float, Default]] = _default, ) -> 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: hostname = self.source_address 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, Default]] = _default, client_key: Optional[Union[str, Default]] = _default, cert_bundle: Optional[Union[str, Default]] = _default, tls_context: Optional[Union[ssl.SSLContext, Default]] = _default, timeout: Optional[Union[float, Default]] = _default, ) -> 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 """ await self._ehlo_or_helo_if_needed() if self.protocol is None: raise SMTPServerDisconnected("Server not connected") 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, timeout=timeout, ) self._validate_config() if server_hostname is None: server_hostname = self.hostname tls_context = self._get_tls_context() if not self.supports_extension("starttls"): raise SMTPException("SMTP STARTTLS extension not supported by server.") response = await self.protocol.start_tls( tls_context, server_hostname=server_hostname, timeout=self.timeout ) if self.protocol is None: raise SMTPServerDisconnected("Connection lost") # 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 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 = {} auth_types = [] # type: 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 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/protocol.py0000644000000000000000000003155700000000000016402 0ustar0000000000000000""" An ``asyncio.Protocol`` subclass for lower level IO handling. """ import asyncio import re import ssl from typing import Callable, Optional, cast from .compat import start_tls from .errors import ( SMTPDataError, SMTPReadTimeoutError, SMTPResponseException, SMTPServerDisconnected, SMTPTimeoutError, ) from .response import SMTPResponse from .status 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_waiter = None # type: Optional[asyncio.Future[None]] self._connection_lost = False def pause_writing(self) -> None: self._paused = True def resume_writing(self) -> None: self._paused = False waiter = self._drain_waiter if waiter is not None: self._drain_waiter = None if not waiter.done(): waiter.set_result(None) def connection_lost(self, exc) -> None: self._connection_lost = True # Wake up the writer if currently paused. if not self._paused: return waiter = self._drain_waiter if waiter is None: return self._drain_waiter = None if waiter.done(): return 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._drain_waiter waiter = self._loop.create_future() self._drain_waiter = waiter await waiter def _get_close_waiter(self, stream: asyncio.StreamWriter) -> asyncio.Future: raise NotImplementedError class SMTPProtocol(FlowControlMixin, asyncio.Protocol): def __init__( self, loop: Optional[asyncio.AbstractEventLoop] = None, connection_lost_callback: Optional[Callable] = None, ) -> None: super().__init__(loop=loop) self._over_ssl = False self._buffer = bytearray() self._response_waiter = None # type: Optional[asyncio.Future[SMTPResponse]] self._connection_lost_callback = connection_lost_callback self._connection_lost_waiter = None # type: Optional[asyncio.Future[None]] self.transport = None # type: Optional[asyncio.Transport] self._command_lock = None # type: Optional[asyncio.Lock] self._closed = self._loop.create_future() # type: asyncio.Future[None] def __del__(self): waiters = (self._response_waiter, self._connection_lost_waiter) for waiter in filter(None, waiters): if waiter.done() and not waiter.cancelled(): # Avoid 'Future exception was never retrieved' warnings waiter.exception() def _get_close_waiter(self, stream: asyncio.StreamWriter) -> asyncio.Future: return self._closed @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() if self._connection_lost_callback is not None: self._connection_lost_waiter = self._loop.create_future() self._connection_lost_waiter.add_done_callback( self._connection_lost_callback ) def connection_lost(self, exc: Optional[Exception]) -> None: super().connection_lost(exc) 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) if self._connection_lost_waiter and not self._connection_lost_waiter.done(): if exc: self._connection_lost_waiter.set_exception(smtp_exc) else: self._connection_lost_waiter.set_result(None) self.transport = None self._command_lock = None def data_received(self, data: bytes) -> None: if self._response_waiter is None: raise RuntimeError( "data_received called without a response waiter set: {!r}".format(data) ) 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) if self._connection_lost_waiter and not self._connection_lost_waiter.done(): self._connection_lost_waiter.set_exception(exc) # Returning false closes the transport return False 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: raise SMTPResponseException( SMTPStatus.invalid_response.value, "Malformed SMTP response line: {!r}".format(line), ) 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 ) # type: SMTPResponse except 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") self.transport.write(data) 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) 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 start_tls( self._loop, self.transport, self, tls_context, server_side=False, server_hostname=server_hostname, ssl_handshake_timeout=timeout, ) except 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(exc.args[0]) from exc except ConnectionResetError as exc: if exc.args: message = exc.args[0] else: message = "Connection was reset while upgrading transport" raise SMTPServerDisconnected(message) from exc self.transport = tls_transport return response ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/py.typed0000644000000000000000000000014100000000000015647 0ustar0000000000000000This file exists to help mypy (and other tools) find inline type hints. See PR #141 and PEP 561. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/response.py0000644000000000000000000000140700000000000016366 0ustar0000000000000000""" SMTPResponse class, a simple namedtuple of (code, message). """ from typing import NamedTuple __all__ = ("SMTPResponse",) BaseResponse = NamedTuple("BaseResponse", [("code", int), ("message", str)]) class SMTPResponse(BaseResponse): """ 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 """ __slots__ = () def __repr__(self) -> str: return "({self.code}, {self.message})".format(self=self) def __str__(self) -> str: return "{self.code} {self.message}".format(self=self) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/smtp.py0000644000000000000000000003022500000000000015513 0ustar0000000000000000""" Main SMTP client class. Implementation is split into the following parent classes: * :class:`.auth.SMTPAuth` - login and authentication methods * :class:`.esmtp.ESMTP` - ESMTP command support * :class:`.connection.SMTPConnection` - connection handling """ import asyncio import email.message from typing import Dict, Iterable, Optional, Sequence, Tuple, Union from .auth import SMTPAuth from .connection import SMTPConnection from .default import Default, _default from .email import extract_recipients, extract_sender, flatten_message from .errors import ( SMTPNotSupported, SMTPRecipientRefused, SMTPRecipientsRefused, SMTPResponseException, ) from .response import SMTPResponse from .sync import async_to_sync __all__ = ("SMTP",) class SMTP(SMTPAuth): """ Main SMTP client class. Basic usage: >>> loop = asyncio.get_event_loop() >>> smtp = aiosmtplib.SMTP(hostname="127.0.0.1", port=1025) >>> loop.run_until_complete(smtp.connect()) (220, ...) >>> sender = "root@localhost" >>> recipients = ["somebody@localhost"] >>> message = "Hello World" >>> send = smtp.sendmail(sender, recipients, "Hello World") >>> loop.run_until_complete(send) ({}, 'OK') """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._sendmail_lock = None # type: Optional[asyncio.Lock] # Hack to make Sphinx find the SMTPConnection docstring __init__.__doc__ = SMTPConnection.__init__.__doc__ 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, Default]] = _default, ) -> 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: >>> loop = asyncio.get_event_loop() >>> smtp = aiosmtplib.SMTP(hostname="127.0.0.1", port=1025) >>> loop.run_until_complete(smtp.connect()) (220, ...) >>> recipients = ["one@one.org", "two@two.org", "3@three.org"] >>> message = "From: Me@my.org\\nSubject: testing\\nHello World" >>> send_coro = smtp.sendmail("me@my.org", recipients, message) >>> loop.run_until_complete(send_coro) ({}, 'OK') >>> loop.run_until_complete(smtp.quit()) (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"): size_option = "size={}".format(len(message)) 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, Default]] = _default, ) -> Dict[str, SMTPResponse]: """ Send the recipients given to the server. Used as part of :meth:`.sendmail`. """ recipient_errors = [] 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, Default]] = _default, ) -> 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, **kwargs) -> Tuple[Dict[str, SMTPResponse], str]: """ Synchronous version of :meth:`.sendmail`. This method starts the event loop to connect, send the message, and disconnect. """ async def sendmail_coroutine(): async with self: result = await self.sendmail(*args, **kwargs) return result return async_to_sync(sendmail_coroutine(), loop=self.loop) def send_message_sync(self, *args, **kwargs) -> Tuple[Dict[str, SMTPResponse], str]: """ Synchronous version of :meth:`.send_message`. This method starts the event loop to connect, send the message, and disconnect. """ async def send_message_coroutine(): async with self: result = await self.send_message(*args, **kwargs) return result return async_to_sync(send_message_coroutine(), loop=self.loop) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/status.py0000644000000000000000000000206600000000000016055 0ustar0000000000000000""" SMTP status codes. """ import enum __all__ = ("SMTPStatus",) @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 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/aiosmtplib/sync.py0000644000000000000000000000264100000000000015505 0ustar0000000000000000""" Synchronous execution helpers. """ import asyncio from typing import Any, Awaitable, Optional from .compat import PY36_OR_LATER, all_tasks __all__ = ("async_to_sync", "shutdown_loop") def async_to_sync( coro: Awaitable, loop: Optional[asyncio.AbstractEventLoop] = None ) -> Any: if loop is None: loop = asyncio.get_event_loop() if loop.is_running(): raise RuntimeError("Event loop is already running.") result = loop.create_future() try: loop.run_until_complete(_await_with_future(coro, result)) finally: shutdown_loop(loop) return result.result() async def _await_with_future(coro: Awaitable, future: asyncio.Future) -> None: try: result = await coro except Exception as exc: future.set_exception(exc) else: future.set_result(result) def shutdown_loop(loop: asyncio.AbstractEventLoop, timeout: float = 1.0) -> None: """ Do the various dances to gently shutdown an event loop. """ tasks = all_tasks(loop=loop) if tasks: for task in tasks: task.cancel() try: loop.run_until_complete(asyncio.wait(tasks, timeout=timeout)) except RuntimeError: pass if not loop.is_closed(): if PY36_OR_LATER: loop.run_until_complete(loop.shutdown_asyncgens()) loop.call_soon(loop.stop) loop.run_forever() loop.close() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/Makefile0000644000000000000000000000114000000000000014375 0ustar0000000000000000# 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) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/changelog.rst0000644000000000000000000000003600000000000015421 0ustar0000000000000000.. include:: ../CHANGELOG.rst ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/client.rst0000644000000000000000000001365700000000000014765 0ustar0000000000000000.. module:: aiosmtplib :noindex: 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() event_loop = asyncio.get_event_loop() event_loop.run_until_complete(client.connect(hostname="127.0.0.1", port=1025)) Connecting over TLS/SSL ~~~~~~~~~~~~~~~~~~~~~~~ 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 ~~~~~~~~~~~~~~~~~~~~ Many SMTP servers support the STARTTLS extension over port 587. When using STARTTLS, the initial connection is made over plaintext, and after connecting a STARTTLS command is sent which initiates the upgrade to a secure connection. To connect to a server that uses STARTTLS, set ``use_tls`` to ``False`` when connecting, and call :meth:`SMTP.starttls` on the client. .. code-block:: python smtp_client = aiosmtplib.SMTP(hostname="smtp.gmail.com", port=587, 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) event_loop = asyncio.get_event_loop() event_loop.run_until_complete(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.Message` 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() event_loop = asyncio.get_event_loop() event_loop.run_until_complete(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")) smtp_client = SMTP(hostname="127.0.0.1", port=1025) event_loop.run_until_complete(smtp_client.connect()) event_loop.run_until_complete(smtp_client.send_message(message)) event_loop.run_until_complete(smtp_client.quit()) :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() event_loop = asyncio.get_event_loop() event_loop.run_until_complete(send_with_sendmail()) Timeouts -------- All commands accept a ``timeout`` keyword argument of a numerical value in seconds. This value is used for all socket operations, and will raise :exc:`.SMTPTimeoutError` if exceeded. Timeout values passed to :func:`send`, :meth:`SMTP.__init__` or :meth:`SMTP.connect` will be used as the default value for commands executed on the connection. The default timeout is 60 seconds. 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. ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/conf.py0000644000000000000000000001404200000000000014241 0ustar0000000000000000#!/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 from typing import Dict, List 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", ] # 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 = "{year}, {author}".format(year=year, author=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("../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 = None # 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 = "alabaster" # 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 = { "show_powered_by": False, "github_user": "cole", "github_repo": "aiosmtplib", "github_banner": False, "show_related": False, } # 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 = [] # type: List[str] # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = "aiosmtplibdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # 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', } # type: Dict[str, str] # 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.8", None)} html_sidebars = { "**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"] } nitpick_ignore = [ ("py:class", "typing.Tuple"), ("py:class", "concurrent.futures._base.TimeoutError"), ("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() """ ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/index.rst0000644000000000000000000000034300000000000014602 0ustar0000000000000000.. module:: aiosmtplib aiosmtplib ========== .. toctree:: :maxdepth: 2 overview usage client reference changelog Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/overview.rst0000644000000000000000000000005700000000000015343 0ustar0000000000000000.. include:: ../README.rst :start-line: 12 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/reference.rst0000644000000000000000000000117000000000000015430 0ustar0000000000000000API Reference ============= .. testsetup:: import aiosmtplib from aiosmtplib import SMTPResponse The send Coroutine ------------------ .. autofunction:: aiosmtplib.send The SMTP Class -------------- .. autoclass:: aiosmtplib.SMTP :members: :inherited-members: .. automethod:: aiosmtplib.SMTP.__init__ Server Responses ---------------- .. autoclass:: aiosmtplib.response.SMTPResponse :members: Status Codes ------------ .. autoclass:: aiosmtplib.status.SMTPStatus :members: :undoc-members: Exceptions ---------- .. automodule:: aiosmtplib.errors :members: :show-inheritance: ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/docs/usage.rst0000644000000000000000000000744600000000000014612 0ustar0000000000000000.. module:: aiosmtplib :noindex: User's Guide ============ Sending Messages ---------------- Sending Message Objects ~~~~~~~~~~~~~~~~~~~~~~~ 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 `_. .. note:: 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) event_loop = asyncio.get_event_loop() event_loop.run_until_complete(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. .. 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 ) event_loop = asyncio.get_event_loop() event_loop.run_until_complete(send_hello_world()) Authentication -------------- To authenticate, pass the ``username`` and ``password`` keyword arguments to :func:`send`. .. code-block:: python await send( message, hostname="127.0.0.1", port=1025, username="test", password="test" ) Connection Options ------------------ Connecting Over TLS/SSL ~~~~~~~~~~~~~~~~~~~~~~~ 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 ~~~~~~~~~~~~~~~~~~~~ Many SMTP servers support the STARTTLS extension over port 587. When using STARTTLS, the initial connection is made over plaintext, and after connecting a STARTTLS command is sent, which initiates the upgrade to a secure connection. To connect to a server that uses STARTTLS, set ``start_tls`` to ``True``. .. code-block:: python await send(message, hostname="smtp.gmail.com", port=587, start_tls=True) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/pyproject.toml0000644000000000000000000000317100000000000014727 0ustar0000000000000000[build-system] requires = ["poetry>=1.0.0"] build-backend = "poetry.masonry.api" [tool.poetry] name = "aiosmtplib" version = "1.1.6" description = "asyncio SMTP client" authors = ["Cole Maclean "] license = "MIT" packages = [ { include = "aiosmtplib" }, { include = "tests", format = "sdist" }, { include = "docs", format = "sdist" }, ] readme = "README.rst" repository = "https://github.com/cole/aiosmtplib" documentation = "https://aiosmtplib.readthedocs.io/en/stable/" 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", ] exclude = [ "docs/requirements.txt", ] include = [ # We should include the changelog, but that also puts it in the wheel. # "CHANGELOG.rst" ] [tool.poetry.dependencies] python = "^3.5.2" uvloop = { version = ">=0.13,<0.15", optional = true } sphinx = { version = ">=2,<4", optional = true } sphinx_autodoc_typehints = { version = "^1.7.0", optional = true } [tool.poetry.dev-dependencies] pytest = "^5.4.3" pytest-asyncio = "^0.12.0" pytest-cov = "^2.9.0" coverage = "^5.1" hypothesis = "~4.57" aiosmtpd = "1.2" pytest-xdist = "^1.32.0" [tool.poetry.extras] docs = ["sphinx", "sphinx_autodoc_typehints"] uvloop = ["uvloop"] ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/tests/__init__.py0000644000000000000000000000000000000000000015252 0ustar0000000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/tests/certs/invalid.crt0000644000000000000000000000221400000000000016432 0ustar0000000000000000-----BEGIN CERTIFICATE----- MIIDLjCCAhYCCQDZIELcykI/LDANBgkqhkiG9w0BAQsFADBZMQswCQYDVQQGEwJB VTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0 cyBQdHkgTHRkMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMTcwNDE4MDUyNDI5WhcN MTgwNDE4MDUyNDI5WjBZMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0 ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDEwls b2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCxVexRynjB i2eT1fQIVPNFjOOGthdmj5m7ynWDPX+G0YMtuDKhZqQx3/jbzGrNXinBO+TE141k lOQLHLq+ZghPCqSvg7i2rbr6Pw5ur1/AjymHpWqec4tk6Q8SfgKjaQeEBH4dG5nl V0gclBJc40Ji/sdfs/UVetJM+rjrJSVZ0T0dH2Tl8xnzJXrCfgB7BGK6abBqUqkg mTllCaDNz3fh/XU9B0eeaDqloVDpvByKrQeNug0Xwi6i4jay0NUCU8jX5vFDx+2E UF31S/NBsL6g9CwBVhq7Q3YjHE7VfukgSCyXhokRmQ4hETG18XLVIyIK765707L4 Y1exUNqnOothAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAB38heY2ecJ7d23RAPsC coCFkB8ZkK4yjwEHA15T/1uge5aHDB8AIM35WP2jqmo8jh3My/FPA1a/gUXiwqtG JkEKCqluoSpLnBRZEUeTiCIg4zQME1cIsiUbC1g9pOujLKsetlDhUlfRl0pRpncu G2sAwcskNj/AicuXKbT/2iK6ecpnnooj/qzn01EfRS6vTUVvtjIIaCt/jAHlw1JA jm3m2WZc6B8JWiWMEYMN3s4dGwqfkxT6ZqDI9bKYBArJWCgYJc0WtV2xXs2X01wT tHS+tytbIMMtdt04HoLSPNcTeVZ6r3KL8MLqZyl5Opl8+B+pVcOU9LEcLyPLug9m xLA= -----END CERTIFICATE----- ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.3994298 aiosmtplib-1.1.6/tests/certs/invalid.key0000644000000000000000000000321700000000000016436 0ustar0000000000000000-----BEGIN RSA PRIVATE KEY----- AAAAAAAAAAAAAAAAsVXsUcp4wYtnk9X0CFTzRYzjhrYXZo+Zu8p1gz1/htGDLbgy oWakMd/428xqzV4pwTvkxNeNZJTkCxy6vmYITwqkr4O4tq26+j8Obq9fwI8ph6Vq nnOLZOkPEn4Co2kHhAR+HRuZ5VdIHJQSXONCYvxDYtP1FXrSTPq46yUlWdE9HR9k 5fMZ8yV6wn4AewRiummwalKpIJk5ZQmgzc934f11PQdHnmg6paFQ6bwciq0HjboN F8IuouI2stDVAlPI1+bxQ8fthFBd9UvzQbC+oPQsAVYau0N2IxxO1X7pIEgsl4aJ EZkOIRExtfFy1SMiCu+ue9Oy+GNXsVDapzqLYQIDAQABAoIBAQCl3CdbmHO5Ehme O84yvMGLm2py5HyegFDZQ1MFnM9Z2TtH17ADJW0M+N5ZNafuzNNg2BPvx2uHV3qy qfw/eE7tHpdJUXnB4luvEqr8+yojf8LoC97ctFT73L8sHI97JxDQeSM7NkWKK0hW 8mb6WdQNoMOl+3iOzoFDXnz18Zgtl+Z8NXX+VNGxipZgkvCVs4E7AyHmeNyELw54 YQmHhsxBXcFqqnLct+XM1gU+/ffNrr6R0pGFmQBImILP96RZxNTHsBMQfFIO1AIt TXwxsdQBfPtpvFPdqRSRjyKKn4HhVRMpo+jM7mAyJsuImAGV98kuZ7wcUCARWqHv /iCNcZTBAoGBAORRwG+XDOlRihuxnKcqhAKo7SPrAa3kOeI/5ajCYJlMdIWWhvDF VBnRxH2nN5MXKTJ3fxx5cd/FtBqyS4kLdlImJvgXAwoQ/Yj/kyiUueVNsV9qtuqW OpIeqfLhwTasRSXzcXbToD47ZJ+An89wV7r2YQa9KbmzuzW+JwPWCOm7AoGBAMbV z/J8XFVbhlsTpd8O/2Cb7ReVUrTRAYJqXxl8w5Xpwduik3YGRear63NqEE2HZqVw gPTthazgr11i+XwBHhZ7vIU78MffaxWjqg0I2sJkGKWJF1iqKNKqa5fI9y4Bg7V0 vexqpY/x8L2UQo99JVmwlfY+x6iKG7kgZwwG6S+TAoGAFdjYwBTFEIJT48iAA+Lm lNaTMm+nTntbKpIQqLBIzAJr1+iKavmGzZ2r3pYklDeQIpsal5/rTI0/aZqL7cYi AURTUEPrb4gmqnhCHYz1qMJhaY7th87uNdMnUe1WLqQXTcVYoxUm7S2DuFt3b0MB sgsnR5zMJE/VZxQV6aEOY0cCgYEAve10sfKanCpN32eYNMZ1qEmpIrLPCsrufy2u Y4EqrmNYer0D0GRTlvC3ekphAj2JDhS2Cb9sxeLlf/XPy+ShYeaWAEpuR+2gs03f XI4NsEu+wzh9ZYM8dWa2WYlsmrR1o/m8hTyloyb+x4f8QGXRLKghxtLXEqXBr4dF B1b+nSMCgYA3+npTH/FV6I4OjUcou0sPiLGTQzcFOsTvKLial9ZF08EpvpmVR9x3 2MkKFkqyEOewosuNJKAkuPyb+jQAlG4L4O0ANICXHbQY9yFujuA2jaarjD5D5w3T LwKe+fMjEg4DOgdqtCTLRCfxPM5pMN1QfggS3A8l6/LOmocL9I/qkQ== -----END RSA PRIVATE KEY----- ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/certs/selfsigned.crt0000644000000000000000000000207600000000000017135 0ustar0000000000000000-----BEGIN CERTIFICATE----- MIIC9DCCAdwCCQCuTqcRQkBTWzANBgkqhkiG9w0BAQsFADA8MQswCQYDVQQGEwJD QTESMBAGA1UEAwwJbG9jYWxob3N0MRkwFwYJKoZIhvcNAQkBFgpoaUBjb2xlLmlv MB4XDTE5MTAwODA5MzE0M1oXDTQ5MTIzMTA5MzE0N1owPDELMAkGA1UEBhMCQ0Ex EjAQBgNVBAMMCWxvY2FsaG9zdDEZMBcGCSqGSIb3DQEJARYKaGlAY29sZS5pbzCC ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALyHMwootYC+B/dtrBVn58Yi BPQ5y7eWFNKx69khe9iUYkIL75WtoC9weA0wrBeyleca9CKh32Tun3A4A3EztLfo qx2MX5+ixT4WnwvAupYqCV0u2NLlYfgCwgTsnLm1mkrdbLyitf/00MGuQAbk1H3h gyCFv8bQslGS43B9GohGW7vAFNGaWB1MEgC2r3cmdzmA5IVqzEC+cuQTqP56iVz1 1nEEd5za98LX4oaiB78ZkHW/KbeKdwmLB8MO27BLnAS1O85Twedw3gOohe75vbA5 6fTCg5YVTGFpPZqSfzaZGsuz7M8lilqhPLMEh9xQgESotRVvmTimdmyA8a2l06kC AwEAATANBgkqhkiG9w0BAQsFAAOCAQEAJxdOKBqTSyWlmQMX12jlI+uPQw9SzINc JIzdjt2Lc2z/Z6mbrNvT/Nok1hW1c6nsBbLdaaCOcglJ7NRG0ZLHotIDvU6Jp1U3 FxTDldBc1iplZP//OmsI1hiPQLcREYyoU7DVK/r/exxLrOOsdB2N9rpBnZmcerd6 Opm1qDZdU7RUhnrQ+seevb9AJog5sEwdPgD12mctzoIpMN+Ke6bI8hSf2Lyl0JNz C0Tpt8nQ4/N8aIakT3zKuLyCfb/UuTJvUCt8rxHtimBYPXD2vWDuH81UMa8xBwV8 WHiRMjXLiW5l9VZJqkK00duADead0owoo1T7SVeVLBQ+nEwMkzhorQ== -----END CERTIFICATE----- ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/certs/selfsigned.key0000644000000000000000000000321700000000000017133 0ustar0000000000000000-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAvIczCii1gL4H922sFWfnxiIE9DnLt5YU0rHr2SF72JRiQgvv la2gL3B4DTCsF7KV5xr0IqHfZO6fcDgDcTO0t+irHYxfn6LFPhafC8C6lioJXS7Y 0uVh+ALCBOycubWaSt1svKK1//TQwa5ABuTUfeGDIIW/xtCyUZLjcH0aiEZbu8AU 0ZpYHUwSALavdyZ3OYDkhWrMQL5y5BOo/nqJXPXWcQR3nNr3wtfihqIHvxmQdb8p t4p3CYsHww7bsEucBLU7zlPB53DeA6iF7vm9sDnp9MKDlhVMYWk9mpJ/Npkay7Ps zyWKWqE8swSH3FCARKi1FW+ZOKZ2bIDxraXTqQIDAQABAoIBAQCMFEzZNIw//3K9 5tBhC1ZMgoR5zuPOIgaQ0sByEg4KS56CgonfBiYqeX/KFSsZZIk7MWzKusnZHfB7 mjL8jrWtnIRgLSfz3iZ+TqKYQfihkNRqV1+lu+hCNhJhREnjNstQ6xtbQe7HIull r4pFVQuKCOC8boSjPffw4pp5v4rSS2Szhw/Y0SsdTDxD1M+vRem/vKAxXlOlnklc dvnbV3WWSxyw8V55NGoe3cygcFsQJiULJX6yiL/PpMT5CpubwaL7isHNomnBHOmP 7ZPtBQOvyrzLPnijqZz0Yh14m6cQQ0OjztPS4d/VYofouAFy7yKK3jl2M5ZRvfIg biKRjDINAoGBAPOHtKWFBs3qGqPmU09xqbuDwegt2BjH+vZNA5WZ934Zzy+YJjCR WgZ6mFgDVVyG5BmCDbrbldyTrtOawZtRSShKjh2iiprBUYeUduTQWQx5tNSjn6Fy B0eoaNoKIs/DazFCSqkPDCflfDEh95TZEIfD3rAFiSSRoFO7l2WHbLMfAoGBAMYu gTsuBBznwsCVSSDcX2iMRRj7wMp89DdFEHPcXpM4bC8I8083hd3nFF3ByWzUXjZZ icti0QFEtRpEQwcrtVnkbBMtF/o/4m89HOf9Ln8g5mQmIYBXFyhRu9STIJehQymU atanezD4V4CbTB56ymQAqAOutxHWDGBjQwp/Yag3AoGBAOtTSmPbrRyTyNRygDS3 kB2sD1dt3XDs5fzVpM4ObxMCm6vRKa4q9sRl+QqrhfQYQRjJDL1w7VqttvWxiL6u iLQPf07xv2AVyNPrygwHrNUaUnxb9KhMiO5ctZ1kyAjePJnoBj9hVAzFfAH6YcUL ECcxReHwJ3sX4can8n9gotBfAoGAA78nVE4XZ9B93xQzcLqZ1yknuUQxLeLU3yKg IACfjRr4+J6rsuiy0DIpMWiTfUo6ib6Bm2W/281HuzplJgKOiCeIX4hvtpx0lHPM Uxp3QZIGqQgLhBihaHK7IVayvAshcOzMZEiATLm1Nco50y2xG9jjVEZ+UYkBbOUT hGRKHVsCgYEAoZgZ45qIOe+wDF50iH+fIOCXooj2L5tDORqFBayMEXfkXcxeYCbq 0zFLwvoQKM5qgIAWa0fzNFHTpYpWp9vkOz/nHT/Vf6KIRK6tRVANsfZGpAX/YC2l 5nf2x+YkhanuduKgMOXwK6PgVrGULVs6A+Bp3h4BH0IOrA9XTyjpfh8= -----END RSA PRIVATE KEY----- ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/conftest.py0000644000000000000000000003252300000000000015357 0ustar0000000000000000""" 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 import traceback from pathlib import Path import hypothesis import pytest from aiosmtplib import SMTP, SMTPStatus from aiosmtplib.sync import shutdown_loop from .smtpd import RecordingHandler, SMTPDController, 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 AsyncPytestWarning(pytest.PytestWarning): pass def pytest_addoption(parser): 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="server address to bind on, e.g 127.0.0.1", ) @pytest.fixture(scope="session") def event_loop_policy(request): loop_type = request.config.getoption("--event-loop") if loop_type == "uvloop": if not HAS_UVLOOP: raise RuntimeError("uvloop not installed.") old_policy = asyncio.get_event_loop_policy() policy = uvloop.EventLoopPolicy() asyncio.set_event_loop_policy(policy) request.addfinalizer(lambda: asyncio.set_event_loop_policy(old_policy)) return asyncio.get_event_loop_policy() @pytest.fixture(scope="function") def event_loop(request, event_loop_policy): verbosity = request.config.getoption("verbose", default=0) old_loop = event_loop_policy.get_event_loop() loop = event_loop_policy.new_event_loop() event_loop_policy.set_event_loop(loop) def handle_async_exception(loop, context): message = "{}: {}".format(context["message"], repr(context["exception"])) if verbosity > 1: message += "\n" message += "Future: {}".format(repr(context["future"])) message += "\nTraceback:\n" message += "".join(traceback.format_list(context["source_traceback"])) request.node.warn(AsyncPytestWarning(message)) loop.set_exception_handler(handle_async_exception) def cleanup(): shutdown_loop(loop) event_loop_policy.set_event_loop(old_loop) request.addfinalizer(cleanup) return loop @pytest.fixture(scope="session") def hostname(request): return "localhost" @pytest.fixture(scope="session") def bind_address(request): """Server side address for socket binding""" return request.config.getoption("--bind-addr") @pytest.fixture( scope="function", params=( str, bytes, pytest.param( lambda path: path, marks=pytest.mark.xfail( sys.version_info < (3, 7), reason="os.PathLike support introduced in 3.7.", ), ), ), ids=("str", "bytes", "pathlike"), ) def socket_path(request, tmp_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 / "aiosmtplib-test{}".format(index) while socket_path.exists(): index += 1 socket_path = tmp_dir / "aiosmtplib-test{}".format(index) return request.param(socket_path) @pytest.fixture(scope="function") def compat32_message(request): message = email.message.Message() message["To"] = email.header.Header("recipient@example.com") message["From"] = email.header.Header("sender@example.com") message["Subject"] = "A message" message.set_payload("Hello World") return message @pytest.fixture(scope="function") def mime_message(request): message = email.mime.multipart.MIMEMultipart() message["To"] = "recipient@example.com" message["From"] = "sender@example.com" 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, compat32_message, mime_message): if request.param == "compat32": return compat32_message else: return mime_message @pytest.fixture(scope="session") def recipient_str(request): return "recipient@example.com" @pytest.fixture(scope="session") def sender_str(request): return "sender@example.com" @pytest.fixture(scope="session") def message_str(request, recipient_str, sender_str): return ( "Content-Type: multipart/mixed; " 'boundary="===============6842273139637972052=="\n' "MIME-Version: 1.0\n" "To: recipient@example.com\n" "From: sender@example.com\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="function") def received_messages(request): return [] @pytest.fixture(scope="function") def received_commands(request): return [] @pytest.fixture(scope="function") def smtpd_responses(request): return [] @pytest.fixture(scope="function") def smtpd_handler(request, received_messages, received_commands, smtpd_responses): return RecordingHandler(received_messages, received_commands, smtpd_responses) @pytest.fixture(scope="session") def smtpd_class(request): return TestSMTPD @pytest.fixture(scope="session") def valid_cert_path(request): return str(BASE_CERT_PATH.joinpath("selfsigned.crt")) @pytest.fixture(scope="session") def valid_key_path(request): return str(BASE_CERT_PATH.joinpath("selfsigned.key")) @pytest.fixture(scope="session") def invalid_cert_path(request): return str(BASE_CERT_PATH.joinpath("invalid.crt")) @pytest.fixture(scope="session") def invalid_key_path(request): return str(BASE_CERT_PATH.joinpath("invalid.key")) @pytest.fixture(scope="session") def client_tls_context(request, valid_cert_path, valid_key_path): tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) tls_context.check_hostname = False tls_context.verify_mode = ssl.CERT_NONE return tls_context @pytest.fixture(scope="session") def server_tls_context(request, valid_cert_path, valid_key_path): tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) tls_context.load_cert_chain(valid_cert_path, keyfile=valid_key_path) return tls_context @pytest.fixture(scope="function") def smtpd_server( request, event_loop, bind_address, hostname, smtpd_class, smtpd_handler, server_tls_context, ): def factory(): return smtpd_class( smtpd_handler, hostname=hostname, enable_SMTPUTF8=False, tls_context=server_tls_context, ) server = event_loop.run_until_complete( event_loop.create_server( factory, host=bind_address, port=0, family=socket.AF_INET ) ) def close_server(): server.close() event_loop.run_until_complete(server.wait_closed()) request.addfinalizer(close_server) return server @pytest.fixture(scope="function") def smtpd_server_port(request, smtpd_server): return smtpd_server.sockets[0].getsockname()[1] @pytest.fixture(scope="function") def smtpd_server_smtputf8( request, event_loop, bind_address, hostname, smtpd_class, smtpd_handler, server_tls_context, ): def factory(): return smtpd_class( smtpd_handler, hostname=hostname, enable_SMTPUTF8=True, tls_context=server_tls_context, ) server = event_loop.run_until_complete( event_loop.create_server( factory, host=bind_address, port=0, family=socket.AF_INET ) ) def close_server(): server.close() event_loop.run_until_complete(server.wait_closed()) request.addfinalizer(close_server) return server @pytest.fixture(scope="function") def smtpd_server_smtputf8_port(request, smtpd_server_smtputf8): return smtpd_server_smtputf8.sockets[0].getsockname()[1] @pytest.fixture(scope="function") def smtpd_server_socket_path( request, socket_path, event_loop, smtpd_class, smtpd_handler, server_tls_context ): def factory(): return smtpd_class( smtpd_handler, hostname=hostname, enable_SMTPUTF8=False, tls_context=server_tls_context, ) server = event_loop.run_until_complete( event_loop.create_unix_server(factory, path=socket_path) ) def close_server(): server.close() event_loop.run_until_complete(server.wait_closed()) request.addfinalizer(close_server) return server @pytest.fixture(scope="session") def smtpd_response_handler_factory(request): def smtpd_response( response_text, second_response_text=None, write_eof=False, close_after=False ): async def response_handler(smtpd, *args, **kwargs): if args and args[0]: smtpd.session.host_name = args[0] if response_text is not None: await smtpd.push(response_text) if write_eof: smtpd.transport.write_eof() if second_response_text is not None: await smtpd.push(second_response_text) if close_after: smtpd.transport.close() return response_handler return smtpd_response @pytest.fixture(scope="function") def smtp_client(request, event_loop, hostname, smtpd_server_port): client = SMTP(hostname=hostname, port=smtpd_server_port, timeout=1.0) return client @pytest.fixture(scope="function") def smtp_client_smtputf8(request, event_loop, hostname, smtpd_server_smtputf8_port): client = SMTP(hostname=hostname, port=smtpd_server_smtputf8_port, timeout=1.0) return client class EchoServerProtocol(asyncio.Protocol): def connection_made(self, transport): self.transport = transport def data_received(self, data): self.transport.write(data) @pytest.fixture(scope="function") def echo_server(request, bind_address, event_loop): server = event_loop.run_until_complete( event_loop.create_server( EchoServerProtocol, host=bind_address, port=0, family=socket.AF_INET ) ) def close_server(): server.close() event_loop.run_until_complete(server.wait_closed()) request.addfinalizer(close_server) return server @pytest.fixture(scope="function") def echo_server_port(request, echo_server): return echo_server.sockets[0].getsockname()[1] @pytest.fixture( params=[ SMTPStatus.mailbox_unavailable, SMTPStatus.unrecognized_command, SMTPStatus.bad_command_sequence, SMTPStatus.syntax_error, ], ids=[ SMTPStatus.mailbox_unavailable.name, SMTPStatus.unrecognized_command.name, SMTPStatus.bad_command_sequence.name, SMTPStatus.syntax_error.name, ], ) def error_code(request): return request.param @pytest.fixture(scope="function") def tls_smtpd_server( request, event_loop, bind_address, smtpd_class, smtpd_handler, server_tls_context ): def factory(): return smtpd_class( smtpd_handler, hostname=bind_address, enable_SMTPUTF8=False, tls_context=server_tls_context, ) server = event_loop.run_until_complete( event_loop.create_server( factory, host=bind_address, port=0, ssl=server_tls_context, family=socket.AF_INET, ) ) def close_server(): server.close() event_loop.run_until_complete(server.wait_closed()) request.addfinalizer(close_server) return server @pytest.fixture(scope="function") def tls_smtpd_server_port(request, tls_smtpd_server): return tls_smtpd_server.sockets[0].getsockname()[1] @pytest.fixture(scope="function") def tls_smtp_client(request, event_loop, hostname, tls_smtpd_server_port): tls_client = SMTP( hostname=hostname, port=tls_smtpd_server_port, use_tls=True, validate_certs=False, ) return tls_client @pytest.fixture(scope="function") def threaded_smtpd_server(request, bind_address, smtpd_handler): controller = SMTPDController(smtpd_handler, hostname=bind_address, port=0) controller.start() request.addfinalizer(controller.stop) return controller.server @pytest.fixture(scope="function") def threaded_smtpd_server_port(request, threaded_smtpd_server): return threaded_smtpd_server.sockets[0].getsockname()[1] @pytest.fixture(scope="function") def smtp_client_threaded(request, hostname, threaded_smtpd_server_port): client = SMTP(hostname=hostname, port=threaded_smtpd_server_port, timeout=1.0) return client ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/smtpd.py0000644000000000000000000001445600000000000014666 0ustar0000000000000000""" Implements handlers required on top of aiosmtpd for testing. """ import asyncio import base64 import logging import socket import threading from email.errors import HeaderParseError from email.message import Message from aiosmtpd.handlers import Message as MessageHandler from aiosmtpd.smtp import MISSING from aiosmtpd.smtp import SMTP as SMTPD from aiosmtplib.sync import shutdown_loop log = logging.getLogger("mail.log") class RecordingHandler(MessageHandler): def __init__(self, messages_list, commands_list, responses_list): self.messages = messages_list self.commands = commands_list self.responses = responses_list super().__init__(message_class=Message) def record_command(self, command, *args): self.commands.append((command, *args)) def record_server_response(self, status): self.responses.append(status) def handle_message(self, message): self.messages.append(message) async def handle_EHLO(self, server, session, envelope, hostname): """Advertise auth login support.""" session.host_name = hostname if server._tls_protocol: return "250-AUTH LOGIN\r\n250 HELP" else: return "250 HELP" class TestSMTPD(SMTPD): def _getaddr(self, arg): """ Don't raise an exception on unparsable email address """ try: return super()._getaddr(arg) except HeaderParseError: return None, "" async def _call_handler_hook(self, command, *args): self.event_handler.record_command(command, *args) return await super()._call_handler_hook(command, *args) async def push(self, status): result = await super().push(status) self.event_handler.record_server_response(status) return result async def smtp_EXPN(self, arg): """ Pass EXPN to handler hook. """ status = await self._call_handler_hook("EXPN") await self.push("502 EXPN not implemented" if status is MISSING else status) async def smtp_HELP(self, arg): """ 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): """ Override for uvloop compatibility. """ self.event_handler.record_command("STARTTLS", arg) if arg: await self.push("501 Syntax: STARTTLS") return if not self.tls_context: await self.push("454 TLS not available") return await self.push("220 Ready to start TLS") # Create SSL layer. self._tls_protocol = asyncio.sslproto.SSLProtocol( self.loop, self, self.tls_context, None, server_side=True ) self._original_transport = self.transport if hasattr(self._original_transport, "set_protocol"): self._original_transport.set_protocol(self._tls_protocol) else: self._original_transport._protocol = self._tls_protocol self.transport = self._tls_protocol._app_transport self._tls_protocol.connection_made(self._original_transport) async def smtp_AUTH(self, arg): self.event_handler.record_command("AUTH", arg) if not self._tls_protocol: await self.push("530 Must issue a STARTTLS command first.") return if arg[:5] == "LOGIN": await self.smtp_AUTH_LOGIN(arg[6:]) else: await self.push("504 Unsupported AUTH mechanism.") async def smtp_AUTH_LOGIN(self, arg): username = base64.b64decode(arg) log.debug("SMTP AUTH LOGIN user: %s", username) await self.push("334 VXNlcm5hbWU6") encoded_password = await self._reader.readline() log.debug("SMTP AUTH LOGIN password: %s", encoded_password) password = base64.b64decode(encoded_password) if username == b"test" and password == b"test": await self.push("235 You're in!") else: await self.push("535 Nope.") class SMTPDController: """ Based on https://github.com/aio-libs/aiosmtpd/blob/master/aiosmtpd/controller.py, but we force IPv4. """ def __init__( self, handler, loop=None, hostname=None, port=8025, *, ready_timeout=1.0, enable_SMTPUTF8=True, ssl_context=None ): self.handler = handler self.hostname = hostname self.port = port self.enable_SMTPUTF8 = enable_SMTPUTF8 self.ssl_context = ssl_context self.loop = asyncio.new_event_loop() if loop is None else loop self.server = None self._thread = None self._thread_exception = None self.ready_timeout = ready_timeout def factory(self): """Allow subclasses to customize the handler/server creation.""" return TestSMTPD(self.handler, enable_SMTPUTF8=self.enable_SMTPUTF8) def _run(self, ready_event): asyncio.set_event_loop(self.loop) try: self.server = self.loop.run_until_complete( self.loop.create_server( self.factory, host=self.hostname, port=self.port, ssl=self.ssl_context, family=socket.AF_INET, ) ) except Exception as error: self._thread_exception = error return self.loop.call_soon(ready_event.set) self.loop.run_forever() self.server.close() self.loop.run_until_complete(self.server.wait_closed()) self.server = None def start(self): ready_event = threading.Event() self._thread = threading.Thread(target=self._run, args=(ready_event,)) self._thread.daemon = True self._thread.start() # Wait a while until the server is responding. ready_event.wait(self.ready_timeout) if self._thread_exception is not None: raise self._thread_exception def _stop(self): self.loop.stop() shutdown_loop(self.loop) def stop(self): self.loop.call_soon_threadsafe(self._stop) self._thread.join() self._thread = None ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_api.py0000644000000000000000000000531100000000000015335 0ustar0000000000000000""" send coroutine testing. """ import pytest from aiosmtplib import send pytestmark = pytest.mark.asyncio() async def test_send(hostname, smtpd_server_port, message, received_messages): errors, response = await send(message, hostname=hostname, port=smtpd_server_port) assert not errors assert len(received_messages) == 1 async def test_send_with_str( hostname, smtpd_server_port, recipient_str, sender_str, message_str, received_messages, ): errors, response = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[recipient_str], ) assert not errors assert len(received_messages) == 1 async def test_send_with_bytes( hostname, smtpd_server_port, recipient_str, sender_str, message_str, received_messages, ): errors, response = await send( bytes(message_str, "ascii"), hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[recipient_str], ) assert not errors assert len(received_messages) == 1 async def test_send_without_sender( hostname, smtpd_server_port, recipient_str, message_str, received_messages ): with pytest.raises(ValueError): errors, response = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=None, recipients=[recipient_str], ) async def test_send_without_recipients( hostname, smtpd_server_port, sender_str, message_str, received_messages ): with pytest.raises(ValueError): errors, response = await send( message_str, hostname=hostname, port=smtpd_server_port, sender=sender_str, recipients=[], ) async def test_send_with_start_tls( hostname, smtpd_server_port, message, received_messages, received_commands ): errors, response = await send( message, hostname=hostname, port=smtpd_server_port, start_tls=True, validate_certs=False, ) 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, smtpd_server_port, message, received_messages, received_commands ): errors, response = await send( # nosec message, hostname=hostname, port=smtpd_server_port, start_tls=True, validate_certs=False, username="test", password="test", ) assert not errors assert "AUTH" in [command[0] for command in received_commands] assert len(received_messages) == 1 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_asyncio.py0000644000000000000000000001154500000000000016237 0ustar0000000000000000""" Tests that cover asyncio usage. """ import asyncio import pytest from aiosmtplib import SMTP, SMTPStatus RECIPIENTS = [ "recipient1@example.com", "recipient2@example.com", "recipient3@example.com", ] pytestmark = pytest.mark.asyncio() async def test_sendmail_multiple_times_in_sequence( smtp_client, smtpd_server, sender_str, message_str ): 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, smtpd_server, sender_str, message_str ): 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, smtpd_server_port, sender_str, message_str ): async def connect_and_send(*args, **kwargs): async with SMTP(hostname=hostname, port=smtpd_server_port) 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, smtpd_server_port, sender_str, message_str ): async def connect_and_send(*args, **kwargs): client = SMTP(hostname=hostname, port=smtpd_server_port) 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, smtpd_server_port, sender_str, message_str ): async def connect_and_run_commands(*args, **kwargs): async with SMTP(hostname=hostname, port=smtpd_server_port) 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 async def test_many_commands_with_gather( monkeypatch, smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory ): """ Tests that appropriate locks are in place to prevent commands confusing each other. """ response_handler = smtpd_response_handler_factory( "{} Alice Smith ".format(SMTPStatus.completed) ) monkeypatch.setattr(smtpd_class, "smtp_EXPN", response_handler) async with smtp_client: tasks = [ 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(event_loop, hostname, smtpd_server_port): client = SMTP(hostname=hostname, port=smtpd_server_port) 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( smtpd_server, smtp_client, sender_str, message_str ): async def connect_and_send(*args, **kwargs): async with smtp_client: response = await smtp_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 != "" ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_auth.py0000644000000000000000000001676500000000000015544 0ustar0000000000000000import base64 from collections import deque import pytest from aiosmtplib.auth import SMTPAuth, crammd5_verify from aiosmtplib.errors import SMTPAuthenticationError, SMTPException from aiosmtplib.response import SMTPResponse from aiosmtplib.status import SMTPStatus pytestmark = pytest.mark.asyncio() SUCCESS_RESPONSE = SMTPResponse(SMTPStatus.auth_successful, "OK") FAILURE_RESPONSE = SMTPResponse(SMTPStatus.auth_failed, "Nope") class DummySMTPAuth(SMTPAuth): transport = None def __init__(self): super().__init__() self.received_commands = [] self.responses = deque() self.esmtp_extensions = {"auth": ""} self.server_auth_methods = ["cram-md5", "login", "plain"] self.supports_esmtp = True async def execute_command(self, *args, **kwargs): self.received_commands.append(b" ".join(args)) response = self.responses.popleft() return SMTPResponse(*response) async def _ehlo_or_helo_if_needed(self): pass @pytest.fixture() def mock_auth(request): return DummySMTPAuth() async def test_login_without_extension_raises_error(mock_auth): 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): 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): mock_auth.server_auth_methods = [] with pytest.raises(SMTPException): await mock_auth.login("username", "bogus") async def test_login_tries_all_methods(mock_auth): 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): 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, username, password): """ Check that auth_plain base64 encodes the username/password given. """ mock_auth.responses.append(SUCCESS_RESPONSE) await mock_auth.auth_plain(username, password) b64data = base64.b64encode( b"\0" + username.encode("utf-8") + b"\0" + password.encode("utf-8") ) assert mock_auth.received_commands == [b"AUTH PLAIN " + b64data] async def test_auth_plain_success_bytes(mock_auth): """ 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) b64data = base64.b64encode(b"\0" + username + b"\0" + password) assert mock_auth.received_commands == [b"AUTH PLAIN " + b64data] async def test_auth_plain_error(mock_auth): 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, username, password): continue_response = (SMTPStatus.auth_continue, "VXNlcm5hbWU6") mock_auth.responses.extend([continue_response, SUCCESS_RESPONSE]) await mock_auth.auth_login(username, password) b64username = base64.b64encode(username.encode("utf-8")) b64password = base64.b64encode(password.encode("utf-8")) assert mock_auth.received_commands == [b"AUTH LOGIN " + b64username, b64password] async def test_auth_login_success_bytes(mock_auth): 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) b64username = base64.b64encode(username) b64password = base64.b64encode(password) assert mock_auth.received_commands == [b"AUTH LOGIN " + b64username, b64password] async def test_auth_login_error(mock_auth): 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): 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, username, password): 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 = 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): 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 = 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): 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): 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, smtpd_server): async with smtp_client: with pytest.raises(SMTPException) as excinfo: await smtp_client.login("test", "test") assert "Try connecting via TLS" in excinfo.value.args[0] ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_commands.py0000644000000000000000000004274500000000000016401 0ustar0000000000000000""" Lower level SMTP command tests. """ import pytest from aiosmtplib import ( SMTPDataError, SMTPHeloError, SMTPNotSupported, SMTPResponseException, SMTPStatus, ) pytestmark = pytest.mark.asyncio() @pytest.fixture(scope="session") def bad_data_response_handler(request): async def bad_data_response(smtpd, *args, **kwargs): smtpd._writer.write(b"250 \xFF\xFF\xFF\xFF\r\n") await smtpd._writer.drain() return bad_data_response async def test_helo_ok(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.helo() assert response.code == SMTPStatus.completed async def test_helo_with_hostname(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.helo(hostname="example.com") assert response.code == SMTPStatus.completed async def test_helo_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_HELO", response_handler) async with smtp_client: with pytest.raises(SMTPHeloError) as exception_info: await smtp_client.helo() assert exception_info.value.code == error_code async def test_ehlo_ok(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed async def test_ehlo_with_hostname(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.ehlo(hostname="example.com") assert response.code == SMTPStatus.completed async def test_ehlo_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) async with smtp_client: with pytest.raises(SMTPHeloError) as exception_info: await smtp_client.ehlo() assert exception_info.value.code == error_code async def test_ehlo_parses_esmtp_extensions( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): ehlo_response = """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""" monkeypatch.setattr( smtpd_class, "smtp_EHLO", smtpd_response_handler_factory(ehlo_response) ) 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") async def test_ehlo_with_no_extensions( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} all done".format(SMTPStatus.completed) ) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) 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, smtpd_server): 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 async def test_ehlo_or_helo_if_needed_helo_success( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) 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.parametrize( "ehlo_error_code", [ SMTPStatus.mailbox_unavailable, SMTPStatus.unrecognized_command, SMTPStatus.bad_command_sequence, SMTPStatus.syntax_error, ], ids=[ SMTPStatus.mailbox_unavailable.name, SMTPStatus.unrecognized_command.name, SMTPStatus.bad_command_sequence.name, SMTPStatus.syntax_error.name, ], ) async def test_ehlo_or_helo_if_needed_neither_succeeds( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ehlo_error_code, ): helo_response_handler = smtpd_response_handler_factory( "{} error".format(error_code) ) monkeypatch.setattr(smtpd_class, "smtp_HELO", helo_response_handler) ehlo_response_handler = smtpd_response_handler_factory( "{} error".format(ehlo_error_code) ) monkeypatch.setattr(smtpd_class, "smtp_EHLO", ehlo_response_handler) 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 == error_code async def test_ehlo_or_helo_if_needed_disconnect_after_ehlo( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} retry in 5 minutes".format(SMTPStatus.domain_unavailable), close_after=True ) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) async with smtp_client: with pytest.raises(SMTPHeloError): await smtp_client._ehlo_or_helo_if_needed() async def test_rset_ok(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.rset() assert response.code == SMTPStatus.completed assert response.message == "OK" async def test_rset_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_RSET", response_handler) async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.rset() assert exception_info.value.code == error_code async def test_noop_ok(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.noop() assert response.code == SMTPStatus.completed assert response.message == "OK" async def test_noop_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_NOOP", response_handler) async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.noop() assert exception_info.value.code == error_code async def test_vrfy_ok(smtp_client, smtpd_server): 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, smtpd_server): bad_address = "" async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.vrfy(bad_address) async def test_vrfy_smtputf8_supported(smtp_client_smtputf8, smtpd_server_smtputf8): async with smtp_client_smtputf8: response = await smtp_client_smtputf8.vrfy( "tést@exåmple.com", options=["SMTPUTF8"] ) assert response.code == SMTPStatus.cannot_vrfy async def test_vrfy_smtputf8_not_supported(smtp_client, smtpd_server): async with smtp_client: with pytest.raises(SMTPNotSupported): await smtp_client.vrfy("tést@exåmple.com", options=["SMTPUTF8"]) async def test_expn_ok( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( """250-Joseph Blow 250 Alice Smith """ ) monkeypatch.setattr(smtpd_class, "smtp_EXPN", response_handler) async with smtp_client: response = await smtp_client.expn("listserv-members") assert response.code == SMTPStatus.completed async def test_expn_error(smtp_client, smtpd_server): """ 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") async def test_expn_smtputf8_supported( smtp_client_smtputf8, smtpd_server_smtputf8, smtpd_class, smtpd_response_handler_factory, monkeypatch, ): response_handler = smtpd_response_handler_factory( """250-Joseph Blow 250 Alice Smith """ ) monkeypatch.setattr(smtpd_class, "smtp_EXPN", response_handler) utf8_list = "tést-lïst" async with smtp_client_smtputf8: response = await smtp_client_smtputf8.expn(utf8_list, options=["SMTPUTF8"]) assert response.code == SMTPStatus.completed async def test_expn_smtputf8_not_supported(smtp_client, smtpd_server): 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, smtpd_server): async with smtp_client: help_message = await smtp_client.help() assert "Supported commands" in help_message async def test_help_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_HELP", response_handler) async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.help() assert exception_info.value.code == error_code async def test_quit_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_QUIT", response_handler) async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.quit() assert exception_info.value.code == error_code async def test_supported_methods(smtp_client, smtpd_server): 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, smtpd_server): async with smtp_client: response = await smtp_client.mail("j@example.com") assert response.code == SMTPStatus.completed assert response.message == "OK" async def test_mail_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_MAIL", response_handler) async with smtp_client: with pytest.raises(SMTPResponseException) as exception_info: await smtp_client.mail("test@example.com") assert exception_info.value.code == error_code async def test_mail_options_not_implemented(smtp_client, smtpd_server): async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.mail("j@example.com", options=["OPT=1"]) async def test_mail_smtputf8(smtp_client_smtputf8, smtpd_server_smtputf8): async with smtp_client_smtputf8: response = await smtp_client_smtputf8.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, smtpd_server): 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, smtpd_server): 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" async def test_rcpt_options_ok( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): # RCPT options are not implemented in aiosmtpd, so force success response response_handler = smtpd_response_handler_factory( "{} all done".format(SMTPStatus.completed) ) monkeypatch.setattr(smtpd_class, "smtp_RCPT", response_handler) 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, smtpd_server): # 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.code == SMTPStatus.syntax_error async def test_rcpt_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_RCPT", response_handler) 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 == error_code async def test_rcpt_smtputf8(smtp_client_smtputf8, smtpd_server_smtputf8): async with smtp_client_smtputf8: await smtp_client_smtputf8.mail("j@example.com", options=["SMTPUTF8"]) response = await smtp_client_smtputf8.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, smtpd_server): 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, smtpd_server): 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" async def test_data_error_on_start_input( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_DATA", response_handler) 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 == error_code async def test_data_complete_error( smtp_client, smtpd_server, smtpd_handler, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_handler, "handle_DATA", response_handler) 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 == error_code async def test_gibberish_raises_exception( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory("sdfjlfwqejflqw") monkeypatch.setattr(smtpd_class, "smtp_NOOP", response_handler) async with smtp_client: with pytest.raises(SMTPResponseException): await smtp_client.noop() async def test_badly_encoded_text_response( smtp_client, smtpd_server, smtpd_class, bad_data_response_handler, monkeypatch ): monkeypatch.setattr(smtpd_class, "smtp_NOOP", bad_data_response_handler) async with smtp_client: response = await smtp_client.noop() assert response.code == SMTPStatus.completed ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_compat.py0000644000000000000000000000125600000000000016053 0ustar0000000000000000""" Compat method tests. """ import asyncio import pytest from aiosmtplib.compat import PY37_OR_LATER, all_tasks, get_running_loop @pytest.mark.asyncio async def test_get_running_loop(event_loop): running_loop = get_running_loop() assert running_loop is event_loop def test_get_running_loop_runtime_error(event_loop): with pytest.raises(RuntimeError): get_running_loop() @pytest.mark.asyncio async def test_all_tasks(event_loop): tasks = all_tasks(event_loop) if PY37_OR_LATER: current_task = asyncio.current_task(loop=event_loop) else: current_task = asyncio.Task.current_task(loop=event_loop) assert current_task in tasks ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_config.py0000644000000000000000000001626400000000000016042 0ustar0000000000000000""" Tests covering SMTP configuration options. """ import asyncio import socket import pytest from aiosmtplib import SMTP pytestmark = pytest.mark.asyncio() async def test_tls_context_and_cert_raises(): with pytest.raises(ValueError): SMTP(use_tls=True, client_cert="foo.crt", tls_context=True) async def test_tls_context_and_cert_to_connect_raises(): client = SMTP(use_tls=True, tls_context=True) with pytest.raises(ValueError): await client.connect(client_cert="foo.crt") async def test_tls_context_and_cert_to_starttls_raises(smtp_client, smtpd_server): async with smtp_client: with pytest.raises(ValueError): await smtp_client.starttls(client_cert="test.cert", tls_context=True) async def test_use_tls_and_start_tls_raises(): with pytest.raises(ValueError): SMTP(use_tls=True, start_tls=True) async def test_use_tls_and_start_tls_to_connect_raises(): client = SMTP(use_tls=True) with pytest.raises(ValueError): await client.connect(start_tls=True) async def test_socket_and_hostname_raises(): with pytest.raises(ValueError): SMTP(hostname="example.com", sock=socket.socket(socket.AF_INET)) async def test_socket_and_port_raises(): with pytest.raises(ValueError): SMTP(port=1, sock=socket.socket(socket.AF_INET)) async def test_socket_and_socket_path_raises(): with pytest.raises(ValueError): SMTP(socket_path="/tmp/test", sock=socket.socket(socket.AF_INET)) # nosec async def test_hostname_and_socket_path_raises(): with pytest.raises(ValueError): SMTP(hostname="example.com", socket_path="/tmp/test") # nosec async def test_port_and_socket_path_raises(): with pytest.raises(ValueError): SMTP(port=1, socket_path="/tmp/test") # nosec async def test_config_via_connect_kwargs(hostname, smtpd_server_port): client = SMTP( hostname="", use_tls=True, port=smtpd_server_port + 1, source_address="example.com", ) source_address = socket.getfqdn() await client.connect( hostname=hostname, port=smtpd_server_port, use_tls=False, 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.source_address == source_address 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( event_loop, bind_address, use_tls, start_tls, expected_port ): 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( event_loop, hostname, smtpd_server_port ): client = SMTP(hostname="example.com", port=smtpd_server_port) await client.connect(hostname=hostname) assert client.hostname == hostname await client.quit() async def test_connect_port_takes_precedence(event_loop, hostname, smtpd_server_port): client = SMTP(hostname=hostname, port=17) await client.connect(port=smtpd_server_port) assert client.port == smtpd_server_port await client.quit() async def test_connect_timeout_takes_precedence(hostname, smtpd_server_port): client = SMTP(hostname=hostname, port=smtpd_server_port, timeout=0.66) await client.connect(timeout=0.99) assert client.timeout == 0.99 await client.quit() async def test_connect_source_address_takes_precedence(hostname, smtpd_server_port): client = SMTP( hostname=hostname, port=smtpd_server_port, source_address="example.com" ) await client.connect(source_address=socket.getfqdn()) assert client.source_address != "example.com" await client.quit() async def test_connect_event_loop_takes_precedence( event_loop, event_loop_policy, hostname, smtpd_server_port ): init_loop = event_loop_policy.new_event_loop() with pytest.warns(DeprecationWarning): client = SMTP(hostname=hostname, port=smtpd_server_port, loop=init_loop) with pytest.warns(DeprecationWarning): await client.connect(loop=event_loop) assert init_loop is not event_loop assert client.loop is event_loop await client.quit() async def test_connect_use_tls_takes_precedence(hostname, smtpd_server_port): client = SMTP(hostname=hostname, port=smtpd_server_port, use_tls=True) await client.connect(use_tls=False) assert client.use_tls is False await client.quit() async def test_connect_validate_certs_takes_precedence(hostname, smtpd_server_port): client = SMTP(hostname=hostname, port=smtpd_server_port, validate_certs=False) await client.connect(validate_certs=True) assert client.validate_certs is True await client.quit() async def test_connect_certificate_options_take_precedence(hostname, smtpd_server_port): client = SMTP( hostname=hostname, port=smtpd_server_port, client_cert="test", client_key="test", cert_bundle="test", ) 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, smtpd_server_port, client_tls_context, server_tls_context ): 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, smtpd_server_port, valid_cert_path, valid_key_path ): client = SMTP( hostname=hostname, port=smtpd_server_port, 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=valid_cert_path, validate_certs=True, ) assert client.client_cert == valid_cert_path assert client.client_key == valid_key_path assert client.cert_bundle == valid_cert_path assert client.validate_certs is True await client.quit() async def test_loop_kwarg_deprecation_warning_init(event_loop): with pytest.warns(DeprecationWarning): client = SMTP(loop=event_loop) assert client.loop == event_loop async def test_loop_kwarg_deprecation_warning_connect( event_loop, hostname, smtpd_server_port, smtpd_server ): client = SMTP(hostname=hostname, port=smtpd_server_port) with pytest.warns(DeprecationWarning): await client.connect(loop=event_loop) assert client.loop == event_loop ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_connect.py0000644000000000000000000002533600000000000016226 0ustar0000000000000000""" Connectivity tests. """ import socket import pytest from aiosmtplib import ( SMTP, SMTPConnectError, SMTPResponseException, SMTPServerDisconnected, SMTPStatus, ) pytestmark = pytest.mark.asyncio() @pytest.fixture(scope="session") def close_during_read_response_handler(request): async def close_during_read_response(smtpd, *args, **kwargs): # Read one line of data, then cut the connection. await smtpd.push( "{} End data with .".format(SMTPStatus.start_input) ) await smtpd._reader.readline() smtpd.transport.close() return close_during_read_response async def test_plain_smtp_connect(smtp_client, smtpd_server): """ 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, smtpd_server): 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 async def test_bad_connect_response_raises_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} retry in 5 minutes".format(SMTPStatus.domain_unavailable), close_after=True ) monkeypatch.setattr(smtpd_class, "_handle_client", response_handler) with pytest.raises(SMTPConnectError): await smtp_client.connect() assert smtp_client.transport is None assert smtp_client.protocol is None async def test_eof_on_connect_raises_connect_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory(None, write_eof=True) monkeypatch.setattr(smtpd_class, "_handle_client", response_handler) with pytest.raises(SMTPConnectError): await smtp_client.connect() assert smtp_client.transport is None assert smtp_client.protocol is None async def test_close_on_connect_raises_connect_error( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory(None, close_after=True) monkeypatch.setattr(smtpd_class, "_handle_client", response_handler) with pytest.raises(SMTPConnectError): await smtp_client.connect() assert smtp_client.transport is None assert smtp_client.protocol is None async def test_421_closes_connection( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} Please come back in 15 seconds.".format(SMTPStatus.domain_unavailable) ) monkeypatch.setattr(smtpd_class, "smtp_NOOP", response_handler) 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, unused_tcp_port): client = SMTP(hostname=hostname, port=unused_tcp_port) with pytest.raises(SMTPConnectError): # SMTPConnectTimeoutError vs SMTPConnectError here depends on # processing time. await client.connect(timeout=1.0) async def test_disconnected_server_raises_on_client_read( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory(None, close_after=True) monkeypatch.setattr(smtpd_class, "smtp_NOOP", response_handler) await smtp_client.connect() with pytest.raises(SMTPServerDisconnected): await smtp_client.execute_command(b"NOOP") assert smtp_client.protocol is None assert smtp_client.transport is None async def test_disconnected_server_raises_on_client_write( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( None, write_eof=True, close_after=True ) monkeypatch.setattr(smtpd_class, "smtp_NOOP", response_handler) await smtp_client.connect() with pytest.raises(SMTPServerDisconnected): await smtp_client.execute_command(b"NOOP") assert smtp_client.protocol is None assert smtp_client.transport is None async def test_disconnected_server_raises_on_data_read( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): """ The `data` command is a special case - it accesses protocol directly, rather than using `execute_command`. """ response_handler = smtpd_response_handler_factory(None, close_after=True) monkeypatch.setattr(smtpd_class, "smtp_DATA", response_handler) 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 smtp_client.protocol is None assert smtp_client.transport is None async def test_disconnected_server_raises_on_data_write( smtp_client, smtpd_server, smtpd_class, close_during_read_response_handler, monkeypatch, ): """ The `data` command is a special case - it accesses protocol directly, rather than using `execute_command`. """ monkeypatch.setattr(smtpd_class, "smtp_DATA", close_during_read_response_handler) 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 smtp_client.protocol is None assert smtp_client.transport is None async def test_disconnected_server_raises_on_starttls( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): """ The `starttls` command is a special case - it accesses protocol directly, rather than using `execute_command`. """ response_handler = smtpd_response_handler_factory(None, close_after=True) monkeypatch.setattr(smtpd_class, "smtp_STARTTLS", response_handler) await smtp_client.connect() await smtp_client.ehlo() with pytest.raises(SMTPServerDisconnected): await smtp_client.starttls(validate_certs=False, timeout=1.0) assert smtp_client.protocol is None assert smtp_client.transport is None async def test_context_manager(smtp_client, smtpd_server): 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 async def test_context_manager_disconnect_handling( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): """ Exceptions can be raised, but the context manager should handle disconnection. """ response_handler = smtpd_response_handler_factory(None, close_after=True) monkeypatch.setattr(smtpd_class, "smtp_NOOP", response_handler) 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, smtpd_server, received_commands ): with pytest.raises(ZeroDivisionError): async with smtp_client: 1 / 0 assert received_commands[-1][0] == "QUIT" async def test_context_manager_connect_exception_closes( smtp_client, smtpd_server, received_commands ): 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, smtpd_server): 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, smtpd_server): 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, unused_tcp_port): 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() async def test_server_unexpected_disconnect( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} OK".format(SMTPStatus.completed), second_response_text="{} Bye now!".format(SMTPStatus.closing), close_after=True, ) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) await smtp_client.connect() await smtp_client.ehlo() with pytest.raises(SMTPServerDisconnected): await smtp_client.noop() async def test_connect_with_login( smtp_client, smtpd_server, message, received_messages, received_commands ): # STARTTLS is required for login await smtp_client.connect( # nosec start_tls=True, validate_certs=False, username="test", password="test" ) assert "AUTH" in [command[0] for command in received_commands] await smtp_client.quit() async def test_connect_via_socket(smtp_client, hostname, smtpd_server_port): 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, smtpd_server_socket_path, socket_path ): await smtp_client.connect(hostname=None, port=None, socket_path=socket_path) response = await smtp_client.ehlo() assert response.code == SMTPStatus.completed ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_email_utils.py0000644000000000000000000002454700000000000017107 0ustar0000000000000000""" Test message and address parsing/formatting functions. """ from email.header import Header from email.headerregistry import Address from email.message import EmailMessage, Message 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 ', ""), ("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, expected_address): 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): assert quote_address(email) == "<{}>".format(email) def test_flatten_message(): 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( "utf8, cte_type, expected_chunk", ( (False, "7bit", b"=?utf-8?q?=C3=A5lice?="), (True, "7bit", b"From: \xc3\xa5lice@example.com"), (False, "8bit", b"=?utf-8?q?=C3=A5lice?="), (True, "8bit", b"\xc3\xa5lice@example.com"), ), ids=("ascii-7bit", "utf8-7bit", "ascii-8bit", "utf8-8bit"), ) def test_flatten_message_utf8_options(utf8, cte_type, expected_chunk): message = EmailMessage() 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(): 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(): 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, mime_cc_header, compat32_to_header, compat32_cc_header, expected_recipients, ): 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(): message = EmailMessage() message["Bcc"] = "alice@example.com" recipients = extract_recipients(message) assert recipients == [message["Bcc"]] def test_extract_recipients_invalid_email(): message = EmailMessage() message["Cc"] = "me" recipients = extract_recipients(message) assert recipients == ["me"] def test_extract_recipients_with_iterable_of_strings(): message = EmailMessage() message["To"] = ("me@example.com", "you") recipients = extract_recipients(message) assert recipients == ["me@example.com", "you"] def test_extract_recipients_resent_message(): 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(): 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, compat32_header, expected_sender): 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(): 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(): 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(): 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) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_errors.py0000644000000000000000000000716300000000000016107 0ustar0000000000000000""" Test error class imports, arguments, and inheritance. """ import asyncio import pytest from hypothesis import given from hypothesis.strategies import integers, lists, text from aiosmtplib import ( SMTPAuthenticationError, SMTPConnectError, SMTPConnectTimeoutError, SMTPDataError, SMTPException, SMTPHeloError, SMTPNotSupported, SMTPReadTimeoutError, SMTPRecipientRefused, SMTPRecipientsRefused, SMTPResponseException, SMTPSenderRefused, SMTPServerDisconnected, SMTPTimeoutError, ) @given(text()) def test_raise_smtp_exception(message): with pytest.raises(SMTPException) as excinfo: raise SMTPException(message) assert excinfo.value.message == message @given(integers(), text()) def test_raise_smtp_response_exception(code, message): with pytest.raises(SMTPResponseException) as excinfo: raise SMTPResponseException(code, message) assert issubclass(excinfo.type, SMTPException) assert excinfo.value.code == code assert excinfo.value.message == message @pytest.mark.parametrize( "error_class", (SMTPServerDisconnected, SMTPConnectError, SMTPConnectTimeoutError) ) @given(message=text()) def test_connection_exceptions(message, error_class): with pytest.raises(error_class) as excinfo: raise error_class(message) assert issubclass(excinfo.type, SMTPException) assert issubclass(excinfo.type, ConnectionError) assert excinfo.value.message == message @pytest.mark.parametrize( "error_class", (SMTPTimeoutError, SMTPConnectTimeoutError, SMTPReadTimeoutError) ) @given(message=text()) def test_timeout_exceptions(message, error_class): with pytest.raises(error_class) as excinfo: raise error_class(message) assert issubclass(excinfo.type, SMTPException) assert issubclass(excinfo.type, asyncio.TimeoutError) assert excinfo.value.message == message @pytest.mark.parametrize( "error_class", (SMTPHeloError, SMTPDataError, SMTPAuthenticationError) ) @given(code=integers(), message=text()) def test_simple_response_exceptions(code, message, error_class): with pytest.raises(error_class) as excinfo: raise error_class(code, message) assert issubclass(excinfo.type, SMTPResponseException) assert excinfo.value.code == code assert excinfo.value.message == message @given(integers(), text(), text()) def test_raise_smtp_sender_refused(code, message, sender): with pytest.raises(SMTPSenderRefused) as excinfo: raise SMTPSenderRefused(code, message, sender) assert issubclass(excinfo.type, SMTPResponseException) assert excinfo.value.code == code assert excinfo.value.message == message assert excinfo.value.sender == sender @given(integers(), text(), text()) def test_raise_smtp_recipient_refused(code, message, recipient): with pytest.raises(SMTPRecipientRefused) as excinfo: raise SMTPRecipientRefused(code, message, recipient) assert issubclass(excinfo.type, SMTPResponseException) assert excinfo.value.code == code assert excinfo.value.message == message assert excinfo.value.recipient == recipient @given(lists(elements=text())) def test_raise_smtp_recipients_refused(addresses): with pytest.raises(SMTPRecipientsRefused) as excinfo: raise SMTPRecipientsRefused(addresses) assert issubclass(excinfo.type, SMTPException) assert excinfo.value.recipients == addresses @given(message=text()) def test_raise_smtp_not_supported(message): with pytest.raises(SMTPNotSupported) as excinfo: raise SMTPNotSupported(message) assert issubclass(excinfo.type, SMTPException) assert excinfo.value.message == message ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_esmtp_utils.py0000644000000000000000000000275400000000000017144 0ustar0000000000000000""" Tests for ESMTP extension parsing. """ from aiosmtplib.esmtp import parse_esmtp_extensions def test_basic_extension_parsing(): 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(): 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(): 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(): 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(): 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 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_live.py0000644000000000000000000000431700000000000015530 0ustar0000000000000000""" Tests run against live mail providers. These aren't generally run as part of the test suite. """ import os from email.message import EmailMessage import pytest from aiosmtplib import ( SMTP, SMTPAuthenticationError, SMTPSenderRefused, SMTPStatus, send, ) pytestmark = [ pytest.mark.skipif( os.environ.get("AIOSMTPLIB_LIVE_TESTS") != "true", reason="No tests against real servers unless requested", ), pytest.mark.asyncio(), ] async def test_starttls_gmail(): client = SMTP(hostname="smtp.gmail.com", port=587, use_tls=False) await client.connect(timeout=1.0) await client.ehlo() await client.starttls(validate_certs=False) response = await client.ehlo() assert response.code == SMTPStatus.completed assert "smtp.gmail.com at your service" in response.message assert client.server_auth_methods with pytest.raises(SMTPAuthenticationError): await client.login("test", "test") async def test_qq_login(): client = SMTP(hostname="smtp.qq.com", port=587, use_tls=False) await client.connect(timeout=2.0) await client.ehlo() await client.starttls(validate_certs=False) with pytest.raises(SMTPAuthenticationError): await client.login("test", "test") async def test_office365_auth_send(): message = EmailMessage() message["From"] = "user@mydomain.com" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.set_content("Sent via aiosmtplib") with pytest.raises(SMTPAuthenticationError): await send( message, hostname="smtp.office365.com", port=587, start_tls=True, password="test", username="test", ) async def test_office365_skip_login(): message = EmailMessage() message["From"] = "user@mydomain.com" message["To"] = "somebody@example.com" message["Subject"] = "Hello World!" message.set_content("Sent via aiosmtplib") smtp_client = SMTP("smtp.office365.com", 587) await smtp_client.connect() await smtp_client.starttls() # skip login, which is required with pytest.raises(SMTPSenderRefused): await smtp_client.send_message(message) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_main.py0000644000000000000000000000163500000000000015515 0ustar0000000000000000import asyncio import sys import pytest pytestmark = pytest.mark.asyncio() async def test_command_line_send(hostname, smtpd_server_port): 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 ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_protocol.py0000644000000000000000000001300100000000000016420 0ustar0000000000000000""" Protocol level tests. """ import asyncio import socket import pytest from aiosmtplib import SMTPResponseException, SMTPServerDisconnected from aiosmtplib.protocol import SMTPProtocol pytestmark = pytest.mark.asyncio() async def test_protocol_connect(event_loop, hostname, echo_server_port): 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 protocol.transport is transport assert not protocol.transport.is_closing() transport.close() async def test_protocol_read_limit_overrun( event_loop, bind_address, hostname, monkeypatch ): async def client_connected(reader, writer): 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] 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) assert exc_info.value.code == 500 assert "Response too long" in exc_info.value.message server.close() await server.wait_closed() async def test_protocol_connected_check_on_read_response(monkeypatch): protocol = SMTPProtocol() monkeypatch.setattr(protocol, "transport", None) with pytest.raises(SMTPServerDisconnected): await protocol.read_response(timeout=1.0) async def test_protocol_reader_connected_check_on_start_tls(client_tls_context): smtp_protocol = SMTPProtocol() with pytest.raises(SMTPServerDisconnected): await smtp_protocol.start_tls(client_tls_context, timeout=1.0) async def test_protocol_writer_connected_check_on_start_tls(client_tls_context): smtp_protocol = SMTPProtocol() with pytest.raises(SMTPServerDisconnected): await smtp_protocol.start_tls(client_tls_context) async def test_error_on_readline_with_partial_line(event_loop, bind_address, hostname): partial_response = b"499 incomplete response\\" async def client_connected(reader, writer): 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] 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) server.close() await server.wait_closed() async def test_protocol_response_waiter_unset( event_loop, bind_address, hostname, monkeypatch ): async def client_connected(reader, writer): 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] 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) server.close() await server.wait_closed() async def test_protocol_data_received_called_twice( event_loop, bind_address, hostname, monkeypatch ): async def client_connected(reader, writer): 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] 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) assert response.code == 220 assert response.message == "Hi" server.close() await server.wait_closed() async def test_protocol_eof_response(event_loop, bind_address, hostname, monkeypatch): async def client_connected(reader, writer): writer.transport.abort() server = await asyncio.start_server( client_connected, host=bind_address, port=0, family=socket.AF_INET ) server_port = server.sockets[0].getsockname()[1] connect_future = event_loop.create_connection( SMTPProtocol, host=hostname, port=server_port ) transport, _ = await asyncio.wait_for(connect_future, timeout=1.0) server.close() await server.wait_closed() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_response.py0000644000000000000000000000076500000000000016432 0ustar0000000000000000from hypothesis import given from hypothesis.strategies import integers, text from aiosmtplib.response import SMTPResponse @given(integers(), text()) def test_response_repr(code, message): response = SMTPResponse(code, message) assert repr(response) == "({}, {})".format(response.code, response.message) @given(integers(), text()) def test_response_str(code, message): response = SMTPResponse(code, message) assert str(response) == "{} {}".format(response.code, response.message) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_sendmail.py0000644000000000000000000003175000000000000016366 0ustar0000000000000000""" SMTP.sendmail and SMTP.send_message method testing. """ import copy import email.generator import email.header import pytest from aiosmtplib import ( SMTPNotSupported, SMTPRecipientsRefused, SMTPResponseException, SMTPStatus, ) from aiosmtplib.email import formataddr pytestmark = pytest.mark.asyncio() async def test_sendmail_simple_success( smtp_client, smtpd_server, sender_str, recipient_str, message_str ): 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, smtpd_server, sender_str, recipient_str, message_str ): 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, smtpd_server, sender_str, recipient_str, message_str ): 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, smtpd_server, sender_str, recipient_str, message_str ): 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 != "" async def test_sendmail_without_size_option( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, sender_str, recipient_str, message_str, received_commands, ): response_handler = smtpd_response_handler_factory( "{} done".format(SMTPStatus.completed) ) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) 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, smtpd_server, sender_str, recipient_str, message_str ): 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, smtpd_server, sender_str, recipient_str, message_str ): 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, smtpd_server): async with smtp_client: with pytest.raises(SMTPRecipientsRefused): # @@ is an invalid recipient. await smtp_client.sendmail("test@example.com", ["@@"], "blah") async def test_sendmail_error_silent_rset_handles_disconnect( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, sender_str, recipient_str, message_str, ): response_handler = smtpd_response_handler_factory( "{} error".format(SMTPStatus.unrecognized_parameters), close_after=True ) monkeypatch.setattr(smtpd_class, "smtp_DATA", response_handler) 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, smtpd_server, received_commands ): """ 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, smtpd_server, received_commands ): """ 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" async def test_rset_after_sendmail_error_response_to_data( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, sender_str, recipient_str, message_str, received_commands, ): """ If an error response is given to the DATA command in the sendmail method, test that we reset the server session. """ response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_DATA", response_handler) 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 == error_code assert received_commands[-1][0] == "RSET" async def test_send_message(smtp_client, smtpd_server, message): 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, smtpd_server, message, received_messages ): 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_multiple_messages_in_sequence(smtp_client, smtpd_server, message): 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, smtpd_server, message): 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, smtpd_server, message): del message["From"] async with smtp_client: with pytest.raises(ValueError): await smtp_client.send_message(message) async def test_send_message_smtputf8_sender( smtp_client_smtputf8, smtpd_server_smtputf8, message, received_commands, received_messages, ): del message["From"] message["From"] = "séndër@exåmple.com" async with smtp_client_smtputf8: errors, response = await smtp_client_smtputf8.send_message(message) assert not errors assert response != "" assert received_commands[1][0] == "MAIL" assert received_commands[1][1] == message["From"] # Size varies depending on the message type assert received_commands[1][2][0].startswith("SIZE=") assert received_commands[1][2][1:] == ["SMTPUTF8", "BODY=8BITMIME"] assert len(received_messages) == 1 assert received_messages[0]["X-MailFrom"] == message["From"] async def test_send_mime_message_smtputf8_recipient( smtp_client_smtputf8, smtpd_server_smtputf8, mime_message, received_commands, received_messages, ): mime_message["To"] = "reçipïént@exåmple.com" async with smtp_client_smtputf8: errors, response = await smtp_client_smtputf8.send_message(mime_message) assert not errors assert response != "" assert received_commands[2][0] == "RCPT" assert received_commands[2][1] == mime_message["To"] assert len(received_messages) == 1 assert received_messages[0]["X-RcptTo"] == ", ".join(mime_message.get_all("To")) async def test_send_compat32_message_smtputf8_recipient( smtp_client_smtputf8, smtpd_server_smtputf8, compat32_message, received_commands, received_messages, ): 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_smtputf8: errors, response = await smtp_client_smtputf8.send_message(compat32_message) assert not errors assert response != "" assert received_commands[2][0] == "RCPT" assert received_commands[2][1] == compat32_message["To"] assert len(received_messages) == 1 assert ( received_messages[0]["X-RcptTo"] == "recipient@example.com, reçipïént@exåmple.com" ) async def test_send_message_smtputf8_not_supported(smtp_client, smtpd_server, message): message["To"] = "reçipïént2@exåmple.com" async with smtp_client: with pytest.raises(SMTPNotSupported): await smtp_client.send_message(message) async def test_send_message_with_formataddr(smtp_client, smtpd_server, message): message["To"] = formataddr(("æøå", "someotheruser@example.com")) async with smtp_client: errors, response = await smtp_client.send_message(message) assert not errors assert response != "" async def test_send_compat32_message_utf8_text_without_smtputf8( smtp_client, smtpd_server, compat32_message, received_commands, received_messages ): 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] == 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==?=", ] async def test_send_mime_message_utf8_text_without_smtputf8( smtp_client, smtpd_server, mime_message, received_commands, received_messages ): 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] == 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==?=", ] async def test_sendmail_empty_sender( smtp_client, smtpd_server, recipient_str, message_str ): async with smtp_client: errors, response = await smtp_client.sendmail("", [recipient_str], message_str) assert not errors assert isinstance(errors, dict) assert response != "" ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_sync.py0000644000000000000000000000345100000000000015543 0ustar0000000000000000""" Sync method tests. """ import pytest from aiosmtplib.sync import async_to_sync def test_sendmail_sync( event_loop, smtp_client_threaded, sender_str, recipient_str, message_str ): errors, response = smtp_client_threaded.sendmail_sync( sender_str, [recipient_str], message_str ) assert not errors assert isinstance(errors, dict) assert response != "" def test_sendmail_sync_when_connected( event_loop, smtp_client_threaded, sender_str, recipient_str, message_str ): event_loop.run_until_complete(smtp_client_threaded.connect()) 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(event_loop, smtp_client_threaded, message): errors, response = smtp_client_threaded.send_message_sync(message) assert not errors assert isinstance(errors, dict) assert response != "" def test_send_message_sync_when_connected(event_loop, smtp_client_threaded, message): event_loop.run_until_complete(smtp_client_threaded.connect()) errors, response = smtp_client_threaded.send_message_sync(message) assert not errors assert isinstance(errors, dict) assert response != "" def test_async_to_sync_without_loop(event_loop): async def test_func(): return 7 result = async_to_sync(test_func()) assert result == 7 def test_async_to_sync_with_exception(event_loop): async def test_func(): raise ZeroDivisionError with pytest.raises(ZeroDivisionError): async_to_sync(test_func(), loop=event_loop) @pytest.mark.asyncio async def test_async_to_sync_with_running_loop(event_loop): with pytest.raises(RuntimeError): async_to_sync(None) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_timeouts.py0000644000000000000000000001136200000000000016440 0ustar0000000000000000""" Timeout tests. """ import asyncio import socket import pytest from aiosmtplib import ( SMTP, SMTPConnectTimeoutError, SMTPServerDisconnected, SMTPStatus, SMTPTimeoutError, ) from aiosmtplib.protocol import SMTPProtocol pytestmark = pytest.mark.asyncio() @pytest.fixture(scope="session") def delayed_ok_response_handler(request): async def delayed_ok_response(smtpd, *args, **kwargs): await asyncio.sleep(1.0) await smtpd.push("{} all done".format(SMTPStatus.completed)) return delayed_ok_response @pytest.fixture(scope="session") def delayed_read_response_handler(request): async def delayed_read_response(smtpd, *args, **kwargs): await smtpd.push("{}-hi".format(SMTPStatus.ready)) await asyncio.sleep(1.0) return delayed_read_response async def test_command_timeout_error( smtp_client, smtpd_server, smtpd_class, delayed_ok_response_handler, monkeypatch ): monkeypatch.setattr(smtpd_class, "smtp_EHLO", delayed_ok_response_handler) await smtp_client.connect() with pytest.raises(SMTPTimeoutError): await smtp_client.ehlo("example.com", timeout=0.0) async def test_data_timeout_error( smtp_client, smtpd_server, smtpd_class, delayed_ok_response_handler, monkeypatch ): monkeypatch.setattr(smtpd_class, "smtp_DATA", delayed_ok_response_handler) 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) async def test_timeout_error_on_connect( smtp_client, smtpd_server, smtpd_class, delayed_ok_response_handler, monkeypatch ): monkeypatch.setattr(smtpd_class, "_handle_client", delayed_ok_response_handler) with pytest.raises(SMTPTimeoutError): await smtp_client.connect(timeout=0.0) assert smtp_client.transport is None assert smtp_client.protocol is None async def test_timeout_on_initial_read( smtp_client, smtpd_server, smtpd_class, delayed_read_response_handler, monkeypatch ): monkeypatch.setattr(smtpd_class, "_handle_client", delayed_read_response_handler) 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) async def test_timeout_on_starttls( smtp_client, smtpd_server, smtpd_class, delayed_ok_response_handler, monkeypatch ): monkeypatch.setattr(smtpd_class, "smtp_STARTTLS", delayed_ok_response_handler) await smtp_client.connect() await smtp_client.ehlo() with pytest.raises(SMTPTimeoutError): await smtp_client.starttls(validate_certs=False, timeout=0.0) async def test_protocol_read_response_with_timeout_times_out( event_loop, echo_server, hostname, echo_server_port ): 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) transport.close() assert str(exc.value) == "Timed out waiting for server response" async def test_connect_timeout_error(hostname, unused_tcp_port): client = SMTP(hostname=hostname, port=unused_tcp_port, timeout=0.0) with pytest.raises(SMTPConnectTimeoutError) as exc: await client.connect() expected_message = "Timed out connecting to {host} on port {port}".format( host=hostname, port=unused_tcp_port ) assert str(exc.value) == expected_message async def test_server_disconnected_error_after_connect_timeout( hostname, unused_tcp_port, sender_str, recipient_str, message_str ): 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( event_loop, bind_address, hostname, client_tls_context ): async def client_connected(reader, writer): 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] 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) server.close() await server.wait_closed() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583603.4034297 aiosmtplib-1.1.6/tests/test_tls.py0000644000000000000000000002076500000000000015400 0ustar0000000000000000""" TLS and STARTTLS handling. """ import copy import ssl import pytest from aiosmtplib import ( SMTP, SMTPConnectError, SMTPException, SMTPResponseException, SMTPServerDisconnected, SMTPStatus, ) pytestmark = pytest.mark.asyncio() async def test_tls_connection(tls_smtp_client, tls_smtpd_server): """ Use an explicit connect/quit here, as other tests use the context manager. """ await tls_smtp_client.connect() assert tls_smtp_client.is_connected await tls_smtp_client.quit() assert not tls_smtp_client.is_connected async def test_starttls(smtp_client, smtpd_server): async with smtp_client: response = await smtp_client.starttls(validate_certs=False) 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, smtpd_server_port): smtp_client = SMTP( hostname=hostname, port=smtpd_server_port, start_tls=True, validate_certs=False ) 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_connect_kwarg(smtp_client, smtpd_server): await smtp_client.connect(start_tls=True, validate_certs=False) # 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_with_explicit_server_hostname( smtp_client, hostname, smtpd_server ): async with smtp_client: await smtp_client.ehlo() await smtp_client.starttls(validate_certs=False, server_hostname=hostname) async def test_starttls_not_supported( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} HELP".format(SMTPStatus.completed) ) monkeypatch.setattr(smtpd_class, "smtp_EHLO", response_handler) async with smtp_client: await smtp_client.ehlo() with pytest.raises(SMTPException): await smtp_client.starttls(validate_certs=False) async def test_starttls_advertised_but_not_supported( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} please login".format(SMTPStatus.tls_not_available) ) monkeypatch.setattr(smtpd_class, "smtp_STARTTLS", response_handler) async with smtp_client: await smtp_client.ehlo() with pytest.raises(SMTPException): await smtp_client.starttls(validate_certs=False) async def test_starttls_disconnect_before_upgrade( smtp_client, smtpd_server, smtpd_class, smtpd_response_handler_factory, monkeypatch ): response_handler = smtpd_response_handler_factory( "{} Go for it".format(SMTPStatus.ready), close_after=True ) monkeypatch.setattr(smtpd_class, "smtp_STARTTLS", response_handler) async with smtp_client: with pytest.raises(SMTPServerDisconnected): await smtp_client.starttls(validate_certs=False) async def test_starttls_invalid_responses( smtp_client, smtpd_server, event_loop, smtpd_class, smtpd_response_handler_factory, monkeypatch, error_code, ): response_handler = smtpd_response_handler_factory("{} error".format(error_code)) monkeypatch.setattr(smtpd_class, "smtp_STARTTLS", response_handler) 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(validate_certs=False) assert exception_info.value.code == error_code # 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__ async def test_starttls_with_client_cert( smtp_client, smtpd_server, valid_cert_path, valid_key_path ): async with smtp_client: response = await smtp_client.starttls( client_cert=valid_cert_path, client_key=valid_key_path, cert_bundle=valid_cert_path, validate_certs=True, ) 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 == valid_cert_path async def test_starttls_with_invalid_client_cert( smtp_client, smtpd_server, invalid_cert_path, invalid_key_path ): 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, validate_certs=True, ) async def test_starttls_cert_error(event_loop, smtp_client, smtpd_server): # Don't fail on the expected exception event_loop.set_exception_handler(None) async with smtp_client: with pytest.raises(ssl.SSLError): await smtp_client.starttls(validate_certs=True) async def test_tls_get_transport_info( tls_smtp_client, hostname, tls_smtpd_server_port, event_loop ): async with tls_smtp_client: compression = tls_smtp_client.get_transport_info("compression") assert compression is None # Compression is not used here peername = tls_smtp_client.get_transport_info("peername") assert peername[0] in ("127.0.0.1", "::1") # IP v4 and 6 assert peername[1] == tls_smtpd_server_port sock = tls_smtp_client.get_transport_info("socket") assert sock is not None sockname = tls_smtp_client.get_transport_info("sockname") assert sockname is not None cipher = tls_smtp_client.get_transport_info("cipher") assert cipher is not None peercert = tls_smtp_client.get_transport_info("peercert") assert peercert is not None sslcontext = tls_smtp_client.get_transport_info("sslcontext") assert sslcontext is not None sslobj = tls_smtp_client.get_transport_info("ssl_object") assert sslobj is not None async def test_tls_smtp_connect_to_non_tls_server( event_loop, tls_smtp_client, smtpd_server_port ): # Don't fail on the expected exception event_loop.set_exception_handler(None) with pytest.raises(SMTPConnectError): await tls_smtp_client.connect(port=smtpd_server_port) assert not tls_smtp_client.is_connected async def test_tls_connection_with_existing_sslcontext( tls_smtp_client, tls_smtpd_server, client_tls_context ): await tls_smtp_client.connect(tls_context=client_tls_context) assert tls_smtp_client.is_connected assert tls_smtp_client.tls_context is client_tls_context await tls_smtp_client.quit() assert not tls_smtp_client.is_connected async def test_tls_connection_with_client_cert( tls_smtp_client, tls_smtpd_server, hostname, valid_cert_path, valid_key_path ): await tls_smtp_client.connect( hostname=hostname, validate_certs=True, client_cert=valid_cert_path, client_key=valid_key_path, cert_bundle=valid_cert_path, ) assert tls_smtp_client.is_connected await tls_smtp_client.quit() assert not tls_smtp_client.is_connected async def test_tls_connection_with_cert_error( event_loop, tls_smtp_client, tls_smtpd_server ): # Don't fail on the expected exception event_loop.set_exception_handler(None) with pytest.raises(SMTPConnectError) as exception_info: await tls_smtp_client.connect(validate_certs=True) assert "CERTIFICATE" in str(exception_info.value).upper() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1620583605.1854465 aiosmtplib-1.1.6/setup.py0000644000000000000000000000552700000000000013534 0ustar0000000000000000# -*- coding: utf-8 -*- from setuptools import setup packages = \ ['aiosmtplib', 'docs', 'tests'] package_data = \ {'': ['*'], 'tests': ['certs/*']} extras_require = \ {'docs': ['sphinx>=2,<4', 'sphinx_autodoc_typehints>=1.7.0,<2.0.0'], 'uvloop': ['uvloop>=0.13,<0.15']} setup_kwargs = { 'name': 'aiosmtplib', 'version': '1.1.6', 'description': 'asyncio SMTP client', 'long_description': 'aiosmtplib\n==========\n\n|circleci| |codecov| |pypi-version| |pypi-python-versions| |pypi-status| |downloads|\n|pypi-license| |black|\n\n------------\n\naiosmtplib is an asynchronous SMTP client for use with asyncio.\n\nFor documentation, see `Read The Docs`_.\n\nQuickstart\n----------\n\n.. code-block:: python\n\n import asyncio\n from email.message import EmailMessage\n\n import aiosmtplib\n\n message = EmailMessage()\n message["From"] = "root@localhost"\n message["To"] = "somebody@example.com"\n message["Subject"] = "Hello World!"\n message.set_content("Sent via aiosmtplib")\n\n loop = asyncio.get_event_loop()\n loop.run_until_complete(aiosmtplib.send(message, hostname="127.0.0.1", port=25))\n\n\nRequirements\n------------\nPython 3.5.2+, compiled with SSL support, is required.\n\n\nBug reporting\n-------------\nBug reports (and feature requests) are welcome via Github issues.\n\n\n\n.. |circleci| image:: https://circleci.com/gh/cole/aiosmtplib/tree/main.svg?style=shield\n :target: https://circleci.com/gh/cole/aiosmtplib/tree/main\n :alt: "aiosmtplib CircleCI build status"\n.. |pypi-version| image:: https://img.shields.io/pypi/v/aiosmtplib.svg\n :target: https://pypi.python.org/pypi/aiosmtplib\n :alt: "aiosmtplib on the Python Package Index"\n.. |pypi-python-versions| image:: https://img.shields.io/pypi/pyversions/aiosmtplib.svg\n.. |pypi-status| image:: https://img.shields.io/pypi/status/aiosmtplib.svg\n.. |pypi-license| image:: https://img.shields.io/pypi/l/aiosmtplib.svg\n.. |codecov| image:: https://codecov.io/gh/cole/aiosmtplib/branch/main/graph/badge.svg\n :target: https://codecov.io/gh/cole/aiosmtplib\n.. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg\n :target: https://github.com/ambv/black\n :alt: "Code style: black"\n.. |downloads| image:: https://pepy.tech/badge/aiosmtplib\n :target: https://pepy.tech/project/aiosmtplib\n :alt: "aiosmtplib on pypy.tech"\n.. _Read The Docs: https://aiosmtplib.readthedocs.io/en/stable/overview.html\n', 'author': 'Cole Maclean', 'author_email': 'hi@colemaclean.dev', 'maintainer': None, 'maintainer_email': None, 'url': 'https://github.com/cole/aiosmtplib', 'packages': packages, 'package_data': package_data, 'extras_require': extras_require, 'python_requires': '>=3.5.2,<4.0.0', } setup(**setup_kwargs) ././@PaxHeader0000000000000000000000000000003100000000000011447 xustar000000000000000025 mtime=1620583605.1858 aiosmtplib-1.1.6/PKG-INFO0000644000000000000000000000673400000000000013120 0ustar0000000000000000Metadata-Version: 2.1 Name: aiosmtplib Version: 1.1.6 Summary: asyncio SMTP client Home-page: https://github.com/cole/aiosmtplib License: MIT Keywords: smtp,email,asyncio Author: Cole Maclean Author-email: hi@colemaclean.dev Requires-Python: >=3.5.2,<4.0.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: No Input/Output (Daemon) Classifier: Framework :: AsyncIO Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Communications Classifier: Topic :: Communications :: Email Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Typing :: Typed Provides-Extra: docs Provides-Extra: uvloop Requires-Dist: sphinx (>=2,<4); extra == "docs" Requires-Dist: sphinx_autodoc_typehints (>=1.7.0,<2.0.0); extra == "docs" Requires-Dist: uvloop (>=0.13,<0.15); extra == "uvloop" Project-URL: Documentation, https://aiosmtplib.readthedocs.io/en/stable/ Project-URL: Repository, https://github.com/cole/aiosmtplib Description-Content-Type: text/x-rst aiosmtplib ========== |circleci| |codecov| |pypi-version| |pypi-python-versions| |pypi-status| |downloads| |pypi-license| |black| ------------ aiosmtplib is an asynchronous SMTP client for use with asyncio. For documentation, see `Read The Docs`_. 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") loop = asyncio.get_event_loop() loop.run_until_complete(aiosmtplib.send(message, hostname="127.0.0.1", port=25)) Requirements ------------ Python 3.5.2+, compiled with SSL support, is required. Bug reporting ------------- Bug reports (and feature requests) are welcome via Github issues. .. |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-python-versions| image:: https://img.shields.io/pypi/pyversions/aiosmtplib.svg .. |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 .. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/ambv/black :alt: "Code style: black" .. |downloads| image:: https://pepy.tech/badge/aiosmtplib :target: https://pepy.tech/project/aiosmtplib :alt: "aiosmtplib on pypy.tech" .. _Read The Docs: https://aiosmtplib.readthedocs.io/en/stable/overview.html