import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js"; /** * The identity feature's outbound messages, and the seam between Better Auth's callbacks and the * host's mail transport (ADR 0038). * * ## What this owns and what it deliberately does not * * It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the * port, the TLS mode, a credential, or the sender: those are host configuration held by the mail * provider, and there is no field on the message contract through which a caller could choose any * of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file. * * It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them; * this receives one already-minted token and puts it in a link. There is no Ekklesia token table, * no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those * would be a second thing to get wrong. * * ## Why the link is rebuilt rather than used as given * * Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or * `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted * origins, and that validation is sound — but an emailed link is the one artifact a person is most * likely to click without looking, so its destination is derived here from validated configuration * alone and from nothing a request could influence. The token is the only value taken from Better * Auth. No request parameter can steer where a recipient lands, independently of whether the origin * check behaves as expected. */ /** What could not be delivered, for an operator — never who it was for, and never the link. */ export type IdentityNotificationKind = "password-reset" | "email-verification"; export interface IdentityNotificationFailure { readonly kind: IdentityNotificationKind; /** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */ readonly reason: string; } /** * Where a delivery failure is reported. * * It exists because these callbacks must **never throw**. Better Auth's own * `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns * "this address has an unverified account" into a distinguishable response — an account-enumeration * oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced * out of band instead, where no HTTP response shape depends on it. * * The default implementation writes one fixed line per failure. It carries the flow and a sanitized * code and nothing else: no address, no token, no URL, no provider text. */ export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void; export interface IdentityNotificationRecipient { readonly email: string; readonly name?: string; } export interface CreateIdentityNotificationsOptions { /** * The host mail provider, or `undefined` when this installation has none. * * `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner * still signs in, and only verification and recovery are unavailable — reported as * `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response. */ readonly mail: MailProvider | undefined; /** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */ readonly authBaseUrl: string; /** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */ readonly dashboardOrigin: string; /** Defaults to a single fixed sanitized console line. */ readonly report?: IdentityNotificationReporter; } export interface IdentityNotifications { /** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */ readonly sendVerificationEmail: (data: { readonly user: IdentityNotificationRecipient; readonly token: string; }) => Promise; /** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */ readonly sendResetPassword: (data: { readonly user: IdentityNotificationRecipient; readonly token: string; }) => Promise; } /** * The two query values the Dashboard bundle reads to decide which host-owned auth screen to show. * * They are query parameters on the Dashboard's root path rather than distinct paths deliberately: * the shell is a static single-page bundle, and a deep path would require every deployment's static * host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query * parameter works on any static host, including the repository's own development server. */ export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth"; export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email"; export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password"; function landingUrl(dashboardOrigin: string, landing: string): string { const url = new URL(dashboardOrigin); url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing); return url.href; } /** * Better Auth's own verification URL, rebuilt from configuration. * * The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email? * token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts * on the token. Only the destination is ours. */ function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string { const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING); return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent( token, )}&callbackURL=${encodeURIComponent(callback)}`; } /** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the * browser to the Dashboard landing with the token attached. */ function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string { const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING); return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent( token, )}?callbackURL=${encodeURIComponent(callback)}`; } /** Escapes the four characters that could otherwise close an element or attribute in the HTML body. * Only the link and the greeting name are interpolated, and the name comes from the user record. */ function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } /** * The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point * scan rather than a regular expression, because a regex over a control-character range is itself * something a linter (rightly) objects to. */ function hasControlCharacter(value: string): boolean { for (const character of value) { const codePoint = character.codePointAt(0); if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { return true; } } return false; } /** * A greeting that is safe to place in a message body. * * A stored display name is not trusted to be free of control characters — the message validator * would reject the whole message for one stray carriage return, which would turn a cosmetic problem * into an undeliverable password reset. So an unusable name simply falls back to no greeting. */ function greeting(name: string | undefined): string { if (typeof name !== "string") { return "Hello,"; } const trimmed = name.trim(); if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) { return "Hello,"; } return `Hello ${trimmed},`; } const EXPIRY_SENTENCE = "This link expires in one hour."; const UNEXPECTED_SENTENCE = "If you did not expect this message, you can ignore it and nothing will change."; interface MessageBodies { readonly subject: string; readonly text: string; readonly html: string; } function verificationBodies(name: string | undefined, url: string): MessageBodies { const lead = greeting(name); return { subject: "Verify your Ekklesia email address", html: [ `

${escapeHtml(lead)}

`, "

Confirm this email address so it can be used to sign in to Ekklesia.

", `

Verify this email address

`, `

${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}

`, ].join("\n"), text: [ lead, "", "Confirm this email address so it can be used to sign in to Ekklesia:", "", url, "", EXPIRY_SENTENCE, UNEXPECTED_SENTENCE, ].join("\n"), }; } function resetBodies(name: string | undefined, url: string): MessageBodies { const lead = greeting(name); const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`; return { subject: "Reset your Ekklesia password", html: [ `

${escapeHtml(lead)}

`, "

Someone asked to reset the password for this Ekklesia account.

", `

Choose a new password

`, `

${EXPIRY_SENTENCE} ${escapeHtml(closing)}

`, ].join("\n"), text: [ lead, "", "Someone asked to reset the password for this Ekklesia account. Choose a new one here:", "", url, "", EXPIRY_SENTENCE, closing, ].join("\n"), }; } function defaultReporter(failure: IdentityNotificationFailure): void { // One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go // look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here. console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`); } function failureReason(error: unknown): string { return error instanceof MailError ? error.code : "MAIL_SEND_FAILED"; } /** * Builds the two Better Auth callbacks. * * Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the * HTTP response an unauthenticated caller sees is identical whether the address exists, whether it * was already verified, and whether the relay was reachable. */ export function createIdentityNotifications( options: CreateIdentityNotificationsOptions, ): IdentityNotifications { const report = options.report ?? defaultReporter; const deliver = async ( kind: IdentityNotificationKind, recipient: IdentityNotificationRecipient, bodies: MessageBodies, ): Promise => { if (!options.mail) { report({ kind, reason: "MAIL_NOT_CONFIGURED" }); return; } try { await options.mail.send( validateMailMessage({ html: bodies.html, subject: bodies.subject, text: bodies.text, to: recipient.email, }), ); } catch (error) { report({ kind, reason: failureReason(error) }); } }; const notifications: IdentityNotifications = { sendResetPassword: async (data) => { await deliver( "password-reset", data.user, resetBodies(data.user.name, resetUrl(options, data.token)), ); }, sendVerificationEmail: async (data) => { await deliver( "email-verification", data.user, verificationBodies(data.user.name, verificationUrl(options, data.token)), ); }, }; return Object.freeze(notifications); }