One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Sending new post notifications with Resend
Cover image: a white square with a red circle on top of it
astro resend engineering

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
SeriesPart of How this blog was built — documenting every decision that shaped this site.

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:

scripts/notify-new-post.js
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 permissions
  • RESEND_SEGMENT_ID — the Segment ID to broadcast to
  • SITE_URL — base URL used to construct post links (defaults to https://sourcier.uk)

Optional variables:

  • NOTIFY_FROM_EMAIL — the From: address in the broadcast (defaults to Roger @ 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

Terminal window
node scripts/notify-new-post.js

The 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
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

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.