terp-cap-mail 0.27.0__tar.gz
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.
- terp_cap_mail-0.27.0/.gitignore +73 -0
- terp_cap_mail-0.27.0/PKG-INFO +9 -0
- terp_cap_mail-0.27.0/escape-hatch-budget.json +3 -0
- terp_cap_mail-0.27.0/pyproject.toml +33 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/__init__.py +67 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/delivery.py +237 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/errors.py +45 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/message.py +140 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/py.typed +0 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/settings.py +253 -0
- terp_cap_mail-0.27.0/src/terp/capabilities/mail/smtp.py +166 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
48
|
+
# Per-service renders (a declaration scoped with "services"). A SEPARATE pattern
|
|
49
|
+
# because `.app.env` above is an exact name, not a glob: it does not match
|
|
50
|
+
# `.app.worker.env`, so without this line the one file that exists to hold a single
|
|
51
|
+
# worker's credentials would be the one file in the seam that gets committed.
|
|
52
|
+
# `.app.env.example` stays tracked -- it does not end in `.env`, so neither line
|
|
53
|
+
# claims it.
|
|
54
|
+
.app.*.env
|
|
55
|
+
# `terp smoke`'s throwaway database. Left in place deliberately after a failure — it is
|
|
56
|
+
# the state the chain died on — so it must not show up as an untracked file.
|
|
57
|
+
.terp-smoke.db
|
|
58
|
+
|
|
59
|
+
# graphify: a knowledge graph an agent builds FROM this repository. Derived
|
|
60
|
+
# data that is rebuilt on demand and goes stale the moment the code moves.
|
|
61
|
+
graphify-out/
|
|
62
|
+
|
|
63
|
+
# Playwright browsers recorded to a repo-local path. Needed rather than optional on
|
|
64
|
+
# Windows: the default location under %LOCALAPPDATA% is refused execution by Group
|
|
65
|
+
# Policy on a managed machine ("spawn UNKNOWN" with the binary present and complete),
|
|
66
|
+
# so recording the win32 half of a baseline pair requires PLAYWRIGHT_BROWSERS_PATH
|
|
67
|
+
# pointing somewhere policy allows. 700MB, and nothing in the repo should ever carry it.
|
|
68
|
+
apps/workbench/.playwright-browsers/
|
|
69
|
+
|
|
70
|
+
# Agent-session git worktrees. Local scratch checkouts of this repository, so a `git add -A`
|
|
71
|
+
# would otherwise stage them as embedded repositories — which it did once, and the commit had
|
|
72
|
+
# to be amended.
|
|
73
|
+
.claude/worktrees/
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: terp-cap-mail
|
|
3
|
+
Version: 0.27.0
|
|
4
|
+
Summary: Terp mail capability — outbound e-mail through one declared relay: TLS required, a fixed sender, bounded messages, delivered by the jobs seam so a send commits with the write that caused it.
|
|
5
|
+
Project-URL: Repository, https://github.com/AITT-NL/terp-framework
|
|
6
|
+
Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: terp-core==0.27.0
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-mail"
|
|
7
|
+
version = "0.27.0"
|
|
8
|
+
description = "Terp mail capability — outbound e-mail through one declared relay: TLS required, a fixed sender, bounded messages, delivered by the jobs seam so a send commits with the write that caused it."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.27.0",
|
|
13
|
+
# Nothing else, on purpose. The SMTP client is the standard library's `smtplib`,
|
|
14
|
+
# imported in exactly one module of this distribution -- which is the whole point of
|
|
15
|
+
# `no_raw_outbound_http` refusing it everywhere else.
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# A LIBRARY capability (like terp-cap-egress): no table, no router, and NO `terp.capabilities`
|
|
19
|
+
# auto-discovery entry point. Sending mail is not something an app should acquire by
|
|
20
|
+
# installing a package; it declares a relay with `configure_mail(...)` and registers the
|
|
21
|
+
# `MAIL_SEND` job in its control plane, and both are visible in the composition root.
|
|
22
|
+
|
|
23
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.mail`.
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
sources = ["src"]
|
|
26
|
+
only-include = ["src/terp/capabilities/mail"]
|
|
27
|
+
|
|
28
|
+
# Where this package comes from. The shipped changelog (`terp guide changelog`)
|
|
29
|
+
# ends at the installed version; the notes for a release you do not have yet
|
|
30
|
+
# live at these URLs, which `pip show` and the index page both surface.
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/AITT-NL/terp-framework"
|
|
33
|
+
Changelog = "https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Terp mail capability — outbound e-mail through one declared relay.
|
|
2
|
+
|
|
3
|
+
``no_raw_outbound_http`` refuses ``smtplib`` in application code, for the reason it
|
|
4
|
+
refuses a raw HTTP client: whether the connection is encrypted, whether the certificate
|
|
5
|
+
is checked, which account signs in and how long a dead server may hold a worker are
|
|
6
|
+
decisions, and a raw client makes them again at every call site. This capability makes
|
|
7
|
+
them once.
|
|
8
|
+
|
|
9
|
+
A **library** capability, like ``terp-cap-egress``: no router, no table, no
|
|
10
|
+
auto-discovery entry point. An application declares its relay in the composition root
|
|
11
|
+
and registers the one job; a feature then sends with one call::
|
|
12
|
+
|
|
13
|
+
# app/main.py — the relay, from MAIL_FROM / SMTP_HOST / SMTP_PORT / SMTP_SECURITY /
|
|
14
|
+
# SMTP_USERNAME / SMTP_PASSWORD
|
|
15
|
+
configure_mail(mail_settings_from_environment(os.environ))
|
|
16
|
+
|
|
17
|
+
# control_plane/jobs.py
|
|
18
|
+
job_catalog = JobCatalog([MAIL_SEND])
|
|
19
|
+
|
|
20
|
+
# a module's service, on the session of the write the mail is about
|
|
21
|
+
send_mail(session, MailMessage(
|
|
22
|
+
to=[order.customer_email],
|
|
23
|
+
subject="Your order has shipped",
|
|
24
|
+
text=f"Order {order.number} is on its way.",
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
``send_mail`` sends nothing itself: it enqueues ``MAIL_SEND`` on the caller's session, so
|
|
28
|
+
with the durable outbox wired the mail commits — or rolls back — with the write, and the
|
|
29
|
+
worker delivers it with retries. The session to the relay is encrypted (STARTTLS or TLS)
|
|
30
|
+
with the certificate verified, credentials never cross an unencrypted connection, the
|
|
31
|
+
sender is fixed, and a message is plain text with one-line headers and a bounded number
|
|
32
|
+
of recipients.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from terp.capabilities.mail.delivery import (
|
|
38
|
+
MAIL_SEND,
|
|
39
|
+
CapturingMailTransport,
|
|
40
|
+
MailTransport,
|
|
41
|
+
configure_mail,
|
|
42
|
+
reset_mail,
|
|
43
|
+
send_mail,
|
|
44
|
+
)
|
|
45
|
+
from terp.capabilities.mail.errors import MailConfigurationError, MailDeliveryError
|
|
46
|
+
from terp.capabilities.mail.message import MAX_RECIPIENTS, MailMessage
|
|
47
|
+
from terp.capabilities.mail.settings import (
|
|
48
|
+
MailSecurity,
|
|
49
|
+
MailSettings,
|
|
50
|
+
mail_settings_from_environment,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"MAIL_SEND",
|
|
55
|
+
"MAX_RECIPIENTS",
|
|
56
|
+
"CapturingMailTransport",
|
|
57
|
+
"MailConfigurationError",
|
|
58
|
+
"MailDeliveryError",
|
|
59
|
+
"MailMessage",
|
|
60
|
+
"MailSecurity",
|
|
61
|
+
"MailSettings",
|
|
62
|
+
"MailTransport",
|
|
63
|
+
"configure_mail",
|
|
64
|
+
"mail_settings_from_environment",
|
|
65
|
+
"reset_mail",
|
|
66
|
+
"send_mail",
|
|
67
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""``send_mail`` and the ``MAIL_SEND`` job: a send commits with the write that caused it.
|
|
2
|
+
|
|
3
|
+
A feature sends mail *because* something happened — a work order was closed, an account
|
|
4
|
+
was created — and the send has to agree with the write about whether it happened. Sent
|
|
5
|
+
from the request, it does not: the relay is slow or down and the request fails with it,
|
|
6
|
+
or the mail goes out and the write then rolls back, and a customer is told about a change
|
|
7
|
+
that does not exist. So :func:`send_mail` never talks to a relay. It validates the
|
|
8
|
+
message, mints its ``Message-ID``, and **enqueues** the typed :data:`MAIL_SEND` job on the
|
|
9
|
+
caller's session — which, with the durable outbox wired, is a row committed in the same
|
|
10
|
+
transaction as the business write. The worker (``terp jobs worker``) delivers it
|
|
11
|
+
afterwards, and a delivery that fails raises, so the outbox retries it with backoff and
|
|
12
|
+
dead-letters it once the budget is spent: the one place an operator looks for work that
|
|
13
|
+
did not happen.
|
|
14
|
+
|
|
15
|
+
With the in-process job queue — the zero-infrastructure default of a development stack —
|
|
16
|
+
the job runs inline, so a failing send fails the request that asked for it. That is the
|
|
17
|
+
loud version of the same failure, in the environment where loud is what you want.
|
|
18
|
+
|
|
19
|
+
The relay is a process-wide decision made once, in the composition root, by
|
|
20
|
+
:func:`configure_mail` — the API process and the worker both import it, so both hold the
|
|
21
|
+
same one. What it takes is a :class:`~terp.capabilities.mail.MailSettings`, or ``None``
|
|
22
|
+
when the environment names no relay; ``None`` refuses a production boot and, anywhere
|
|
23
|
+
else, installs a stand-in that delivers nothing and says so in the log for every message
|
|
24
|
+
(ADR 0128: permissive in the inner loop, never quiet).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from email.headerregistry import Address
|
|
35
|
+
from email.message import EmailMessage
|
|
36
|
+
|
|
37
|
+
from sqlmodel import Field, Session
|
|
38
|
+
|
|
39
|
+
from terp.core import (
|
|
40
|
+
JobContext,
|
|
41
|
+
JobDefinition,
|
|
42
|
+
JobVisibility,
|
|
43
|
+
RetryPolicy,
|
|
44
|
+
enqueue,
|
|
45
|
+
)
|
|
46
|
+
from terp.core import settings as _platform_settings
|
|
47
|
+
|
|
48
|
+
from terp.capabilities.mail.errors import MailConfigurationError
|
|
49
|
+
from terp.capabilities.mail.message import MailMessage, build_email
|
|
50
|
+
from terp.capabilities.mail.settings import MailSettings
|
|
51
|
+
from terp.capabilities.mail.smtp import SmtpTransport
|
|
52
|
+
|
|
53
|
+
_logger = logging.getLogger("terp.capabilities.mail")
|
|
54
|
+
|
|
55
|
+
#: Where a message goes once it is built: the SMTP relay in a deployment, a
|
|
56
|
+
#: :class:`CapturingMailTransport` in a test. A transport raises to signal a failed
|
|
57
|
+
#: delivery, which is what makes the outbox retry it.
|
|
58
|
+
MailTransport = Callable[[EmailMessage], None]
|
|
59
|
+
|
|
60
|
+
#: The sender while no relay is configured. ``.invalid`` is reserved (RFC 2606) for names
|
|
61
|
+
#: that must never resolve, which is exactly what an undelivered message should carry.
|
|
62
|
+
_UNCONFIGURED_SENDER = Address(addr_spec="unconfigured@mail.invalid")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CapturingMailTransport:
|
|
66
|
+
"""A transport that keeps every message instead of sending it — for tests.
|
|
67
|
+
|
|
68
|
+
``configure_mail(settings, transport=CapturingMailTransport())`` in a test's setup,
|
|
69
|
+
and each sent message is an :class:`~email.message.EmailMessage` in :attr:`sent`,
|
|
70
|
+
rendered exactly as the relay would have received it.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self.sent: list[EmailMessage] = []
|
|
75
|
+
|
|
76
|
+
def __call__(self, message: EmailMessage) -> None:
|
|
77
|
+
self.sent.append(message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _log_undelivered(message: EmailMessage) -> None:
|
|
81
|
+
"""The stand-in outside production when no relay is configured: say so, send nothing.
|
|
82
|
+
|
|
83
|
+
The subject and the number of recipients reach the log; the addresses and the body
|
|
84
|
+
do not, because a development database is often a copy of a real one.
|
|
85
|
+
"""
|
|
86
|
+
_logger.warning(
|
|
87
|
+
"mail NOT delivered — no relay is configured (set MAIL_FROM and SMTP_HOST): "
|
|
88
|
+
"%r to %d recipient(s), %s",
|
|
89
|
+
message["Subject"],
|
|
90
|
+
len(message["To"].addresses),
|
|
91
|
+
message["Message-ID"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class _MailRuntime:
|
|
97
|
+
"""What ``configure_mail`` decided: the sender and where a built message goes."""
|
|
98
|
+
|
|
99
|
+
sender: Address
|
|
100
|
+
transport: MailTransport
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_runtime: _MailRuntime | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def configure_mail(
|
|
107
|
+
settings: MailSettings | None, *, transport: MailTransport | None = None
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Declare this process's relay, once, from the composition root.
|
|
110
|
+
|
|
111
|
+
``settings`` is normally ``mail_settings_from_environment(os.environ)``. ``None`` —
|
|
112
|
+
the environment names no relay — refuses a **production** boot with
|
|
113
|
+
:class:`MailConfigurationError` and, anywhere else, installs a stand-in that delivers
|
|
114
|
+
nothing and logs every message it did not deliver. ``transport`` replaces the SMTP
|
|
115
|
+
relay — with a test's :class:`CapturingMailTransport`, or with a mail provider's HTTP
|
|
116
|
+
API built on ``terp.capabilities.egress`` — and is visible here, in the composition
|
|
117
|
+
root, rather than at any call site. It cannot be combined with ``None``, because a
|
|
118
|
+
message still needs a sender.
|
|
119
|
+
"""
|
|
120
|
+
global _runtime
|
|
121
|
+
if settings is None:
|
|
122
|
+
if transport is not None:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"configure_mail(None, transport=...) has no sender to build a message "
|
|
125
|
+
"from; pass the MailSettings the test sends as"
|
|
126
|
+
)
|
|
127
|
+
if _platform_settings.is_production:
|
|
128
|
+
raise MailConfigurationError(
|
|
129
|
+
"This application sends mail but no relay is configured: set MAIL_FROM "
|
|
130
|
+
"and SMTP_HOST for this environment."
|
|
131
|
+
)
|
|
132
|
+
_logger.warning(
|
|
133
|
+
"no mail relay is configured: messages are logged, not delivered. A "
|
|
134
|
+
"production boot is REFUSED in this state."
|
|
135
|
+
)
|
|
136
|
+
_runtime = _MailRuntime(_UNCONFIGURED_SENDER, _log_undelivered)
|
|
137
|
+
return
|
|
138
|
+
_runtime = _MailRuntime(
|
|
139
|
+
settings.sender_address,
|
|
140
|
+
transport if transport is not None else SmtpTransport(settings),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def reset_mail() -> None:
|
|
145
|
+
"""Forget the configured relay (the test-isolation reset)."""
|
|
146
|
+
global _runtime
|
|
147
|
+
_runtime = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _configured() -> _MailRuntime:
|
|
151
|
+
if _runtime is None:
|
|
152
|
+
raise MailConfigurationError(
|
|
153
|
+
"send_mail was called before configure_mail: declare the relay in the "
|
|
154
|
+
"composition root with configure_mail(mail_settings_from_environment(os.environ))."
|
|
155
|
+
)
|
|
156
|
+
return _runtime
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class MailJobPayload(MailMessage):
|
|
160
|
+
"""The ``MAIL_SEND`` job's payload: the message, plus the identity it was given.
|
|
161
|
+
|
|
162
|
+
The ``Message-ID`` rides the payload rather than being minted at delivery, so every
|
|
163
|
+
retry of one send is the same message to the recipient's mail client.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
message_id: str = Field(min_length=1, max_length=300)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def send_mail(session: Session, message: MailMessage) -> str:
|
|
170
|
+
"""Queue *message* for delivery on *session*, and return its ``Message-ID``.
|
|
171
|
+
|
|
172
|
+
Pass the session of the write the mail is about, so the send commits — or rolls back
|
|
173
|
+
— with it. Nothing is sent from here: the ``MAIL_SEND`` job delivers it. Refused with
|
|
174
|
+
:class:`MailConfigurationError` when the composition root never called
|
|
175
|
+
:func:`configure_mail`, so a missing declaration surfaces at the first send rather
|
|
176
|
+
than as a queue of jobs that can never run.
|
|
177
|
+
"""
|
|
178
|
+
message_id = f"<{uuid.uuid4().hex}@{_configured().sender.domain}>"
|
|
179
|
+
enqueue(
|
|
180
|
+
session,
|
|
181
|
+
job=MAIL_SEND,
|
|
182
|
+
payload=MailJobPayload(**message.model_dump(), message_id=message_id),
|
|
183
|
+
idempotency_key=message_id,
|
|
184
|
+
)
|
|
185
|
+
return message_id
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _utc_now() -> datetime:
|
|
189
|
+
"""UTC ``now`` for the ``Date`` header (private so tests can patch it)."""
|
|
190
|
+
return datetime.now(UTC)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def deliver_mail(ctx: JobContext, payload: MailJobPayload) -> None:
|
|
194
|
+
"""Build the message from the declared sender and hand it to the configured transport.
|
|
195
|
+
|
|
196
|
+
``ctx`` is part of every job handler's contract and unused here: a delivery writes
|
|
197
|
+
nothing, so it needs neither the session nor the re-bound actor. A failure propagates — :class:`~terp.capabilities.mail.MailDeliveryError` from the
|
|
198
|
+
relay, or :class:`MailConfigurationError` from a worker that was started without the
|
|
199
|
+
composition root's ``configure_mail`` — so the outbox retries and, in the end,
|
|
200
|
+
dead-letters it where an operator will see it.
|
|
201
|
+
"""
|
|
202
|
+
runtime = _configured()
|
|
203
|
+
runtime.transport(
|
|
204
|
+
build_email(
|
|
205
|
+
payload,
|
|
206
|
+
sender=runtime.sender,
|
|
207
|
+
message_id=payload.message_id,
|
|
208
|
+
sent_at=_utc_now(),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
#: The typed job contract an app registers in its control plane's ``JobCatalog`` — the
|
|
214
|
+
#: capability cannot enqueue a job the catalog does not declare. ``RESTRICTED``, because
|
|
215
|
+
#: the payload holds addresses and a message body. Retries lean on the outbox: a relay
|
|
216
|
+
#: that is down for a while is the common failure, so the backoff starts at a minute,
|
|
217
|
+
#: doubles, and levels off at half an hour before the last attempt dead-letters.
|
|
218
|
+
MAIL_SEND = JobDefinition(
|
|
219
|
+
name="mail.message.send",
|
|
220
|
+
payload_schema=MailJobPayload,
|
|
221
|
+
handler=deliver_mail,
|
|
222
|
+
retry=RetryPolicy(max_attempts=8, backoff_seconds=60.0, max_backoff_seconds=1800.0),
|
|
223
|
+
queue="mail",
|
|
224
|
+
visibility=JobVisibility.RESTRICTED,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = [
|
|
229
|
+
"MAIL_SEND",
|
|
230
|
+
"CapturingMailTransport",
|
|
231
|
+
"MailJobPayload",
|
|
232
|
+
"MailTransport",
|
|
233
|
+
"configure_mail",
|
|
234
|
+
"deliver_mail",
|
|
235
|
+
"reset_mail",
|
|
236
|
+
"send_mail",
|
|
237
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The two things that can go wrong on the way to a mail relay, as typed errors.
|
|
2
|
+
|
|
3
|
+
Both are ``AppError`` subclasses, so a send that fails inline (the in-process job queue,
|
|
4
|
+
in development) reaches a client through the same envelope as every other failure rather
|
|
5
|
+
than as whichever exception ``smtplib`` happened to raise. The distinction between them
|
|
6
|
+
is who has to act: a configuration error is the application's own declaration being
|
|
7
|
+
absent or refused, and a delivery error is the relay.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from terp.core import AppError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MailConfigurationError(AppError):
|
|
16
|
+
"""500 — mail is used, but this process has no relay the deployment accepts.
|
|
17
|
+
|
|
18
|
+
Raised when :func:`~terp.capabilities.mail.send_mail` is called before the
|
|
19
|
+
composition root ran :func:`~terp.capabilities.mail.configure_mail`, and by
|
|
20
|
+
``configure_mail`` itself when a production process is given no relay at all. A 500
|
|
21
|
+
rather than a 502: nothing upstream failed — the application was started without a
|
|
22
|
+
decision it needs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
status_code = 500
|
|
26
|
+
code = "mail_not_configured"
|
|
27
|
+
default_message = "Sending mail is not configured for this application."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MailDeliveryError(AppError):
|
|
31
|
+
"""502 — the relay could not be reached, refused the session, or refused the message.
|
|
32
|
+
|
|
33
|
+
Deliberately not a carrier for the relay's own reply. An SMTP error string routinely
|
|
34
|
+
names an internal host, an account or a policy; it belongs in the log with the
|
|
35
|
+
exception chained to it, and what reaches a client is that a mail was not sent.
|
|
36
|
+
Raised inside the ``MAIL_SEND`` job, it is also the signal that makes the durable
|
|
37
|
+
outbox retry with backoff and dead-letter once the budget is spent.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
status_code = 502
|
|
41
|
+
code = "mail_delivery_failed"
|
|
42
|
+
default_message = "The mail could not be handed to the mail server."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["MailConfigurationError", "MailDeliveryError"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""What one message may carry, validated before it is queued rather than when it is sent.
|
|
2
|
+
|
|
3
|
+
A mail message is built from data — a customer's address, a work order's title — and the
|
|
4
|
+
two classic ways that goes wrong are both about headers. A line break in a subject or an
|
|
5
|
+
address ends the header and starts a new one, so ``"Hello\\r\\nBcc: everyone@..."`` turns
|
|
6
|
+
a notification into a bulk mail the application never sent on purpose; and an address
|
|
7
|
+
field that accepts ``"a@example.com, b@example.com"`` sends to two people where the code
|
|
8
|
+
meant one. Both are refused here, at the moment a feature *asks* to send, so the refusal
|
|
9
|
+
reaches the code that made the mistake instead of a worker log an hour later.
|
|
10
|
+
|
|
11
|
+
The message is plain text. That is a decision, not an omission: an HTML body assembled
|
|
12
|
+
from data is a second injection surface — a link or a form placed in a trusted sender's
|
|
13
|
+
mail — and it needs an auto-escaping renderer before it is safe to offer, which this
|
|
14
|
+
capability does not yet have. Everything a notification, a confirmation or a password
|
|
15
|
+
reset needs fits in text.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from email.headerregistry import Address
|
|
22
|
+
from email.message import EmailMessage
|
|
23
|
+
from email.policy import SMTP
|
|
24
|
+
from email.utils import format_datetime
|
|
25
|
+
from typing import Annotated, Final
|
|
26
|
+
|
|
27
|
+
from pydantic import StringConstraints, field_validator
|
|
28
|
+
from sqlmodel import Field
|
|
29
|
+
|
|
30
|
+
from terp.core import BaseSchema
|
|
31
|
+
|
|
32
|
+
from terp.capabilities.mail.settings import breaks_a_header, parse_address
|
|
33
|
+
|
|
34
|
+
#: At most this many recipients per message. A message to many visible recipients hands
|
|
35
|
+
#: every one of them everybody else's address, and it is the shape relays score as bulk
|
|
36
|
+
#: mail. Sending the same notice to many people is one message per person — one
|
|
37
|
+
#: ``send_mail`` each, which also means one refusal does not sink the rest.
|
|
38
|
+
MAX_RECIPIENTS: Final[int] = 50
|
|
39
|
+
|
|
40
|
+
#: The subject bound. Long enough for any subject a person reads; short enough that a
|
|
41
|
+
#: feature pasting a whole record into it is refused where it happens.
|
|
42
|
+
MAX_SUBJECT_LENGTH: Final[int] = 250
|
|
43
|
+
|
|
44
|
+
#: The body bound, in characters. The message travels as a job payload — a row in the
|
|
45
|
+
#: durable outbox — so it is bounded like any other stored input.
|
|
46
|
+
MAX_TEXT_LENGTH: Final[int] = 100_000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MailMessage(BaseSchema):
|
|
50
|
+
"""One message: who it is for, what it says, and where a reply should go.
|
|
51
|
+
|
|
52
|
+
There is no ``sender`` field: every message is from the address the application
|
|
53
|
+
declared in :class:`~terp.capabilities.mail.MailSettings`. ``reply_to`` is where a
|
|
54
|
+
person's answer lands — the case a per-message sender is usually wanted for, without
|
|
55
|
+
letting a feature send mail that claims to come from someone else.
|
|
56
|
+
|
|
57
|
+
``text`` is kept exactly as written. The platform trims every other input string,
|
|
58
|
+
and here that would take the indentation off a first line and the blank lines off
|
|
59
|
+
the end of a signature — a change to what the author wrote, made in silence.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
to: list[str] = Field(min_length=1, max_length=MAX_RECIPIENTS)
|
|
63
|
+
subject: str = Field(min_length=1, max_length=MAX_SUBJECT_LENGTH)
|
|
64
|
+
text: Annotated[str, StringConstraints(strip_whitespace=False)] = Field(
|
|
65
|
+
min_length=1, max_length=MAX_TEXT_LENGTH
|
|
66
|
+
)
|
|
67
|
+
reply_to: str | None = Field(default=None, max_length=254)
|
|
68
|
+
|
|
69
|
+
@field_validator("to")
|
|
70
|
+
@classmethod
|
|
71
|
+
def _every_recipient_is_one_address(cls, value: list[str]) -> list[str]:
|
|
72
|
+
for address in value:
|
|
73
|
+
parse_address(address)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
@field_validator("reply_to")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _reply_to_is_one_address(cls, value: str | None) -> str | None:
|
|
79
|
+
if value is not None:
|
|
80
|
+
parse_address(value)
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
@field_validator("subject")
|
|
84
|
+
@classmethod
|
|
85
|
+
def _subject_is_one_line(cls, value: str) -> str:
|
|
86
|
+
if breaks_a_header(value):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"the subject is one line of text: a line break, a line separator or "
|
|
89
|
+
"another control character in a header starts a header of its own"
|
|
90
|
+
)
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
@field_validator("text")
|
|
94
|
+
@classmethod
|
|
95
|
+
def _text_says_something(cls, value: str) -> str:
|
|
96
|
+
if not value.strip():
|
|
97
|
+
raise ValueError("the text is empty")
|
|
98
|
+
# The message is stored as a job payload before it is sent, and PostgreSQL's
|
|
99
|
+
# JSON types cannot hold a NUL character: accepting one here would turn a
|
|
100
|
+
# validated send into a database error inside the business write.
|
|
101
|
+
if "\x00" in value:
|
|
102
|
+
raise ValueError("the text contains a NUL character")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_email(
|
|
107
|
+
message: MailMessage,
|
|
108
|
+
*,
|
|
109
|
+
sender: Address,
|
|
110
|
+
message_id: str,
|
|
111
|
+
sent_at: datetime,
|
|
112
|
+
) -> EmailMessage:
|
|
113
|
+
"""Render *message* as an RFC 5322 message from the declared sender.
|
|
114
|
+
|
|
115
|
+
``Auto-Submitted: auto-generated`` (RFC 3834) is always set: this is mail a program
|
|
116
|
+
sent, and saying so is what stops an out-of-office reply from starting a loop with
|
|
117
|
+
it. The ``Message-ID`` is minted when the send was requested and stays the same on
|
|
118
|
+
every retry, so a message delivered twice after a lost connection is recognisably one
|
|
119
|
+
message. The body is quoted-printable UTF-8, readable in its raw form.
|
|
120
|
+
"""
|
|
121
|
+
email = EmailMessage(policy=SMTP)
|
|
122
|
+
email["From"] = sender
|
|
123
|
+
email["To"] = [parse_address(address) for address in message.to]
|
|
124
|
+
if message.reply_to is not None:
|
|
125
|
+
email["Reply-To"] = parse_address(message.reply_to)
|
|
126
|
+
email["Subject"] = message.subject
|
|
127
|
+
email["Date"] = format_datetime(sent_at)
|
|
128
|
+
email["Message-ID"] = message_id
|
|
129
|
+
email["Auto-Submitted"] = "auto-generated"
|
|
130
|
+
email.set_content(message.text, cte="quoted-printable")
|
|
131
|
+
return email
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = [
|
|
135
|
+
"MAX_RECIPIENTS",
|
|
136
|
+
"MAX_SUBJECT_LENGTH",
|
|
137
|
+
"MAX_TEXT_LENGTH",
|
|
138
|
+
"MailMessage",
|
|
139
|
+
"build_email",
|
|
140
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""The one relay an application sends through, declared once instead of decided per send.
|
|
2
|
+
|
|
3
|
+
An application that sends mail decides which server it talks to, how the connection is
|
|
4
|
+
protected, which account it signs in as and who the mail is *from*. If any of those is
|
|
5
|
+
an argument to a send, it is decided again at every call site, and the call site is where
|
|
6
|
+
"just this once over plaintext" gets written. So all of them are declaration here, and a
|
|
7
|
+
message carries none of them:
|
|
8
|
+
|
|
9
|
+
* the **relay** is one host and port, named by configuration and never by a message — a
|
|
10
|
+
recipient address decides where a mail *ends up*, never which server this process
|
|
11
|
+
opens a connection to;
|
|
12
|
+
* the connection is **encrypted by default** — STARTTLS on the submission port, or TLS
|
|
13
|
+
from the first byte on 465 — with the certificate and the hostname verified, and there
|
|
14
|
+
is no setting that turns the verification off;
|
|
15
|
+
* **credentials travel only over an encrypted connection**, refused at construction
|
|
16
|
+
otherwise, in every environment;
|
|
17
|
+
* the **sender** is fixed. A message may name a ``Reply-To``, never a ``From``, so a
|
|
18
|
+
feature cannot be talked into sending mail that claims to come from someone else;
|
|
19
|
+
* a relay without encryption (``MailSecurity.NONE``) exists for the local mail catcher a
|
|
20
|
+
development stack runs, and a production boot refuses it (ADR 0128's shape: the answer
|
|
21
|
+
is environment-independent, so a gate can ask it off the production host).
|
|
22
|
+
|
|
23
|
+
Deliberately absent: the SSRF denylist the egress capability applies. That list exists
|
|
24
|
+
because an outbound URL can be *steered* — built from data a caller influences — and
|
|
25
|
+
this destination cannot: it is the one host the deployment configured. What protects a
|
|
26
|
+
mail relay is that the session is encrypted and the certificate proves the name, which
|
|
27
|
+
is also why a relay on a private network (an on-premises mail server, a development
|
|
28
|
+
catcher on the compose network) needs no exception here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import re
|
|
35
|
+
import unicodedata
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from email.errors import HeaderParseError
|
|
39
|
+
from email.headerregistry import Address
|
|
40
|
+
from enum import StrEnum
|
|
41
|
+
|
|
42
|
+
from terp.core import settings as _platform_settings
|
|
43
|
+
|
|
44
|
+
_logger = logging.getLogger("terp.capabilities.mail")
|
|
45
|
+
|
|
46
|
+
#: A bare hostname: letters, digits and hyphens in dot-separated labels. No scheme, no
|
|
47
|
+
#: port, no path and no user part — each of those is a sign that a URL was pasted where a
|
|
48
|
+
#: host belongs, and a relay "host" of ``smtp://mail.example.com:587`` fails far from here.
|
|
49
|
+
_HOSTNAME = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$")
|
|
50
|
+
|
|
51
|
+
#: ``Display Name <address@example.com>`` — the one shape besides a bare address that a
|
|
52
|
+
#: sender setting takes.
|
|
53
|
+
_NAMED_ADDRESS = re.compile(r"^(?P<name>[^<>]*?)\s*<(?P<address>[^<>]+)>$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MailSecurity(StrEnum):
|
|
57
|
+
"""How the connection to the relay is protected.
|
|
58
|
+
|
|
59
|
+
``STARTTLS`` upgrades a plain connection before anything else is said — and a relay
|
|
60
|
+
that does not offer the upgrade is refused rather than spoken to in the clear, which
|
|
61
|
+
is the downgrade an attacker on the path would otherwise only have to strip one line
|
|
62
|
+
to cause. ``TLS`` is encrypted from the first byte (port 465). ``NONE`` is the local
|
|
63
|
+
catcher a development stack runs, and a production boot refuses it.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
STARTTLS = "starttls"
|
|
67
|
+
TLS = "tls"
|
|
68
|
+
NONE = "none"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_DEFAULT_PORT: dict[MailSecurity, int] = {
|
|
72
|
+
MailSecurity.STARTTLS: 587,
|
|
73
|
+
MailSecurity.TLS: 465,
|
|
74
|
+
MailSecurity.NONE: 25,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def breaks_a_header(value: str) -> bool:
|
|
79
|
+
"""Whether *value* holds a character that ends a header line, or any other control.
|
|
80
|
+
|
|
81
|
+
Not only CR and LF. The email package validates and folds a header with
|
|
82
|
+
``str.splitlines``, which also breaks at the C1 control U+0085 and the Unicode line
|
|
83
|
+
and paragraph separators U+2028 and U+2029, so a value checked for CR and LF alone
|
|
84
|
+
passes validation and is refused when the message is built — a queued mail that can
|
|
85
|
+
never be sent. The categories below are exactly the ones those breaks fall in (Cc,
|
|
86
|
+
Zl, Zp), and Cc takes the remaining controls with it.
|
|
87
|
+
"""
|
|
88
|
+
return any(unicodedata.category(ch) in ("Cc", "Zl", "Zp") for ch in value)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_address(value: str) -> Address:
|
|
92
|
+
"""Parse one addr-spec (``someone@example.com``) strictly, or raise ``ValueError``.
|
|
93
|
+
|
|
94
|
+
Stricter than what RFC 5322 permits, on purpose: the forms refused here are the ones a
|
|
95
|
+
feature never means and an attacker sometimes does. A CR or LF anywhere (header
|
|
96
|
+
injection), a second address smuggled after a comma, an IP-literal domain
|
|
97
|
+
(``someone@[10.0.0.1]``, which skips the recipient's own mail routing), a domain with no
|
|
98
|
+
dot, and a non-ASCII local part — which needs an extension (SMTPUTF8) this capability
|
|
99
|
+
does not negotiate, so accepting it here would only move the refusal to the relay.
|
|
100
|
+
"""
|
|
101
|
+
if not value or len(value) > 254:
|
|
102
|
+
raise ValueError(f"not a mail address: {value!r}")
|
|
103
|
+
try:
|
|
104
|
+
address = Address(addr_spec=value)
|
|
105
|
+
except (HeaderParseError, ValueError) as exc: # the parser's two ways of saying "no"
|
|
106
|
+
raise ValueError(f"not a mail address: {value!r}") from exc
|
|
107
|
+
domain = address.domain.lower()
|
|
108
|
+
if address.addr_spec != value or "." not in domain or not _HOSTNAME.match(domain):
|
|
109
|
+
raise ValueError(f"not a mail address: {value!r}")
|
|
110
|
+
return address
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_sender(value: str) -> Address:
|
|
114
|
+
"""Parse a sender setting: ``someone@example.com`` or ``Name <someone@example.com>``."""
|
|
115
|
+
named = _NAMED_ADDRESS.match(value.strip())
|
|
116
|
+
if named is None:
|
|
117
|
+
return parse_address(value.strip())
|
|
118
|
+
name = named.group("name").strip().strip('"').strip()
|
|
119
|
+
if breaks_a_header(name):
|
|
120
|
+
raise ValueError(f"not a sender: {value!r}")
|
|
121
|
+
spec = parse_address(named.group("address").strip())
|
|
122
|
+
return Address(display_name=name, username=spec.username, domain=spec.domain)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class MailSettings:
|
|
127
|
+
"""The declared relay and sender of one application.
|
|
128
|
+
|
|
129
|
+
``sender`` is the ``From`` of every message — ``someone@example.com`` or
|
|
130
|
+
``Name <someone@example.com>``. ``host`` is the relay's bare hostname and ``port``
|
|
131
|
+
defaults to the conventional one for ``security`` (587, 465, or 25). ``username`` and
|
|
132
|
+
``password`` are both set or both empty; ``password`` is kept out of ``repr`` so the
|
|
133
|
+
object can be logged.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
sender: str
|
|
137
|
+
host: str
|
|
138
|
+
port: int | None = None
|
|
139
|
+
security: MailSecurity = MailSecurity.STARTTLS
|
|
140
|
+
username: str = ""
|
|
141
|
+
password: str = field(default="", repr=False)
|
|
142
|
+
|
|
143
|
+
def __post_init__(self) -> None:
|
|
144
|
+
parse_sender(self.sender)
|
|
145
|
+
if not _HOSTNAME.match(self.host):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"MailSettings.host is the relay's bare lowercase hostname — no scheme, "
|
|
148
|
+
f"port or path: {self.host!r}"
|
|
149
|
+
)
|
|
150
|
+
if self.port is not None and not 0 < self.port < 65536:
|
|
151
|
+
raise ValueError(f"MailSettings.port must be a TCP port: {self.port!r}")
|
|
152
|
+
if bool(self.username) != bool(self.password):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"MailSettings.username and MailSettings.password are set together or not "
|
|
155
|
+
"at all — a relay account with one half missing fails on the first send"
|
|
156
|
+
)
|
|
157
|
+
if self.username and self.security is MailSecurity.NONE:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"MailSettings refuses to sign in to a relay over an unencrypted "
|
|
160
|
+
"connection: the password would cross the network in the clear. Use "
|
|
161
|
+
"MailSecurity.STARTTLS or MailSecurity.TLS."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Decided by an environment-INDEPENDENT predicate, so the answer exists somewhere
|
|
165
|
+
# a gate can read it and not only inside a branch that runs on the production
|
|
166
|
+
# host. Outside production the same state is said out loud, never tolerated in
|
|
167
|
+
# silence (ADR 0128).
|
|
168
|
+
problems = self.production_problems()
|
|
169
|
+
if problems:
|
|
170
|
+
if _platform_settings.is_production:
|
|
171
|
+
raise ValueError("; ".join(problems))
|
|
172
|
+
_logger.warning(
|
|
173
|
+
"mail relay %s is configured WITHOUT encryption in this deployment. A "
|
|
174
|
+
"production boot is REFUSED in this state.",
|
|
175
|
+
self.host,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def sender_address(self) -> Address:
|
|
180
|
+
"""The parsed ``From`` address."""
|
|
181
|
+
return parse_sender(self.sender)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def resolved_port(self) -> int:
|
|
185
|
+
"""The declared port, or the conventional one for the declared security."""
|
|
186
|
+
return self.port if self.port is not None else _DEFAULT_PORT[self.security]
|
|
187
|
+
|
|
188
|
+
def production_problems(self) -> list[str]:
|
|
189
|
+
"""What a production boot refuses about this relay, environment-independent."""
|
|
190
|
+
if self.security is MailSecurity.NONE:
|
|
191
|
+
return [
|
|
192
|
+
f"mail relay {self.host!r} is configured without encryption "
|
|
193
|
+
"(MailSecurity.NONE); a production relay uses STARTTLS or TLS, because "
|
|
194
|
+
"every message would otherwise cross the network readable"
|
|
195
|
+
]
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def mail_settings_from_environment(
|
|
200
|
+
environ: Mapping[str, str],
|
|
201
|
+
) -> MailSettings | None:
|
|
202
|
+
"""Read the relay from the fixed environment variables, or ``None`` when none is set.
|
|
203
|
+
|
|
204
|
+
The names are fixed — ``MAIL_FROM``, ``SMTP_HOST``, ``SMTP_PORT``, ``SMTP_SECURITY``,
|
|
205
|
+
``SMTP_USERNAME`` and ``SMTP_PASSWORD`` — so every Terp application configures its
|
|
206
|
+
relay the same way, and a deployment tool can name them without reading the code.
|
|
207
|
+
|
|
208
|
+
``MAIL_FROM`` and ``SMTP_HOST`` are the two that decide whether a relay is configured
|
|
209
|
+
at all, and they are set together: one without the other is a half-finished
|
|
210
|
+
configuration, and it is refused here rather than discovered at the first send.
|
|
211
|
+
``SMTP_SECURITY`` is ``starttls`` (the default), ``tls`` or ``none``. Pass
|
|
212
|
+
``os.environ`` from the composition root.
|
|
213
|
+
"""
|
|
214
|
+
sender = environ.get("MAIL_FROM", "").strip()
|
|
215
|
+
host = environ.get("SMTP_HOST", "").strip()
|
|
216
|
+
if not sender and not host:
|
|
217
|
+
return None
|
|
218
|
+
if not sender or not host:
|
|
219
|
+
missing = "MAIL_FROM" if not sender else "SMTP_HOST"
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"{missing} is not set, but the other half of the mail relay is: set "
|
|
222
|
+
"MAIL_FROM and SMTP_HOST together, or neither"
|
|
223
|
+
)
|
|
224
|
+
raw_security = environ.get("SMTP_SECURITY", "").strip().lower() or MailSecurity.STARTTLS
|
|
225
|
+
try:
|
|
226
|
+
security = MailSecurity(raw_security)
|
|
227
|
+
except ValueError as exc:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"SMTP_SECURITY must be one of starttls, tls or none, not {raw_security!r}"
|
|
230
|
+
) from exc
|
|
231
|
+
raw_port = environ.get("SMTP_PORT", "").strip()
|
|
232
|
+
try:
|
|
233
|
+
port = int(raw_port) if raw_port else None
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise ValueError(f"SMTP_PORT must be a number, not {raw_port!r}") from exc
|
|
236
|
+
return MailSettings(
|
|
237
|
+
sender=sender,
|
|
238
|
+
host=host.lower(),
|
|
239
|
+
port=port,
|
|
240
|
+
security=security,
|
|
241
|
+
username=environ.get("SMTP_USERNAME", "").strip(),
|
|
242
|
+
password=environ.get("SMTP_PASSWORD", ""),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
__all__ = [
|
|
247
|
+
"MailSecurity",
|
|
248
|
+
"breaks_a_header",
|
|
249
|
+
"MailSettings",
|
|
250
|
+
"mail_settings_from_environment",
|
|
251
|
+
"parse_address",
|
|
252
|
+
"parse_sender",
|
|
253
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The platform's one SMTP client: the declared relay, encrypted, verified, time-bounded.
|
|
2
|
+
|
|
3
|
+
``no_raw_outbound_http`` refuses ``smtplib`` in application code and sends the author to
|
|
4
|
+
this capability, for the same arithmetic it applies to HTTP: the things that must be
|
|
5
|
+
right about a connection to a mail server are right in as many places as there are
|
|
6
|
+
clients. Here they are right once:
|
|
7
|
+
|
|
8
|
+
* **the session is encrypted before anything is said** — STARTTLS, or TLS from the first
|
|
9
|
+
byte — and a relay that does not offer STARTTLS is refused, never spoken to in the
|
|
10
|
+
clear, because stripping that one capability line is the whole of a downgrade attack;
|
|
11
|
+
* **the certificate and the hostname are verified**, by the standard library's default
|
|
12
|
+
context with TLS 1.2 as the floor, and there is no parameter that turns that off;
|
|
13
|
+
* **credentials are sent only inside that session**;
|
|
14
|
+
* **every socket operation is time-bounded**, so a relay that stops answering holds one
|
|
15
|
+
delivery attempt rather than a worker;
|
|
16
|
+
* the ``EHLO`` greeting names the **sender's domain**, not this machine: the default is
|
|
17
|
+
the process's own hostname, which inside a container is an internal name the relay
|
|
18
|
+
would then write into the ``Received`` header of every message.
|
|
19
|
+
|
|
20
|
+
The relay's own reply is kept out of every error a caller sees. It goes to the log, with
|
|
21
|
+
the exception chained, because a mail server's error text routinely names accounts and
|
|
22
|
+
internal hosts.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import smtplib # arch-allow-no-raw-outbound-http: this IS the sanctioned mail seam the rule points every other package at; the relay is the one declared host, encrypted and certificate-verified, and no call site can choose another
|
|
29
|
+
import ssl
|
|
30
|
+
from email.message import EmailMessage
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
from terp.capabilities.mail.errors import MailDeliveryError
|
|
34
|
+
from terp.capabilities.mail.settings import MailSecurity, MailSettings
|
|
35
|
+
|
|
36
|
+
_logger = logging.getLogger("terp.capabilities.mail")
|
|
37
|
+
|
|
38
|
+
#: Bounds every socket operation on the relay connection (connect, each command, each
|
|
39
|
+
#: reply). Not a setting: a relay that needs longer than this to answer one command is
|
|
40
|
+
#: not going to deliver the message, and the call site is exactly the place that is
|
|
41
|
+
#: tempted to raise it "just here".
|
|
42
|
+
TIMEOUT_SECONDS: Final[float] = 30.0
|
|
43
|
+
|
|
44
|
+
#: How much of a relay's error reply reaches the log.
|
|
45
|
+
_REPLY_LOG_LIMIT: Final[int] = 300
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _tls_context() -> ssl.SSLContext:
|
|
49
|
+
"""Certificate and hostname verified, TLS 1.2 at least — the only context there is."""
|
|
50
|
+
context = ssl.create_default_context()
|
|
51
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
52
|
+
return context
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SmtpTransport:
|
|
56
|
+
"""Hand one message to the declared relay; raise :class:`MailDeliveryError` if it fails.
|
|
57
|
+
|
|
58
|
+
Constructed from :class:`~terp.capabilities.mail.MailSettings` by
|
|
59
|
+
:func:`~terp.capabilities.mail.configure_mail`; a composition root never builds one
|
|
60
|
+
itself.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, settings: MailSettings) -> None:
|
|
64
|
+
self._settings = settings
|
|
65
|
+
|
|
66
|
+
def _open(self) -> smtplib.SMTP:
|
|
67
|
+
settings = self._settings
|
|
68
|
+
greeting = settings.sender_address.domain
|
|
69
|
+
if settings.security is MailSecurity.TLS:
|
|
70
|
+
return smtplib.SMTP_SSL(
|
|
71
|
+
settings.host,
|
|
72
|
+
settings.resolved_port,
|
|
73
|
+
local_hostname=greeting,
|
|
74
|
+
timeout=TIMEOUT_SECONDS,
|
|
75
|
+
context=_tls_context(),
|
|
76
|
+
)
|
|
77
|
+
return smtplib.SMTP(
|
|
78
|
+
settings.host,
|
|
79
|
+
settings.resolved_port,
|
|
80
|
+
local_hostname=greeting,
|
|
81
|
+
timeout=TIMEOUT_SECONDS,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __call__(self, message: EmailMessage) -> None:
|
|
85
|
+
settings = self._settings
|
|
86
|
+
try:
|
|
87
|
+
client = self._open()
|
|
88
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
89
|
+
raise self._failure(message, exc) from exc
|
|
90
|
+
try:
|
|
91
|
+
if settings.security is MailSecurity.STARTTLS:
|
|
92
|
+
# Raises SMTPNotSupportedError when the relay does not offer the upgrade:
|
|
93
|
+
# there is no fallback to the unencrypted session.
|
|
94
|
+
client.starttls(context=_tls_context())
|
|
95
|
+
if settings.username:
|
|
96
|
+
client.login(settings.username, settings.password)
|
|
97
|
+
refused = client.send_message(message)
|
|
98
|
+
except (smtplib.SMTPException, OSError) as exc:
|
|
99
|
+
raise self._failure(message, exc) from exc
|
|
100
|
+
finally:
|
|
101
|
+
_close(client)
|
|
102
|
+
recipients = len(message["To"].addresses)
|
|
103
|
+
if refused:
|
|
104
|
+
# The relay took the message for some recipients and refused others. That is
|
|
105
|
+
# not retried: a retry would deliver a second copy to everyone it accepted.
|
|
106
|
+
_logger.warning(
|
|
107
|
+
"mail relay %s refused %d of %d recipients of %s",
|
|
108
|
+
settings.host,
|
|
109
|
+
len(refused),
|
|
110
|
+
recipients,
|
|
111
|
+
message["Message-ID"],
|
|
112
|
+
)
|
|
113
|
+
_logger.info(
|
|
114
|
+
"mail relay %s accepted %s for %d recipient(s)",
|
|
115
|
+
settings.host,
|
|
116
|
+
message["Message-ID"],
|
|
117
|
+
recipients - len(refused),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _failure(self, message: EmailMessage, exc: BaseException) -> MailDeliveryError:
|
|
121
|
+
"""Log what the relay said, and return the error a caller is allowed to see.
|
|
122
|
+
|
|
123
|
+
``OSError`` covers what happens below SMTP: a refused or timed-out connection, a
|
|
124
|
+
name that does not resolve, a TLS handshake or certificate failure. The relay's
|
|
125
|
+
reply is logged here, for the operator, because nothing downstream logs it — the
|
|
126
|
+
durable outbox records only the typed error, and that says nothing on purpose.
|
|
127
|
+
"""
|
|
128
|
+
settings = self._settings
|
|
129
|
+
code = getattr(exc, "smtp_code", None)
|
|
130
|
+
reply = getattr(exc, "smtp_error", b"")
|
|
131
|
+
if isinstance(reply, bytes):
|
|
132
|
+
reply = reply.decode("utf-8", "replace")
|
|
133
|
+
_logger.warning(
|
|
134
|
+
"mail relay %s:%d did not take %s: %s %s %s",
|
|
135
|
+
settings.host,
|
|
136
|
+
settings.resolved_port,
|
|
137
|
+
message["Message-ID"],
|
|
138
|
+
type(exc).__name__,
|
|
139
|
+
code if code is not None else "-",
|
|
140
|
+
str(reply)[:_REPLY_LOG_LIMIT],
|
|
141
|
+
)
|
|
142
|
+
return MailDeliveryError(
|
|
143
|
+
"The mail could not be handed to the mail server.",
|
|
144
|
+
log_context={
|
|
145
|
+
"relay": settings.host,
|
|
146
|
+
"port": settings.resolved_port,
|
|
147
|
+
"smtp_code": code,
|
|
148
|
+
"cause": type(exc).__name__,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _close(client: smtplib.SMTP) -> None:
|
|
154
|
+
"""End the session without letting its ending decide the outcome.
|
|
155
|
+
|
|
156
|
+
``QUIT`` comes after the relay has already accepted or refused the message, so an
|
|
157
|
+
error here says nothing about delivery — and treating it as a failure would make the
|
|
158
|
+
outbox deliver an accepted message a second time.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
client.quit()
|
|
162
|
+
except (smtplib.SMTPException, OSError):
|
|
163
|
+
client.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["TIMEOUT_SECONDS", "SmtpTransport"]
|