feat: let a customer resend their own verification email (#110)

A verification email was sent once, at registration. If it was lost, filtered, or arrived after the 24-hour token had expired, the customer had no route back except registering again with a different address.

POST /api/customers/resend-verification mints a fresh token and sends the mail, and the account page grows a "Send it again" button beside the warning that was already there. The button only exists while the address is unverified: on a verified account it would be a control whose only outcome is a refusal, and the endpoint refuses that case rather than sending a pointless email.

The mint-token-and-send sequence now lives in one place. Registration and the email change already did the same three steps - supersede any outstanding token, mint a new one, send it - and this would have been a third copy. The step most likely to be dropped in a copy is the supersede, and it is the one that matters: without it an older message still sitting in the inbox goes on verifying.

Anything that makes the server send mail on request is an abuse vector, so this is rate limited to three an hour, keyed on the customer id. That is tighter than either existing limiter and sidesteps #84's IPv6 problem entirely, since a signed-in caller has an identity better than an address to count against and cannot escape the bucket by moving within a delegated prefix. The refusal says the mail probably did send and to check the spam folder, which is both more useful and more honest than a bare 429.

The claim that keying on customer id also solved test isolation was wrong, and the tests caught it. resetDb truncates with RESTART IDENTITY, so every integration test's first customer is id 1: three tests that each sent once left the fourth starting at its limit, and two tests failed on a 429 they never asked for. A "fresh customer per test" is a fresh row with a recycled identity. The limiter now has an explicit exported store the suite clears between tests, and the comment that claimed otherwise has been corrected rather than left to mislead the next reader.

Verification: seven integration tests covering the unauthenticated refusal, the send, the new token invalidating the old one - asserted through the endpoint that would honour the stale link rather than by counting rows - the new link actually verifying, the already-verified refusal, the allowance stopping the fourth send rather than merely reporting it, and one customer's exhausted allowance leaving another's intact. Three end-to-end tests for the button, its confirmation and the message on the fourth click. The 33 integration tests across the three suites this touched all pass, as do the 199 backend unit tests. tsc clean on both sides, ESLint no errors.

Closes #110
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:59:09 -05:00
co-authored by Claude Opus 5
parent 748c42628b
commit b8549e9c72
6 changed files with 363 additions and 35 deletions
+25 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import Typography from 'antd/es/typography';
import Switch from 'antd/es/switch';
import Button from 'antd/es/button';
@@ -7,7 +7,7 @@ import message from 'antd/es/message';
import Space from 'antd/es/space';
import Divider from 'antd/es/divider';
import { useNavigate } from 'react-router-dom';
import { updateConsent, exportMyData, deleteMyAccount } from './customerApi';
import { updateConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
@@ -23,6 +23,7 @@ interface Props {
export default function Account({ onClose }: Props) {
const { customer, loading, refresh, logout } = useCustomerAuth();
const navigate = useNavigate();
const [resending, setResending] = useState(false);
useEffect(() => {
if (!loading && !customer) navigate('/login');
@@ -30,6 +31,20 @@ export default function Account({ onClose }: Props) {
if (!customer) return null;
async function handleResendVerification() {
setResending(true);
try {
await resendVerificationEmail();
message.success('Sent. Check your inbox, and your spam folder.');
} catch (err) {
// Shown as it arrives: the rate limit's message says the mail probably
// did send and where to look, which a generic failure would throw away.
message.error((err as Error).message);
} finally {
setResending(false);
}
}
async function handleFavoriteAlertsToggle(checked: boolean) {
try {
await setFavoriteAlerts(checked);
@@ -99,9 +114,17 @@ export default function Account({ onClose }: Props) {
>
<div>
<Text>{customer.email}</Text>
{/* The button only exists while there is something to verify. Offering
it on a verified account would be a control whose only outcome is a
refusal. */}
{!customer.email_verified && (
<div style={{ marginTop: 8 }}>
<Text type="warning">Email not verified check your inbox for a verification link.</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" loading={resending} onClick={handleResendVerification}>
Send it again
</Button>
</div>
</div>
)}
+13
View File
@@ -142,3 +142,16 @@ export function changeMyEmail(currentPassword: string, email: string): Promise<C
body: JSON.stringify({ currentPassword, email })
}).then(res => handle<Customer>(res));
}
export function resendVerificationEmail(): Promise<void> {
// 204 on success, so handle() would throw parsing an empty body. The failure
// path must still reject: the server's message distinguishes "already
// verified" from the rate limit's "check your spam folder", and both are
// worth showing rather than replacing with something generic.
return fetch('/api/customers/resend-verification', { method: 'POST' }).then(async (res) => {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
});
}
@@ -0,0 +1,63 @@
import { test, expect, Page } from './fixtures';
const PASSWORD = 'supersecret123';
const uniqueEmail = () => `resend-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
// The generous wait matches the other account specs: registration is a bcrypt
// round-trip rather than a render, and runs past Playwright's 5s default when
// the suite's workers all register at once.
async function registerCustomer(page: Page): Promise<string> {
const email = uniqueEmail();
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(email);
await page.getByRole('textbox', { name: 'First name' }).fill('Test');
await page.getByRole('textbox', { name: 'Last name' }).fill('Customer');
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
return email;
}
const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' });
test.describe('Resending your verification email', () => {
test('the account page offers it while the address is unverified', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
const modal = accountModal(page);
await expect(modal.getByText('Email not verified')).toBeVisible();
await expect(modal.getByRole('button', { name: 'Send it again' })).toBeVisible();
});
test('confirms when it has sent', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
await accountModal(page).getByRole('button', { name: 'Send it again' }).click();
await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible();
});
// The message the customer gets on the fourth attempt is the point of the
// limiter's copy: it says the mail probably did send and where to look,
// rather than only that a limit exists.
test('says something useful once the allowance runs out', async ({ page }) => {
await registerCustomer(page);
await page.goto('/account');
const resend = accountModal(page).getByRole('button', { name: 'Send it again' });
for (let i = 0; i < 3; i++) {
await resend.click();
await expect(resend).toBeEnabled();
}
await resend.click();
// Matched on the phrase unique to the refusal. "spam folder" appears in the
// success message too, and the three stacked success toasts from the loop
// above are still on screen, so the looser match finds them instead.
await expect(page.getByText(/already sent several/)).toBeVisible();
});
});