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.
@@ -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,3 @@
1
+ {
2
+ "arch-allow-no-raw-outbound-http": 1
3
+ }
@@ -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
+ ]
@@ -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"]