Sending new post notifications with Resend
A Node.js script that reads frontmatter, previews the email, and broadcasts to subscribers
- Published
- 15 May 2026
- Read time
- 6 min read
Was this useful?
When a new post goes live, I want subscribers to know about it. The mailing list runs through Resend — subscribers are stored in a Resend Segment, and broadcasting to them means calling the Resend Broadcasts API. The question was: how do I trigger that broadcast as part of the publish flow, without adding complexity to the build pipeline?
The answer is a standalone Node.js script — scripts/notify-new-post.js — that
runs manually after publishing. It reads frontmatter directly, builds an HTML email,
previews it in the terminal, and asks for confirmation before sending.
Why a script rather than a build hook
There are a few reasons.
A build hook would run on every deploy, including deploys for unrelated changes like CSS fixes or draft work. Broadcasts should only happen for new public posts — triggering them from the build process would require additional logic to detect whether anything post-worthy had actually changed, which gets complicated quickly.
Running the script manually is intentional friction. It forces a moment of review before an email goes out to every subscriber. That’s the right default.
Dependencies
The script uses one external dependency: @inquirer/prompts
for interactive select and confirmation prompts. Everything else is Node.js built-ins —
readFileSync, readdirSync, path utilities. No Astro, no Zod, no content collections.
Reading frontmatter without a build
The script includes a minimal frontmatter parser rather than pulling in a YAML library:
function parseFrontmatter(content) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return {}; const yaml = match[1]; const result = {};
for (const line of yaml.split(/\r?\n/)) { const m = line.match(/^(\w+):\s*["'>]?(.*?)["']?\s*$/); if (m) result[m[1]] = m[2].trim(); }
// Handle YAML block scalar for description (>- or >) const descBlock = yaml.match(/^description:\s*>-?\r?\n((?:[ \t]+.+\r?\n?)*)/m); if (descBlock) { result.description = descBlock[1] .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean) .join(" "); }
return result;}This only handles simple key: value lines and the >- block scalar for
description. It’s intentionally minimal — the script doesn’t need to parse
the full YAML AST.
The listPostIds function finds posts published in the past week that aren’t drafts.
Each directory read is wrapped in a try/catch so unreadable or malformed posts are
silently skipped rather than crashing the script:
function listPostIds() { const postsDir = join(root, "collections", "posts"); const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
return readdirSync(postsDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => { try { const content = readFileSync(join(postsDir, d.name, "index.md"), "utf8"); const fm = parseFrontmatter(content); const pubDate = fm.pubDate ? new Date(fm.pubDate).getTime() : 0; const isDraft = fm.draft === "true"; return { id: d.name, pubDate, isDraft }; } catch { return null; } }) .filter((p) => p && !p.isDraft && p.pubDate >= oneWeekAgo) .sort((a, b) => b.pubDate - a.pubDate) .map((p) => p.id);}Environment variables
The script reads secrets from a .env file in the project root (using Node 20.12’s
process.loadEnvFile) or from shell environment variables — shell variables take
precedence:
const envFile = join(root, ".env");if (existsSync(envFile)) { if (typeof process.loadEnvFile === "function") { process.loadEnvFile(envFile); }}Required variables:
RESEND_API_KEY— Resend API key with broadcast send permissionsRESEND_SEGMENT_ID— the Segment ID to broadcast toSITE_URL— base URL used to construct post links (defaults tohttps://sourcier.uk)
Optional variables:
NOTIFY_FROM_EMAIL— theFrom:address in the broadcast (defaults toRoger @ Sourcier <hello@sourcier.uk>)RESEND_TOPIC_ID— if set, attaches a topic to the broadcast for unsubscribe granularity
The email content
The broadcast is sent with both an HTML body and a plain-text fallback. Email clients that can’t render HTML receive the plain-text version; everything else gets the styled one.
The HTML is built as an inline-styled string. Email clients don’t support external stylesheets or CSS custom properties — everything needs to be inline and use safe font stacks:
function buildHtml() { return `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:560px;margin:0 auto;padding:2rem 1.5rem;color:#0f0f0f"> <p style="margin:0 0 1.5rem;line-height:1.6">Hi — I just published something new on Sourcier.</p> <p style="font-size:1.5rem;font-weight:800;letter-spacing:-0.01em;margin:0 0 1rem;line-height:1.2">${title}</p> ${excerpt ? `<p style="margin:0 0 1.5rem;line-height:1.6;color:#444">${excerpt}</p>` : ""} <a href="${url}" style="display:inline-block;background:#e8006a;color:#fff;text-decoration:none;padding:0.65rem 1.5rem;font-weight:700;font-size:0.875rem;letter-spacing:0.04em;text-transform:uppercase">Read the post →</a> <p style="margin:1.5rem 0 0;line-height:1.6;color:#444">If it sparks any thoughts, I'd love to hear them — there's a comments section at the bottom of the post.</p> <p style="margin:1rem 0 0;line-height:1.6">— Roger</p> <hr style="margin:2rem 0;border:none;border-top:1px solid #e5e5e5"> <p style="margin:0;color:#999;font-size:0.8125rem;line-height:1.5"> You're receiving this because you subscribed at sourcier.uk.<br> <a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#999">Unsubscribe</a> </p></div>`.trim();}The {{{RESEND_UNSUBSCRIBE_URL}}} placeholder is Resend’s broadcast template
variable — it’s replaced at send time with a personalised unsubscribe link for each
recipient. It’s required by anti-spam regulations (CAN-SPAM, GDPR).
Confirmation before sending
The script uses @inquirer/prompts for both the post selection and the send confirmation.
After printing the preview, it asks:
const shouldSend = await confirm({ message: "Send this to all subscribers?", default: false,});
if (!shouldSend) { console.log("Aborted — nothing was sent."); process.exit(0);}The default is false, so pressing Enter without typing y aborts safely.
If the post has draft: true in its frontmatter, the script surfaces a warning
and asks a second confirmation before continuing. It doesn’t block sending outright —
there are legitimate reasons to test-send a draft — but it makes the state explicit.
The two-step API call
Sending a broadcast is two separate calls to the Resend API. The first creates the broadcast and returns an ID:
const createRes = await fetch(`${RESEND_API}/broadcasts`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: title, from: FROM, subject: SUBJECT, html, text, segment_id: segmentId }),});const { id } = await createRes.json();The second dispatches it to the segment:
await fetch(`${RESEND_API}/broadcasts/${id}/send`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}` },});Separating creation from dispatch is useful — it means the broadcast exists in the Resend dashboard before it’s sent, so you can inspect or cancel it if something looks wrong.
Running the script
node scripts/notify-new-post.jsThe script lists posts published in the past week, prompts to select one, shows a
preview, and waits for confirmation. Pass --debug to log the full API request
payload and response status to the terminal without sending anything.
Total runtime is a few seconds.
The approach here is deliberately low-tech: no build integration, no CI step, no webhook. A standalone script with a confirmation prompt is the right level of automation for something that goes out to every subscriber. The friction is the feature.
The full script is in the sourcier.uk repository.
Working on something similar?
Need help raising the bar?
I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.
- Engineering practice review
- Hands-on delivery
- Team mentoring
If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.
Free · Practical · One email per post
Get practical engineering notes
One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.
Comments
Loading comments…
Leave a comment