feat: add a host-provided outbound email transport
Ekklesia could authenticate people and pair devices but could not send a single message. Better Auth has been configured with emailAndPassword since ADR 0011 and neither of its mail hooks — sendResetPassword and emailVerification.sendVerificationEmail — was ever implemented, so email verification, password recovery, and invitations were all blocked on the same missing thing. That missing thing is a transport, not a Google Workspace integration. Email is now a host-provided infrastructure service with a provider seam, shaped exactly like object storage: feature code says "send this transactional message" and the provider owns the host, port, TLS, credentials, connection behaviour, and every transport error. No provider's hostname appears anywhere in Core, and a test asserts it. Google Workspace SMTP Relay is the expected first deployment and a compatibility target — the same variables address Microsoft 365, a transactional provider, or a local Postfix. The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or HTML, an optional reply-to, and nothing else. There is no `from` field at all — the sender is host configuration, so no feature and no future module can choose who mail appears to come from, which is how a platform otherwise acquires an impersonation primitive. Control characters are refused rather than stripped in addresses, display names, subjects, and reply-to values; writing that rule down was not enough, because the first implementation trimmed an address before checking it and silently accepted a trailing carriage return until a test caught it. Security modes are named rather than inferred from a boolean and a port, which is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and hope STARTTLS is offered". `starttls` sets requireTLS so a stripped upgrade fails the send instead of continuing in plaintext, production refuses `plaintext` outright, and certificate verification has no off switch. Credentials are optional as a pair, because a relay authorized by source IP has neither and requiring them would make the intended production arrangement unconfigurable; half a pair fails loudly. Connection, greeting, and socket timeouts are all explicit — the object-storage pass already showed what a network client on library defaults does to a request. Nothing retries: a failure after the message data has gone out is ambiguous, and retrying it turns one password-reset request into several emails, so this is a single bounded attempt and deliberately not an exactly-once promise. Email stays optional. An installation with no SMTP host is a complete deployment, nothing sends at startup, and constructing the transport opens no socket. There is no POST /api/v1/send-email and no route that touches the provider — an authenticated send endpoint is an open relay with extra steps. Modules get nothing either; services are added one at a time when something real needs one, and nothing does yet. Proven against a real in-process SMTP server rather than a mock: delivery with the host's sender on the wire, authentication and its sanitized refusal, a rejected mailbox distinguished from an outage, a refused connection, a silent server hitting its deadline, and a starttls transport refusing to continue when the upgrade is not offered. No test sends real email and no live credential exists in this repository or in CI. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ba32499456
commit
fa3021e49b
@@ -375,6 +375,31 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
|
||||
answer "yes" to every human question, so any accidental fall-through into a human path would flip
|
||||
those assertions.
|
||||
|
||||
Outbound email is proven against a **real SMTP server** rather than a mock
|
||||
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||
`smtp-server` — the reference implementation from the same project as the client — runs in-process,
|
||||
so this needs no container and adds no CI infrastructure. **No test sends real email, and no live
|
||||
credential exists in this repository or in CI.**
|
||||
|
||||
It pins the things only a server can settle: a delivered message whose envelope sender and `From:`
|
||||
header are the *host's* configured address rather than anything a caller chose; both bodies and a
|
||||
reply-to arriving intact; authentication succeeding and its refusal mapping to a sanitized
|
||||
`MAIL_REJECTED` that echoes neither the password nor the server's response text; a mailbox refused at
|
||||
`RCPT TO` mapping to `MAIL_RECIPIENT_INVALID` rather than a generic failure; a refused connection
|
||||
mapping to `MAIL_PROVIDER_UNAVAILABLE`; a server that accepts a connection and never greets hitting
|
||||
its deadline instead of hanging; a `starttls` transport refusing to continue when the upgrade is not
|
||||
offered; `verify()` connecting without sending anything; and one `send` producing exactly one
|
||||
message.
|
||||
|
||||
`core/tests/mail-contract.test.ts` covers the boundary with no network at all, and is mostly
|
||||
negative — the contract's job is to make an impossible message impossible to construct. It also pins
|
||||
that a validated message has no `from` key, which is the structural reason no feature can spoof a
|
||||
sender. `core/tests/mail-boundaries.test.ts` asserts the properties no runtime test can: the SMTP
|
||||
dependency exists in Core alone, no browser-facing package imports it, module SDKs have no way to
|
||||
send, no HTTP route references the mail module, Core's mail code names no provider hostname and
|
||||
never mentions disabling certificate verification, and `.env.example` documents the variables with
|
||||
no password value.
|
||||
|
||||
### Which kind of timing proof lives where
|
||||
|
||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||
|
||||
Reference in New Issue
Block a user