Event-driven email automation platform. Define templates, set conditional triggers, and automate delivery based on real-time events, no engineer required to change copy or logic.
Most SaaS email setups require an engineer every time marketing wants to change a subject line or tweak a send condition. MailFlow separates content and logic from code.
Email automation platforms exist. But most of them either lock you into a vendor's event model or require engineers to touch code every time a send condition changes.
MailFlow does two things: lets non-technical users own both the content of email templates and the logic of when they fire. The trigger engine evaluates incoming events against stored condition rules and decides whether to send, skip, or defer, without any code changes.
Built as a 72-hour engineering assessment. Fully deployed, end-to-end functional.
Templates are first-class objects. Each one has a subject, HTML body, and a list of dynamic placeholders {{user.name}}, {{plan.name}}, whatever the event payload carries.
The editor lets you create, search, preview, and send test emails through Resend. Variables are resolved at render time from the incoming event payload if a placeholder is missing, it logs a warning and renders empty rather than crashing.
This is the part I spent the most time on.
Each trigger is tied to an event type and a condition group. Conditions are stored as JSON and evaluated at runtime:
{
"operator": "AND",
"rules": [
{ "field": "user.plan", "op": "eq", "value": "pro" },
{ "field": "user.daysSinceSignup", "op": "gte", "value": 14 }
]
}
The evaluator is recursive, condition groups can nest inside each other, so you can express things like "plan is pro AND (country is IN or AU)." AND groups short-circuit on first failure. OR groups short-circuit on first pass. No unnecessary DB queries.
Deduplication is built into the condition layer. There's a not_sent_within_days operator that runs a SendLog query inline during evaluation. You can express a 30-day suppression window as a condition rule rather than a separate config field.
sendOnce and cooldownDays are separate trigger-level flags. sendOnce checks SendLog for any successful send to that recipient for that trigger. cooldownDays checks within a rolling window. Both run before condition evaluation.
Events arrive via REST API. Each one gets an idempotency key scoped to the user, duplicate submissions are rejected at the DB constraint level, not with a pre-check.
After the event is stored as PENDING, it's handed off to Inngest. The API returns immediately. Everything from here is async.
Inngest picks up the event, marks it PROCESSING, finds matching triggers, evaluates conditions, renders templates, creates EmailJobs, sends via Resend, and writes SendLogs. If Inngest is offline at submission time, the event sits in PENDING. A recovery job can re-queue stuck events, not implemented in this build but the schema supports it.
EmailJob stores an immutable rendered snapshot subject and HTML at send time. If the template changes later, the history stays accurate.
Five tables: Templates, Triggers, Events, EmailJobs, SendLogs.
Indexed for the actual query patterns: (userId, eventType, status) on Triggers for fast lookup, (processingStatus) on Events for the retry queue, (nextRetryAt) on EmailJobs for scheduled retries.
The schema is multi-tenant by default every row carries a userId foreign key to Supabase auth. Adding a new event type requires zero migrations. The trigger engine is payload-agnostic: it evaluates whatever fields the caller sends.
The current build uses a simulation for event ingestion. In production, this would be an API key system, each account gets a key, events come in from external SaaS products, the whole flow runs the same way.
Race conditions on sendOnce are best-effort, not guaranteed. Two concurrent events for the same recipient could both pass the check simultaneously. The fix is a unique partial index on a TriggerSend table, not in this build.
The HTTP call to Resend sits inside a DB transaction. That holds the connection open during a network call, which is fine at low volume and wrong at high volume. The correct approach is to create the job, commit, send outside the transaction, then update status.
I'd also add Zod validation on the conditions JSON before the type cast, and a depth guard on the recursive evaluator.
Frontend deployed on custom domain. Backend on GCP Cloud Run, stateless container, cold starts possible, scales to zero between loads.
CI/CD via GitHub Actions. Prisma migrations run on deploy. Resend handles delivery. Inngest handles async orchestration and retries with full step-level observability.