diff --git a/api/subscribe.ts b/api/subscribe.ts index a90b0fa..4fc8ff8 100644 --- a/api/subscribe.ts +++ b/api/subscribe.ts @@ -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; @@ -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; + 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.' }); } } diff --git a/e2e/newsletter.spec.ts b/e2e/newsletter.spec.ts new file mode 100644 index 0000000..1f31a5d --- /dev/null +++ b/e2e/newsletter.spec.ts @@ -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(); + }); +}); diff --git a/package.json b/package.json index 8ac6ea1..af17847 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/playwright.config.ts b/playwright.config.ts index b59a5f8..2a31aba 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './tests/a11y', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, @@ -9,12 +8,17 @@ export default defineConfig({ 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'] }, }, ], @@ -22,6 +26,6 @@ export default defineConfig({ command: 'pnpm build && pnpm preview -- --port 4173', url: 'http://localhost:4173', reuseExistingServer: !process.env.CI, - timeout: 120000, + timeout: 120_000, }, }); diff --git a/public/sitemap.xml b/public/sitemap.xml index 6566f1c..1eaf480 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -24,6 +24,12 @@ daily 0.8 + + https://usewraith.xyz/newsletter + 2026-08-03 + daily + 0.8 + https://usewraith.xyz/privacy 2026-07-30 @@ -42,4 +48,4 @@ daily 0.8 - \ No newline at end of file + diff --git a/scripts/og.ts b/scripts/og.ts index fc698a0..51693bc 100644 --- a/scripts/og.ts +++ b/scripts/og.ts @@ -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) { diff --git a/src/App.tsx b/src/App.tsx index 5ffe66b..7eb45da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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')); @@ -75,6 +76,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/__tests__/newsletter.test.tsx b/src/__tests__/newsletter.test.tsx new file mode 100644 index 0000000..d843712 --- /dev/null +++ b/src/__tests__/newsletter.test.tsx @@ -0,0 +1,255 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Newsletter from '../pages/Newsletter'; +import Footer from '../components/Footer'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * NOTE: react-i18next is not initialised in the Vitest environment, so + * t('some.key') returns the raw key string. All queries below are written + * against the rendered HTML rather than translated strings so the suite + * remains fast and self-contained. + */ + +function renderNewsletter() { + return render( + + + , + ); +} + +function renderFooter() { + return render( + +