feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s

Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -220,8 +220,7 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
/**
* Send a password-reset link. `to` is the account's email, `resetUrl` the tokened
* reset link, `username` names which account it's for (email is non-unique, so one
* address may receive a link per account). If email is not configured, returns
* reset link, `username` names which account it's for. If email is not configured, returns
* { sent: false, reason: 'NOT_CONFIGURED' } — the caller still returns a generic
* success to avoid leaking whether the address exists. Throws only on a send failure.
*/
@@ -251,6 +250,45 @@ async function sendPasswordReset({ to, resetUrl, username }) {
}
}
/**
* Send an email-verification link (engagement Phase 1b). `to` is the address
* being PROVED — which is by definition not yet the account's address, and may
* belong to someone who has never heard of this site. So the copy names the
* account and says plainly what to do if it was not you, and the link installs
* an address rather than granting any access.
*
* Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured;
* the caller surfaces that honestly, because unlike a password reset there is no
* enumeration reason to pretend a mail went out to an address the CALLER typed.
*/
async function sendEmailVerification({ to, verifyUrl, username }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const forWhom = username ? `${username}` : ''
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: replyToFor(config),
subject: `Confirm your email address for ${brand.name}`,
text:
`The ${brand.name} account${forWhom} asked to use this address for contact and account recovery.\n\n` +
`Confirm it here:\n${verifyUrl}\n\n` +
`This link is single-use and expires in about a day. Until it is used, nothing changes — ` +
`the account keeps whatever address it had.\n\n` +
`If you did not ask for this, you can ignore this email. Someone may have mistyped their ` +
`own address; no account of yours is affected and this link grants no access to anything.`,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Verification send OK', lastVerifiedAt: new Date() })
return { sent: true }
} catch (err) {
log.error('email verification send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
throw err
}
}
/**
* Send a Team notification — one event (`immediate` mode) or a day's worth
* (`digest` mode). TEAMS.md §6.4.
@@ -328,5 +366,6 @@ module.exports = {
sendTest,
sendInvite,
sendPasswordReset,
sendEmailVerification,
sendTeamNotification,
}