-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathnewsletter-subscribe.js
More file actions
68 lines (58 loc) · 2.77 KB
/
Copy pathnewsletter-subscribe.js
File metadata and controls
68 lines (58 loc) · 2.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// POST /api/newsletter-subscribe — double opt-in newsletter signup.
//
// Step 1 of 2: record a `pending` subscriber + a confirm token, then email a
// confirmation link. The contact is NOT added to the Resend audience until the
// link is clicked (api/newsletter-confirm.js) — so we never mail someone who
// didn't prove ownership of the address, and a typo'd/hostile email can't be
// used to subscribe a third party.
//
// Always returns a generic success so the endpoint can't be used to probe which
// addresses are already subscribed.
import { z } from 'zod';
import { sql } from './_lib/db.js';
import { cors, json, method, wrap, readJson, rateLimited } from './_lib/http.js';
import { parse } from './_lib/validate.js';
import { limits, clientIp } from './_lib/rate-limit.js';
import { randomToken } from './_lib/crypto.js';
import { sendNewsletterConfirmEmail } from './_lib/email.js';
import { captureException } from './_lib/sentry.js';
const APP_URL = process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || 'https://three.ws';
const bodySchema = z.object({
email: z.string().trim().toLowerCase().email().max(254),
locale: z.string().trim().max(10).optional(),
source: z.string().trim().max(40).optional(),
});
export default wrap(async (req, res) => {
if (cors(req, res, { methods: 'POST,OPTIONS', credentials: false })) return;
if (!method(req, res, ['POST'])) return;
const rl = await limits.newsletterIp(clientIp(req));
if (!rl.success) return rateLimited(res, rl);
const { email, locale, source } = parse(bodySchema, await readJson(req));
// Already confirmed → idempotent success, no second email.
const [existing] = await sql`
select status from newsletter_subscribers where email = ${email}
`;
if (existing?.status === 'confirmed') {
return json(res, 200, { success: true, status: 'confirmed' });
}
// New or re-subscribing (pending/unsubscribed) → fresh token + confirm email.
const token = randomToken(24);
await sql`
insert into newsletter_subscribers (email, status, confirm_token, locale, source)
values (${email}, 'pending', ${token}, ${locale || null}, ${source || null})
on conflict (email) do update set
status = 'pending',
confirm_token = excluded.confirm_token,
locale = coalesce(excluded.locale, newsletter_subscribers.locale),
source = coalesce(excluded.source, newsletter_subscribers.source),
unsubbed_at = null
`;
const confirmUrl = `${APP_URL}/api/newsletter-confirm?token=${encodeURIComponent(token)}`;
try {
await sendNewsletterConfirmEmail({ to: email, confirmUrl, locale });
} catch (err) {
// Email transport failed — the row is saved, so a resend will reuse it.
captureException(err, { email, where: 'newsletter-confirm-email' });
}
return json(res, 200, { success: true, status: 'pending' });
});