diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index d7322e4..9304a85 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -32,6 +32,23 @@ function toPublicUser(r: typeof users.$inferSelect): PublicUser { return { id: r.id, email: r.email, displayName: r.displayName, role: r.role }; } +// Send an email but never let a delivery failure break the request flow. +// Used by resend-verification and forgot-password, where the response must stay +// generic regardless of whether the mail actually went out. +async function trySendEmail( + to: string, + msg: { subject: string; html: string; text: string }, + context: string, +): Promise { + try { + await getMailer().send(to, msg); + return true; + } catch (err) { + console.error(`[mail] failed to send ${context} to ${to}:`, err); + return false; + } +} + export function authRouter(db: Db): Router { const r = Router(); @@ -61,7 +78,16 @@ export function authRouter(db: Db): Router { const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL); const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName); - await getMailer().send(user!.email, tpl); + try { + await getMailer().send(user!.email, tpl); + } catch (mailErr) { + // Roll back the half-created account (token cascades) so the user can + // retry cleanly once the mailer is fixed, instead of being left with an + // unverifiable orphan account and a 500. + db.delete(users).where(eq(users.id, user!.id)).run(); + console.error(`[mail] verification email failed during register; rolled back user ${user!.id}:`, mailErr); + throw new ApiError(502, 'EMAIL_SEND_FAILED', 'Account could not be created because the verification email failed to send. Please try again later.'); + } res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role }); } catch (e) { next(e); } @@ -103,7 +129,7 @@ export function authRouter(db: Db): Router { await invalidateTokensForUser(db, u.id, 'verify_email'); const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL); const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName); - await getMailer().send(u.email, tpl); + await trySendEmail(u.email, tpl, 'verify_email (resend)'); } res.status(200).json({ ok: true }); } catch (e) { next(e); } @@ -147,7 +173,7 @@ export function authRouter(db: Db): Router { await invalidateTokensForUser(db, u.id, 'password_reset'); const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL); const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName); - await getMailer().send(u.email, tpl); + await trySendEmail(u.email, tpl, 'password_reset'); } res.status(200).json({ ok: true }); } catch (e) { next(e); } diff --git a/packages/backend/src/tests/auth.integration.test.ts b/packages/backend/src/tests/auth.integration.test.ts index b4f4ed4..d969da2 100644 --- a/packages/backend/src/tests/auth.integration.test.ts +++ b/packages/backend/src/tests/auth.integration.test.ts @@ -11,6 +11,12 @@ class CaptureMailer implements Mailer { } } +class ThrowingMailer implements Mailer { + async send(): Promise { + throw new Error('SES rejected: sender not verified'); + } +} + let env: ReturnType; let mailer: CaptureMailer; let app: ReturnType; @@ -92,4 +98,34 @@ describe('auth integration', () => { expect(dup.status).toBe(409); expect(dup.body.error.code).toBe('EMAIL_TAKEN'); }); + + it('rolls back registration when the verification email fails to send', async () => { + setMailerForTests(new ThrowingMailer()); + const failed = await request(app).post('/api/auth/register').send({ + email: 'bob@example.com', displayName: 'Bob', password: 'secretpass', + }); + expect(failed.status).toBe(502); + expect(failed.body.error.code).toBe('EMAIL_SEND_FAILED'); + + // The account must NOT persist — registering again with a working mailer succeeds. + setMailerForTests(mailer); + const retry = await request(app).post('/api/auth/register').send({ + email: 'bob@example.com', displayName: 'Bob', password: 'secretpass', + }); + expect(retry.status).toBe(201); + expect(mailer.sent).toHaveLength(1); + }); + + it('forgot-password still returns 200 when the mailer fails (no enumeration leak)', async () => { + // Create a verified user first. + await request(app).post('/api/auth/register').send({ + email: 'carol@example.com', displayName: 'Carol', password: 'secretpass', + }); + const token = tokenFromMail(mailer.sent[0]!.text); + await request(app).post('/api/auth/verify-email').send({ token }); + + setMailerForTests(new ThrowingMailer()); + const r = await request(app).post('/api/auth/forgot-password').send({ email: 'carol@example.com' }); + expect(r.status).toBe(200); + }); });