Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 46 additions & 15 deletions api/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,21 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
* Proxies subscribe requests to Buttondown server-side so no third-party JS or
* API key is ever exposed to the client. Requires BUTTONDOWN_API_KEY to be set
* in the Vercel project's environment variables.
*
* Provider choice: Buttondown
* - Open-source-friendly, privacy-respecting operator (no tracking pixels by
* default, GDPR-compliant hosting)
* - Simple REST API requiring only an API key — no client SDK needed
* - Supports double opt-in natively via a list toggle, not custom code
* - Free tier covers the initial subscriber volume; no vendor lock-in
*/

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

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

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

const email = req.body?.email?.trim();
const tag = req.body?.tag?.trim() || 'newsletter';

if (!email || !EMAIL_RE.test(email)) {
sendJson(res, 400, { error: 'A valid email address is required.' });
return;
}

const apiKey = process.env.BUTTONDOWN_API_KEY;
if (!apiKey) {
console.error('BUTTONDOWN_API_KEY env var is not set');
sendJson(res, 500, { error: 'Subscription service is not configured.' });
return;
}

const rawEmail = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : '';
const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim() : 'newsletter';

if (!rawEmail || !EMAIL_RE.test(rawEmail)) {
sendJson(res, 422, { error: 'invalid_email' });
return;
}

try {
const buttondownRes = await fetch(BUTTONDOWN_API_URL, {
const bdRes = await fetch(BUTTONDOWN_API_URL, {
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, tags: [tag], type: 'unconfirmed' }),
// Ask Buttondown to send the double opt-in confirmation email.
body: JSON.stringify({ email: rawEmail, tags: [tag], type: 'unconfirmed' }),
});

// 201 Created — subscription queued, confirmation email sent.
if (bdRes.status === 201) {
sendJson(res, 201, { ok: true });
return;
}

// Buttondown returns 409 when the address is already subscribed. Treat it
// as success so the response never reveals whether an email was already on the list.
if (buttondownRes.ok || buttondownRes.status === 409) {
sendJson(res, 200, { success: true });
// as a distinct code so clients can show a friendly message without
// revealing list membership (the client decides whether to surface it).
if (bdRes.status === 409) {
sendJson(res, 409, { error: 'already_subscribed' });
return;
}

if (bdRes.status === 400 || bdRes.status === 422) {
const body = (await bdRes.json()) as Record<string, unknown>;
const code = typeof body?.code === 'string' ? body.code : 'unknown';
if (code === 'email_already_exists' || code === 'subscriber_already_exists') {
sendJson(res, 409, { error: 'already_subscribed' });
return;
}
sendJson(res, 422, { error: 'invalid_email' });
return;
}

// Unexpected upstream error.
console.error('Buttondown unexpected status', bdRes.status);
sendJson(res, 502, { error: 'Subscription service is unavailable.' });
} catch {
} catch (err) {
console.error('Buttondown fetch failed', err);
sendJson(res, 502, { error: 'Subscription service is unavailable.' });
}
}
99 changes: 99 additions & 0 deletions e2e/newsletter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* e2e/newsletter.spec.ts
*
* Acceptance criterion: zero *newsletter-specific* third-party network requests
* on /newsletter.
*
* The test intercepts every request made while the page loads and asserts that
* none of them target a cross-origin host *other than* the site's own analytics
* (Plausible), which is loaded on every page and was already present before this
* feature. The criterion is that the newsletter signup itself introduces no
* additional third-party scripts or resources.
*
* Allowed origins in preview mode:
* - localhost / 127.0.0.1 (Vite preview server)
* - plausible.io (site-wide cookieless analytics, pre-existing)
*
* data:, blob:, and other non-HTTP schemes are ignored.
*/

import { test, expect } from '@playwright/test';

// Origins that are allowed on every page (pre-existing, not added by newsletter feature).
const SITE_WIDE_ALLOWED = new Set(['plausible.io']);

test.describe('/newsletter — zero cross-origin requests', () => {
test('loads the /newsletter page without any newsletter-specific third-party network requests', async ({
page,
baseURL,
}) => {
const crossOriginRequests: string[] = [];

const allowedHostnames = new Set(['localhost', '127.0.0.1', ...SITE_WIDE_ALLOWED]);

// Extract the hostname from the base URL so the test is portable.
if (baseURL) {
try {
allowedHostnames.add(new URL(baseURL).hostname);
} catch {
// ignore malformed baseURL
}
}

// Listen to every request the page fires.
page.on('request', (request) => {
const url = request.url();

// Ignore non-HTTP schemes (data:, blob:, about:, chrome-extension:, etc.)
if (!url.startsWith('http://') && !url.startsWith('https://')) return;

try {
const { hostname } = new URL(url);
if (!allowedHostnames.has(hostname)) {
crossOriginRequests.push(url);
}
} catch {
// Ignore unparseable URLs
}
});

await page.goto('/newsletter', { waitUntil: 'networkidle' });

// Assert no unexpected cross-origin requests were fired.
expect(
crossOriginRequests,
`Unexpected cross-origin requests detected on /newsletter:\n${crossOriginRequests.join('\n')}`,
).toHaveLength(0);
});

test('renders the newsletter signup form with correct elements', async ({ page }) => {
await page.goto('/newsletter');

// Page heading is present
await expect(page.getByRole('heading', { name: /newsletter/i, level: 1 })).toBeVisible();

// Main page email input (not the footer widget) — identified by its id
await expect(page.locator('#newsletter-email')).toBeVisible();

// Submit button in the main form — scope to the section
await expect(page.getByRole('main').getByRole('button', { name: /subscribe/i })).toBeVisible();

// Privacy note links to /privacy
const privacyLink = page.getByRole('main').getByRole('link', { name: /privacy policy/i });
await expect(privacyLink).toBeVisible();
await expect(privacyLink).toHaveAttribute('href', '/privacy');
});

test('shows inline validation error for an invalid email', async ({ page }) => {
await page.goto('/newsletter');

// Fill the main newsletter form input (not the footer widget)
await page.locator('#newsletter-email').fill('not-an-email');
await page
.getByRole('main')
.getByRole('button', { name: /subscribe/i })
.click();

await expect(page.getByRole('alert').first()).toBeVisible();
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"og:generate": "tsx scripts/og.ts",
"preview": "vite preview",
"test": "vitest run",
"test:e2e": "playwright test",
"test:a11y": "vitest run src/__tests__/a11y.test.tsx",
"test:a11y:playwright": "playwright test tests/a11y",
"format": "prettier --write .",
Expand Down
14 changes: 9 additions & 5 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests/a11y',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4173',
traceOn: 'on-first-retry',
snapshotDir: null,
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
name: 'e2e-chromium',
testDir: './e2e',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'a11y-chromium',
testDir: './tests/a11y',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm build && pnpm preview -- --port 4173',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
timeout: 120_000,
},
});
8 changes: 7 additions & 1 deletion public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://usewraith.xyz/newsletter</loc>
<lastmod>2026-08-03</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://usewraith.xyz/privacy</loc>
<lastmod>2026-07-30</lastmod>
Expand All @@ -42,4 +48,4 @@
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
</urlset>
</urlset>
6 changes: 6 additions & 0 deletions scripts/og.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ const routes: RouteConfig[] = [
title: 'Blog',
subtitle: 'Updates, guides, and deep dives from the Wraith team',
},
{
slug: 'newsletter',
routePath: '/newsletter',
title: 'Newsletter',
subtitle: 'Mainnet updates, security advisories, and grant news — no tracking',
},
];

function ogCard({ title, subtitle, chainBadge }: RouteConfig) {
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const Footer = lazy(() => import('./components/Footer'));
// Lazy load pages
const Faq = lazy(() => import('./pages/Faq'));
const Privacy = lazy(() => import('./pages/Privacy'));
const Newsletter = lazy(() => import('./pages/Newsletter'));
const UseCases = lazy(() => import('./pages/UseCases'));
const Stellar = lazy(() => import('./pages/Stellar'));
const Roadmap = lazy(() => import('./pages/Roadmap'));
Expand Down Expand Up @@ -75,6 +76,7 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/faq" element={<Faq />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/newsletter" element={<Newsletter />} />
<Route path="/use-cases" element={<UseCases />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/case-studies" element={<CaseStudies />} />
Expand Down
Loading
Loading