From 2ae160af71f0fff77501cbc2764aa0b9e6b553b5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 23 May 2026 14:41:59 -0700 Subject: [PATCH 1/2] Add email subscribe form to blog posts via Resend. A subscribe form renders at the bottom of every blog post (gated on frontmatter.date so it doesn't appear on /, /demo, or the blog index). The form POSTs to /api/subscribe, a Worker entry that adds the email to a Resend Segment. A GitHub Action on push to main detects newly-added blog MDX files and sends a Resend broadcast for each. Also switches the staging route from new.moq.dev to moq.wtf and adopts the Workers-with-Static-Assets pattern (main + ASSETS binding in wrangler.jsonc) so the same project can serve both static pages and the /api/* endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/notify-new-post.yml | 42 ++++++++++ scripts/notify-subscribers.ts | 113 ++++++++++++++++++++++++++ src/components/subscribe.astro | 23 ++++++ src/components/subscribe.tsx | 62 ++++++++++++++ src/layouts/global.astro | 2 + worker/index.ts | 71 ++++++++++++++++ wrangler.jsonc | 7 +- 7 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/notify-new-post.yml create mode 100644 scripts/notify-subscribers.ts create mode 100644 src/components/subscribe.astro create mode 100644 src/components/subscribe.tsx create mode 100644 worker/index.ts diff --git a/.github/workflows/notify-new-post.yml b/.github/workflows/notify-new-post.yml new file mode 100644 index 0000000..cb3a88c --- /dev/null +++ b/.github/workflows/notify-new-post.yml @@ -0,0 +1,42 @@ +name: notify-new-post + +on: + push: + branches: ["main"] + paths: ["src/pages/blog/**.mdx"] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.4 + + - name: Find newly added blog posts + id: detect + env: + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} + run: | + git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/*.mdx' > new_posts.txt + if [ ! -s new_posts.txt ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + cat new_posts.txt + + - name: Send broadcast(s) + if: steps.detect.outputs.skip != 'true' + env: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_SEGMENT_ID: ${{ secrets.RESEND_SEGMENT_ID }} + run: bun scripts/notify-subscribers.ts diff --git a/scripts/notify-subscribers.ts b/scripts/notify-subscribers.ts new file mode 100644 index 0000000..97c1710 --- /dev/null +++ b/scripts/notify-subscribers.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env bun +// Sends a Resend broadcast for each newly added blog post listed in new_posts.txt +// (paths relative to repo root, one per line). Invoked by the GitHub Action +// .github/workflows/notify-new-post.yml on push to main. + +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +const SITE = "https://moq.dev"; +const FROM = "Media over QUIC "; + +const apiKey = requireEnv("RESEND_API_KEY"); +const segmentId = requireEnv("RESEND_SEGMENT_ID"); + +const newPostsList = readFileSync("new_posts.txt", "utf8").trim(); +if (!newPostsList) { + console.log("No new posts. Exiting."); + process.exit(0); +} + +const paths = newPostsList.split("\n").filter(Boolean); +console.log(`Found ${paths.length} new post(s): ${paths.join(", ")}`); + +for (const path of paths) { + const slug = basename(path, ".mdx"); + const fm = parseFrontmatter(readFileSync(path, "utf8")); + const title = fm.title ?? slug; + const description = fm.description ?? ""; + const url = `${SITE}/blog/${slug}`; + + console.log(`Creating broadcast for "${title}" → ${url}`); + + const create = await fetch("https://api.resend.com/broadcasts", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + segment_id: segmentId, + from: FROM, + subject: title, + html: renderHtml({ title, description, url }), + }), + }); + + if (!create.ok) { + const err = await create.text(); + throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); + } + + const { id } = (await create.json()) as { id: string }; + + const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!send.ok) { + const err = await send.text(); + throw new Error(`Resend broadcast send failed (${send.status}): ${err}`); + } + + console.log(`✓ Sent broadcast ${id} for "${title}"`); +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env var: ${name}`); + return v; +} + +function parseFrontmatter(source: string): Record { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + const out: Record = {}; + for (const line of match[1].split(/\r?\n/)) { + const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); + if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); + } + return out; +} + +function renderHtml({ title, description, url }: { title: string; description: string; url: string }): string { + const safeTitle = escapeHtml(title); + const safeDescription = escapeHtml(description); + return ` + +

${safeTitle}

+ ${safeDescription ? `

${safeDescription}

` : ""} +

+ Read it on moq.dev → +

+

Or open it directly: ${url}

+`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} diff --git a/src/components/subscribe.astro b/src/components/subscribe.astro new file mode 100644 index 0000000..f6262ec --- /dev/null +++ b/src/components/subscribe.astro @@ -0,0 +1,23 @@ +--- +import Subscribe from "@/components/subscribe.tsx"; +--- + + diff --git a/src/components/subscribe.tsx b/src/components/subscribe.tsx new file mode 100644 index 0000000..c495eb4 --- /dev/null +++ b/src/components/subscribe.tsx @@ -0,0 +1,62 @@ +import { createSignal } from "solid-js"; + +type State = "idle" | "submitting" | "success" | "error"; + +export default function Subscribe() { + const [email, setEmail] = createSignal(""); + const [state, setState] = createSignal("idle"); + const [error, setError] = createSignal(""); + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + setState("submitting"); + setError(""); + + try { + const res = await fetch("/api/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email() }), + }); + + if (res.ok) { + setState("success"); + } else { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(body.error ?? "Something went wrong. Try again?"); + setState("error"); + } + } catch { + setError("Couldn't reach the server. Try again?"); + setState("error"); + } + }; + + return ( + <> + {state() === "success" ? ( +

Thanks! You'll get an email when a new post goes up.

+ ) : ( +
+ setEmail(e.currentTarget.value)} + disabled={state() === "submitting"} + class="flex-1 rounded border border-slate-700 bg-slate-800 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-blue-500 focus:outline-none" + /> + +
+ )} + {state() === "error" &&

{error()}

} + + ); +} diff --git a/src/layouts/global.astro b/src/layouts/global.astro index 0b0973b..84164fb 100644 --- a/src/layouts/global.astro +++ b/src/layouts/global.astro @@ -3,6 +3,7 @@ import "./global.css"; // Imported after global.css so the theme's .hljs-* color rules land later in // the cascade and beat the prose plugin's .markdown :where(pre code) { color: inherit }. import "highlight.js/styles/atom-one-dark.css"; +import Subscribe from "@/components/subscribe.astro"; // NOTE: This is magically used as the type for Astro.props interface Props { @@ -95,6 +96,7 @@ const ogImage = new URL(frontmatter?.cover ?? "/layout/icon.png", siteUrl).toStr ) } + {frontmatter?.date && } diff --git a/worker/index.ts b/worker/index.ts new file mode 100644 index 0000000..a47f264 --- /dev/null +++ b/worker/index.ts @@ -0,0 +1,71 @@ +// Cloudflare Worker entry. Static asset requests fall through to the ASSETS +// binding (Workers-with-Static-Assets). Only /api/* is handled here. + +interface Env { + ASSETS: { fetch: (request: Request) => Promise }; + RESEND_API_KEY: string; + RESEND_SEGMENT_ID: string; +} + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/api/subscribe") { + if (request.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + return handleSubscribe(request, env); + } + + return env.ASSETS.fetch(request); + }, +}; + +async function handleSubscribe(request: Request, env: Env): Promise { + let email: unknown; + try { + const body = (await request.json()) as { email?: unknown }; + email = body.email; + } catch { + return json({ error: "invalid body" }, 400); + } + + if (typeof email !== "string" || !EMAIL_RE.test(email)) { + return json({ error: "invalid email" }, 400); + } + + const res = await fetch("https://api.resend.com/contacts", { + method: "POST", + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + unsubscribed: false, + segments: [env.RESEND_SEGMENT_ID], + }), + }); + + // Treat any non-5xx as success so we don't leak whether an address is + // already on the list (Resend returns 4xx for duplicates). Log 4xx for + // debugging since a misconfigured segment ID would silently break sends. + if (!res.ok) { + console.error(`Resend POST /contacts → ${res.status}: ${await res.text()}`); + } + if (res.status >= 500) { + return json({ error: "subscribe failed" }, 502); + } + + return json({ ok: true }, 200); +} + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index c7c304f..f4eed66 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -7,9 +7,12 @@ "compatibility_date": "2025-08-13", "account_id": "dd618f5dbd5da77b8296f1613c301f5c", + "main": "worker/index.ts", + "assets": { "directory": "./dist", - "not_found_handling": "404-page" + "not_found_handling": "404-page", + "binding": "ASSETS" }, // Environment-specific configurations @@ -17,7 +20,7 @@ "staging": { "name": "moq-dev-staging", "route": { - "pattern": "new.moq.dev", + "pattern": "moq.wtf", "custom_domain": true } }, From 341786ad64747628d39cfb33830fcaf692e2ac71 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 23 May 2026 14:51:22 -0700 Subject: [PATCH 2/2] Address PR review: harden subscribe + notify pipeline. - Workflow: recursive `**/*.mdx` glob in both the `paths` filter and the `git diff` pathspec, so future nested posts still trigger. - Worker: wrap the Resend fetch in try/catch and return 502 on network failures; redact the error log to status + x-request-id only (response body could echo the email). - notify-subscribers: 15s `AbortSignal.timeout` on both broadcast fetches so CI fails fast instead of hanging the full job timeout; encode the slug and HTML-escape the URL before injecting into the broadcast body. Skipped two suggestions: SHA-pinning external actions (deploy.yml and pr.yml don't pin either; consistency over isolated change) and routing form errors through fail.tsx (which is for Error-object crash banners, not inline form validation feedback). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/notify-new-post.yml | 4 ++-- scripts/notify-subscribers.ts | 12 ++++++---- worker/index.ts | 33 ++++++++++++++++----------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/notify-new-post.yml b/.github/workflows/notify-new-post.yml index cb3a88c..ceb3061 100644 --- a/.github/workflows/notify-new-post.yml +++ b/.github/workflows/notify-new-post.yml @@ -3,7 +3,7 @@ name: notify-new-post on: push: branches: ["main"] - paths: ["src/pages/blog/**.mdx"] + paths: ["src/pages/blog/**/*.mdx"] permissions: contents: read @@ -28,7 +28,7 @@ jobs: BEFORE_SHA: ${{ github.event.before }} AFTER_SHA: ${{ github.sha }} run: | - git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/*.mdx' > new_posts.txt + git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/**/*.mdx' > new_posts.txt if [ ! -s new_posts.txt ]; then echo "skip=true" >> "$GITHUB_OUTPUT" fi diff --git a/scripts/notify-subscribers.ts b/scripts/notify-subscribers.ts index 97c1710..ff87421 100644 --- a/scripts/notify-subscribers.ts +++ b/scripts/notify-subscribers.ts @@ -22,15 +22,17 @@ const paths = newPostsList.split("\n").filter(Boolean); console.log(`Found ${paths.length} new post(s): ${paths.join(", ")}`); for (const path of paths) { - const slug = basename(path, ".mdx"); + const rawSlug = basename(path, ".mdx"); + const slug = encodeURIComponent(rawSlug); const fm = parseFrontmatter(readFileSync(path, "utf8")); - const title = fm.title ?? slug; + const title = fm.title ?? rawSlug; const description = fm.description ?? ""; const url = `${SITE}/blog/${slug}`; console.log(`Creating broadcast for "${title}" → ${url}`); const create = await fetch("https://api.resend.com/broadcasts", { + signal: AbortSignal.timeout(15000), method: "POST", headers: { Authorization: `Bearer ${apiKey}`, @@ -52,6 +54,7 @@ for (const path of paths) { const { id } = (await create.json()) as { id: string }; const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { + signal: AbortSignal.timeout(15000), method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, }); @@ -84,14 +87,15 @@ function parseFrontmatter(source: string): Record { function renderHtml({ title, description, url }: { title: string; description: string; url: string }): string { const safeTitle = escapeHtml(title); const safeDescription = escapeHtml(description); + const safeUrl = escapeHtml(url); return `

${safeTitle}

${safeDescription ? `

${safeDescription}

` : ""}

- Read it on moq.dev → + Read it on moq.dev →

-

Or open it directly: ${url}

+

Or open it directly: ${safeUrl}

`; } diff --git a/worker/index.ts b/worker/index.ts index a47f264..cc81084 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -37,24 +37,31 @@ async function handleSubscribe(request: Request, env: Env): Promise { return json({ error: "invalid email" }, 400); } - const res = await fetch("https://api.resend.com/contacts", { - method: "POST", - headers: { - Authorization: `Bearer ${env.RESEND_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - unsubscribed: false, - segments: [env.RESEND_SEGMENT_ID], - }), - }); + let res: Response; + try { + res = await fetch("https://api.resend.com/contacts", { + method: "POST", + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + unsubscribed: false, + segments: [env.RESEND_SEGMENT_ID], + }), + }); + } catch (err) { + console.error(`Resend POST /contacts → fetch threw: ${err}`); + return json({ error: "subscribe failed" }, 502); + } // Treat any non-5xx as success so we don't leak whether an address is // already on the list (Resend returns 4xx for duplicates). Log 4xx for // debugging since a misconfigured segment ID would silently break sends. + // Only log status + request id, not the body (which may contain the email). if (!res.ok) { - console.error(`Resend POST /contacts → ${res.status}: ${await res.text()}`); + console.error(`Resend POST /contacts → ${res.status} (request-id: ${res.headers.get("x-request-id") ?? "n/a"})`); } if (res.status >= 500) { return json({ error: "subscribe failed" }, 502);