feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user