From a507bd767e9ea25dd1039bca49f0280a0c85da3b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 05:18:48 +0000 Subject: [PATCH] fix(api): preserve verification token on resend mail failure Co-authored-by: Deesmo --- api/src/lib/verification.ts | 31 +++++++++--- api/src/services/email.ts | 11 +++-- api/tests/verify-resend.test.mjs | 81 ++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/api/src/lib/verification.ts b/api/src/lib/verification.ts index c4ce260d..a5876d11 100644 --- a/api/src/lib/verification.ts +++ b/api/src/lib/verification.ts @@ -309,9 +309,9 @@ export async function reissueEmailVerification(email: string): Promise const lowered = email.toLowerCase().trim(); const normalized = normalizeEmailIdentity(email); const rows = await prisma.$queryRaw< - { id: string; email: string; email_verified: boolean; pending_credits: number; verify_token_expiry: Date | null }[] + { id: string; email: string; email_verified: boolean; pending_credits: number; verify_token: string | null; verify_token_expiry: Date | null }[] >` - SELECT "id", "email", "email_verified", "pending_credits", "verify_token_expiry" FROM "Agent" + SELECT "id", "email", "email_verified", "pending_credits", "verify_token", "verify_token_expiry" FROM "Agent" WHERE ( CASE WHEN lower(split_part(email, '@', 2)) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(email), '@', 1), '+', 1), '.', '') || '@gmail.com' @@ -326,6 +326,7 @@ export async function reissueEmailVerification(email: string): Promise id: row.id, email: row.email, pendingCredits: row.pending_credits, + verifyToken: row.verify_token, verifyTokenExpiry: row.verify_token_expiry, }; if (agent.verifyTokenExpiry) { @@ -343,10 +344,28 @@ export async function reissueEmailVerification(email: string): Promise }, }); const verifyUrl = `https://archtools.dev/v1/agent/verify-email?token=${token}`; - sendVerificationEmail({ to: agent.email, verifyUrl, pendingCredits: agent.pendingCredits }).catch((e) => { - logger.warn({ agentId: agent.id, error: String(e) }, "Verification resend email failed"); - }); - logger.info({ agentId: agent.id }, "Verification token re-issued"); + const restorePreviousToken = async (message: string, error?: unknown) => { + try { + await prisma.agent.updateMany({ + where: { id: agent.id, verifyToken: token }, + data: { + verifyToken: agent.verifyToken, + verifyTokenExpiry: agent.verifyTokenExpiry, + }, + }); + logger.warn({ agentId: agent.id, ...(error ? { error: String(error) } : {}) }, message); + } catch (restoreError) { + logger.error({ agentId: agent.id, error: String(restoreError) }, "Verification token rollback failed"); + } + }; + void sendVerificationEmail({ to: agent.email, verifyUrl, pendingCredits: agent.pendingCredits }) + .then((sent) => { + if (!sent) { + return restorePreviousToken("Verification token reissue rolled back because email was not delivered"); + } + logger.info({ agentId: agent.id }, "Verification token re-issued"); + }) + .catch((e) => restorePreviousToken("Verification token reissue rolled back because email send failed", e)); return true; } diff --git a/api/src/services/email.ts b/api/src/services/email.ts index 1d7d22a2..a9a32563 100644 --- a/api/src/services/email.ts +++ b/api/src/services/email.ts @@ -240,11 +240,16 @@ export function renderVerificationEmail(verifyUrl: string, pendingCredits?: numb return { subject, html, text }; } -export async function sendVerificationEmail(args: { to: string; verifyUrl: string; pendingCredits?: number }): Promise { +export async function sendVerificationEmail(args: { to: string; verifyUrl: string; pendingCredits?: number }): Promise { const { to, verifyUrl, pendingCredits } = args; const { subject, html, text } = renderVerificationEmail(verifyUrl, pendingCredits); - await sendEmail(to, subject, html, text); - logger.info({ to }, "Verification email sent"); + const sent = await sendEmail(to, subject, html, text); + if (sent) { + logger.info({ to }, "Verification email sent"); + } else { + logger.warn({ to }, "Verification email not sent"); + } + return sent; } // ─── 2. Welcome Email ─── diff --git a/api/tests/verify-resend.test.mjs b/api/tests/verify-resend.test.mjs index 924c1a1d..28049729 100644 --- a/api/tests/verify-resend.test.mjs +++ b/api/tests/verify-resend.test.mjs @@ -23,6 +23,21 @@ import assert from "assert"; process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub"; +process.env.RESEND_API_KEY = "test-resend-key"; + +const realFetch = globalThis.fetch.bind(globalThis); +let emailSendOk = true; +let emailSendGate = null; +let sentEmails = []; +globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input?.url; + if (url === "https://api.resend.com/emails") { + sentEmails.push(JSON.parse(String(init?.body ?? "{}"))); + if (emailSendGate) await emailSendGate; + return new Response(emailSendOk ? "{}" : "resend unavailable", { status: emailSendOk ? 200 : 503 }); + } + return realFetch(input, init); +}; const { prisma } = await import("../dist/lib/prisma.js"); const { renderVerifyErrorPage, renderVerifyResendSentPage } = await import("../dist/assets/verifyEmailHtml.js"); @@ -94,17 +109,20 @@ const BASE = `http://127.0.0.1:${server.address().port}`; // with the raw snake_case column names. let agentRow = null; // what the normalized-identity lookup returns let updates = []; // captured update() calls +let updateManyCalls = []; // captured rollback updateMany() calls let lookupParams = []; // captured $queryRaw bind values const rawRow = () => ({ id: agentRow.id, email: agentRow.email ?? "stored@example.com", email_verified: agentRow.emailVerified, pending_credits: agentRow.pendingCredits, + verify_token: agentRow.verifyToken ?? null, verify_token_expiry: agentRow.verifyTokenExpiry, }); const stubLookup = async (_strings, ...values) => { lookupParams = values; return agentRow ? [rawRow()] : []; }; prisma.$queryRaw = stubLookup; prisma.agent.update = async (args) => { updates.push(args); return {}; }; +prisma.agent.updateMany = async (args) => { updateManyCalls.push(args); return { count: 1 }; }; async function postResend(email, headers = {}) { const res = await fetch(`${BASE}/v1/agent/verify-email/resend`, { @@ -116,6 +134,21 @@ async function postResend(email, headers = {}) { return { res, text }; } +function deferred() { + let resolve; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +async function waitFor(predicate, label, timeoutMs = 250) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.ok(predicate(), label); +} + // The exact neutral body every internal outcome must produce (modulo request_id). function neutralShape(text) { const body = JSON.parse(text); @@ -127,10 +160,14 @@ function neutralShape(text) { await atest("happy path: unverified account + EXPIRED token → 200, single token-only rotation", async () => { updates = []; + updateManyCalls = []; + sentEmails = []; + emailSendOk = true; agentRow = { id: "agent-resend-1", emailVerified: false, pendingCredits: 75, + verifyToken: "old-expired-token", verifyTokenExpiry: new Date(Date.now() - 60_000), // expired 1 min ago (issued 31 min ago) }; const { res, text } = await postResend("stranded@example.com"); @@ -145,6 +182,8 @@ await atest("happy path: unverified account + EXPIRED token → 200, single toke for (const forbidden of ["credits", "pendingCredits", "emailVerified"]) { assert.ok(!(forbidden in data), `resend must not write ${forbidden}`); } + assert.strictEqual(updateManyCalls.length, 0, "successful delivery must not roll back the token"); + assert.strictEqual(sentEmails.length, 1, "verification email sent once"); }); let happyBody; @@ -188,10 +227,52 @@ await atest("cooldown: token minted moments ago → 200 but NOT re-minted", asyn await atest("no-token unverified account (failed signup setup) → recoverable", async () => { updates = []; + updateManyCalls = []; + emailSendOk = true; agentRow = { id: "agent-notoken", emailVerified: false, pendingCredits: 0, verifyTokenExpiry: null }; const { res } = await postResend("failedsetup@example.com"); assert.strictEqual(res.status, 200); assert.strictEqual(updates.length, 1, "token issued even when none existed"); + assert.strictEqual(updateManyCalls.length, 0, "successful recovery must not roll back"); +}); + +await atest("provider failure → neutral 200 before provider completes, then previous token restored", async () => { + updates = []; + updateManyCalls = []; + sentEmails = []; + emailSendOk = false; + const gate = deferred(); + emailSendGate = gate.promise; + const oldExpiry = new Date(Date.now() + 10 * 60 * 1000); // live token, minted outside cooldown + agentRow = { + id: "agent-maildown", + email: "maildown@example.com", + emailVerified: false, + pendingCredits: 75, + verifyToken: "still-usable-token", + verifyTokenExpiry: oldExpiry, + }; + const resendPromise = postResend("maildown@example.com"); + const returnedBeforeProvider = await Promise.race([ + resendPromise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 50)), + ]); + assert.strictEqual(returnedBeforeProvider, true, "neutral response must not wait on the email provider"); + const { res, text } = await resendPromise; + assert.strictEqual(res.status, 200); + assert.strictEqual(neutralShape(text), happyBody); + assert.strictEqual(updates.length, 1, "resend attempts to mint a fresh token"); + const mintedToken = updates[0].data.verifyToken; + assert.strictEqual(sentEmails.length, 1, "delivery was attempted"); + assert.strictEqual(updateManyCalls.length, 0, "rollback waits for the provider result"); + gate.resolve(); + emailSendGate = null; + await waitFor(() => updateManyCalls.length === 1, "failed delivery rolls the token back"); + assert.strictEqual(updateManyCalls.length, 1, "failed delivery rolls the token back"); + assert.deepStrictEqual(updateManyCalls[0].where, { id: "agent-maildown", verifyToken: mintedToken }); + assert.strictEqual(updateManyCalls[0].data.verifyToken, "still-usable-token"); + assert.strictEqual(updateManyCalls[0].data.verifyTokenExpiry, oldExpiry); + emailSendOk = true; }); await atest("gmail alias submitted → stored dotted account still found, token rotated", async () => {