Download PDF

Rethinking Email: a Developer‑First, Unified, and Trustworthy System

A simple paper about what we’re building, why it matters now, and why it will work.


1) The problem, from first principles

Job to be done: Help product teams compose, send, observe, and govern email—across both transactional flows (e.g., verification, receipts) and marketing campaigns—with deterministic outcomes: did it send, deliver, bounce, get clicked, or lead to an unsubscribe?

What’s broken today (deconstructed):

Constraints we accept:


2) The one simple idea

Email as a Deterministic Ledger

Instead of “send + hope + parse some webhooks,” we treat each email as a transaction with a lifecycle recorded in a canonical, append‑only ledger. This ledger becomes the single place to see, trust, and act on what happened—live.

We collapse the entire domain into a tiny, human‑sized algebra:

And two guardrail objects:

What this unlocks:


3) Why now


4) What we are building (in plain language)

Our stack, at a glance (no deep detail):


5) Assumptions we explicitly challenge


6) What teams get (outcomes that matter)


7) Our core beliefs & values

  1. Correctness by default The safest behavior should be the easiest path. If a send isn’t compliant or authenticated, it doesn’t go.

  2. Transparency > mystery A single ledger and live timeline replace “check five dashboards” and guesswork.

  3. Privacy‑respecting analytics Measure what’s real—clicks and conversions—and label the rest honestly.

  4. Safety in every environment Dev/staging never hit real inboxes unless explicitly allowed. Logs don’t leak PII.

  5. Everything in code, UI for the right edits Types, schemas, and flows live in code; a safe UI lets non‑engineers edit content within guardrails.

  6. Small, composable primitives A tiny vocabulary (Template, Audience, Send, Message, Event, Policy, Suppression, Link) covers 80–90% of use cases.

  7. Portability and no lock‑in We prefer standard tech (Next.js, Convex, AWS SES/SNS). Adapters keep provider choices open.

  8. Operational empathy Deliverability isn’t “ops only.” Developers get proactive feedback (auth checks, complaint budgets, warm‑up) and can prevent issues before they happen.


8) Why this will work


9) How we will measure ourselves


10) The near‑term roadmap


11) The promise

We’re building the simplest possible way to send email the right way: one model, one ledger, live truth—with guardrails that keep you safe and signals that move your product forward.

This is email that respects developers, honors users, and meets the web where it is today.


Below is a “now‑or‑never” plan that distills the report into one radical, simple idea (like Convex/Autumn did in their spaces), plus a concrete stack blueprint for Next.js + Convex + AWS (SES/SNS). I’ll keep it first‑principles, then give you the exact tech/tools and a minimal reference implementation path.


1) The “never‑seen simple” innovation

Email as a Deterministic Ledger (not “an API you hope worked”)

Treat every email as a transaction with a lifecycle recorded in a canonical, append‑only ledger you query directly—no webhook glue in your app. This collapses templating, send, tracking, unsub, resend, and compliance into five primitives that are observable and correct by construction:

Then add two policy objects:

Net effect: Developers never parse webhooks, never re‑implement resend logic, never forget one‑click unsubscribe, and never guess what happened—because the ledger is the source of truth, reactively updating the UI and code, like Convex queries auto‑react.

Why this is a genuine simplification


2) Design tenets (first principles → your stack)

  1. Correctness by default

    • Every Send is idempotent; every outcome is an Event; every Event is queryable.
    • Policies gate sending (auth aligned, one‑click unsub present, complaint budget OK) before mail leaves. Gmail/Yahoo one‑click and low complaint rate are enforced centrally. (Google Help, Senders)
  2. Reactive DX

    • The ledger lives in Convex, so Next.js pages subscribe to live timelines (no polling/webhooks in-app). This mirrors Convex’s “reactive by default” model.
  3. Collapse to a tiny algebra

    • App code touches Template, Audience, Send, Events—nothing else. Everything else (warm‑up, unsub headers, suppression, retries) is policy.
  4. Escape hatches, zero lock‑in

    • Under the hood, we use SES/SNS for deliverability & events. Our model remains provider‑agnostic and would let you add other adapters later.

3) Reference architecture (Next.js + Convex + AWS SES/SNS)

Outbound path

  1. Next.js (admin UI + public flows) calls Convex mutations/actions.

  2. Convex action uses AWS SDK v3 SESv2 SendEmail with:

    • ConfigurationSetName enabling event publishing (delivery, bounce, complaint, delay, open, click). (AWS Documentation)
    • Custom Headers: List-Unsubscribe (https POST URL) and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC‑8058). Supported in SESv2 via Headers. (Boto3, Amazon Web Services, Inc.)
    • MessageTags to correlate provider events with your ledger rows.

Inbound event path (two options)

Tracking domains

Compliance gates


4) Key modern technologies & best tools (why each fits)

Core runtime & data

Email templating

Schema & safety

Security & tokens

Deliverability tooling

Local/dev safety

Event parsing


5) Minimal API surface (what devs actually use)

// 1) Create a typed template (React Email or MJML-backed)
Template.define<'verify_email', { userName: string; link: string }>('verify_email')
 
// 2) One line to send with idempotency + policy
await Send.create({
  template: 'verify_email',
  to: user.email,
  data: { userName: user.name, link: Link.issue('verify', user.id, { ttl: '24h' }) },
  idempotencyKey: `verify:${user.id}`,
  policy: 'transactional'
})
 
// 3) Reactively observe outcomes (Convex query)
const timeline = useQuery('events.timelineByUser', { userId })

Resend on policy, not a button

await Send.retry({ sendId, policy: 'verification_resend' }) // rotates token, enforces cooldown & caps

6) Glue you do not write anymore


7) SES/SNS wiring (concrete)

A. Outbound (Convex action using SESv2)

// convex/actions/sendEmail.ts
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
const ses = new SESv2Client({ region: process.env.AWS_REGION })
 
export async function sendEmail({ to, subject, html, text, headers, configSet, tags }:{
  to:string; subject:string; html:string; text:string;
  headers:{Name:string; Value:string}[]; configSet:string; tags:{Name:string;Value:string}[];
}) {
  await ses.send(new SendEmailCommand({
    Destination: { ToAddresses: [to] },
    FromEmailAddress: 'MyApp <noreply@myapp.com>',
    ConfigurationSetName: configSet, // enables events to SNS
    EmailTags: tags,                  // correlate later
    Content: {
      Simple: {
        Subject: { Data: subject },
        Body: { Html: { Data: html }, Text: { Data: text } },
        Headers: headers,  // include RFC‑8058 headers below
      }
    }
  }))
}

RFC‑8058 headers (bulk only):

const headers = [
  { Name: 'List-Unsubscribe', Value: `<https://app.example.com/api/unsub?tok=${tok}>` },
  { Name: 'List-Unsubscribe-Post', Value: 'List-Unsubscribe=One-Click' }
]

SESv2 supports custom Headers on SendEmail; pair with a Configuration Set that publishes deliveries, bounces, complaints, delays, opens, clicks to SNS/SQS. (Boto3, AWS Documentation)

B. Inbound (SNS → Next.js)

// app/api/ses/sns/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server'
import { MessageValidator } from 'aws-sns-message-validator'
import { serverActionIngest } from '@/convex/_server' // call a Convex action/mutation
 
const validator = new MessageValidator()
 
export async function POST(req: NextRequest) {
  const msg = await req.json()
  await new Promise<void>((resolve, reject) =>
    validator.validate(msg, (err) => err ? reject(err) : resolve())
  )
 
  // Handle subscription confirmation
  if (msg.Type === 'SubscriptionConfirmation') {
    await fetch(msg.SubscribeURL) // confirm
    return NextResponse.json({ ok: true })
  }
 
  // Notification → map SES JSON to canonical Event and insert into Convex
  const sesEvent = JSON.parse(msg.Message)  // deliveries/bounces/complaints/opens/clicks/...
  await serverActionIngest(sesEvent)        // write to ledger, update suppressions, etc.
 
  return NextResponse.json({ ok: true })
}

When you want bullet‑proof buffering & retries: switch to SNS → SQS → Lambda → Convex, using the same mapper. (AWS Documentation)


8) The “deliverability autopilot” you get for free


9) Tooling checklist (pick these and you’re done)


10) Why this will feel like Convex/Autumn–level simplicity


11) Immediate next steps (concrete)

  1. Create SES Configuration Set (+ SNS destination) with events: Delivery, Bounce, Complaint, Delay, Open, Click. (AWS Documentation)
  2. Add custom tracking domain for SES open/click (or skip opens; keep clicks). (AWS Documentation)
  3. Stand up /api/ses/sns (Next.js) with signature validation; map to Convex events.ingest.
  4. Ship the 5 primitives in Convex (schema + basic mutations); require idempotencyKey.
  5. Implement RFC‑8058 autopilot: inject List-Unsubscribe + List-Unsubscribe-Post for policy.kind === 'bulk'. (SESv2 Headers supports this.) (Boto3)
  6. Wire the resend policy (cooldown/TTL/cap) and link tokens (opaque, hashed, expiring).
  7. Add the CI doctor (DNS/auth + “no bulk without one‑click”).
  8. Timeline UI in Next.js using Convex live queries.

This gives you a production‑safe MVP with deterministic outcomes and radically less glue—an innovation that’s simple enough to explain in one breath and strong enough to carry the platform for years.

If you want, I can draft the Convex schema and the two or three core mutations/actions next, following this exact blueprint.