iTranslated by AI
Building a Phased Email Notification System for an Indie App: Design and Insights with SST, Resend, and React Email
Introduction
In my indie app Storyie, a diary application, I relied solely on push notifications for user interaction. However, push notifications stop working once the app is deleted, creating a challenge where there's zero reach to churning users.
Therefore, I decided to build an email notification system. Since resources are limited for indie development, I focused on a design that allows for incremental feature additions rather than "building everything at once."
In this article, I will share the design decisions and operational insights gained from building an email notification base combining SST (Serverless Stack) Cron jobs, the Resend API, and React Email templates.
Overall Architecture
┌─────────────────────────────────────────────────┐
│ SST Cron Jobs │
│ │
│ WelcomeEmail (Every 15 min) ─┐ │
│ WeeklySummary (Sunday) ──┤ │
│ Milestone (Every 4 hours) ───┼→ @storyie/emails pkg │
│ WinBack (Daily) ────────┤ ├ templates/ │
│ EmailQueue (Every 5 min) ────┘ ├ services/ │
│ └ translations/ │
│ ↓ │
│ Resend API → User │
└─────────────────────────────────────────────────┘
In a monorepo structure, templates, delivery services, and translations are placed in packages/emails, and Cron handlers in packages/jobs. Each email type is deployed to SST as an independent Lambda function, starting on its respective appropriate schedule.
Why I Chose Resend
When choosing an email delivery service for indie development, candidates include SendGrid, Amazon SES, and Resend. I chose Resend for the following reasons:
High affinity with React Email. Resend is developed by the same creators as React Email, allowing you to pass JSX components directly to the react property. The era of building HTML strings with template engines is over.
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}
/>,
});
Sufficient free tier. A free tier of 3,000 emails per month is sufficient for the early phase of an indie development app.
Excellent DX. Checking delivery status on the dashboard and setting up domain authentication is intuitive, ensuring indie developers don't waste time on operations.
Building Step-by-Step: Email Types and Priorities
Instead of implementing all email types at once, I phased them based on their impact on the user experience.
Phase 1: Transactional Emails
- Welcome emails (immediately after registration)
- Payment confirmation/failure notifications
I prioritized these as they are emails that users expect as a matter of course.
Phase 2: Engagement Emails
- Weekly summary (Sunday 12:00 UTC)
- Milestone achievements (first post, 7-day streak, 30-day streak, 100 entries, 1-year anniversary)
Emails to encourage continued use. I prioritized milestone emails because creating a "celebratory experience" significantly contributes to retention.
Phase 3: Win-back Emails
- 7 days of inactivity → gentle reminder
- 14 days of inactivity → with feature introductions
- 30 days of inactivity → final approach
Approaching churning users is difficult to measure in terms of effectiveness, so I deprioritized it. In practice, I keep them commented out in the SST config during production deployment and enable them when ready.
Preventing Duplicate Delivery: Designing the email_logs Table
The thing to avoid most in email notifications is sending the same email multiple times. Cron jobs must be idempotent, so I designed the email_logs table for this purpose.
-- For weekly summaries, manage weekly via period_key = "2026-W07"
-- For milestones, manage per event via period_key = "firstDiary_2026-02-13"
SELECT 1 FROM email_logs
WHERE user_id = $1
AND email_type = $2
AND period_key = $3
The key point is expressing the granularity of the email using a column called period_key. Weekly summaries use the ISO week number, milestones use event name + date, and Win-back uses tier name + date. Since the definition of "the same email" differs by email type, this general-purpose key handles it.
Respecting User Intent: Designing Delivery Settings
While compliance with the CAN-SPAM Act and GDPR is essential, even in indie development, "not sending unnecessary emails" is the foundation of user trust.
I implemented delivery control with a two-layer structure.
// 1. Master toggle (Stop all emails at once)
if (!preferences.emailEnabled) return false;
// 2. Category-specific toggle
const preferenceField = getPreferenceFieldForEmailType(emailType);
if (preferenceField && !preferences[preferenceField]) return false;
-
emailEnabled: Master switch. If false, stops all emails. -
emailWeeklySummary: Individual switch for weekly summaries. -
emailMilestones: Individual switch for milestone emails. -
emailWinBack: Individual switch for Win-back emails.
Transactional emails (like payment notifications) bypass this control and are always sent. I clearly separate things that are legally required to be sent from marketing-related ones.
Each email includes a one-click unsubscribe link, authenticating the user via a JWT token.
Multi-language Support: Keeping Translations within a Monorepo Package
Storyie supports 10 languages, including Japanese and English. The subject lines and bodies of the emails also needed to be multi-lingual.
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.ts
We obtain the translation object using getTranslations(locale) and use it within the templates. The translation keys are type-safe, allowing any missing keys to be detected during compilation.
What is important here is keeping the translation files closed within the email package. Since the update frequency and review criteria for app-wide translations (i18n) and email translations differ, mixing them into the same translation file would lead to a breakdown in management.
SQL for Milestone Detection: Techniques for Streak Calculation
Among the milestone emails, the most interesting implementation is the detection of streaks (consecutive days).
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 - 1
This is a "gap detection pattern" using ROW_NUMBER(). It utilizes the property that subtracting a sequence number from consecutive dates results in the same value (streak_group) as long as there is no break. While this SQL pattern is famous as an interview question, using it in an actual product gives you a real sense of "I see, this is how it's useful."
Monitoring and Alerts: Systems to Ensure Nothing is Missed even in Indie Development
It is common in indie development to not notice when emails stop being sent. I added the following monitoring to the Email Queue Processor:
- Structured Logs: JSON logs queryable with CloudWatch Insights.
-
Error Categorization: Classified by cause, such as
user_opted_out/user_not_found/resend_api_error. - Slack Alerts: Notify Slack if the failure rate exceeds 50%.
- Exponential Backoff: Retry up to 3 times for temporary failures.
In indie development specifically, I've realized that having systems to notice abnormalities is better when it's almost excessive. Since I don't have the luxury of checking the CloudWatch dashboard every day, I need a system that pushes notifications when something is wrong.
Reflection: What Went Well and Lessons Learned
What Went Well
- Incremental Deployment: I can enable or disable features simply by commenting out the Cron definitions in SST. For Win-back, the code itself is complete, but it is currently on hold as a comment until the preparation for measuring effectiveness is ready.
- React Email: Being able to write emails as components offers an overwhelmingly superior DX. Previewing and testing are also easy.
-
Monorepo Package Separation: By making
@storyie/emailsan independent package, adding templates does not affect the Cron job code.
Lessons Learned
- Insufficient Testing: Because I postponed snapshot testing for email templates, missing translations were discovered in production.
- Bounce Handling: I should have implemented a mechanism to automatically deactivate email addresses upon detecting bounces via Resend webhooks from the beginning.
Conclusion
In indie development, you can never release an email notification system if you aim for an "all-in-one" solution. By following a phased approach—Transactional → Engagement → Win-back—and using a configuration that allows independent deployment as SST Cron jobs, I was able to create a foundation that starts small and grows.
I feel the combination of Resend and React Email is the most well-balanced choice for indie developers. The major benefit is not having to face the "darkness" of HTML emails while being able to bring TypeScript's type safety even into email templates.
This article was written based on the knowledge gained through the development of the diary app Storyie.
📖 Storyie — Write a diary with AI
- 🌐 Web: https://storyie.com
- 🍎 iOS: App Store
- 🤖 Android Beta: https://storyie.com/android-beta
Discussion