Building an Email Notification System with SST, Resend, and React Email
Storyie started with push notifications as its only channel for re-engaging users. The problem is obvious in hindsight: push notifications vanish the moment someone deletes the app. If a user drifts away, there is no way to reach them.
Email solves that. But building a complete email system upfront — welcome sequences, weekly digests, milestone alerts, win-back campaigns — is a significant investment. We needed something that could start small, stay maintainable, and grow without requiring rewrites.
This post covers the design decisions and practical lessons from building Storyie's email notification infrastructure using SST Cron jobs, Resend, and React Email templates in a pnpm monorepo.
TL;DR
- We ship email through a
@storyie/emailspackage containing templates, delivery services, and translations, with@storyie/jobshousing the Cron handlers. - Each email type is an independent Lambda function deployed by SST with its own schedule. Enabling or disabling a job is a one-line config change — no code deletion.
- Resend handles delivery. React Email handles templates. Both interoperate natively.
- An
email_logstable with aperiod_keycolumn makes every Cron handler idempotent. - Email preferences use a two-layer model: a master kill switch plus per-category toggles.
| Component | What it does |
| ----------------- | --------------------------------------------------------------- |
| packages/emails | Templates (React Email), delivery service, per-locale translations |
| packages/jobs | Cron handlers — one Lambda per email type |
| SST Cron | Schedules and deploys each Lambda independently |
| Resend | SMTP delivery, delivery logs, domain authentication |
| email_logs | Idempotency table — prevents duplicate sends |
Architecture overview
┌─────────────────────────────────────────────────┐
│ SST Cron Jobs │
│ │
│ WelcomeEmail (every 15 min) ─┐ │
│ WeeklySummary (Sundays) ──┤ │
│ Milestone (every 4 hrs) ───┼→ @storyie/emails
│ WinBack (daily) ──┤ ├ templates/
│ EmailQueue (every 5 min) ──┘ ├ services/
│ └ translations/
│ ↓
│ Resend API → users
└─────────────────────────────────────────────────┘Each Cron handler is its own Lambda. The email package is a shared dependency consumed by all of them. Resend is the only external delivery dependency.
Why Resend
When we evaluated delivery providers, the real question was not cost — at early scale any provider is cheap. It was DX.
Resend is built by the same team as React Email, and that shows. You pass a JSX component directly as the react property on the send call:
const result = await resend.emails.send({
from: "Storyie <noreply@storyie.com>",
to: userInfo.email,
subject,
react: <WeeklySummaryEmail
userName={userInfo.name}
locale={userInfo.locale}
diaryCount={stats.diaryCount}
currentStreak={stats.currentStreak}
/>,
});No HTML string wrangling, no template engine, no special rendering step. The JSX is rendered server-side by Resend's infrastructure. Email templates become React components with full TypeScript support.
The free tier covers 3,000 emails per month, which is more than enough for an early-stage product. The dashboard — delivery status, bounce tracking, domain verification — is clean and opinionated in a good way.
Phased rollout: what to build first
We resisted the temptation to build everything at once. Instead, we split email types by user impact and implemented them in phases.
Phase 1 — Transactional mail
- Welcome email (sent shortly after registration)
- Payment confirmation and failure notifications
Users expect these. Shipping them first meant the most critical communication path was covered before anything else.
Phase 2 — Engagement mail
- Weekly summary (Sundays at 12:00 UTC)
- Milestone alerts: first diary, 7-day streak, 30-day streak, 100 entries, one-year anniversary
Milestone emails create a "you're being celebrated" moment that has outsized retention impact relative to the implementation effort. The streak detection SQL is covered below.
Phase 3 — Win-back mail
- 7 days inactive → a light nudge
- 14 days inactive → a feature reminder
- 30 days inactive → a final outreach
Win-back emails are hardest to measure, so we deferred them. The code is complete, but the Cron definition stays commented out in sst.config.ts until we have a measurement plan in place. SST makes this trivially easy — one line to enable, one to disable.
Preventing duplicate sends: the email_logs table
The core requirement for any Cron-driven email system is idempotency. If the scheduler fires twice in a window, or a Lambda retries after a partial failure, users must not receive duplicate emails.
We solve this with an email_logs table and a period_key column:
-- Weekly summary: period_key = "2026-W07"
-- Milestone: period_key = "firstDiary_2026-02-13"
-- Win-back: period_key = "winback_7d_2026-05-01"
SELECT 1 FROM email_logs
WHERE user_id = $1
AND email_type = $2
AND period_key = $3period_key is how we express "same email" for each type. For weekly summaries, it is the ISO week number. For milestone emails, it is the event name plus the triggering date. For win-back, it is the tier name plus the date. The definition of uniqueness varies by type, so a single flexible key absorbs all cases without per-type schema changes.
Before every send, we check for an existing row. If it exists, we skip. If not, we send and immediately insert the log row. The handler is safe to run any number of times.
Email preferences: two layers
Respecting user preferences is not just a legal requirement — it is the baseline for maintaining trust. We implemented a two-layer preference model:
// Layer 1: master switch — one toggle to stop everything
if (!preferences.emailEnabled) return false;
// Layer 2: per-category toggles
const preferenceField = getPreferenceFieldForEmailType(emailType);
if (preferenceField && !preferences[preferenceField]) return false;The categories are:
emailWeeklySummary— weekly digestemailMilestones— milestone alertsemailWinBack— re-engagement sequences
Transactional emails — payment confirmations and failure notices — bypass both layers. There is a legal obligation to send them, and a practical expectation from users that they will arrive regardless of marketing preferences. The separation is explicit in the code.
Every email includes a one-click unsubscribe link. The link encodes a signed JWT token so we can authenticate the user without requiring them to log in.
Keeping translations inside the email package
Storyie supports 10 languages. Email subjects and body copy need to be localized too.
We store all email translations inside packages/emails, isolated from the app's i18n files:
packages/emails/src/translations/
├── en.ts
├── ja.ts
├── zh.ts
├── fr.ts
├── de.ts
├── es.ts
├── pt.ts
├── hi.ts
├── ar.ts
├── ru.ts
└── getTranslations.tsgetTranslations(locale) returns a typed object. Missing translation keys are caught at compile time. The package is self-contained.
The reason for the isolation: app translations and email translations have different update cadences, different reviewers, and different quality criteria. Mixing them into the same files creates coupling that does not pay off.
Streak detection SQL
The 7-day streak milestone is the most interesting query in the system. It uses the gap-detection pattern built on ROW_NUMBER():
WITH daily_diaries AS (
SELECT DISTINCT author_id, DATE(created_at) as diary_date
FROM diaries
WHERE created_at >= NOW() - INTERVAL '8 days'
),
streaks AS (
SELECT
author_id,
diary_date,
diary_date - ROW_NUMBER() OVER (
PARTITION BY author_id ORDER BY diary_date
)::int as streak_group
FROM daily_diaries
)
SELECT author_id
FROM streaks
GROUP BY author_id, streak_group
HAVING COUNT(*) = 7
AND MAX(diary_date) >= CURRENT_DATE - 1The core insight: for a consecutive daily sequence, subtracting the row number from the date always produces the same value. If there is a gap, the value changes. Grouping by streak_group and counting gives streak length. We then filter for groups of exactly 7 where the most recent date is yesterday or today. The same pattern handles 30-day streaks by changing the HAVING clause.
Monitoring
Silent email failures are a classic solo-dev blind spot. We instrumented the Email Queue Processor to make failures visible without requiring active monitoring:
- Structured JSON logs: queryable via CloudWatch Insights
- Error categorization: each failure is tagged with a reason (
user_opted_out,user_not_found,resend_api_error) so we can distinguish configuration problems from transient failures - Exponential backoff retries: up to three retries for transient errors before giving up
- Slack alerts: if the failure rate for a single run exceeds 50%, a Slack message fires immediately
For a small team, the Slack alert is the most important piece. Nobody has time to watch CloudWatch every day. An alert that arrives within minutes of a problem means issues get fixed before users notice.
What worked, what we'd do differently
Worked well
- Incremental deployment via SST config comments: shipping Phase 1, then enabling Phase 2 later was a single config change. No migrations, no feature flags, no rollback risk.
- React Email: writing email templates as React components is strictly better than every alternative. Local previews work out of the box, props are typed, and the rendering output is predictable.
- Monorepo package isolation: decoupling
@storyie/emailsfrom the Cron handlers meant template changes never touched job code and vice versa.
Would do differently
- Snapshot tests for email templates: we skipped these initially and caught a missing translation key in production. Every template should have a snapshot test that renders it in all supported locales and checks that the output is non-empty.
- Bounce handling from day one: Resend offers webhooks for bounce and complaint events. We should have wired those up immediately — automatically suppressing addresses that hard-bounce is table stakes for maintaining sender reputation, and retrofitting it later means dealing with accumulated bad data.
Summary
The system we ended up with is incrementally extensible by design. Adding a new email type means writing a template, a translation file, and a Cron handler — three independent files, each with a clear scope. Enabling or disabling a job in production is a one-line change to sst.config.ts.
The Resend + React Email combination removed the most painful part of email development — HTML compatibility and template rendering — and replaced it with TypeScript components that behave like any other code in the monorepo.
If we had to compress the design into one principle: build email as independent, idempotent jobs with explicit preference controls. The complexity of email systems comes from the interactions between those concerns; keeping each one clearly defined makes the whole thing manageable.
Related Posts
- Building a Monorepo with pnpm and TypeScript — workspace structure and cross-package dependency rules
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — how we share code across web and mobile in the same pnpm workspace
Try Storyie
Storyie is available at storyie.com and on the iOS App Store. Write a diary — you might get a milestone email sooner than you expect.