feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s

Ekklesia could authenticate a person and, since Pass 1, send a
transactional email — but it had no way to establish that an address
belonged to whoever claimed it, and no way back in for someone who
forgot their password.

A verified address is now a precondition for holding a session. Better
Auth enforces that after the password check and before any session
exists, so an unverified sign-in returns 403 with no cookie. That is why
no "unverified principal" was needed: a correct password on an
unverified address carries exactly the authority of no password, and
/api/v1/principal is unchanged.

Better Auth keeps every token. Ekklesia adds no token table, no hashing,
and no expiry clock. It supplies a recipient, a subject, and two bodies,
and the mail provider supplies everything about SMTP.

Verification and reset links are rebuilt from a validated Dashboard
origin, taking only the token — never the URL Better Auth prebuilds from
a caller-supplied callbackURL — so no request can steer where a
recipient lands. Following a verification link mints no session, because
mail infrastructure dereferences links. Reset revokes that person's
other sessions and leaves managed devices alone.

Recovery answers identically for an address with an account, without
one, already verified, or undeliverable — including when SMTP is broken,
which is why the delivery adapter never throws.

The initial platform owner is the one exception: created already
verified by an operator command with no mail prerequisite, so a fresh
server can be signed in to before a relay exists. Migration 0007 extends
that to owners of installations that already exist. Public signup stays
disabled; verification is not a licence to self-register.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-25 07:21:17 -05:00
co-authored by Claude Opus 5
parent 3581ad7c8e
commit ea28f8aa2e
52 changed files with 5675 additions and 197 deletions
+40 -7
View File
@@ -3,9 +3,14 @@
Ekklesia can send transactional email through a host-configured SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
complete, valid deployment — the platform starts and every existing feature works.
Two things send email: **email verification** and **password recovery**
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
do not exist.
An installation with no SMTP configured is still a complete, valid deployment — the platform starts,
every existing feature works, and the initial platform owner can sign in. What it cannot do is verify
a new account or recover a password, because both need a message to arrive. See
[When no relay is configured](#when-no-relay-is-configured).
## Configuring a relay
@@ -121,8 +126,36 @@ 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.**
## Next
## What the platform actually sends
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
hooks — both currently unimplemented — to this transport. See
[Identity](identity.md#outbound-email-and-the-verification-seam).
| message | when | contents |
| --- | --- | --- |
| Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording |
| Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works |
Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing
content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a
subject, and two bodies, and nothing about SMTP.
Links are rebuilt from validated configuration rather than taken from the request that triggered
them, so no caller can steer where a recipient lands. They point at
`EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See
[Identity](identity.md#email-verification-and-password-recovery).
## When no relay is configured
Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it
would with a working relay — the response must not reveal whether an account exists, and it must not
start revealing it the moment SMTP breaks — and the operator is told out of band:
```text
[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED
```
That line carries a flow and a sanitized code. It never carries the address, the token, or the link.
`pnpm doctor` reports the same absence under `outbound-email.configuration`.
The operator requirement is therefore:
- **normal production use of verification and recovery** → requires configured outbound email
- **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not