hawkapi-mail 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hawkapi_mail/__init__.py +95 -0
- hawkapi_mail/_backends.py +419 -0
- hawkapi_mail/_deps.py +17 -0
- hawkapi_mail/_mailer.py +171 -0
- hawkapi_mail/_message.py +154 -0
- hawkapi_mail/_outbox.py +350 -0
- hawkapi_mail/_templates.py +54 -0
- hawkapi_mail/_webhooks.py +292 -0
- hawkapi_mail/py.typed +0 -0
- hawkapi_mail-0.1.0.dist-info/METADATA +222 -0
- hawkapi_mail-0.1.0.dist-info/RECORD +13 -0
- hawkapi_mail-0.1.0.dist-info/WHEEL +4 -0
- hawkapi_mail-0.1.0.dist-info/licenses/LICENSE +21 -0
hawkapi_mail/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""hawkapi-mail — email plugin for HawkAPI.
|
|
2
|
+
|
|
3
|
+
Backends: SMTP, AWS SES, SendGrid, Mailgun, Resend, in-memory.
|
|
4
|
+
Extras: Jinja2 templates, persistent outbox + retry worker, webhook handlers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._backends import (
|
|
10
|
+
Backend,
|
|
11
|
+
InMemoryBackend,
|
|
12
|
+
MailgunBackend,
|
|
13
|
+
MailgunConfig,
|
|
14
|
+
ResendBackend,
|
|
15
|
+
ResendConfig,
|
|
16
|
+
SendError,
|
|
17
|
+
SendGridBackend,
|
|
18
|
+
SendGridConfig,
|
|
19
|
+
SendResult,
|
|
20
|
+
SESBackend,
|
|
21
|
+
SESConfig,
|
|
22
|
+
SMTPBackend,
|
|
23
|
+
SMTPConfig,
|
|
24
|
+
)
|
|
25
|
+
from ._deps import get_mailer
|
|
26
|
+
from ._mailer import Mailer, init_mail, resolve_mailer
|
|
27
|
+
from ._message import Attachment, EmailMessage, format_address, new_message_id
|
|
28
|
+
from ._outbox import (
|
|
29
|
+
MemoryOutbox,
|
|
30
|
+
Outbox,
|
|
31
|
+
OutboxEntry,
|
|
32
|
+
OutboxWorker,
|
|
33
|
+
RetryPolicy,
|
|
34
|
+
SQLiteOutbox,
|
|
35
|
+
)
|
|
36
|
+
from ._templates import TemplateRenderer
|
|
37
|
+
from ._webhooks import (
|
|
38
|
+
EventKind,
|
|
39
|
+
SignatureError,
|
|
40
|
+
WebhookEvent,
|
|
41
|
+
confirm_ses_subscription,
|
|
42
|
+
parse_mailgun,
|
|
43
|
+
parse_resend,
|
|
44
|
+
parse_sendgrid,
|
|
45
|
+
parse_ses_sns,
|
|
46
|
+
verify_mailgun,
|
|
47
|
+
verify_resend,
|
|
48
|
+
verify_sendgrid,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__version__ = "0.1.0"
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Attachment",
|
|
55
|
+
"Backend",
|
|
56
|
+
"EmailMessage",
|
|
57
|
+
"EventKind",
|
|
58
|
+
"InMemoryBackend",
|
|
59
|
+
"Mailer",
|
|
60
|
+
"MailgunBackend",
|
|
61
|
+
"MailgunConfig",
|
|
62
|
+
"MemoryOutbox",
|
|
63
|
+
"Outbox",
|
|
64
|
+
"OutboxEntry",
|
|
65
|
+
"OutboxWorker",
|
|
66
|
+
"ResendBackend",
|
|
67
|
+
"ResendConfig",
|
|
68
|
+
"RetryPolicy",
|
|
69
|
+
"SESBackend",
|
|
70
|
+
"SESConfig",
|
|
71
|
+
"SMTPBackend",
|
|
72
|
+
"SMTPConfig",
|
|
73
|
+
"SQLiteOutbox",
|
|
74
|
+
"SendError",
|
|
75
|
+
"SendGridBackend",
|
|
76
|
+
"SendGridConfig",
|
|
77
|
+
"SendResult",
|
|
78
|
+
"SignatureError",
|
|
79
|
+
"TemplateRenderer",
|
|
80
|
+
"WebhookEvent",
|
|
81
|
+
"__version__",
|
|
82
|
+
"confirm_ses_subscription",
|
|
83
|
+
"format_address",
|
|
84
|
+
"get_mailer",
|
|
85
|
+
"init_mail",
|
|
86
|
+
"new_message_id",
|
|
87
|
+
"parse_mailgun",
|
|
88
|
+
"parse_resend",
|
|
89
|
+
"parse_sendgrid",
|
|
90
|
+
"parse_ses_sns",
|
|
91
|
+
"resolve_mailer",
|
|
92
|
+
"verify_mailgun",
|
|
93
|
+
"verify_resend",
|
|
94
|
+
"verify_sendgrid",
|
|
95
|
+
]
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Email backends — SMTP, SES, SendGrid, Mailgun, Resend, in-memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import ssl
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
9
|
+
|
|
10
|
+
import aiosmtplib
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._message import EmailMessage
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SendError(Exception):
|
|
20
|
+
"""Raised when a backend cannot deliver a message."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class SendResult:
|
|
25
|
+
message_id: str
|
|
26
|
+
provider: str
|
|
27
|
+
provider_message_id: str = ""
|
|
28
|
+
raw_response: Any = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Backend(Protocol):
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
async def send(self, message: EmailMessage) -> SendResult: ...
|
|
35
|
+
async def close(self) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# SMTP
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class SMTPConfig:
|
|
45
|
+
host: str = "localhost"
|
|
46
|
+
port: int = 25
|
|
47
|
+
username: str = ""
|
|
48
|
+
password: str = ""
|
|
49
|
+
use_tls: bool = False # implicit TLS / SMTPS (port 465)
|
|
50
|
+
start_tls: bool = False # STARTTLS (port 587)
|
|
51
|
+
timeout: float = 30.0
|
|
52
|
+
validate_certs: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SMTPBackend:
|
|
57
|
+
config: SMTPConfig
|
|
58
|
+
name: str = "smtp"
|
|
59
|
+
|
|
60
|
+
async def send(self, message: EmailMessage) -> SendResult:
|
|
61
|
+
if not message.sender:
|
|
62
|
+
raise SendError("sender required for SMTP")
|
|
63
|
+
ctx = ssl.create_default_context()
|
|
64
|
+
if not self.config.validate_certs:
|
|
65
|
+
ctx.check_hostname = False
|
|
66
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
67
|
+
try:
|
|
68
|
+
await aiosmtplib.send(
|
|
69
|
+
message.to_mime(),
|
|
70
|
+
sender=message.sender,
|
|
71
|
+
recipients=message.all_recipients(),
|
|
72
|
+
hostname=self.config.host,
|
|
73
|
+
port=self.config.port,
|
|
74
|
+
username=self.config.username or None,
|
|
75
|
+
password=self.config.password or None,
|
|
76
|
+
use_tls=self.config.use_tls,
|
|
77
|
+
start_tls=self.config.start_tls,
|
|
78
|
+
timeout=self.config.timeout,
|
|
79
|
+
tls_context=ctx,
|
|
80
|
+
)
|
|
81
|
+
except aiosmtplib.SMTPException as exc:
|
|
82
|
+
raise SendError(f"SMTP error: {exc}") from exc
|
|
83
|
+
return SendResult(message_id=message.message_id, provider="smtp")
|
|
84
|
+
|
|
85
|
+
async def close(self) -> None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Shared HTTP helper
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class _HTTPMixin:
|
|
96
|
+
_client: httpx.AsyncClient | None = field(default=None, init=False)
|
|
97
|
+
|
|
98
|
+
def _get_client(self, timeout: float) -> httpx.AsyncClient:
|
|
99
|
+
if self._client is None:
|
|
100
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
101
|
+
return self._client
|
|
102
|
+
|
|
103
|
+
async def close(self) -> None:
|
|
104
|
+
if self._client is not None:
|
|
105
|
+
await self._client.aclose()
|
|
106
|
+
self._client = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# AWS SES (raw send via boto3)
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class SESConfig:
|
|
116
|
+
region: str = "us-east-1"
|
|
117
|
+
aws_access_key_id: str = ""
|
|
118
|
+
aws_secret_access_key: str = ""
|
|
119
|
+
configuration_set: str = ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class SESBackend:
|
|
124
|
+
config: SESConfig
|
|
125
|
+
name: str = "ses"
|
|
126
|
+
_client: Any = field(default=None, init=False)
|
|
127
|
+
|
|
128
|
+
def _get_client(self) -> Any:
|
|
129
|
+
if self._client is None:
|
|
130
|
+
try:
|
|
131
|
+
import boto3
|
|
132
|
+
except ImportError as exc: # pragma: no cover
|
|
133
|
+
raise SendError("boto3 not installed; pip install 'hawkapi-mail[ses]'") from exc
|
|
134
|
+
kw: dict[str, Any] = {"region_name": self.config.region}
|
|
135
|
+
if self.config.aws_access_key_id:
|
|
136
|
+
kw["aws_access_key_id"] = self.config.aws_access_key_id
|
|
137
|
+
if self.config.aws_secret_access_key:
|
|
138
|
+
kw["aws_secret_access_key"] = self.config.aws_secret_access_key
|
|
139
|
+
self._client = boto3.client("ses", **kw)
|
|
140
|
+
return self._client
|
|
141
|
+
|
|
142
|
+
async def send(self, message: EmailMessage) -> SendResult:
|
|
143
|
+
client = self._get_client()
|
|
144
|
+
raw = message.to_mime()
|
|
145
|
+
kw: dict[str, Any] = {
|
|
146
|
+
"Source": message.sender,
|
|
147
|
+
"Destinations": message.all_recipients(),
|
|
148
|
+
"RawMessage": {"Data": raw},
|
|
149
|
+
}
|
|
150
|
+
if self.config.configuration_set:
|
|
151
|
+
kw["ConfigurationSetName"] = self.config.configuration_set
|
|
152
|
+
try:
|
|
153
|
+
resp = client.send_raw_email(**kw)
|
|
154
|
+
except Exception as exc: # pragma: no cover - network
|
|
155
|
+
raise SendError(f"SES error: {exc}") from exc
|
|
156
|
+
return SendResult(
|
|
157
|
+
message_id=message.message_id,
|
|
158
|
+
provider="ses",
|
|
159
|
+
provider_message_id=resp.get("MessageId", ""),
|
|
160
|
+
raw_response=resp,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def close(self) -> None:
|
|
164
|
+
self._client = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# SendGrid (HTTP API v3)
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(slots=True)
|
|
173
|
+
class SendGridConfig:
|
|
174
|
+
api_key: str
|
|
175
|
+
base_url: str = "https://api.sendgrid.com"
|
|
176
|
+
timeout: float = 30.0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class SendGridBackend(_HTTPMixin):
|
|
181
|
+
config: SendGridConfig = field(default_factory=lambda: SendGridConfig(api_key=""))
|
|
182
|
+
name: str = "sendgrid"
|
|
183
|
+
|
|
184
|
+
async def send(self, message: EmailMessage) -> SendResult:
|
|
185
|
+
payload = _sendgrid_payload(message)
|
|
186
|
+
client = self._get_client(self.config.timeout)
|
|
187
|
+
resp = await client.post(
|
|
188
|
+
f"{self.config.base_url}/v3/mail/send",
|
|
189
|
+
headers={"Authorization": f"Bearer {self.config.api_key}"},
|
|
190
|
+
json=payload,
|
|
191
|
+
)
|
|
192
|
+
if resp.status_code >= 300:
|
|
193
|
+
raise SendError(f"SendGrid {resp.status_code}: {resp.text}")
|
|
194
|
+
provider_id = resp.headers.get("x-message-id", "")
|
|
195
|
+
return SendResult(
|
|
196
|
+
message_id=message.message_id,
|
|
197
|
+
provider="sendgrid",
|
|
198
|
+
provider_message_id=provider_id,
|
|
199
|
+
raw_response={"status_code": resp.status_code, "headers": dict(resp.headers)},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _sendgrid_payload(message: EmailMessage) -> dict[str, Any]:
|
|
204
|
+
personalizations: dict[str, Any] = {"to": [{"email": e} for e in message.to]}
|
|
205
|
+
if message.cc:
|
|
206
|
+
personalizations["cc"] = [{"email": e} for e in message.cc]
|
|
207
|
+
if message.bcc:
|
|
208
|
+
personalizations["bcc"] = [{"email": e} for e in message.bcc]
|
|
209
|
+
content: list[dict[str, str]] = []
|
|
210
|
+
if message.text_body:
|
|
211
|
+
content.append({"type": "text/plain", "value": message.text_body})
|
|
212
|
+
if message.html_body:
|
|
213
|
+
content.append({"type": "text/html", "value": message.html_body})
|
|
214
|
+
if not content:
|
|
215
|
+
content.append({"type": "text/plain", "value": ""})
|
|
216
|
+
body: dict[str, Any] = {
|
|
217
|
+
"personalizations": [personalizations],
|
|
218
|
+
"from": {"email": message.sender},
|
|
219
|
+
"subject": message.subject,
|
|
220
|
+
"content": content,
|
|
221
|
+
}
|
|
222
|
+
if message.reply_to:
|
|
223
|
+
body["reply_to"] = {"email": message.reply_to[0]}
|
|
224
|
+
if message.attachments:
|
|
225
|
+
body["attachments"] = [
|
|
226
|
+
{
|
|
227
|
+
"content": base64.b64encode(a.content).decode("ascii"),
|
|
228
|
+
"filename": a.filename,
|
|
229
|
+
"type": a.mime_type,
|
|
230
|
+
"disposition": "inline" if a.inline else "attachment",
|
|
231
|
+
**({"content_id": a.content_id.strip("<>")} if a.inline and a.content_id else {}),
|
|
232
|
+
}
|
|
233
|
+
for a in message.attachments
|
|
234
|
+
]
|
|
235
|
+
if message.tags:
|
|
236
|
+
body["categories"] = list(message.tags)
|
|
237
|
+
if message.metadata:
|
|
238
|
+
body["custom_args"] = dict(message.metadata)
|
|
239
|
+
if message.headers:
|
|
240
|
+
body["headers"] = dict(message.headers)
|
|
241
|
+
return body
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Mailgun (HTTP API v3)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(slots=True)
|
|
250
|
+
class MailgunConfig:
|
|
251
|
+
api_key: str
|
|
252
|
+
domain: str
|
|
253
|
+
base_url: str = "https://api.mailgun.net"
|
|
254
|
+
timeout: float = 30.0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class MailgunBackend(_HTTPMixin):
|
|
259
|
+
config: MailgunConfig = field(default_factory=lambda: MailgunConfig(api_key="", domain=""))
|
|
260
|
+
name: str = "mailgun"
|
|
261
|
+
|
|
262
|
+
async def send(self, message: EmailMessage) -> SendResult:
|
|
263
|
+
data: list[tuple[str, str]] = [
|
|
264
|
+
("from", message.sender),
|
|
265
|
+
("subject", message.subject),
|
|
266
|
+
]
|
|
267
|
+
for r in message.to:
|
|
268
|
+
data.append(("to", r))
|
|
269
|
+
for r in message.cc:
|
|
270
|
+
data.append(("cc", r))
|
|
271
|
+
for r in message.bcc:
|
|
272
|
+
data.append(("bcc", r))
|
|
273
|
+
if message.text_body:
|
|
274
|
+
data.append(("text", message.text_body))
|
|
275
|
+
if message.html_body:
|
|
276
|
+
data.append(("html", message.html_body))
|
|
277
|
+
if message.reply_to:
|
|
278
|
+
data.append(("h:Reply-To", ", ".join(message.reply_to)))
|
|
279
|
+
for k, v in message.headers.items():
|
|
280
|
+
data.append((f"h:{k}", v))
|
|
281
|
+
for tag in message.tags:
|
|
282
|
+
data.append(("o:tag", tag))
|
|
283
|
+
for k, v in message.metadata.items():
|
|
284
|
+
data.append((f"v:{k}", v))
|
|
285
|
+
files: list[tuple[str, tuple[str, bytes, str]]] = []
|
|
286
|
+
for a in message.attachments:
|
|
287
|
+
field_name = "inline" if a.inline else "attachment"
|
|
288
|
+
files.append((field_name, (a.filename, a.content, a.mime_type)))
|
|
289
|
+
client = self._get_client(self.config.timeout)
|
|
290
|
+
if files:
|
|
291
|
+
resp = await client.post(
|
|
292
|
+
f"{self.config.base_url}/v3/{self.config.domain}/messages",
|
|
293
|
+
auth=("api", self.config.api_key),
|
|
294
|
+
data=data, # type: ignore[arg-type]
|
|
295
|
+
files=files,
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
from urllib.parse import urlencode
|
|
299
|
+
|
|
300
|
+
body = urlencode(data).encode("utf-8")
|
|
301
|
+
resp = await client.post(
|
|
302
|
+
f"{self.config.base_url}/v3/{self.config.domain}/messages",
|
|
303
|
+
auth=("api", self.config.api_key),
|
|
304
|
+
content=body,
|
|
305
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
306
|
+
)
|
|
307
|
+
if resp.status_code >= 300:
|
|
308
|
+
raise SendError(f"Mailgun {resp.status_code}: {resp.text}")
|
|
309
|
+
body = resp.json()
|
|
310
|
+
return SendResult(
|
|
311
|
+
message_id=message.message_id,
|
|
312
|
+
provider="mailgun",
|
|
313
|
+
provider_message_id=body.get("id", ""),
|
|
314
|
+
raw_response=body,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Resend (HTTP API)
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(slots=True)
|
|
324
|
+
class ResendConfig:
|
|
325
|
+
api_key: str
|
|
326
|
+
base_url: str = "https://api.resend.com"
|
|
327
|
+
timeout: float = 30.0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass
|
|
331
|
+
class ResendBackend(_HTTPMixin):
|
|
332
|
+
config: ResendConfig = field(default_factory=lambda: ResendConfig(api_key=""))
|
|
333
|
+
name: str = "resend"
|
|
334
|
+
|
|
335
|
+
async def send(self, message: EmailMessage) -> SendResult:
|
|
336
|
+
body: dict[str, Any] = {
|
|
337
|
+
"from": message.sender,
|
|
338
|
+
"to": message.to,
|
|
339
|
+
"subject": message.subject,
|
|
340
|
+
}
|
|
341
|
+
if message.cc:
|
|
342
|
+
body["cc"] = message.cc
|
|
343
|
+
if message.bcc:
|
|
344
|
+
body["bcc"] = message.bcc
|
|
345
|
+
if message.reply_to:
|
|
346
|
+
body["reply_to"] = message.reply_to
|
|
347
|
+
if message.html_body:
|
|
348
|
+
body["html"] = message.html_body
|
|
349
|
+
if message.text_body:
|
|
350
|
+
body["text"] = message.text_body
|
|
351
|
+
if message.tags:
|
|
352
|
+
body["tags"] = [{"name": "tag", "value": t} for t in message.tags]
|
|
353
|
+
if message.headers:
|
|
354
|
+
body["headers"] = dict(message.headers)
|
|
355
|
+
if message.attachments:
|
|
356
|
+
body["attachments"] = [
|
|
357
|
+
{
|
|
358
|
+
"filename": a.filename,
|
|
359
|
+
"content": base64.b64encode(a.content).decode("ascii"),
|
|
360
|
+
"content_type": a.mime_type,
|
|
361
|
+
}
|
|
362
|
+
for a in message.attachments
|
|
363
|
+
]
|
|
364
|
+
client = self._get_client(self.config.timeout)
|
|
365
|
+
resp = await client.post(
|
|
366
|
+
f"{self.config.base_url}/emails",
|
|
367
|
+
headers={"Authorization": f"Bearer {self.config.api_key}"},
|
|
368
|
+
json=body,
|
|
369
|
+
)
|
|
370
|
+
if resp.status_code >= 300:
|
|
371
|
+
raise SendError(f"Resend {resp.status_code}: {resp.text}")
|
|
372
|
+
data = resp.json()
|
|
373
|
+
return SendResult(
|
|
374
|
+
message_id=message.message_id,
|
|
375
|
+
provider="resend",
|
|
376
|
+
provider_message_id=data.get("id", ""),
|
|
377
|
+
raw_response=data,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# In-memory (test outbox)
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass
|
|
387
|
+
class InMemoryBackend:
|
|
388
|
+
"""Backend that captures messages instead of sending them — perfect for tests."""
|
|
389
|
+
|
|
390
|
+
name: str = "memory"
|
|
391
|
+
sent: list[EmailMessage] = field(default_factory=list)
|
|
392
|
+
|
|
393
|
+
async def send(self, message: EmailMessage) -> SendResult:
|
|
394
|
+
self.sent.append(message)
|
|
395
|
+
return SendResult(message_id=message.message_id, provider="memory")
|
|
396
|
+
|
|
397
|
+
async def close(self) -> None:
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def clear(self) -> None:
|
|
401
|
+
self.sent.clear()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
__all__ = [
|
|
405
|
+
"Backend",
|
|
406
|
+
"InMemoryBackend",
|
|
407
|
+
"MailgunBackend",
|
|
408
|
+
"MailgunConfig",
|
|
409
|
+
"ResendBackend",
|
|
410
|
+
"ResendConfig",
|
|
411
|
+
"SESBackend",
|
|
412
|
+
"SESConfig",
|
|
413
|
+
"SMTPBackend",
|
|
414
|
+
"SMTPConfig",
|
|
415
|
+
"SendError",
|
|
416
|
+
"SendGridBackend",
|
|
417
|
+
"SendGridConfig",
|
|
418
|
+
"SendResult",
|
|
419
|
+
]
|
hawkapi_mail/_deps.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""DI helpers for handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hawkapi import HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from ._mailer import Mailer, resolve_mailer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_mailer(request: Request) -> Mailer:
|
|
11
|
+
mailer = resolve_mailer(request.scope.get("app"))
|
|
12
|
+
if mailer is None:
|
|
13
|
+
raise HTTPException(500, detail="Mailer not configured — call init_mail(app, ...) first")
|
|
14
|
+
return mailer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["get_mailer"]
|
hawkapi_mail/_mailer.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""High-level Mailer + plugin entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._backends import Backend, InMemoryBackend, SendResult
|
|
11
|
+
from ._message import Attachment, EmailMessage
|
|
12
|
+
from ._outbox import Outbox, OutboxWorker, RetryPolicy
|
|
13
|
+
from ._templates import TemplateRenderer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Mailer:
|
|
18
|
+
backend: Backend
|
|
19
|
+
default_sender: str = ""
|
|
20
|
+
templates: TemplateRenderer | None = None
|
|
21
|
+
outbox: Outbox | None = None
|
|
22
|
+
worker: OutboxWorker | None = None
|
|
23
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
24
|
+
|
|
25
|
+
async def send(
|
|
26
|
+
self,
|
|
27
|
+
message: EmailMessage,
|
|
28
|
+
*,
|
|
29
|
+
deferred: bool = False,
|
|
30
|
+
) -> SendResult | int:
|
|
31
|
+
"""Send ``message``. When ``deferred`` is True and an outbox is attached,
|
|
32
|
+
enqueue the message instead and return the outbox entry id."""
|
|
33
|
+
if not message.sender and self.default_sender:
|
|
34
|
+
message.sender = self.default_sender
|
|
35
|
+
if deferred:
|
|
36
|
+
if self.outbox is None:
|
|
37
|
+
raise RuntimeError("deferred=True but no outbox configured")
|
|
38
|
+
return await self.outbox.enqueue(message)
|
|
39
|
+
return await self.backend.send(message)
|
|
40
|
+
|
|
41
|
+
async def send_template(
|
|
42
|
+
self,
|
|
43
|
+
template: str,
|
|
44
|
+
*,
|
|
45
|
+
context: dict[str, Any] | None = None,
|
|
46
|
+
subject: str,
|
|
47
|
+
sender: str = "",
|
|
48
|
+
to: str | Iterable[str] = (),
|
|
49
|
+
cc: str | Iterable[str] = (),
|
|
50
|
+
bcc: str | Iterable[str] = (),
|
|
51
|
+
reply_to: str | Iterable[str] = (),
|
|
52
|
+
text_template: str | None = None,
|
|
53
|
+
attachments: Iterable[Attachment] = (),
|
|
54
|
+
tags: Iterable[str] = (),
|
|
55
|
+
metadata: dict[str, str] | None = None,
|
|
56
|
+
headers: dict[str, str] | None = None,
|
|
57
|
+
deferred: bool = False,
|
|
58
|
+
) -> SendResult | int:
|
|
59
|
+
if self.templates is None:
|
|
60
|
+
raise RuntimeError("send_template called but no TemplateRenderer is configured")
|
|
61
|
+
ctx = dict(context or {})
|
|
62
|
+
html = await self.templates.render_async(template, **ctx)
|
|
63
|
+
text = await self.templates.render_async(text_template, **ctx) if text_template else ""
|
|
64
|
+
msg = EmailMessage.build(
|
|
65
|
+
subject=subject,
|
|
66
|
+
sender=sender or self.default_sender,
|
|
67
|
+
to=to,
|
|
68
|
+
cc=cc,
|
|
69
|
+
bcc=bcc,
|
|
70
|
+
reply_to=reply_to,
|
|
71
|
+
text=text,
|
|
72
|
+
html=html,
|
|
73
|
+
attachments=attachments,
|
|
74
|
+
tags=tags,
|
|
75
|
+
metadata=metadata,
|
|
76
|
+
headers=headers,
|
|
77
|
+
)
|
|
78
|
+
return await self.send(msg, deferred=deferred)
|
|
79
|
+
|
|
80
|
+
async def attach_file(
|
|
81
|
+
self,
|
|
82
|
+
message: EmailMessage,
|
|
83
|
+
path: str | Path,
|
|
84
|
+
*,
|
|
85
|
+
filename: str | None = None,
|
|
86
|
+
mime_type: str | None = None,
|
|
87
|
+
inline: bool = False,
|
|
88
|
+
) -> Attachment:
|
|
89
|
+
return message.add_attachment(path, filename=filename, mime_type=mime_type, inline=inline)
|
|
90
|
+
|
|
91
|
+
async def shutdown(self) -> None:
|
|
92
|
+
if self.worker is not None:
|
|
93
|
+
await self.worker.stop()
|
|
94
|
+
await self.backend.close()
|
|
95
|
+
if self.outbox is not None:
|
|
96
|
+
await self.outbox.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Plugin registry + DI helpers
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _StateNamespace:
|
|
105
|
+
mail: Any
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_ACTIVE_MAILERS: dict[int, Mailer] = {}
|
|
109
|
+
_LAST_MAILER: list[Mailer | None] = [None]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def init_mail(
|
|
113
|
+
app: Any,
|
|
114
|
+
*,
|
|
115
|
+
backend: Backend | None = None,
|
|
116
|
+
default_sender: str = "",
|
|
117
|
+
templates: TemplateRenderer | None = None,
|
|
118
|
+
outbox: Outbox | None = None,
|
|
119
|
+
retry: RetryPolicy | None = None,
|
|
120
|
+
start_worker: bool = False,
|
|
121
|
+
) -> Mailer:
|
|
122
|
+
"""Attach a Mailer to ``app.state.mail`` and register it for DI lookup.
|
|
123
|
+
|
|
124
|
+
When ``outbox`` is provided and ``start_worker=True`` the OutboxWorker is
|
|
125
|
+
started during the HawkAPI startup phase via :pyfunc:`app.on_startup`.
|
|
126
|
+
"""
|
|
127
|
+
if backend is None:
|
|
128
|
+
backend = InMemoryBackend()
|
|
129
|
+
mailer = Mailer(
|
|
130
|
+
backend=backend,
|
|
131
|
+
default_sender=default_sender,
|
|
132
|
+
templates=templates,
|
|
133
|
+
outbox=outbox,
|
|
134
|
+
retry=retry or RetryPolicy(),
|
|
135
|
+
)
|
|
136
|
+
if outbox is not None:
|
|
137
|
+
mailer.worker = OutboxWorker(outbox=outbox, backend=backend, retry=mailer.retry)
|
|
138
|
+
if getattr(app, "state", None) is None:
|
|
139
|
+
app.state = _StateNamespace()
|
|
140
|
+
app.state.mail = mailer
|
|
141
|
+
_ACTIVE_MAILERS[id(app)] = mailer
|
|
142
|
+
_LAST_MAILER[0] = mailer
|
|
143
|
+
|
|
144
|
+
if start_worker and mailer.worker is not None and hasattr(app, "on_startup"):
|
|
145
|
+
worker = mailer.worker
|
|
146
|
+
|
|
147
|
+
def _start() -> None:
|
|
148
|
+
worker.start()
|
|
149
|
+
|
|
150
|
+
async def _stop() -> None:
|
|
151
|
+
await worker.stop()
|
|
152
|
+
|
|
153
|
+
app.on_startup(_start)
|
|
154
|
+
if hasattr(app, "on_shutdown"):
|
|
155
|
+
app.on_shutdown(_stop)
|
|
156
|
+
return mailer
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_mailer(app: Any) -> Mailer | None:
|
|
160
|
+
if app is None:
|
|
161
|
+
return _LAST_MAILER[0]
|
|
162
|
+
mailer = _ACTIVE_MAILERS.get(id(app))
|
|
163
|
+
if mailer is not None:
|
|
164
|
+
return mailer
|
|
165
|
+
state = getattr(app, "state", None)
|
|
166
|
+
if state is not None and hasattr(state, "mail"):
|
|
167
|
+
return state.mail # type: ignore[no-any-return]
|
|
168
|
+
return _LAST_MAILER[0]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["Mailer", "init_mail", "resolve_mailer"]
|