update to python fastpi

This commit is contained in:
Iliyan Angelov
2025-11-16 15:59:05 +02:00
parent 93d4c1df80
commit 98ccd5b6ff
4464 changed files with 773233 additions and 13740 deletions

View File

@@ -0,0 +1,60 @@
"""
aiosmtplib
==========
An asyncio SMTP client.
Originally based on smtplib from the Python 3 standard library by:
The Dragon De Monsyne <dragondm@integral.org>
Author: Cole Maclean <hi@colemaclean.dev>
"""
from .api import send
from .errors import (
SMTPAuthenticationError,
SMTPConnectError,
SMTPConnectTimeoutError,
SMTPDataError,
SMTPException,
SMTPHeloError,
SMTPNotSupported,
SMTPReadTimeoutError,
SMTPRecipientRefused,
SMTPRecipientsRefused,
SMTPResponseException,
SMTPSenderRefused,
SMTPServerDisconnected,
SMTPTimeoutError,
SMTPConnectResponseError,
)
from .response import SMTPResponse
from .smtp import SMTP
from .typing import SMTPStatus
__title__ = "aiosmtplib"
__version__ = "3.0.1"
__author__ = "Cole Maclean"
__license__ = "MIT"
__copyright__ = "Copyright 2022 Cole Maclean"
__all__ = (
"send",
"SMTP",
"SMTPResponse",
"SMTPStatus",
"SMTPAuthenticationError",
"SMTPConnectError",
"SMTPDataError",
"SMTPException",
"SMTPHeloError",
"SMTPNotSupported",
"SMTPRecipientRefused",
"SMTPRecipientsRefused",
"SMTPResponseException",
"SMTPSenderRefused",
"SMTPServerDisconnected",
"SMTPTimeoutError",
"SMTPConnectTimeoutError",
"SMTPReadTimeoutError",
"SMTPConnectResponseError",
)

View File

@@ -0,0 +1,30 @@
from aiosmtplib.smtp import SMTP, SMTP_PORT
raw_hostname = input("SMTP server hostname [localhost]: ") # nosec
raw_port = input(f"SMTP server port [{SMTP_PORT}]: ") # nosec
raw_sender = input("From: ") # nosec
raw_recipients = input("To: ") # nosec
hostname = raw_hostname or "localhost"
port = int(raw_port) if raw_port else SMTP_PORT
recipients = raw_recipients.split(",")
lines = []
print("Enter message, end with ^D:")
while True:
try:
lines.append(input()) # nosec
except EOFError:
break
message = "\n".join(lines)
message_len = len(message.encode("utf-8"))
print(f"Message length (bytes): {message_len}")
smtp_client = SMTP(hostname=hostname or "localhost", port=port, start_tls=False)
sendmail_errors, sendmail_response = smtp_client.sendmail_sync(
raw_sender, recipients, message
)
print(f"Server response: {sendmail_response}")

View File

@@ -0,0 +1,139 @@
"""
Main public API.
"""
import email.message
import socket
import ssl
from typing import Dict, Optional, Sequence, Tuple, Union, cast
from .response import SMTPResponse
from .smtp import DEFAULT_TIMEOUT, SMTP
from .typing import SocketPathType
__all__ = ("send",)
async def send(
message: Union[email.message.EmailMessage, email.message.Message, str, bytes],
/,
*,
sender: Optional[str] = None,
recipients: Optional[Union[str, Sequence[str]]] = None,
mail_options: Optional[Sequence[str]] = None,
rcpt_options: Optional[Sequence[str]] = None,
hostname: Optional[str] = "localhost",
port: Optional[int] = None,
username: Optional[Union[str, bytes]] = None,
password: Optional[Union[str, bytes]] = None,
local_hostname: Optional[str] = None,
source_address: Optional[Tuple[str, int]] = None,
timeout: Optional[float] = DEFAULT_TIMEOUT,
use_tls: bool = False,
start_tls: Optional[bool] = None,
validate_certs: bool = True,
client_cert: Optional[str] = None,
client_key: Optional[str] = None,
tls_context: Optional[ssl.SSLContext] = None,
cert_bundle: Optional[str] = None,
socket_path: Optional[SocketPathType] = None,
sock: Optional[socket.socket] = None,
) -> Tuple[Dict[str, SMTPResponse], str]:
"""
Send an email message. On await, connects to the SMTP server using the details
provided, sends the message, then disconnects.
:param message: Message text. Either an :py:class:`email.message.EmailMessage`
object, ``str`` or ``bytes``. If an :py:class:`email.message.EmailMessage`
object is provided, sender and recipients set in the message headers will be
used, unless overridden by the respective keyword arguments.
:keyword sender: From email address. Not required if an
:py:class:`email.message.EmailMessage` object is provided for the `message`
argument.
:keyword recipients: Recipient email addresses. Not required if an
:py:class:`email.message.EmailMessage` object is provided for the `message`
argument.
:keyword hostname: Server name (or IP) to connect to. Defaults to "localhost".
:keyword port: Server port. Defaults ``465`` if ``use_tls`` is ``True``,
``587`` if ``start_tls`` is ``True``, or ``25`` otherwise.
:keyword username: Username to login as after connect.
:keyword password: Password for login after connect.
:keyword local_hostname: The hostname of the client. If specified, used as the
FQDN of the local host in the HELO/EHLO command. Otherwise, the result of
:func:`socket.getfqdn`. **Note that getfqdn will block the event loop.**
:keyword source_address: Takes a 2-tuple (host, port) for the socket to bind to
as its source address before connecting. If the host is '' and port is 0,
the OS default behavior will be used.
:keyword timeout: Default timeout value for the connection, in seconds.
Defaults to 60.
:keyword use_tls: If True, make the initial connection to the server
over TLS/SSL. Mutually exclusive with ``start_tls``; if the server uses
STARTTLS, ``use_tls`` should be ``False``.
:keyword start_tls: Flag to initiate a STARTTLS upgrade on connect.
If ``None`` (the default), upgrade will be initiated if supported by the
server.
If ``True``, and upgrade will be initiated regardless of server support.
If ``False``, no upgrade will occur.
Mutually exclusive with ``use_tls``.
:keyword validate_certs: Determines if server certificates are
validated. Defaults to ``True``.
:keyword client_cert: Path to client side certificate, for TLS.
:keyword client_key: Path to client side key, for TLS.
:keyword tls_context: An existing :py:class:`ssl.SSLContext`, for TLS.
Mutually exclusive with ``client_cert``/``client_key``.
:keyword cert_bundle: Path to certificate bundle, for TLS verification.
:keyword socket_path: Path to a Unix domain socket. Not compatible with
hostname or port. Accepts str, bytes, or a pathlike object.
:keyword sock: An existing, connected socket object. If given, none of
hostname, port, or socket_path should be provided.
:raises ValueError: required arguments missing or mutually exclusive options
provided
"""
if not isinstance(message, (email.message.EmailMessage, email.message.Message)):
if not recipients:
raise ValueError("Recipients must be provided with raw messages.")
if not sender:
raise ValueError("Sender must be provided with raw messages.")
sender = cast(str, sender)
recipients = cast(Union[str, Sequence[str]], recipients)
client = SMTP(
hostname=hostname,
port=port,
local_hostname=local_hostname,
source_address=source_address,
timeout=timeout,
use_tls=use_tls,
start_tls=start_tls,
validate_certs=validate_certs,
client_cert=client_cert,
client_key=client_key,
tls_context=tls_context,
cert_bundle=cert_bundle,
socket_path=socket_path,
sock=sock,
username=username,
password=password,
)
async with client:
if isinstance(message, (email.message.EmailMessage, email.message.Message)):
result = await client.send_message(
message,
sender=sender,
recipients=recipients,
mail_options=mail_options,
rcpt_options=rcpt_options,
)
else:
result = await client.sendmail(
sender,
recipients,
message,
mail_options=mail_options,
rcpt_options=rcpt_options,
)
return result

View File

@@ -0,0 +1,72 @@
"""
Authentication related methods.
"""
import base64
import hmac
from typing import Tuple, Union
__all__ = ("auth_crammd5_verify", "auth_plain_encode", "auth_login_encode")
def _ensure_bytes(value: Union[str, bytes]) -> bytes:
if isinstance(value, bytes):
return value
return value.encode("utf-8")
def auth_crammd5_verify(
username: Union[str, bytes],
password: Union[str, bytes],
challenge: Union[str, bytes],
/,
) -> bytes:
"""
CRAM-MD5 auth uses the password as a shared secret to MD5 the server's
response, and sends the username combined with that (base64 encoded).
"""
username_bytes = _ensure_bytes(username)
password_bytes = _ensure_bytes(password)
decoded_challenge = base64.b64decode(challenge)
md5_digest = hmac.new(password_bytes, msg=decoded_challenge, digestmod="md5")
verification = username_bytes + b" " + md5_digest.hexdigest().encode("ascii")
encoded_verification = base64.b64encode(verification)
return encoded_verification
def auth_plain_encode(
username: Union[str, bytes],
password: Union[str, bytes],
/,
) -> bytes:
"""
PLAIN auth base64 encodes the username and password together.
"""
username_bytes = _ensure_bytes(username)
password_bytes = _ensure_bytes(password)
username_and_password = b"\0" + username_bytes + b"\0" + password_bytes
encoded = base64.b64encode(username_and_password)
return encoded
def auth_login_encode(
username: Union[str, bytes],
password: Union[str, bytes],
/,
) -> Tuple[bytes, bytes]:
"""
LOGIN auth base64 encodes the username and password and sends them
in sequence.
"""
username_bytes = _ensure_bytes(username)
password_bytes = _ensure_bytes(password)
encoded_username = base64.b64encode(username_bytes)
encoded_password = base64.b64encode(password_bytes)
return encoded_username, encoded_password

View File

@@ -0,0 +1,186 @@
"""
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 f"<{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 f"{encoded_name} <{address}>"
else:
quotes = ""
if SPECIALS_REGEX.search(name):
quotes = '"'
name = ESCAPES_REGEX.sub(r"\\\g<0>", name)
return f"{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, email.message.EmailMessage):
# New message class, default policy
policy = email.policy.default.clone(
linesep=LINE_SEP,
utf8=utf8,
cte_type=cte_type,
)
else:
# Old message class, Compat32 policy.
# Compat32 cannot use UTF8
policy = email.policy.compat32.clone(linesep=LINE_SEP, 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: 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

View File

@@ -0,0 +1,137 @@
from asyncio import TimeoutError
from typing import List
__all__ = (
"SMTPAuthenticationError",
"SMTPConnectError",
"SMTPDataError",
"SMTPException",
"SMTPHeloError",
"SMTPNotSupported",
"SMTPRecipientRefused",
"SMTPRecipientsRefused",
"SMTPResponseException",
"SMTPSenderRefused",
"SMTPServerDisconnected",
"SMTPTimeoutError",
"SMTPConnectTimeoutError",
"SMTPReadTimeoutError",
"SMTPConnectResponseError",
)
class SMTPException(Exception):
"""
Base class for all SMTP exceptions.
"""
def __init__(self, message: str, /) -> None:
self.message = message
self.args = (message,)
class SMTPServerDisconnected(SMTPException, ConnectionError):
"""
The connection was lost unexpectedly, or a command was run that requires
a connection.
"""
class SMTPConnectError(SMTPException, ConnectionError):
"""
An error occurred while connecting to the SMTP server.
"""
class SMTPTimeoutError(SMTPException, TimeoutError):
"""
A timeout occurred while performing a network operation.
"""
class SMTPConnectTimeoutError(SMTPTimeoutError, SMTPConnectError):
"""
A timeout occurred while connecting to the SMTP server.
"""
class SMTPReadTimeoutError(SMTPTimeoutError):
"""
A timeout occurred while waiting for a response from the SMTP server.
"""
class SMTPNotSupported(SMTPException):
"""
A command or argument sent to the SMTP server is not supported.
"""
class SMTPResponseException(SMTPException):
"""
Base class for all server responses with error codes.
"""
def __init__(self, code: int, message: str, /) -> None:
self.code = code
self.message = message
self.args = (code, message)
class SMTPConnectResponseError(SMTPResponseException, SMTPConnectError):
"""
The SMTP server returned an invalid response code after connecting.
"""
class SMTPHeloError(SMTPResponseException):
"""
Server refused HELO or EHLO.
"""
class SMTPDataError(SMTPResponseException):
"""
Server refused DATA content.
"""
class SMTPAuthenticationError(SMTPResponseException):
"""
Server refused our AUTH request; may be caused by invalid credentials.
"""
class SMTPSenderRefused(SMTPResponseException):
"""
SMTP server refused the message sender.
"""
def __init__(self, code: int, message: str, sender: str, /) -> None:
self.code = code
self.message = message
self.sender = sender
self.args = (code, message, sender)
class SMTPRecipientRefused(SMTPResponseException):
"""
SMTP server refused a message recipient.
"""
def __init__(self, code: int, message: str, recipient: str, /) -> None:
self.code = code
self.message = message
self.recipient = recipient
self.args = (code, message, recipient)
class SMTPRecipientsRefused(SMTPException):
"""
SMTP server refused multiple recipients.
"""
def __init__(self, recipients: List[SMTPRecipientRefused], /) -> None:
self.recipients = recipients
self.args = (recipients,)

View File

@@ -0,0 +1,72 @@
"""
ESMTP utils
"""
import re
from typing import Dict, List, Tuple
__all__ = ("parse_esmtp_extensions",)
OLDSTYLE_AUTH_REGEX = re.compile(r"auth=(?P<auth>.*)", flags=re.I)
EXTENSIONS_REGEX = re.compile(r"(?P<ext>[A-Za-z0-9][A-Za-z0-9\-]*) ?")
def parse_esmtp_extensions(message: str) -> Tuple[Dict[str, str], List[str]]:
"""
Parse an EHLO response from the server into a dict of {extension: params}
and a list of auth method names.
It might look something like:
220 size.does.matter.af.MIL (More ESMTP than Crappysoft!)
EHLO heaven.af.mil
250-size.does.matter.af.MIL offers FIFTEEN extensions:
250-8BITMIME
250-PIPELINING
250-DSN
250-ENHANCEDSTATUSCODES
250-EXPN
250-HELP
250-SAML
250-SEND
250-SOML
250-TURN
250-XADR
250-XSTA
250-ETRN
250-XGEN
250 SIZE 51200000
"""
esmtp_extensions = {}
auth_types: List[str] = []
response_lines = message.split("\n")
# ignore the first line
for line in response_lines[1:]:
# To be able to communicate with as many SMTP servers as possible,
# we have to take the old-style auth advertisement into account,
# because:
# 1) Else our SMTP feature parser gets confused.
# 2) There are some servers that only advertise the auth methods we
# support using the old style.
auth_match = OLDSTYLE_AUTH_REGEX.match(line)
if auth_match is not None:
auth_type = auth_match.group("auth")
auth_types.append(auth_type.lower().strip())
# RFC 1869 requires a space between ehlo keyword and parameters.
# It's actually stricter, in that only spaces are allowed between
# parameters, but were not going to check for that here. Note
# that the space isn't present if there are no parameters.
extensions = EXTENSIONS_REGEX.match(line)
if extensions is not None:
extension = extensions.group("ext").lower()
params = extensions.string[extensions.end("ext") :].strip()
esmtp_extensions[extension] = params
if extension == "auth":
auth_types.extend([param.strip().lower() for param in params.split()])
return esmtp_extensions, auth_types

View File

@@ -0,0 +1,373 @@
"""
An ``asyncio.Protocol`` subclass for lower level IO handling.
"""
import asyncio
import collections
import re
import ssl
from typing import Deque, Optional, cast
from .errors import (
SMTPDataError,
SMTPReadTimeoutError,
SMTPResponseException,
SMTPServerDisconnected,
SMTPTimeoutError,
)
from .response import SMTPResponse
from .typing import SMTPStatus
__all__ = ("SMTPProtocol",)
MAX_LINE_LENGTH = 8192
LINE_ENDINGS_REGEX = re.compile(rb"(?:\r\n|\n|\r(?!\n))")
PERIOD_REGEX = re.compile(rb"(?m)^\.")
class FlowControlMixin(asyncio.Protocol):
"""
Reusable flow control logic for StreamWriter.drain().
This implements the protocol methods pause_writing(),
resume_writing() and connection_lost(). If the subclass overrides
these it must call the super methods.
StreamWriter.drain() must wait for _drain_helper() coroutine.
Copied from stdlib as per recommendation: https://bugs.python.org/msg343685.
Logging and asserts removed, type annotations added.
"""
def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None):
if loop is None:
self._loop = asyncio.get_event_loop()
else:
self._loop = loop
self._paused = False
self._drain_waiters: Deque[asyncio.Future[None]] = collections.deque()
self._connection_lost = False
def pause_writing(self) -> None:
self._paused = True
def resume_writing(self) -> None:
self._paused = False
for waiter in self._drain_waiters:
if not waiter.done():
waiter.set_result(None)
def connection_lost(self, exc: Optional[Exception]) -> None:
self._connection_lost = True
# Wake up the writer(s) if currently paused.
if not self._paused:
return
for waiter in self._drain_waiters:
if not waiter.done():
if exc is None:
waiter.set_result(None)
else:
waiter.set_exception(exc)
async def _drain_helper(self) -> None:
if self._connection_lost:
raise ConnectionResetError("Connection lost")
if not self._paused:
return
waiter = self._loop.create_future()
self._drain_waiters.append(waiter)
try:
await waiter
finally:
self._drain_waiters.remove(waiter)
def _get_close_waiter(self, stream: asyncio.StreamWriter) -> "asyncio.Future[None]":
raise NotImplementedError
class SMTPProtocol(FlowControlMixin, asyncio.BaseProtocol):
def __init__(
self,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
super().__init__(loop=loop)
self._over_ssl = False
self._buffer = bytearray()
self._response_waiter: Optional[asyncio.Future[SMTPResponse]] = None
self.transport: Optional[asyncio.BaseTransport] = None
self._command_lock: Optional[asyncio.Lock] = None
self._closed: "asyncio.Future[None]" = self._loop.create_future()
self._quit_sent = False
def _get_close_waiter(self, stream: asyncio.StreamWriter) -> "asyncio.Future[None]":
return self._closed
def __del__(self) -> None:
# Avoid 'Future exception was never retrieved' warnings
# Some unknown race conditions can sometimes trigger these :(
self._retrieve_response_exception()
@property
def is_connected(self) -> bool:
"""
Check if our transport is still connected.
"""
return bool(self.transport is not None and not self.transport.is_closing())
def connection_made(self, transport: asyncio.BaseTransport) -> None:
self.transport = cast(asyncio.Transport, transport)
self._over_ssl = transport.get_extra_info("sslcontext") is not None
self._response_waiter = self._loop.create_future()
self._command_lock = asyncio.Lock()
self._quit_sent = False
def connection_lost(self, exc: Optional[Exception]) -> None:
super().connection_lost(exc)
if not self._quit_sent:
smtp_exc = SMTPServerDisconnected("Connection lost")
if exc:
smtp_exc.__cause__ = exc
if self._response_waiter and not self._response_waiter.done():
self._response_waiter.set_exception(smtp_exc)
self.transport = None
self._command_lock = None
def data_received(self, data: bytes) -> None:
if self._response_waiter is None:
raise RuntimeError(
f"data_received called without a response waiter set: {data!r}"
)
elif self._response_waiter.done():
# We got a response without issuing a command; ignore it.
return
self._buffer.extend(data)
# If we got an obvious partial message, don't try to parse the buffer
last_linebreak = data.rfind(b"\n")
if (
last_linebreak == -1
or data[last_linebreak + 3 : last_linebreak + 4] == b"-"
):
return
try:
response = self._read_response_from_buffer()
except Exception as exc:
self._response_waiter.set_exception(exc)
else:
if response is not None:
self._response_waiter.set_result(response)
def eof_received(self) -> bool:
exc = SMTPServerDisconnected("Unexpected EOF received")
if self._response_waiter and not self._response_waiter.done():
self._response_waiter.set_exception(exc)
# Returning false closes the transport
return False
def _retrieve_response_exception(self) -> Optional[BaseException]:
"""
Return any exception that has been set on the response waiter.
Used to avoid 'Future exception was never retrieved' warnings
"""
if (
self._response_waiter
and self._response_waiter.done()
and not self._response_waiter.cancelled()
):
return self._response_waiter.exception()
return None
def _read_response_from_buffer(self) -> Optional[SMTPResponse]:
"""Parse the actual response (if any) from the data buffer"""
code = -1
message = bytearray()
offset = 0
message_complete = False
while True:
line_end_index = self._buffer.find(b"\n", offset)
if line_end_index == -1:
break
line = bytes(self._buffer[offset : line_end_index + 1])
if len(line) > MAX_LINE_LENGTH:
raise SMTPResponseException(
SMTPStatus.unrecognized_command, "Response too long"
)
try:
code = int(line[:3])
except ValueError:
raise SMTPResponseException(
SMTPStatus.invalid_response.value,
f"Malformed SMTP response line: {line!r}",
) from None
offset += len(line)
if len(message):
message.extend(b"\n")
message.extend(line[4:].strip(b" \t\r\n"))
if line[3:4] != b"-":
message_complete = True
break
if message_complete:
response = SMTPResponse(
code, bytes(message).decode("utf-8", "surrogateescape")
)
del self._buffer[:offset]
return response
else:
return None
async def read_response(self, timeout: Optional[float] = None) -> SMTPResponse:
"""
Get a status response from the server.
This method must be awaited once per command sent; if multiple commands
are written to the transport without awaiting, response data will be lost.
Returns an :class:`.response.SMTPResponse` namedtuple consisting of:
- server response code (e.g. 250, or such, if all goes well)
- server response string (multiline responses are converted to a
single, multiline string).
"""
if self._response_waiter is None:
raise SMTPServerDisconnected("Connection lost")
try:
result = await asyncio.wait_for(self._response_waiter, timeout)
except (TimeoutError, asyncio.TimeoutError) as exc:
raise SMTPReadTimeoutError("Timed out waiting for server response") from exc
finally:
# If we were disconnected, don't create a new waiter
if self.transport is None:
self._response_waiter = None
else:
self._response_waiter = self._loop.create_future()
return result
def write(self, data: bytes) -> None:
if self.transport is None or self.transport.is_closing():
raise SMTPServerDisconnected("Connection lost")
if not hasattr(self.transport, "write"):
raise RuntimeError(
f"Transport {self.transport!r} does not support writing."
)
self.transport.write(data) # type: ignore
async def execute_command(
self, *args: bytes, timeout: Optional[float] = None
) -> SMTPResponse:
"""
Sends an SMTP command along with any args to the server, and returns
a response.
"""
if self._command_lock is None:
raise SMTPServerDisconnected("Server not connected")
command = b" ".join(args) + b"\r\n"
async with self._command_lock:
self.write(command)
if command == b"QUIT\r\n":
self._quit_sent = True
response = await self.read_response(timeout=timeout)
return response
async def execute_data_command(
self, message: bytes, timeout: Optional[float] = None
) -> SMTPResponse:
"""
Sends an SMTP DATA command to the server, followed by encoded message content.
Automatically quotes lines beginning with a period per RFC821.
Lone \\\\r and \\\\n characters are converted to \\\\r\\\\n
characters.
"""
if self._command_lock is None:
raise SMTPServerDisconnected("Server not connected")
message = LINE_ENDINGS_REGEX.sub(b"\r\n", message)
message = PERIOD_REGEX.sub(b"..", message)
if not message.endswith(b"\r\n"):
message += b"\r\n"
message += b".\r\n"
async with self._command_lock:
self.write(b"DATA\r\n")
start_response = await self.read_response(timeout=timeout)
if start_response.code != SMTPStatus.start_input:
raise SMTPDataError(start_response.code, start_response.message)
self.write(message)
response = await self.read_response(timeout=timeout)
if response.code != SMTPStatus.completed:
raise SMTPDataError(response.code, response.message)
return response
async def start_tls(
self,
tls_context: ssl.SSLContext,
server_hostname: Optional[str] = None,
timeout: Optional[float] = None,
) -> SMTPResponse:
"""
Puts the connection to the SMTP server into TLS mode.
"""
if self._over_ssl:
raise RuntimeError("Already using TLS.")
if self._command_lock is None:
raise SMTPServerDisconnected("Server not connected")
async with self._command_lock:
self.write(b"STARTTLS\r\n")
response = await self.read_response(timeout=timeout)
if response.code != SMTPStatus.ready:
raise SMTPResponseException(response.code, response.message)
# Check for disconnect after response
if self.transport is None or self.transport.is_closing():
raise SMTPServerDisconnected("Connection lost")
try:
tls_transport = await self._loop.start_tls(
self.transport,
self,
tls_context,
server_side=False,
server_hostname=server_hostname,
ssl_handshake_timeout=timeout,
)
except (TimeoutError, asyncio.TimeoutError) as exc:
raise SMTPTimeoutError("Timed out while upgrading transport") from exc
# SSLProtocol only raises ConnectionAbortedError on timeout
except ConnectionAbortedError as exc:
raise SMTPTimeoutError(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

View File

@@ -0,0 +1 @@
This file exists to help mypy (and other tools) find inline type hints. See PR #141 and PEP 561.

View File

@@ -0,0 +1,33 @@
"""
SMTPResponse class, a simple namedtuple of (code, message).
"""
from typing import NamedTuple
__all__ = ("SMTPResponse",)
class SMTPResponse(NamedTuple):
"""
NamedTuple of server response code and server response message.
``code`` and ``message`` can be accessed via attributes or indexes:
>>> response = SMTPResponse(200, "OK")
>>> response.message
'OK'
>>> response[0]
200
>>> response.code
200
"""
code: int
message: str
def __repr__(self) -> str:
return f"({self.code}, {self.message})"
def __str__(self) -> str:
return f"{self.code} {self.message}"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
from .typing import SMTPStatus
# alias SMTPStatus for backwards compatibility
__all__ = ("SMTPStatus",)

View File

@@ -0,0 +1,60 @@
import enum
import os
from typing import Union
__all__ = ("Default", "SMTPStatus", "SocketPathType", "_default")
SocketPathType = Union[str, bytes, os.PathLike]
class Default(enum.Enum):
"""
Used for type hinting kwarg defaults.
"""
token = 0
_default = Default.token
@enum.unique
class SMTPStatus(enum.IntEnum):
"""
Defines SMTP statuses for code readability.
See also: http://www.greenend.org.uk/rjk/tech/smtpreplies.html
"""
invalid_response = -1
system_status_ok = 211
help_message = 214
ready = 220
closing = 221
auth_successful = 235
completed = 250
will_forward = 251
cannot_vrfy = 252
auth_continue = 334
start_input = 354
domain_unavailable = 421
mailbox_unavailable = 450
error_processing = 451
insufficient_storage = 452
tls_not_available = 454
unrecognized_command = 500
unrecognized_parameters = 501
command_not_implemented = 502
bad_command_sequence = 503
parameter_not_implemented = 504
domain_does_not_accept_mail = 521
access_denied = 530 # Sendmail specific
auth_failed = 535
mailbox_does_not_exist = 550
user_not_local = 551
storage_exceeded = 552
mailbox_name_invalid = 553
transaction_failed = 554
syntax_error = 555