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.
@@ -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"]
@@ -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"]