Skip to content

Commit 0dc2e3b

Browse files
authored
feat: add async SMTP support and Message.send_async() (#211)
1 parent a14f3ed commit 0dc2e3b

14 files changed

Lines changed: 1088 additions & 26 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ docs/plans/
4747
# CodeQL
4848
.codeql-db
4949
codeql-results.sarif
50+
51+
# ralphex progress logs
52+
.ralphex/progress/

emails/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@
4545

4646
from .message import Message, html
4747
from .utils import MessageID
48+
from .exc import HTTPLoaderError, BadHeaderError, IncompleteMessage
4849

4950

emails/backend/smtp/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11

2-
from .backend import SMTPBackend
2+
from .backend import SMTPBackend
3+
4+
try:
5+
from .aio_backend import AsyncSMTPBackend
6+
except ImportError:
7+
pass

emails/backend/smtp/aio_backend.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
from typing import Any
6+
7+
import aiosmtplib
8+
9+
from ..response import SMTPResponse
10+
from .aio_client import AsyncSMTPClientWithResponse
11+
from ...utils import DNS_NAME
12+
from .exceptions import SMTPConnectNetworkError
13+
14+
15+
__all__ = ['AsyncSMTPBackend']
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class AsyncSMTPBackend:
21+
22+
"""
23+
AsyncSMTPBackend manages an async SMTP connection using aiosmtplib.
24+
"""
25+
26+
DEFAULT_SOCKET_TIMEOUT = 5
27+
28+
response_cls = SMTPResponse
29+
30+
def __init__(self, ssl: bool = False, fail_silently: bool = True,
31+
mail_options: list[str] | None = None, **kwargs: Any) -> None:
32+
33+
self.ssl = ssl
34+
self.tls = kwargs.get('tls')
35+
if self.ssl and self.tls:
36+
raise ValueError(
37+
"ssl/tls are mutually exclusive, so only set "
38+
"one of those settings to True.")
39+
40+
kwargs.setdefault('timeout', self.DEFAULT_SOCKET_TIMEOUT)
41+
kwargs.setdefault('local_hostname', DNS_NAME.get_fqdn())
42+
kwargs['port'] = int(kwargs.get('port', 0))
43+
44+
self.smtp_cls_kwargs = kwargs
45+
46+
self.host: str | None = kwargs.get('host')
47+
self.port: int = kwargs['port']
48+
self.fail_silently = fail_silently
49+
self.mail_options = mail_options or []
50+
51+
self._client: AsyncSMTPClientWithResponse | None = None
52+
self._lock = asyncio.Lock()
53+
54+
async def get_client(self) -> AsyncSMTPClientWithResponse:
55+
async with self._lock:
56+
return await self._get_client_unlocked()
57+
58+
async def _get_client_unlocked(self) -> AsyncSMTPClientWithResponse:
59+
if self._client is None:
60+
client = AsyncSMTPClientWithResponse(
61+
parent=self, ssl=self.ssl, **self.smtp_cls_kwargs
62+
)
63+
await client.initialize()
64+
self._client = client
65+
return self._client
66+
67+
async def close(self) -> None:
68+
"""Closes the connection to the email server."""
69+
async with self._lock:
70+
await self._close_unlocked()
71+
72+
async def _close_unlocked(self) -> None:
73+
if self._client:
74+
try:
75+
await self._client.quit()
76+
except Exception:
77+
if self.fail_silently:
78+
return
79+
raise
80+
finally:
81+
self._client = None
82+
83+
def make_response(self, exception: Exception | None = None) -> SMTPResponse:
84+
return self.response_cls(backend=self, exception=exception)
85+
86+
async def _send(self, **kwargs: Any) -> SMTPResponse | None:
87+
response = None
88+
try:
89+
client = await self._get_client_unlocked()
90+
except aiosmtplib.SMTPConnectError as exc:
91+
cause = exc.__cause__
92+
if isinstance(cause, IOError):
93+
response = self.make_response(
94+
exception=SMTPConnectNetworkError.from_ioerror(cause))
95+
else:
96+
response = self.make_response(exception=exc)
97+
if not self.fail_silently:
98+
raise
99+
except aiosmtplib.SMTPException as exc:
100+
response = self.make_response(exception=exc)
101+
if not self.fail_silently:
102+
raise
103+
except IOError as exc:
104+
response = self.make_response(
105+
exception=SMTPConnectNetworkError.from_ioerror(exc))
106+
if not self.fail_silently:
107+
raise
108+
109+
if response:
110+
return response
111+
else:
112+
return await client.sendmail(**kwargs)
113+
114+
async def _send_with_retry(self, **kwargs: Any) -> SMTPResponse | None:
115+
async with self._lock:
116+
try:
117+
return await self._send(**kwargs)
118+
except aiosmtplib.SMTPServerDisconnected:
119+
logger.debug('SMTPServerDisconnected, retry once')
120+
await self._close_unlocked()
121+
return await self._send(**kwargs)
122+
123+
async def sendmail(self, from_addr: str, to_addrs: str | list[str],
124+
msg: Any, mail_options: list[str] | None = None,
125+
rcpt_options: list[str] | None = None) -> SMTPResponse | None:
126+
127+
if not to_addrs:
128+
return None
129+
130+
if not isinstance(to_addrs, (list, tuple)):
131+
to_addrs = [to_addrs]
132+
133+
response = await self._send_with_retry(
134+
from_addr=from_addr,
135+
to_addrs=to_addrs,
136+
msg=msg.as_bytes(),
137+
mail_options=mail_options or self.mail_options,
138+
rcpt_options=rcpt_options,
139+
)
140+
141+
if response and not self.fail_silently:
142+
response.raise_if_needed()
143+
144+
return response
145+
146+
async def __aenter__(self) -> AsyncSMTPBackend:
147+
return self
148+
149+
async def __aexit__(self, exc_type: type[BaseException] | None,
150+
exc_value: BaseException | None,
151+
traceback: Any | None) -> None:
152+
await self.close()

emails/backend/smtp/aio_client.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
from __future__ import annotations
2+
3+
__all__ = ["AsyncSMTPClientWithResponse"]
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
import aiosmtplib
9+
10+
from ..response import SMTPResponse
11+
from ...utils import sanitize_email
12+
13+
if TYPE_CHECKING:
14+
from .aio_backend import AsyncSMTPBackend
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class AsyncSMTPClientWithResponse:
20+
"""Async SMTP client built on aiosmtplib that returns SMTPResponse objects."""
21+
22+
def __init__(self, parent: AsyncSMTPBackend, **kwargs):
23+
self.parent = parent
24+
self.make_response = parent.make_response
25+
26+
self.tls = kwargs.pop("tls", False)
27+
self.ssl = kwargs.pop("ssl", False)
28+
self.debug = kwargs.pop("debug", 0)
29+
if self.debug:
30+
logger.warning(
31+
"debug parameter is not supported in async mode; "
32+
"use Python logging instead"
33+
)
34+
self.user = kwargs.pop("user", None)
35+
self.password = kwargs.pop("password", None)
36+
37+
# aiosmtplib uses use_tls for implicit TLS (SMTPS) and
38+
# start_tls for STARTTLS after connect
39+
smtp_kwargs = dict(kwargs)
40+
smtp_kwargs["use_tls"] = self.ssl
41+
smtp_kwargs["start_tls"] = self.tls
42+
43+
# aiosmtplib uses 'hostname' instead of 'host'
44+
if "host" in smtp_kwargs:
45+
smtp_kwargs["hostname"] = smtp_kwargs.pop("host")
46+
47+
self._smtp = aiosmtplib.SMTP(**smtp_kwargs)
48+
self._esmtp = False
49+
50+
async def initialize(self):
51+
await self._smtp.connect()
52+
# connect() may have already completed EHLO internally
53+
self._esmtp = self._smtp.supports_esmtp
54+
try:
55+
if self._smtp.is_ehlo_or_helo_needed:
56+
try:
57+
await self._smtp.ehlo()
58+
self._esmtp = True
59+
except aiosmtplib.SMTPHeloError:
60+
# aiosmtplib closes the transport on 421 responses
61+
# before raising; don't attempt HELO on a dead connection
62+
if not self._smtp.is_connected:
63+
raise
64+
await self._smtp.helo()
65+
# aiosmtplib sets supports_esmtp before checking the
66+
# response code, so it may be True even after EHLO
67+
# failed. Track the real state ourselves.
68+
self._esmtp = False
69+
if self.user:
70+
await self._smtp.login(self.user, self.password)
71+
except Exception:
72+
await self.quit()
73+
raise
74+
75+
async def quit(self):
76+
"""Closes the connection to the email server."""
77+
try:
78+
await self._smtp.quit()
79+
except (aiosmtplib.SMTPServerDisconnected, ConnectionError):
80+
self._smtp.close()
81+
82+
async def _rset(self):
83+
try:
84+
await self._smtp.rset()
85+
except (aiosmtplib.SMTPServerDisconnected, ConnectionError):
86+
pass
87+
88+
async def sendmail(
89+
self,
90+
from_addr: str,
91+
to_addrs: list[str] | str,
92+
msg: bytes,
93+
mail_options: list[str] | None = None,
94+
rcpt_options: list[str] | None = None,
95+
) -> SMTPResponse | None:
96+
97+
if not to_addrs:
98+
return None
99+
100+
rcpt_options = rcpt_options or []
101+
mail_options = mail_options or []
102+
esmtp_opts = []
103+
if self._esmtp:
104+
if self._smtp.supports_extension("size"):
105+
esmtp_opts.append("size=%d" % len(msg))
106+
for option in mail_options:
107+
esmtp_opts.append(option)
108+
109+
response = self.make_response()
110+
111+
from_addr = sanitize_email(from_addr)
112+
113+
response.from_addr = from_addr
114+
response.esmtp_opts = esmtp_opts[:]
115+
116+
try:
117+
resp = await self._smtp.mail(from_addr, options=esmtp_opts)
118+
except aiosmtplib.SMTPSenderRefused as exc:
119+
response.set_status(
120+
"mail",
121+
exc.code,
122+
exc.message.encode() if isinstance(exc.message, str) else exc.message,
123+
)
124+
response.set_exception(exc)
125+
await self._rset()
126+
return response
127+
128+
response.set_status(
129+
"mail",
130+
resp.code,
131+
resp.message.encode() if isinstance(resp.message, str) else resp.message,
132+
)
133+
134+
if resp.code != 250:
135+
await self._rset()
136+
response.set_exception(
137+
aiosmtplib.SMTPSenderRefused(resp.code, resp.message, from_addr)
138+
)
139+
return response
140+
141+
if not isinstance(to_addrs, (list, tuple)):
142+
to_addrs = [to_addrs]
143+
144+
to_addrs = [sanitize_email(e) for e in to_addrs]
145+
146+
response.to_addrs = to_addrs
147+
response.rcpt_options = rcpt_options[:]
148+
response.refused_recipients = {}
149+
150+
for a in to_addrs:
151+
try:
152+
resp = await self._smtp.rcpt(a, options=rcpt_options)
153+
code = resp.code
154+
resp_msg = (
155+
resp.message.encode()
156+
if isinstance(resp.message, str)
157+
else resp.message
158+
)
159+
except aiosmtplib.SMTPRecipientRefused as exc:
160+
code = exc.code
161+
resp_msg = (
162+
exc.message.encode()
163+
if isinstance(exc.message, str)
164+
else exc.message
165+
)
166+
167+
response.set_status("rcpt", code, resp_msg, recipient=a)
168+
if (code != 250) and (code != 251):
169+
response.refused_recipients[a] = (code, resp_msg)
170+
171+
if len(response.refused_recipients) == len(to_addrs):
172+
await self._rset()
173+
refused_list = [
174+
aiosmtplib.SMTPRecipientRefused(
175+
code, msg.decode() if isinstance(msg, bytes) else msg, addr
176+
)
177+
for addr, (code, msg) in response.refused_recipients.items()
178+
]
179+
response.set_exception(aiosmtplib.SMTPRecipientsRefused(refused_list))
180+
return response
181+
182+
try:
183+
resp = await self._smtp.data(msg)
184+
except aiosmtplib.SMTPDataError as exc:
185+
resp_msg = (
186+
exc.message.encode() if isinstance(exc.message, str) else exc.message
187+
)
188+
response.set_status("data", exc.code, resp_msg)
189+
response.set_exception(exc)
190+
await self._rset()
191+
return response
192+
193+
resp_msg = (
194+
resp.message.encode() if isinstance(resp.message, str) else resp.message
195+
)
196+
response.set_status("data", resp.code, resp_msg)
197+
if resp.code != 250:
198+
await self._rset()
199+
response.set_exception(aiosmtplib.SMTPDataError(resp.code, resp.message))
200+
return response
201+
202+
response._finished = True
203+
return response

0 commit comments

Comments
 (0)