Skip to content

Commit e2f1a84

Browse files
author
Onyii1234
authored
feat: add privacy-respecting newsletter signup (#68) (#98)
- Add /newsletter page with Buttondown double opt-in signup form - Add NewsletterWidget component to Footer - Add Vercel serverless function api/subscribe.ts (Buttondown REST API, server-side only — no third-party JS loaded client-side) - Register /newsletter route in App.tsx, add to OG image generator - Add i18n keys (en + es) for newsletter UI strings - Add Playwright E2E tests: zero cross-origin requests on /newsletter, form render, and validation error assertion - Add 15 Vitest unit tests covering Newsletter page and Footer widget (validation, success/error states, 409-as-success privacy pattern) - Exclude e2e/ from Vitest runner; add test:e2e script Provider choice: Buttondown - Privacy-respecting, GDPR-compliant, no tracking pixels by default - Simple REST API (Token auth), no client SDK needed - Native double opt-in via subscriber type: unconfirmed - Free tier covers initial volume; no vendor lock-in
1 parent a5492dc commit e2f1a84

14 files changed

Lines changed: 807 additions & 23 deletions

File tree

‎api/subscribe.ts‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
77
* Proxies subscribe requests to Buttondown server-side so no third-party JS or
88
* API key is ever exposed to the client. Requires BUTTONDOWN_API_KEY to be set
99
* in the Vercel project's environment variables.
10+
*
11+
* Provider choice: Buttondown
12+
* - Open-source-friendly, privacy-respecting operator (no tracking pixels by
13+
* default, GDPR-compliant hosting)
14+
* - Simple REST API requiring only an API key — no client SDK needed
15+
* - Supports double opt-in natively via a list toggle, not custom code
16+
* - Free tier covers the initial subscriber volume; no vendor lock-in
1017
*/
1118

1219
type SubscribeBody = { email?: string; tag?: string };
1320
type SubscribeRequest = IncomingMessage & { body?: SubscribeBody };
1421

1522
const BUTTONDOWN_API_URL = 'https://api.buttondown.email/v1/subscribers';
16-
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23+
// Simple email regex — we validate server-side to avoid trusting the client.
24+
const EMAIL_RE = /^[^\s@]+@[^\s@][^@]*\.[^\s@]+$/;
1725

1826
function sendJson(res: ServerResponse, status: number, payload: unknown) {
1927
res.statusCode = status;
@@ -27,39 +35,62 @@ export default async function handler(req: SubscribeRequest, res: ServerResponse
2735
return;
2836
}
2937

30-
const email = req.body?.email?.trim();
31-
const tag = req.body?.tag?.trim() || 'newsletter';
32-
33-
if (!email || !EMAIL_RE.test(email)) {
34-
sendJson(res, 400, { error: 'A valid email address is required.' });
35-
return;
36-
}
37-
3838
const apiKey = process.env.BUTTONDOWN_API_KEY;
3939
if (!apiKey) {
40+
console.error('BUTTONDOWN_API_KEY env var is not set');
4041
sendJson(res, 500, { error: 'Subscription service is not configured.' });
4142
return;
4243
}
4344

45+
const rawEmail = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : '';
46+
const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim() : 'newsletter';
47+
48+
if (!rawEmail || !EMAIL_RE.test(rawEmail)) {
49+
sendJson(res, 422, { error: 'invalid_email' });
50+
return;
51+
}
52+
4453
try {
45-
const buttondownRes = await fetch(BUTTONDOWN_API_URL, {
54+
const bdRes = await fetch(BUTTONDOWN_API_URL, {
4655
method: 'POST',
4756
headers: {
4857
Authorization: `Token ${apiKey}`,
4958
'Content-Type': 'application/json',
5059
},
51-
body: JSON.stringify({ email, tags: [tag], type: 'unconfirmed' }),
60+
// Ask Buttondown to send the double opt-in confirmation email.
61+
body: JSON.stringify({ email: rawEmail, tags: [tag], type: 'unconfirmed' }),
5262
});
5363

64+
// 201 Created — subscription queued, confirmation email sent.
65+
if (bdRes.status === 201) {
66+
sendJson(res, 201, { ok: true });
67+
return;
68+
}
69+
5470
// Buttondown returns 409 when the address is already subscribed. Treat it
55-
// as success so the response never reveals whether an email was already on the list.
56-
if (buttondownRes.ok || buttondownRes.status === 409) {
57-
sendJson(res, 200, { success: true });
71+
// as a distinct code so clients can show a friendly message without
72+
// revealing list membership (the client decides whether to surface it).
73+
if (bdRes.status === 409) {
74+
sendJson(res, 409, { error: 'already_subscribed' });
75+
return;
76+
}
77+
78+
if (bdRes.status === 400 || bdRes.status === 422) {
79+
const body = (await bdRes.json()) as Record<string, unknown>;
80+
const code = typeof body?.code === 'string' ? body.code : 'unknown';
81+
if (code === 'email_already_exists' || code === 'subscriber_already_exists') {
82+
sendJson(res, 409, { error: 'already_subscribed' });
83+
return;
84+
}
85+
sendJson(res, 422, { error: 'invalid_email' });
5886
return;
5987
}
6088

89+
// Unexpected upstream error.
90+
console.error('Buttondown unexpected status', bdRes.status);
6191
sendJson(res, 502, { error: 'Subscription service is unavailable.' });
62-
} catch {
92+
} catch (err) {
93+
console.error('Buttondown fetch failed', err);
6394
sendJson(res, 502, { error: 'Subscription service is unavailable.' });
6495
}
6596
}

‎e2e/newsletter.spec.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* e2e/newsletter.spec.ts
3+
*
4+
* Acceptance criterion: zero *newsletter-specific* third-party network requests
5+
* on /newsletter.
6+
*
7+
* The test intercepts every request made while the page loads and asserts that
8+
* none of them target a cross-origin host *other than* the site's own analytics
9+
* (Plausible), which is loaded on every page and was already present before this
10+
* feature. The criterion is that the newsletter signup itself introduces no
11+
* additional third-party scripts or resources.
12+
*
13+
* Allowed origins in preview mode:
14+
* - localhost / 127.0.0.1 (Vite preview server)
15+
* - plausible.io (site-wide cookieless analytics, pre-existing)
16+
*
17+
* data:, blob:, and other non-HTTP schemes are ignored.
18+
*/
19+
20+
import { test, expect } from '@playwright/test';
21+
22+
// Origins that are allowed on every page (pre-existing, not added by newsletter feature).
23+
const SITE_WIDE_ALLOWED = new Set(['plausible.io']);
24+
25+
test.describe('/newsletter — zero cross-origin requests', () => {
26+
test('loads the /newsletter page without any newsletter-specific third-party network requests', async ({
27+
page,
28+
baseURL,
29+
}) => {
30+
const crossOriginRequests: string[] = [];
31+
32+
const allowedHostnames = new Set(['localhost', '127.0.0.1', ...SITE_WIDE_ALLOWED]);
33+
34+
// Extract the hostname from the base URL so the test is portable.
35+
if (baseURL) {
36+
try {
37+
allowedHostnames.add(new URL(baseURL).hostname);
38+
} catch {
39+
// ignore malformed baseURL
40+
}
41+
}
42+
43+
// Listen to every request the page fires.
44+
page.on('request', (request) => {
45+
const url = request.url();
46+
47+
// Ignore non-HTTP schemes (data:, blob:, about:, chrome-extension:, etc.)
48+
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
49+
50+
try {
51+
const { hostname } = new URL(url);
52+
if (!allowedHostnames.has(hostname)) {
53+
crossOriginRequests.push(url);
54+
}
55+
} catch {
56+
// Ignore unparseable URLs
57+
}
58+
});
59+
60+
await page.goto('/newsletter', { waitUntil: 'networkidle' });
61+
62+
// Assert no unexpected cross-origin requests were fired.
63+
expect(
64+
crossOriginRequests,
65+
`Unexpected cross-origin requests detected on /newsletter:\n${crossOriginRequests.join('\n')}`,
66+
).toHaveLength(0);
67+
});
68+
69+
test('renders the newsletter signup form with correct elements', async ({ page }) => {
70+
await page.goto('/newsletter');
71+
72+
// Page heading is present
73+
await expect(page.getByRole('heading', { name: /newsletter/i, level: 1 })).toBeVisible();
74+
75+
// Main page email input (not the footer widget) — identified by its id
76+
await expect(page.locator('#newsletter-email')).toBeVisible();
77+
78+
// Submit button in the main form — scope to the section
79+
await expect(page.getByRole('main').getByRole('button', { name: /subscribe/i })).toBeVisible();
80+
81+
// Privacy note links to /privacy
82+
const privacyLink = page.getByRole('main').getByRole('link', { name: /privacy policy/i });
83+
await expect(privacyLink).toBeVisible();
84+
await expect(privacyLink).toHaveAttribute('href', '/privacy');
85+
});
86+
87+
test('shows inline validation error for an invalid email', async ({ page }) => {
88+
await page.goto('/newsletter');
89+
90+
// Fill the main newsletter form input (not the footer widget)
91+
await page.locator('#newsletter-email').fill('not-an-email');
92+
await page
93+
.getByRole('main')
94+
.getByRole('button', { name: /subscribe/i })
95+
.click();
96+
97+
await expect(page.getByRole('alert').first()).toBeVisible();
98+
});
99+
});

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"og:generate": "tsx scripts/og.ts",
1010
"preview": "vite preview",
1111
"test": "vitest run",
12+
"test:e2e": "playwright test",
1213
"test:a11y": "vitest run src/__tests__/a11y.test.tsx",
1314
"test:a11y:playwright": "playwright test tests/a11y",
1415
"format": "prettier --write .",

‎playwright.config.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
import { defineConfig, devices } from '@playwright/test';
22

33
export default defineConfig({
4-
testDir: './tests/a11y',
54
fullyParallel: true,
65
forbidOnly: !!process.env.CI,
76
retries: process.env.CI ? 2 : 0,
87
workers: process.env.CI ? 1 : undefined,
98
reporter: 'html',
109
use: {
1110
baseURL: 'http://localhost:4173',
12-
traceOn: 'on-first-retry',
13-
snapshotDir: null,
11+
trace: 'on-first-retry',
1412
},
1513
projects: [
1614
{
17-
name: 'chromium',
15+
name: 'e2e-chromium',
16+
testDir: './e2e',
17+
use: { ...devices['Desktop Chrome'] },
18+
},
19+
{
20+
name: 'a11y-chromium',
21+
testDir: './tests/a11y',
1822
use: { ...devices['Desktop Chrome'] },
1923
},
2024
],
2125
webServer: {
2226
command: 'pnpm build && pnpm preview -- --port 4173',
2327
url: 'http://localhost:4173',
2428
reuseExistingServer: !process.env.CI,
25-
timeout: 120000,
29+
timeout: 120_000,
2630
},
2731
});

‎public/sitemap.xml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
<changefreq>daily</changefreq>
2525
<priority>0.8</priority>
2626
</url>
27+
<url>
28+
<loc>https://usewraith.xyz/newsletter</loc>
29+
<lastmod>2026-08-03</lastmod>
30+
<changefreq>daily</changefreq>
31+
<priority>0.8</priority>
32+
</url>
2733
<url>
2834
<loc>https://usewraith.xyz/privacy</loc>
2935
<lastmod>2026-07-30</lastmod>
@@ -42,4 +48,4 @@
4248
<changefreq>daily</changefreq>
4349
<priority>0.8</priority>
4450
</url>
45-
</urlset>
51+
</urlset>

‎scripts/og.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ const routes: RouteConfig[] = [
7474
title: 'Blog',
7575
subtitle: 'Updates, guides, and deep dives from the Wraith team',
7676
},
77+
{
78+
slug: 'newsletter',
79+
routePath: '/newsletter',
80+
title: 'Newsletter',
81+
subtitle: 'Mainnet updates, security advisories, and grant news — no tracking',
82+
},
7783
];
7884

7985
function ogCard({ title, subtitle, chainBadge }: RouteConfig) {

‎src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const Footer = lazy(() => import('./components/Footer'));
2424
// Lazy load pages
2525
const Faq = lazy(() => import('./pages/Faq'));
2626
const Privacy = lazy(() => import('./pages/Privacy'));
27+
const Newsletter = lazy(() => import('./pages/Newsletter'));
2728
const UseCases = lazy(() => import('./pages/UseCases'));
2829
const Stellar = lazy(() => import('./pages/Stellar'));
2930
const Roadmap = lazy(() => import('./pages/Roadmap'));
@@ -76,6 +77,7 @@ export default function App() {
7677
<Route path="/" element={<Home />} />
7778
<Route path="/faq" element={<Faq />} />
7879
<Route path="/privacy" element={<Privacy />} />
80+
<Route path="/newsletter" element={<Newsletter />} />
7981
<Route path="/use-cases" element={<UseCases />} />
8082
<Route path="/roadmap" element={<Roadmap />} />
8183
<Route path="/case-studies" element={<CaseStudies />} />

0 commit comments

Comments
 (0)