Our open rate had been dropping 1–2 points per month for half a year and we couldn't figure out why. Marketing kept tightening segments. Deliverability kept reviewing infrastructure. The actual culprit was that 38% of our list hadn't opened anything since 2024. Engaged readers were paying the deliverability tax for everyone who'd quietly stopped caring.
The fix is a sunset sequence — a small, polite, automated process that finds long-silent contacts, asks them once if they want to stay, and removes them if they don't answer. We just shipped one for digicore101. This post is the architectural breakdown — what we built, why we chose each part, and what we'd do differently next time.
What a sunset sequence is, exactly
A sunset sequence is a small lifecycle automation that triggers when a contact has been inactive for some threshold (say 90 days), gives them a low-friction way to confirm they want to stay, and removes them from active sends if they don't respond. The "removal" usually means flagging them as unsubscribed-by-system, not deleting the row — you keep the data for resubscribe handling but stop counting them in deliverability.
The shape almost everyone converges on is three emails over 10–14 days:
- Email 1 — check-in (day 1). "Hey, you've been quiet. Stay or go?"
- Email 2 — last chance (day 6). "Five days left, this is the last reminder."
- Email 3 — goodbye (day 12, after auto-unsub). "You're off the list. Here's how to come back if it was a mistake."
The third email is non-negotiable for trust. Silently removing people without telling them feels deceitful and confuses the ones who genuinely wanted to stay but missed the first two.
Why 90 days is the threshold
Inactivity windows are not a science. We tested three options before settling.
| Threshold | What it catches | What it burns |
|---|---|---|
| 60 days | Genuinely dead contacts | Quarterly readers — people who only open monthly newsletters and skip a few |
| 90 days | Dead contacts and one-time-resource grabbers | Almost nothing — anyone who reads even occasionally opens within 90d |
| 180 days | Only the truly inactive | Reputation drag from ~5x more dead weight; defeats most of the point |
We chose 90 days because we send roughly monthly, and the reasonable lower bound for "missed three issues" is the actual signal of disengagement. If you send daily, drop the threshold to 30 days. If you send quarterly, push it to 180. The right number is "missed enough sends to clearly not be reading anymore."
The architecture
There are five moving parts, and they fit together like this:
- Supabase view — derives "who has been silent for 90d" from the email events table, which Resend webhooks populate in real time.
- GitHub Actions cron (daily) — calls our /api/cron/sunset-check endpoint, which fires the sunset-start event for any newly-silent contacts.
- Resend Automation — listens for the sunset-start event and sends emails 1 and 2 over six days.
- GitHub Actions cron (daily, separate) — calls /api/cron/sunset-finalize at +11 days. Marks non-responders as unsubscribed and fires the sunset-removed event.
- Resend Automation (goodbye) — listens for sunset-removed and sends email 3 immediately.
No queue, no worker pool, no Redis. The only persistent state is the Supabase row that tracks "where is this contact in the sunset flow." Everything else is event-driven and idempotent.
Part 1 — The Supabase view
Email events come in via Resend's webhook (our setup notes are in the email infrastructure cluster). Each row is one event: opened, clicked, bounced, unsubscribed. From that, we derive a per-contact view of "last engagement at" and another view of "currently eligible for sunset."
The view is a one-shot SQL query that joins email_events to subscriber_state and filters for contacts whose last_engaged_at is older than 90 days AND who are not already in a sunset flow. The crons read directly from this view; they never do any window math themselves. This is important for two reasons.
- The filter logic lives in one place. If we change the threshold from 90 to 60 days, we change one SQL line, not three endpoints.
- The view is a pure function of the underlying tables. Re-running the cron is safe — no state lives in the cron itself.
A second view, sunset_ready_for_finalize, returns contacts who got the day-1 check-in but haven't engaged in the 11 days since. The finalize cron reads from this view and unsubscribes them in batch.
Part 2 — Cron auth and the GitHub Actions trigger
The two cron endpoints are protected by a shared secret in the Authorization header. We pass it via a GitHub Actions workflow that runs daily at 09:00 UTC. The workflow is a single curl call; the secret comes from GitHub Actions secrets, mirrored into the deployment env (Netlify in our case).
Why GitHub Actions and not the platform's built-in scheduler?
- Cost — GitHub Actions is free for public-or-low-volume use. Netlify Scheduled Functions are free up to a tier, then paid. For something that runs twice a day, GHA is simpler.
- Visibility — every cron run is a logged GHA run with stdout. Failed runs page us via GitHub's built-in notifications. No separate observability stack required.
- Portability — when we move to n8n later (which we will), the trigger logic moves with us. The endpoint is the same; only the caller changes.
Caveat: we use GET requests for the cron, not POST. Astro's built-in CSRF protection blocks unauth POSTs in production by default, and GHA-triggered cron is technically a server-to-server call from outside the origin. Easier to authorize a GET than to disable CSRF for the path.
Part 3 — Namespaced HMAC tokens (the boring correctness move)
Every sunset email contains a "yes, keep me subscribed" link. The link is signed with HMAC-SHA256 over the recipient's email so it can't be enumerated for other addresses, and is verified server-side at /api/sunset-stay.
Originally, our unsubscribe links and stay-subscribed links used the same token format — both were HMAC of the email alone. This is fine until you realize a determined attacker who got one valid link could forge the other for the same recipient. Trivially, but enough that we wanted to fix it.
The fix is namespacing. Each token is now an HMAC of the string "<namespace>|<email>", where namespace is one of "unsubscribe" or "sunset-stay". The two are now cryptographically distinct. Backwards compatibility was important — already-sent emails contain old-format tokens — so the verifier accepts both formats during the transition window.
Total code change: 30 lines. Worth doing on day one. Adding token namespacing later requires a coordinated deployment with backwards-compatible verifiers; doing it from the start costs nothing.
Part 4 — The Resend Automations
Two automations handle the actual sends:
- Sunset · sequence — triggered by event subscriber.unengaged-90d. Waits 24h, sends email 1. Waits 5 days, sends email 2. Stops if the recipient clicks "stay subscribed" or opens any email (Resend handles the cancellation natively).
- Sunset · goodbye — triggered by event subscriber.sunset-removed. Sends email 3 immediately, no delay. Separate automation because the goodbye is conceptually a different flow — it's a notification, not a nurture.
Splitting them keeps each automation focused. We could have used a single 4-step flow with branching, but Resend's branching is more limited than the equivalent in n8n, and the "two flows that share an event vocabulary" pattern is cleaner anyway. Each one's purpose is obvious from its trigger.
Part 5 — Templates and the cooler color choice
All three sunset emails use a cooler grey-purple accent (#6B5B95) instead of our usual warm orange. This is a small thing but it matters: the recipient should feel a different category of email than the marketing/onboarding sends. Cooler colors signal "housekeeping," not "sale."
The copy is honest. We tell the recipient exactly what's happening and why. The check-in email opens with "It's been 90 days since you last opened anything from us. That's fine. Sometimes you grab a resource and you're done." Setting that frame upfront defuses the guilt-trip energy that makes most sunset emails feel manipulative.
The CTA is one button: "Yes, keep me subscribed." There's no quiz, no preference center, no "tell us why you're leaving" form. Friction is the enemy of a clean signal.
What we'd do differently
Three things on the list for v2:
- Replace GitHub Actions cron with n8n. GHA is fine for v1, but n8n gives us the ability to inspect each cron run interactively and add branching logic later. The cost is running n8n somewhere; the upside is the workflow becomes visible to non-engineers on the team.
- Separate "stay" intent from "engaged" signal. Currently, opening any email cancels the sunset. But if someone opens email 1 and ignores it, that's actually still a quiet signal. A more conservative version would only cancel on explicit click of the "stay subscribed" CTA.
- Add a re-engagement sequence ahead of sunset. Right now we go straight from "silent for 90d" to "asking them to confirm." A more generous flow would send one explicit re-engagement email at day 60 with a piece of fresh content, and only sunset the silent contacts at day 90+15.
What this costs to run
At our current volume (~3,000 contacts, ~30 sunset triggers per quarter), monthly cost is functionally zero. Resend's free tier handles the sends. Supabase's free tier handles the views. GitHub Actions handles the cron. No additional infrastructure.
At 10x our volume, we'd still be inside Resend's paid tier we already use, Supabase row counts wouldn't change meaningfully, and the cron stays free. The only thing that scales linearly is the number of contacts hitting the sunset flow per month — which is exactly the cost we wanted to keep linear, because each one is a real action.
How to ship your own version
If you want to copy this pattern, here's the minimum viable shape:
- A table that records last-engaged-at per contact. Update it on every email event from your provider's webhook.
- A view (SQL or app-layer) that returns "contacts silent for N days who aren't already in sunset."
- A daily cron that reads the view, fires a "start sunset" event, and records the contact as in-flow.
- An automation in your email provider that handles the actual sends from that event.
- A second daily cron that finalizes any contact who's been in flow for >11 days without re-engaging.
The whole thing is ~400 lines of code if you write it from scratch. The hardest part is the initial decision about the threshold and the email copy — the engineering is small.
If you'd rather have someone build the whole lifecycle email engine on your stack — sunset, post-purchase, audit nurture, the rest — that's exactly the shape of build we ship. Book a call or read the AI automation audit page for what an audit-then-build engagement looks like.
