I Built a Free, Fully Automated Email Notification System for My Blog — Here's Everything I Learned
A beginner-friendly, deep-dive walkthrough of how I built a zero-cost email notification system for my Astro blog using Resend, Firebase, Vercel, and GitHub Actions — including every concept explained from scratch and every error I hit along the way.
If you’ve ever subscribed to a newsletter or a blog and gotten an email the moment a new post went up, you’ve probably wondered — at least once — how does that actually work? Is there a person sitting there clicking “send” every time? Is there some expensive service running in the background 24/7?
For my own blog, blogs.shoudo.xyz, I wanted exactly that feature. When I publish a new post, anyone who subscribed should get a nicely formatted email about it — automatically, with zero manual work on my end.
But I had a few constraints that made this more interesting than just “use a newsletter service”:
- I didn’t want to pay for anything. My subscriber count is small (think single digits to low tens), so paying $10–20/month for a newsletter platform felt absurd.
- I didn’t want to run a server that’s always on. My development machine is literally my phone running Termux — it’s not always connected to the internet, and I wasn’t going to rent a VPS just for this.
- I wanted it to be fully automatic — write a blog post, push it to GitHub, and that’s it. No extra steps.
This post is a complete walkthrough of how I built this system, explaining every piece from the ground up — assuming you know basically nothing about DNS, email infrastructure, serverless functions, or CI/CD. By the end, you’ll understand not just what I built, but why every piece exists and how they all talk to each other. I’ll also walk through every single error I hit along the way, because honestly, that’s where most of the learning happened.
Grab a coffee — this is a long one. ☕
Part 1: The Big Picture — What Are We Actually Building?
Before diving into any code, let’s zoom out and understand the system as a whole. There are five moving pieces:
- A blog (built with Astro, a website framework) where I write posts as Markdown files.
- A “subscribe” form on the blog where visitors enter their name and email.
- A database that stores the list of subscribers.
- An email-sending service that actually delivers emails to people’s inboxes.
- An automation system that notices when I publish a new post and triggers the emails.
Here’s the mental model: think of it like a restaurant.
- The blog is the dining area where customers (readers) sit.
- The subscribe form is like a guestbook at the entrance — when someone signs it, their contact info goes into a filing cabinet (the database).
- When the kitchen (me, writing a new post) finishes a new dish, a waiter (the automation) walks over to the filing cabinet, grabs every customer’s contact info, and a delivery service (the email sender) sends each of them a note saying “hey, there’s a new dish ready, come check it out.”
Nobody has to stand at the door all day waiting. The waiter only acts when a new dish is ready — there’s no need for anyone to be “always on.”
Now let’s translate that into actual technology:
| Restaurant analogy | Real technology | What it does |
|---|---|---|
| Dining area / blog | Astro + Firebase Hosting | The actual website people visit |
| Guestbook | Subscribe form (HTML form on the blog) | Collects name + email from visitors |
| Filing cabinet | Firebase Firestore | A database that stores subscriber emails |
| Front desk that processes the guestbook | Vercel serverless function | Receives form submissions, saves them, sends a welcome email |
| Delivery service | Resend | The actual service that sends emails |
| The waiter who notices new dishes | GitHub Actions | Automatically runs code when I push a new blog post |
If none of these names mean anything to you yet, don’t worry — we’re going to go through each one slowly.
Part 2: Understanding Email — Why Can’t You Just “Send an Email” From Code?
This is probably the most important concept in this entire post, so let’s spend some real time on it.
You might think: “Sending an email from code should be simple — just write some code that sends a message to an email address, right?”
In theory, yes. In practice, email is one of the oldest and most heavily-spammed systems on the internet, and because of that, every major email provider (Gmail, Outlook, Yahoo, Proton Mail, etc.) is extremely paranoid about which servers are “allowed” to send email claiming to be from a particular domain.
Here’s the problem: if I write a script that says “send an email, and pretend it’s from me@shoudo.xyz”, how does Gmail know that’s actually me, and not some random spammer pretending to be me? Without any verification, anyone could send an email pretending to be from any domain — which is exactly how a lot of phishing scams work.
To solve this, there’s a system of DNS records that act like a digital signature for a domain. Let’s break down what DNS even is first.
What is DNS?
DNS (Domain Name System) is essentially the internet’s phone book. When you type shoudo.xyz into a browser, your computer asks DNS “where is this domain hosted?” and DNS responds with an address (an IP address) that your browser then connects to.
But DNS doesn’t just store “where is this website” — it can store all kinds of other information about a domain, in the form of records. Different record “types” store different kinds of information:
- A record — “this domain points to this IP address” (used for websites)
- MX record — “emails for this domain should be routed to this mail server”
- TXT record — a general-purpose record that can hold arbitrary text. This is used for all kinds of verification purposes.
The Three Records That Prove “This Email Is Really From This Domain”
When you want to send email from a domain (like blog@shoudo.xyz), you need to add specific TXT and MX records to that domain’s DNS settings. There are three main ones:
1. SPF (Sender Policy Framework)
This is a TXT record that essentially says: “Here is the official list of servers allowed to send email on behalf of this domain. If an email claiming to be from this domain arrives from a server NOT on this list, treat it with suspicion.”
For example, an SPF record might look like:
v=spf1 include:amazonses.com ~all
This translates to: “Servers belonging to Amazon SES (which Resend uses under the hood) are allowed to send email for this domain. Anything else — be cautious (soft fail).”
2. DKIM (DomainKeys Identified Mail)
This is more clever. DKIM works like a cryptographic signature. When an email is sent, the sending server “signs” it using a private key that only the sender knows. The recipient’s email server then looks up a public key (published as a DNS TXT record) and uses it to verify the signature.
If the signature checks out, the recipient knows: “This email definitely came from someone who has access to this domain’s private key, and the email content wasn’t tampered with in transit.”
A DKIM record looks something like this (it’s a long string because it’s a cryptographic public key):
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDhJR8MSlEHI...
3. DMARC (Domain-based Message Authentication, Reporting & Conformance)
This is a policy layer on top of SPF and DKIM. It tells receiving email servers: “If an email fails SPF or DKIM checks, here’s what you should do with it” — reject it, mark it as spam, or just let it through but report the failure.
A simple DMARC record looks like:
v=DMARC1; p=none;
The p=none part means “don’t take any drastic action yet, just allow the email through” — this is a common starting point before tightening the policy later.
Why does all this matter for us?
Because Resend (or any email-sending service) cannot send email as you@yourdomain.com until you’ve proven you own that domain by adding these exact records to your domain’s DNS settings. This is a one-time setup step, but it’s the foundation everything else is built on — if these records aren’t correct, nothing else in this system will work, no matter how perfect your code is.
Part 3: Setting Up the Domain — And the First Big Error
I bought my domain shoudo.xyz from a registrar called Spaceship. A domain registrar is just the company you “rent” a domain name from — they also usually give you a control panel where you can edit DNS records.
The process was:
- Sign up for Resend (a service that specializes in sending transactional/programmatic emails — think “emails sent by code”, as opposed to emails you write by hand).
- In Resend’s dashboard, go to Domains → Add Domain, and type in
shoudo.xyz. - Resend then generates the exact DKIM, SPF, MX, and DMARC records I need to add — I just need to copy these into Spaceship’s DNS settings.
- Once added, click “Verify” in Resend, and it checks whether those DNS records are publicly visible and correct.
Sounds simple, right? Here’s where it got interesting.
Error #1: “Invalid DKIM — The record value is incorrect”
I added all four records into Spaceship’s DNS panel. Within a few minutes, three of them — MX, SPF, and DMARC — showed up as “Verified” ✅. But DKIM stubbornly stayed as “Failed” ❌, with the message: “Invalid DKIM: The record value is incorrect. Update it to the value shown in the table.”
This was confusing because I had copied the value Resend gave me. So what went wrong?
The DKIM value is a very long string — over 200 characters, since it’s an RSA public key. My theory (and after some digging, a fairly common issue) is that when pasting extremely long values into some DNS provider UIs, the value can get silently truncated, have characters dropped, or get mangled during copy-paste — especially on mobile browsers.
The fix: I went back into the DKIM TXT record in Spaceship, completely deleted the existing value, and pasted the full key again — fresh, from Resend’s dashboard, double-checking that it ended exactly with the characters Resend showed (...TVQIDAQAB). After saving and clicking “Verify DNS Records” again in Resend, it passed within a few minutes.
The lesson here, which applies far beyond DNS: if a system says “I added this value but it’s still failing,” don’t assume the value is correct just because you remember pasting it. Re-copy and re-paste from the source, especially for long strings. Truncation bugs in copy-paste are sneaky because everything looks fine until you check character-by-character.
Part 4: Storing Subscribers — Understanding Databases with Firebase Firestore
Once email-sending infrastructure works, the next question is: where do we keep track of who subscribed?
This is where a database comes in. If you’ve never used one, think of a database as a very smart, very large spreadsheet that your code can read from and write to.
I used Firebase Firestore, which is a “NoSQL document database” provided by Google. Here’s what that means in plain terms:
- In Firestore, data is organized into collections (think: a named group of items, like a folder) and documents (think: individual items inside that folder, like files).
- I created a collection called
blog_subscribers. Each document inside this collection represents one person who subscribed, and contains fields like:email— their email addressname— their namesubscribedAt— a timestamp of when they subscribedunsubscribeToken— a random unique string (more on this later)
Why Firestore specifically? Two reasons:
- It’s free for small-scale usage (the free tier covers way more reads/writes than a small blog would ever generate).
- My blog was already hosted on Firebase Hosting — so Firestore was already part of the same project, with no extra setup or new accounts needed.
Part 5: The Subscribe API — Connecting the Form to the Database
Now we get into actual code. When someone fills out the “Subscribe” form on my blog and hits submit, something needs to receive that data, check it, save it to Firestore, and send a welcome email. This “something” is what’s called an API endpoint.
What is an API endpoint, really?
An API (Application Programming Interface) endpoint is just a URL that, instead of returning a webpage, runs some code and returns a response — usually some data (often in JSON format).
For example, when the subscribe form is submitted, the browser sends a request to a URL like https://shoudo-api.vercel.app/api/subscribe, along with the name and email the user typed in. A piece of code running on a server receives this request, does some work, and sends back a response like {"success": true}.
What is a “serverless function”?
Here’s where things get interesting for the “no always-on server” requirement. Traditionally, to have code that responds to incoming requests, you’d need a server running constantly, listening for requests 24/7.
A serverless function flips this around. Instead of a server that’s always running, you write a small piece of code (a “function”), and a platform (in our case, Vercel) takes care of running that code only when a request comes in. When there’s no traffic, nothing is running and nothing costs anything. When a request arrives, the platform spins up your code for a few hundred milliseconds, runs it, returns the response, and shuts it down.
This is perfect for our use case — the subscribe endpoint might get hit once a week, or once a month. There’s no reason to pay for (or maintain) a server that sits idle 99.99% of the time.
The Subscribe Function, Explained Line by Line
Here’s the actual code, with explanations:
import admin from 'firebase-admin';
import { Resend } from 'resend';
import { randomUUID } from 'crypto';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
}),
});
}
const db = admin.firestore();
const resend = new Resend(process.env.RESEND_API_KEY);
Let’s unpack this:
firebase-adminis a special library that lets server-side code (code running on a backend, not in a user’s browser) access Firebase services like Firestore with full administrative privileges.- To use
firebase-admin, you need to authenticate as a “service account” — basically, a special non-human identity that represents your backend code. Firebase generates a JSON file containing credentials for this service account: aproject_id, aclient_email, and aprivate_key. - These three values are read from
process.env.*— meaning they come from environment variables, not hardcoded into the code. We’ll talk about why this matters (and the error I hit because of it) shortly. .replace(/\\n/g, '\n')is a small but crucial detail: the private key, as it appears in the JSON file, contains literal backslash-n characters (two characters: a backslash and the letter n) representing line breaks. But the cryptography library needs actual newline characters. This line converts the literal text into real newlines.Resend(process.env.RESEND_API_KEY)sets up the Resend client using an API key — a secret string that identifies my Resend account so emails get sent (and billed, even on the free tier) under my account.
Now the actual handler function:
export default async function handler(req, res) {
if (req.method === 'OPTIONS') return res.status(200).end();
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const { email, name } = req.body;
if (!email || !name) return res.status(400).json({ error: 'Email and name required' });
- This function runs every time someone makes a request to this endpoint.
- The
OPTIONScheck is related to something called CORS (Cross-Origin Resource Sharing) — basically, browsers send a quick “permission check” request before the real request when a webpage talks to an API on a different domain. We just respond “OK” to these. - We only care about
POSTrequests (requests that send data, as opposed toGETrequests which just retrieve data). - We pull
emailandnameout of the request body, and reject the request if either is missing.
const existing = await db.collection('blog_subscribers')
.where('email', '==', email).limit(1).get();
if (!existing.empty) return res.status(409).json({ error: 'Already subscribed' });
- This queries Firestore: “look in the
blog_subscriberscollection for any document where theemailfield equals this person’s email, and give me at most 1 result.” - If we find one, it means this person already subscribed — we return an error (HTTP status 409, “Conflict”) instead of creating a duplicate.
const unsubscribeToken = randomUUID();
await db.collection('blog_subscribers').add({
email, name,
subscribedAt: admin.firestore.FieldValue.serverTimestamp(),
unsubscribeToken,
});
randomUUID()generates a random, unique string (something likea1b2c3d4-e5f6-...). This becomes the subscriber’s personal “unsubscribe token.”- Why do we need this? Imagine the unsubscribe link in emails was just
https://blogs.shoudo.xyz/unsubscribe?email=someone@example.com. Anyone could guess or change the email in that URL and unsubscribe other people without their consent! By generating a random, unguessable token per subscriber and including that in their unique unsubscribe link, only someone with access to that specific email (i.e., the actual subscriber, who received the email containing their unique link) can unsubscribe themselves. - We then save a new document to Firestore with all this information.
serverTimestamp()tells Firestore to record the exact time this happened, using Firestore’s own server clock (more reliable than using the local time of whatever device made the request).
await resend.emails.send({
from: 'Ashikur Sheikh Shoudo <blog@shoudo.xyz>',
to: email,
subject: `Hey ${name}, welcome to Shoudo's blog!`,
html: `/* welcome email template */`,
});
return res.status(200).json({ success: true });
}
- Finally, we use Resend to send a welcome email. The
htmlfield contains the actual email content as an HTML string (we’ll talk about email templates later). - If everything succeeds, we return
{"success": true}with HTTP status 200 (OK) — the frontend uses this to show a “You’re subscribed!” message to the user.
Error #2: Environment Variables That Weren’t There
I deployed this function to Vercel, tested the subscribe form… and the subscriber was saved to Firestore correctly, but no welcome email arrived. Worse, when checking logs, I saw errors related to Firebase initialization too, in some test runs.
The root issue was a misunderstanding about environment variables — and this trips up a lot of people new to deploying backend code, so let’s go deep on it.
What’s an environment variable, conceptually?
It’s a piece of configuration that lives outside your code, in the environment where your code runs. The idea is: your code shouldn’t have secrets like API keys and private keys hardcoded directly in it (especially if that code is in a public GitHub repository — anyone could read your secrets!). Instead, your code says “give me the value of RESEND_API_KEY from the environment,” and the platform running the code is responsible for providing that value securely.
The mistake: On my local machine, I had a .env file — a simple text file with lines like:
RESEND_API_KEY=re_xxxxxxxxxxxx
FIREBASE_PROJECT_ID=my-project
Tools like the dotenv package can read this file and load these values into process.env when running locally. But Vercel doesn’t know or care about your local .env file — when your code is deployed and running on Vercel’s servers, that file simply doesn’t exist there. It never gets uploaded (and for security reasons, it shouldn’t be — .env files should always be in .gitignore so they never get committed to GitHub).
The fix: Environment variables for a Vercel deployment must be configured inside Vercel’s own dashboard — under Project Settings → Environment Variables. You add each variable name and value there, directly in Vercel’s UI. Vercel then securely injects these into process.env whenever your function runs in their cloud — completely separate from anything in your codebase.
One more gotcha: even after adding the variables in the dashboard, the currently-deployed version of your code doesn’t automatically pick them up. Adding/changing environment variables doesn’t trigger a new deployment by itself — you need to manually trigger a redeploy for the new environment variables to actually be available to your running code.
This single misunderstanding — “environment variables work the same locally and when deployed” — is probably one of the most common beginner mistakes when deploying backend code anywhere (Vercel, Netlify, Railway, etc.), not just in this project.
Part 6: The Subscribe Form — The User-Facing Piece
With the backend API working, the frontend piece is comparatively simple. On the blog (built with Astro), there’s a small component containing an HTML form:
<form id="subscribe-form">
<input type="text" name="name" placeholder="Your name" required />
<input type="email" name="email" placeholder="you@example.com" required />
<button type="submit">Subscribe</button>
</form>
<script>
const form = document.getElementById('subscribe-form');
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form as HTMLFormElement);
const res = await fetch('https://shoudo-api.vercel.app/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email'),
}),
});
// show success/error message based on res.ok
});
</script>
What’s happening here:
e.preventDefault()stops the browser’s default behavior for form submissions, which is to reload the page and send the data as a traditional form POST. We want to handle this with JavaScript instead, so the page doesn’t reload.fetch(...)is the browser’s built-in way of making HTTP requests from JavaScript. We’re sending aPOSTrequest to our Vercel API endpoint, with the name and email as JSON in the request body.- Based on whether
res.okis true (status code 200-299) or false, we’d show the user a success message (“Thanks for subscribing!”) or an error message (“Something went wrong” / “You’re already subscribed”).
This is a classic example of what’s sometimes called a “static site with dynamic bits” — the blog itself is just static HTML/CSS/JS files (fast, cheap to host), but it talks to a small backend API for the parts that need to do something (like writing to a database).
Part 7: The Automation — GitHub Actions and “Detecting” New Posts
This is the part I was most excited about, because it’s what makes the entire system “automatic.”
What is GitHub Actions?
GitHub Actions is a feature of GitHub that lets you define workflows — sequences of automated steps — that run in response to events in your repository. Common triggers include “someone pushed new code,” “someone opened a pull request,” or “it’s a scheduled time (like every day at midnight).”
Each workflow runs on a fresh, temporary virtual machine that GitHub spins up just for that run, executes your steps, and then shuts the machine down. You don’t manage any servers — you just describe what should happen and when.
For our use case, the trigger is: “someone (me) pushed a new Markdown file into the src/content/blog/ folder, on the main branch.”
How Blog Posts Are Structured
Every blog post is a Markdown file with a special section at the top called frontmatter — metadata about the post, written between two --- lines:
---
title: "My New Post"
description: "What it's about"
pubDate: 2026-06-11
cover: "/images/blog/my-cover.jpg"
---
The actual content of the post starts here...
Astro (the framework powering the blog) reads this frontmatter to know the post’s title, description, publish date, and so on, and uses it to render the blog’s index page, individual post pages, etc.
For our notification system, we need to extract this same information — title, description, cover image — and use it to build the email.
The Workflow File, Explained
Here’s the GitHub Actions workflow (.github/workflows/notify.yml), broken into pieces:
name: Notify subscribers on new post
on:
push:
branches: [main]
paths: ['src/content/blog/**.md']
This says: “run this workflow whenever someone pushes to the main branch, and at least one of the changed files matches the pattern src/content/blog/**.md” (i.e., any Markdown file inside the blog content folder, including subfolders).
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
runs-on: ubuntu-latestmeans this job runs on a fresh Ubuntu Linux virtual machine.actions/checkout@v4is a pre-built action (a reusable piece of automation, shared by GitHub) that downloads (clones) your repository’s code onto this virtual machine, so subsequent steps can work with the files.fetch-depth: 2means “fetch the last 2 commits” instead of just the latest one. This is important for the next step — we need to compare the latest commit against the previous one to figure out what changed.
Detecting the New File and Extracting Its Metadata
- name: Detect new blog post
id: check
run: |
NEW_FILE=$(git diff --name-only --diff-filter=A HEAD~1 HEAD -- 'src/content/blog/*.md' | head -1)
if [ -z "$NEW_FILE" ]; then
echo "found=false" >> $GITHUB_OUTPUT
exit 0
fi
Let’s break down that git diff command, since it looks intimidating:
git diff HEAD~1 HEADcompares the previous commit (HEAD~1) with the current commit (HEAD) — i.e., “what changed in this push?”--name-onlysays “just give me the filenames that changed, not the actual content differences.”--diff-filter=Ameans “only show files that were Added” (not modified or deleted) — so we only react to brand-new posts, not edits to existing ones.-- 'src/content/blog/*.md'restricts this to Markdown files in the blog folder.| head -1takes just the first result, in case multiple files were somehow added in one push.
If NEW_FILE is empty (no new blog post was added — maybe I edited an existing post, or changed something else), we set found=false as an “output” of this step and exit early. Other steps in the workflow check this output and skip themselves if it’s false.
TITLE=$(grep '^title:' "$NEW_FILE" | head -1 | sed 's/title: *//;s/^"//;s/"$//')
DESC=$(grep '^description:' "$NEW_FILE" | head -1 | sed 's/description: *//;s/^"//;s/"$//')
COVER=$(grep '^cover:' "$NEW_FILE" | head -1 | sed 's/cover: *//;s/^"//;s/"$//')
SLUG=$(basename "$NEW_FILE" .md)
This is using two classic command-line tools, grep and sed, to extract values from the frontmatter:
grep '^title:' "$NEW_FILE"searches the file for a line that starts with (^) the texttitle:.sed 's/title: *//;s/^"//;s/"$//'then cleans up that line:s/title: *//removes thetitle:prefix and any spaces after it.s/^"//removes a leading double-quote, if present.s/"$//removes a trailing double-quote, if present.
- The result is just the value — e.g., from
title: "My New Post", we extractMy New Post. basename "$NEW_FILE" .mdtakes a path likesrc/content/blog/my-new-post.mdand extracts justmy-new-post— this becomes the slug, used to build the post’s URL.
Error #3: Broken Cover Images in Emails
Here’s where I hit my third major issue. The plan was: if a post’s frontmatter includes a cover field (a path to an image), include that image at the top of the notification email. If there’s no cover, just skip the image entirely.
The email-sending code had this logic:
const coverBlock = POST_COVER
? `<img src="${POST_COVER}" alt="${POST_TITLE}" width="560"
style="display:block;width:100%;max-height:260px;object-fit:cover;"/>`
: '';
This is a ternary expression — a compact if/else. In plain English: “if POST_COVER has a value, create an <img> tag using it; otherwise, use an empty string.”
For posts without a cover image, this worked perfectly — coverBlock was just an empty string, and the email looked clean with no broken image icon.
But for posts with a cover image, the email showed a broken image icon instead of the actual picture. The HTML was being generated correctly… so why wouldn’t the image load?
The cause: relative vs. absolute URLs.
In the blog’s frontmatter, cover images are referenced like this:
cover: "/images/blog/discord_banner.jpg"
This is what’s called a relative path (or more specifically, a root-relative path) — it doesn’t specify a domain, just a path. On a website, this works because the browser automatically fills in the missing piece: “relative to the website I’m currently looking at.” So if you’re browsing https://blogs.shoudo.xyz/blog/some-post, the browser understands /images/blog/discord_banner.jpg to mean https://blogs.shoudo.xyz/images/blog/discord_banner.jpg.
But an email has no “current website.” When Gmail or Outlook renders an HTML email and sees an <img> tag pointing to /images/blog/discord_banner.jpg, there’s no domain to resolve that path against. As far as the email client is concerned, that’s not a valid, complete URL — so it can’t fetch the image, and you get a broken image icon.
The fix: convert the relative path into a full, absolute URL — one that includes the domain — before it gets used in the email. I added this logic to the GitHub Actions workflow, right after extracting the COVER value:
if [ -n "$COVER" ]; then
case "$COVER" in
http://*|https://*) ;; # already a full URL, leave as-is
/*) COVER="https://blogs.shoudo.xyz${COVER}" ;;
*) COVER="https://blogs.shoudo.xyz/${COVER}" ;;
esac
fi
Breaking this down:
if [ -n "$COVER" ]— “if$COVERis not an empty string” (i.e., this post does have a cover image).- The
casestatement checks the shape of the value:- If it already starts with
http://orhttps://, it’s already a full URL — leave it alone (covers the case where someone might link to an external image). - If it starts with
/(a root-relative path, like/images/blog/cover.jpg), prepend the blog’s domain directly: the result ofhttps://blogs.shoudo.xyzplus/images/blog/cover.jpgishttps://blogs.shoudo.xyz/images/blog/cover.jpg. - Otherwise (some other relative format), prepend the domain with a slash separator.
- If it already starts with
After this change, /images/blog/discord_banner.jpg becomes https://blogs.shoudo.xyz/images/blog/discord_banner.jpg — a complete, public URL that any email client can fetch, because that image genuinely is publicly accessible at that address (it’s part of the deployed Astro site’s public/ folder, which gets served as static files).
The broader lesson: anything that needs to work outside the context it was originally designed for (a website path, used inside an email; a relative file reference, used in a different program; etc.) often needs to be converted into a more “self-contained” form — in this case, a full URL instead of a path that depends on context.
Part 8: Putting It All Together — The Notification Script
The final piece is the actual script that sends the emails: scripts/send-notifications.mjs. By this point, most of the concepts here should feel familiar:
import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { Resend } from 'resend';
const app = initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
}),
});
const db = getFirestore(app);
const resend = new Resend(process.env.RESEND_API_KEY);
Same pattern as before — connect to Firestore and Resend using credentials from environment variables.
const POST_TITLE = process.env.POST_TITLE || 'New post';
const POST_DESC = process.env.POST_DESC || '';
const POST_SLUG = process.env.POST_SLUG || '';
const POST_COVER = process.env.POST_COVER || '';
const POST_URL = `https://blogs.shoudo.xyz/blog/${POST_SLUG}`;
const UNSUB_URL = `https://blogs.shoudo.xyz/unsubscribe`;
These values are read from environment variables — and remember, these specific environment variables (POST_TITLE, POST_DESC, etc.) aren’t “secrets,” they’re the values we extracted from the new blog post’s frontmatter in the GitHub Actions workflow, and passed into this script via the env: block of the workflow step.
const snapshot = await db.collection('blog_subscribers').get();
if (snapshot.empty) { console.log('No subscribers.'); process.exit(0); }
const subscribers = snapshot.docs.map(d => d.data()).filter(d => d.email);
console.log(`Sending to ${subscribers.length} subscriber(s)...`);
db.collection('blog_subscribers').get()retrieves every document in the subscribers collection.snapshot.docs.map(d => d.data())converts each Firestore “document snapshot” into a plain JavaScript object containing the actual fields (email,name, etc.)..filter(d => d.email)is a small safety check — just in case any document somehow doesn’t have an email field, skip it.
let success = 0;
for (const sub of subscribers) {
const coverBlock = POST_COVER
? `<img src="${POST_COVER}" .../>`
: '';
await resend.emails.send({
from: 'Ashikur Sheikh Shoudo <blog@shoudo.xyz>',
to: sub.email,
subject: `${POST_TITLE} — shoudo`,
html: `... full HTML email template using post title, subscriber name, description, link, unsubscribe link, and cover block ...`,
});
success++;
console.log(`✓ ${sub.email}`);
}
console.log(`Done — ${success}/${subscribers.length} sent.`);
This loop goes through every subscriber, builds a personalized HTML email (using their name, the post’s title/description/link, and the cover image block if applicable), and sends it via Resend. The console.log statements aren’t strictly necessary, but they’re incredibly useful — when this workflow runs on GitHub Actions, these logs show up in the workflow’s run history, so I can confirm “yes, it tried to send to N people, and here’s the list.”
Where Do the Secrets Come From in GitHub Actions?
You might notice this script uses environment variables for Firebase and Resend credentials, just like the Vercel function did. But GitHub Actions runs on a completely different platform than Vercel — so where do these environment variables come from?
GitHub repositories have their own place to store secrets: Settings → Secrets and variables → Actions. You add the same credentials here (Firebase project ID, client email, private key, Resend API key) as repository secrets. Then, in the workflow YAML file, you reference them with a special secrets syntax, and pass them into the script as environment variables for that step.
The post-extracted values (POST_TITLE, POST_DESC, POST_COVER, POST_SLUG) come from the outputs of the earlier “Detect new blog post” step, passed forward into this step. This is how different steps within the same workflow “communicate” with each other.
GitHub encrypts repository secrets and never displays their values once saved — even to you. They’re injected as environment variables only while a workflow is actively running, and are hidden from logs (GitHub automatically redacts any text that matches a secret’s value, in case it accidentally gets printed).
It’s worth noting: yes, this means the same Firebase and Resend credentials end up stored in two places — Vercel’s environment variables (for the subscribe API) and GitHub’s repository secrets (for the notification workflow). That’s expected — these are two completely separate platforms, each running its own piece of the system, and each needs its own copy of the credentials to do its job.
Part 9: The Last Mile — Why Some Emails Don’t Arrive
After fixing the DKIM issue, the environment variable issue, and the broken cover images, the system was working end-to-end: writing a post and pushing to GitHub triggered emails to all subscribers, with working images, correct titles and descriptions, and working links.
But I noticed something: emails reliably arrived in Gmail and Outlook inboxes. But for subscribers using Proton Mail, the emails seemed to just… vanish. Not in spam, not in inbox — just gone.
Error #4: The Proton Mail Mystery
At first, this felt like it had to be a configuration problem — maybe SPF, DKIM, and DMARC weren’t fully correct after all? I double, triple-checked: all three records showed as Verified in Resend’s dashboard. Test emails to Gmail arrived instantly, with no spam warnings, and even showed the little “verified sender” indicators.
So if the cryptographic verification is all passing… what’s going on?
The explanation turned out to be about sender reputation, which is a concept that doesn’t show up in any DNS record or dashboard checkmark.
Here’s the idea: even if a domain has technically correct SPF, DKIM, and DMARC, email providers also track the historical behavior of a sending domain (and the underlying mail servers it uses) over time — how long has this domain been sending mail, how often do recipients mark its mail as spam, how consistent is its sending pattern, etc. A domain that was just set up to send email, even with perfect technical configuration, has no track record yet.
Proton Mail, being a privacy-focused provider, applies notably stricter inbound filtering than mainstream providers like Gmail. It’s known to be more conservative about mail from newly-configured domains, especially ones sending through shared infrastructure. Resend, like many email APIs, sends through shared sending infrastructure — meaning many different customers’ emails go out through overlapping sets of servers, and a new domain on that infrastructure hasn’t built up its own reputation yet.
Is there a “fix”? Not really — not an instant one, anyway. This isn’t a bug to be patched; it’s an inherent characteristic of how trust works in email systems. The realistic path forward is:
- Keep sending consistent, legitimate email (which naturally happens as the blog continues to publish posts).
- Over time, sending domains generally build up reputation, and deliverability to stricter providers tends to improve.
- For a small personal blog, the practical impact is limited — the vast majority of subscribers on mainstream providers receive emails without any issue.
I think this is actually a really valuable thing to understand if you’re building anything that sends email: passing SPF, DKIM, and DMARC checks is necessary, but it’s not sufficient for guaranteed inbox delivery everywhere. Deliverability is partly a technical problem and partly a “trust, earned over time” problem — and the second part can’t be solved by configuration alone.
Part 10: Why This Whole Approach Works So Well for a Small Blog
Stepping back, here’s why I think this particular combination of tools is a great fit for a small, personal project — and might be for yours too:
No servers to maintain. Every piece of this system — Vercel functions, GitHub Actions runners, Firebase — only “exists” while actively doing work. There’s no machine sitting idle, costing money or needing updates and security patches, 99% of the time.
Genuinely free at this scale. Resend’s free tier covers 3,000 emails per month. GitHub Actions gives a generous amount of free minutes per month for private repos, and unlimited for public ones — and this workflow takes maybe 30-60 seconds per run. Firebase Firestore’s free tier covers far more reads and writes than a blog with a handful of subscribers will ever produce. Vercel’s free tier covers serverless function usage at this scale easily. For a blog with single or double-digit subscriber counts, realistically, none of these limits will ever be approached.
No separate image hosting needed. Because cover images live in the same Astro project that’s already deployed (to Firebase Hosting), they’re automatically available at predictable public URLs — there was no need to set up a separate image CDN or storage bucket.
True “set and forget” automation. The only manual action in this entire pipeline is: write a blog post (with frontmatter including a cover image, if desired), and run a single git push command. Everything else — detecting the new post, extracting its metadata, fetching subscribers, formatting and sending emails — happens automatically, with full logs available afterward in the GitHub Actions tab if anything needs debugging.
Part 11: What I’d Improve Next
No system is “done” — here’s what’s on my list:
- A real unsubscribe confirmation page. Right now, clicking the unsubscribe link removes the subscriber via an API call, but the user-facing page is very bare-bones. A proper “You’ve been unsubscribed, sorry to see you go” page with maybe a re-subscribe option would be nicer.
- A proper YAML frontmatter parser. The grep and sed approach for extracting
title,description, andcoverworks, but it’s fragile — it assumes each field is on its own single line, in a simplekey: valueformat. If frontmatter ever becomes more complex (multi-line descriptions, nested fields), this would break. A proper YAML parsing library would handle all of YAML’s syntax correctly, rather than relying on text pattern-matching. - A “digest” option. Right now, every single post triggers an individual email. For someone who posts very frequently, subscribers might prefer a weekly digest instead. This would require a different trigger — a scheduled GitHub Action, running for example weekly, rather than one triggered by every push — and some logic to track which posts haven’t been included in a digest yet.
Final Thoughts
If there’s one big takeaway from this whole project, it’s this: most of the “hard parts” weren’t really about writing clever code — they were about understanding how different systems expect to receive information, and translating between those expectations.
- DNS records needed to be exactly correct, character for character, for email providers to trust the domain.
- Environment variables needed to live in the right platform’s configuration, not just in a local file.
- An image path needed to be a full URL, not a path that only made sense in the context of a website.
- And even with everything technically correct, trust in email systems is something that’s earned over time, not granted by a checkmark.
None of these are things you’d necessarily learn from a single tutorial — they’re the kind of things you run into by actually building something, hitting an error, and digging into why. I hope walking through these in detail — rather than just showing a “final, working” version of the code — makes this useful whether you’re building something similar, or just curious about how all these pieces of modern web infrastructure fit together.
If you made it this far — thanks for reading. And if you want to see this exact system in action, well… you’re one subscribe-button-click away from getting an email the next time I publish something. 😄