Skip to content

Commit 4ddabf8

Browse files
kixelatedclaude
andauthored
Add email subscribe form to blog posts via Resend (#98)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 84a338e commit 4ddabf8

7 files changed

Lines changed: 329 additions & 2 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: notify-new-post
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
paths: ["src/pages/blog/**/*.mdx"]
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
notify:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 5
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 2
20+
21+
- uses: oven-sh/setup-bun@v2
22+
with:
23+
bun-version: 1.3.4
24+
25+
- name: Find newly added blog posts
26+
id: detect
27+
env:
28+
BEFORE_SHA: ${{ github.event.before }}
29+
AFTER_SHA: ${{ github.sha }}
30+
run: |
31+
git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/**/*.mdx' > new_posts.txt
32+
if [ ! -s new_posts.txt ]; then
33+
echo "skip=true" >> "$GITHUB_OUTPUT"
34+
fi
35+
cat new_posts.txt
36+
37+
- name: Send broadcast(s)
38+
if: steps.detect.outputs.skip != 'true'
39+
env:
40+
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
41+
RESEND_SEGMENT_ID: ${{ secrets.RESEND_SEGMENT_ID }}
42+
run: bun scripts/notify-subscribers.ts

scripts/notify-subscribers.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env bun
2+
// Sends a Resend broadcast for each newly added blog post listed in new_posts.txt
3+
// (paths relative to repo root, one per line). Invoked by the GitHub Action
4+
// .github/workflows/notify-new-post.yml on push to main.
5+
6+
import { readFileSync } from "node:fs";
7+
import { basename } from "node:path";
8+
9+
const SITE = "https://moq.dev";
10+
const FROM = "Media over QUIC <blog@moq.dev>";
11+
12+
const apiKey = requireEnv("RESEND_API_KEY");
13+
const segmentId = requireEnv("RESEND_SEGMENT_ID");
14+
15+
const newPostsList = readFileSync("new_posts.txt", "utf8").trim();
16+
if (!newPostsList) {
17+
console.log("No new posts. Exiting.");
18+
process.exit(0);
19+
}
20+
21+
const paths = newPostsList.split("\n").filter(Boolean);
22+
console.log(`Found ${paths.length} new post(s): ${paths.join(", ")}`);
23+
24+
for (const path of paths) {
25+
const rawSlug = basename(path, ".mdx");
26+
const slug = encodeURIComponent(rawSlug);
27+
const fm = parseFrontmatter(readFileSync(path, "utf8"));
28+
const title = fm.title ?? rawSlug;
29+
const description = fm.description ?? "";
30+
const url = `${SITE}/blog/${slug}`;
31+
32+
console.log(`Creating broadcast for "${title}" → ${url}`);
33+
34+
const create = await fetch("https://api.resend.com/broadcasts", {
35+
signal: AbortSignal.timeout(15000),
36+
method: "POST",
37+
headers: {
38+
Authorization: `Bearer ${apiKey}`,
39+
"Content-Type": "application/json",
40+
},
41+
body: JSON.stringify({
42+
segment_id: segmentId,
43+
from: FROM,
44+
subject: title,
45+
html: renderHtml({ title, description, url }),
46+
}),
47+
});
48+
49+
if (!create.ok) {
50+
const err = await create.text();
51+
throw new Error(`Resend broadcast create failed (${create.status}): ${err}`);
52+
}
53+
54+
const { id } = (await create.json()) as { id: string };
55+
56+
const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, {
57+
signal: AbortSignal.timeout(15000),
58+
method: "POST",
59+
headers: { Authorization: `Bearer ${apiKey}` },
60+
});
61+
62+
if (!send.ok) {
63+
const err = await send.text();
64+
throw new Error(`Resend broadcast send failed (${send.status}): ${err}`);
65+
}
66+
67+
console.log(`✓ Sent broadcast ${id} for "${title}"`);
68+
}
69+
70+
function requireEnv(name: string): string {
71+
const v = process.env[name];
72+
if (!v) throw new Error(`Missing env var: ${name}`);
73+
return v;
74+
}
75+
76+
function parseFrontmatter(source: string): Record<string, string> {
77+
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
78+
if (!match) return {};
79+
const out: Record<string, string> = {};
80+
for (const line of match[1].split(/\r?\n/)) {
81+
const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
82+
if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
83+
}
84+
return out;
85+
}
86+
87+
function renderHtml({ title, description, url }: { title: string; description: string; url: string }): string {
88+
const safeTitle = escapeHtml(title);
89+
const safeDescription = escapeHtml(description);
90+
const safeUrl = escapeHtml(url);
91+
return `<!doctype html>
92+
<html><body style="font-family: -apple-system, system-ui, sans-serif; line-height: 1.5; color: #1f2937;">
93+
<h1 style="margin: 0 0 16px;">${safeTitle}</h1>
94+
${safeDescription ? `<p style="font-size: 16px; color: #4b5563;">${safeDescription}</p>` : ""}
95+
<p style="margin: 24px 0;">
96+
<a href="${safeUrl}" style="display: inline-block; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 20px; border-radius: 6px;">Read it on moq.dev →</a>
97+
</p>
98+
<p style="font-size: 13px; color: #6b7280;">Or open it directly: <a href="${safeUrl}">${safeUrl}</a></p>
99+
</body></html>`;
100+
}
101+
102+
function escapeHtml(s: string): string {
103+
return s.replace(/[&<>"']/g, (c) => {
104+
switch (c) {
105+
case "&":
106+
return "&amp;";
107+
case "<":
108+
return "&lt;";
109+
case ">":
110+
return "&gt;";
111+
case '"':
112+
return "&quot;";
113+
default:
114+
return "&#39;";
115+
}
116+
});
117+
}

src/components/subscribe.astro

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
import Subscribe from "@/components/subscribe.tsx";
3+
---
4+
5+
<aside class="not-prose mt-12 border-t border-slate-700 pt-8">
6+
<h3 class="mb-4 text-xl font-semibold text-slate-100">Subscribe for new stuff</h3>
7+
<Subscribe client:load />
8+
<div class="mt-4 flex items-center gap-2 text-sm text-slate-400">
9+
<span>or</span>
10+
<a
11+
href="/rss.xml"
12+
class="inline-flex items-center gap-1.5 text-slate-300 hover:text-white"
13+
title="RSS Feed"
14+
>
15+
<svg width="18" height="18" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RSS Feed">
16+
<circle cx="6" cy="26" r="2" fill="currentColor" />
17+
<path d="M6 16 A10 10 0 0 1 16 26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
18+
<path d="M6 8 A18 18 0 0 1 24 26" fill="none" stroke="#00C02D" stroke-width="2" stroke-linecap="round" />
19+
</svg>
20+
<span class="font-medium">RSS Feed</span>
21+
</a>
22+
</div>
23+
</aside>

src/components/subscribe.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createSignal } from "solid-js";
2+
3+
type State = "idle" | "submitting" | "success" | "error";
4+
5+
export default function Subscribe() {
6+
const [email, setEmail] = createSignal("");
7+
const [state, setState] = createSignal<State>("idle");
8+
const [error, setError] = createSignal("");
9+
10+
const handleSubmit = async (e: SubmitEvent) => {
11+
e.preventDefault();
12+
setState("submitting");
13+
setError("");
14+
15+
try {
16+
const res = await fetch("/api/subscribe", {
17+
method: "POST",
18+
headers: { "Content-Type": "application/json" },
19+
body: JSON.stringify({ email: email() }),
20+
});
21+
22+
if (res.ok) {
23+
setState("success");
24+
} else {
25+
const body = (await res.json().catch(() => ({}))) as { error?: string };
26+
setError(body.error ?? "Something went wrong. Try again?");
27+
setState("error");
28+
}
29+
} catch {
30+
setError("Couldn't reach the server. Try again?");
31+
setState("error");
32+
}
33+
};
34+
35+
return (
36+
<>
37+
{state() === "success" ? (
38+
<p class="text-sm text-green-400">Thanks! You'll get an email when a new post goes up.</p>
39+
) : (
40+
<form onSubmit={handleSubmit} class="flex flex-col gap-2 sm:flex-row">
41+
<input
42+
type="email"
43+
required
44+
placeholder="you@example.com"
45+
value={email()}
46+
onInput={(e) => setEmail(e.currentTarget.value)}
47+
disabled={state() === "submitting"}
48+
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"
49+
/>
50+
<button
51+
type="submit"
52+
disabled={state() === "submitting"}
53+
class="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-500 disabled:bg-slate-700"
54+
>
55+
{state() === "submitting" ? "Subscribing…" : "Subscribe"}
56+
</button>
57+
</form>
58+
)}
59+
{state() === "error" && <p class="mt-2 text-sm text-red-400">{error()}</p>}
60+
</>
61+
);
62+
}

src/layouts/global.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "./global.css";
33
// Imported after global.css so the theme's .hljs-* color rules land later in
44
// the cascade and beat the prose plugin's .markdown :where(pre code) { color: inherit }.
55
import "highlight.js/styles/atom-one-dark.css";
6+
import Subscribe from "@/components/subscribe.astro";
67
78
// NOTE: This is magically used as the type for Astro.props
89
interface Props {
@@ -95,6 +96,7 @@ const ogImage = new URL(frontmatter?.cover ?? "/layout/icon.png", siteUrl).toStr
9596
)
9697
}
9798
<slot />
99+
{frontmatter?.date && <Subscribe />}
98100
</article>
99101
</div>
100102
</body>

worker/index.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Cloudflare Worker entry. Static asset requests fall through to the ASSETS
2+
// binding (Workers-with-Static-Assets). Only /api/* is handled here.
3+
4+
interface Env {
5+
ASSETS: { fetch: (request: Request) => Promise<Response> };
6+
RESEND_API_KEY: string;
7+
RESEND_SEGMENT_ID: string;
8+
}
9+
10+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
11+
12+
export default {
13+
async fetch(request: Request, env: Env): Promise<Response> {
14+
const url = new URL(request.url);
15+
16+
if (url.pathname === "/api/subscribe") {
17+
if (request.method !== "POST") {
18+
return new Response("Method Not Allowed", { status: 405 });
19+
}
20+
return handleSubscribe(request, env);
21+
}
22+
23+
return env.ASSETS.fetch(request);
24+
},
25+
};
26+
27+
async function handleSubscribe(request: Request, env: Env): Promise<Response> {
28+
let email: unknown;
29+
try {
30+
const body = (await request.json()) as { email?: unknown };
31+
email = body.email;
32+
} catch {
33+
return json({ error: "invalid body" }, 400);
34+
}
35+
36+
if (typeof email !== "string" || !EMAIL_RE.test(email)) {
37+
return json({ error: "invalid email" }, 400);
38+
}
39+
40+
let res: Response;
41+
try {
42+
res = await fetch("https://api.resend.com/contacts", {
43+
method: "POST",
44+
headers: {
45+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
46+
"Content-Type": "application/json",
47+
},
48+
body: JSON.stringify({
49+
email,
50+
unsubscribed: false,
51+
segments: [env.RESEND_SEGMENT_ID],
52+
}),
53+
});
54+
} catch (err) {
55+
console.error(`Resend POST /contacts → fetch threw: ${err}`);
56+
return json({ error: "subscribe failed" }, 502);
57+
}
58+
59+
// Treat any non-5xx as success so we don't leak whether an address is
60+
// already on the list (Resend returns 4xx for duplicates). Log 4xx for
61+
// debugging since a misconfigured segment ID would silently break sends.
62+
// Only log status + request id, not the body (which may contain the email).
63+
if (!res.ok) {
64+
console.error(`Resend POST /contacts → ${res.status} (request-id: ${res.headers.get("x-request-id") ?? "n/a"})`);
65+
}
66+
if (res.status >= 500) {
67+
return json({ error: "subscribe failed" }, 502);
68+
}
69+
70+
return json({ ok: true }, 200);
71+
}
72+
73+
function json(body: unknown, status: number): Response {
74+
return new Response(JSON.stringify(body), {
75+
status,
76+
headers: { "Content-Type": "application/json" },
77+
});
78+
}

wrangler.jsonc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@
77
"compatibility_date": "2025-08-13",
88
"account_id": "dd618f5dbd5da77b8296f1613c301f5c",
99

10+
"main": "worker/index.ts",
11+
1012
"assets": {
1113
"directory": "./dist",
12-
"not_found_handling": "404-page"
14+
"not_found_handling": "404-page",
15+
"binding": "ASSETS"
1316
},
1417

1518
// Environment-specific configurations
1619
"env": {
1720
"staging": {
1821
"name": "moq-dev-staging",
1922
"route": {
20-
"pattern": "new.moq.dev",
23+
"pattern": "moq.wtf",
2124
"custom_domain": true
2225
}
2326
},

0 commit comments

Comments
 (0)