JB logo
CoffeeyOUTUBE
JB's avatarUgandan flag

JB 

Muke Johnbaptist

Go (Golang) expert · Creator of the Grit Framework

Blog
Next

Building an Email Client on Top of Resend

A step-by-step build of a full email client on top of Resend: each workspace connects its own Resend account and verifies its own domain, mailboxes become Resend inboxes, inbound mail arrives through signed and verified webhooks and is threaded into conversations, and sending keeps replies in the same thread. Covers safe HTML rendering in a sandboxed iframe, attachments in your own storage, folders, labels, drafts, search, templates with variables, a branded responsive layout, a security checklist, and testing without sending real mail. Backend in Go (Gin + GORM), frontend in Next.js with TanStack Query.

How Outpost sends, receives, threads, stores and shows email for each workspace on the company's own domain, step by step, with the code. Follow it to build the same thing.

What you will have at the end:

  • Each workspace connects its own Resend account and verifies its own domain.
  • Mailboxes (info@, sales@, a person's own), shared or personal, each a Resend inbox.
  • Receiving through Resend webhooks, signed and verified, threaded into conversations.
  • Sending new emails and replies that stay in the same thread in the recipient's mail app.
  • An inbox: folders, labels, stars, assignment, drafts, search, pages, unread counts, attachments.
  • Safe reading of received HTML: sandboxed, quoted history folded, pictures through a signed proxy.
  • Composing: a rich editor with blocks, templates with variables, picture signatures, a branded responsive layout.
  • Extras: AI replies trained on the business, a chat-style view per client, send later, invoices attached as PDFs, new-mail sounds and badges.

The backend in the snippets is Go (Gin and GORM); the frontend is Next.js with TanStack Query. The ideas do not depend on either.


Part 1. Resend, and the model

1.1 What Resend gives you, and what you build

ResendYou
Sending through its API, from a verified domainWho may send from which address
Receiving mail for a domain or subdomain, delivered as webhooksStoring it, threading it, showing it
Inboxes and threads (its inbox API)Folders, labels, assignment, drafts, search
Webhook signing (Svix)Verifying every webhook
Attachments, downloadable for a timeKeeping them in your own storage

Reference docs you will keep open:

1.2 The data model

Six tables carry the whole client. Every row belongs to a workspace (org_id), and every query filters by it.

// The workspace's connection: the API key is not here. It is in an
// encrypted secrets table, never returned by the API after saving.
type MailSettings struct {
    OrgID          string     `gorm:"primaryKey;size:36"`
    InboundToken   string     `gorm:"size:64;not null;uniqueIndex"` // names the workspace in the webhook URL
    ReplyDomain    string     `gorm:"size:200;not null;default:''"` // e.g. in.yourcompany.com
    LastReceivedAt *time.Time // for the setup checklist
    LastWebhookAt  *time.Time // proof Resend can reach you
    LastProblem    string     `gorm:"size:300;not null;default:''"`
    Layout         string     `gorm:"size:16;not null;default:'plain'"` // plain | branded
    Footer         string     `gorm:"size:500;not null;default:''"`
}
 
type Mailbox struct {
    ID, OrgID        string
    Name, Address    string                      // "Sales", sales@yourcompany.com
    Kind             string                      // shared | personal
    OwnerID          string
    Members          datatypes.JSONSlice[string] // who may use a shared box
    Signature        string
    SignatureImage   string                      // a picture signature, https only
    ResendInboxID    string                      // the inbox in Resend
    Forwarding       bool `gorm:"not null;default:false"`
    ReceivingAddress string                      // what Resend says to forward to
}
 
type MailThread struct {
    ID, OrgID, MailboxID string
    ResendThreadID       string `json:"-"`
    Subject, Snippet     string
    Participants         datatypes.JSONSlice[string]
    ContactID, ClientID  *string                 // linked to the CRM by address
    Unread, Archived     bool
    Starred              bool   `gorm:"not null;default:false;index"`
    Folder               string `gorm:"size:16;not null;default:''"` // '' inbox | spam | trash
    Labels               datatypes.JSONSlice[string]
    AssignedTo           *string
    MessageCount         int
    LastMessageAt        time.Time `gorm:"index"`
}
 
type MailMessage struct {
    ID, OrgID, ThreadID, MailboxID string
    Direction       string // inbound | outbound
    From            string
    To, Cc          datatypes.JSONSlice[string]
    Subject, Text   string
    HTML            string `gorm:"type:text"`
    MessageID       string // the RFC 5322 Message-ID, for threading
    InReplyTo       string
    References      string
    Attachments     files.FileRefs // keys in your own storage
    ResendEmailID   string
    SentBy          string
    CreatedAt       time.Time
}
 
type MailDraft struct {
    ID, OrgID, MailboxID, ThreadID string
    To, Cc       datatypes.JSONSlice[string]
    Subject      string
    HTML         string `gorm:"type:text"`
    Attachments  files.FileRefs
    SendAt       *time.Time `gorm:"index"` // send later
    Problem      string
    CreatedBy    string
}
 
type MailLabel struct{ ID, OrgID, Name, Color string }

GORM and booleans. A new NOT NULL boolean column needs default:false, or adding it to a table with rows fails. And GORM skips zero values on Create, so a struct field false with default:true stores true. Set false booleans with an explicit Update after create, or use Select(...) columns on updates.


Part 2. Connecting a workspace's Resend account

2.1 Store the key encrypted, show only a hint

The person pastes an API key with full access (a sending-only key cannot list domains or receive). Check it by calling GET /domains. A 401 with restricted_api_key means the wrong kind of key: say so in words.

func (s *EmailService) Connect(ctx context.Context, me, key string) (*Connection, error) {
    key = strings.TrimSpace(key)
    if !strings.HasPrefix(key, "re_") {
        return nil, &fieldtypes.Error{Field: "api_key", Message: "starts with re_"}
    }
    var domains struct{ Data []Domain `json:"data"` }
    if err := s.call(ctx, key, "/domains", &domains); err != nil {
        return nil, &fieldtypes.Error{Field: "api_key", Message: resendWords(err)}
    }
    // Encrypted with a per-workspace key; the API never returns it again.
    if _, err := orgsecrets.Put(s.DB, org, SecretResend, key, me); err != nil {
        return nil, err
    }
    return s.Connection(ctx) // shows key_hint: the last four characters
}

Never log the key, never return it, never put it in an error message.

2.2 Show the domain and the DNS records it still needs

GET /domains/{id} returns each DNS record with its own status. Show them as a table with the status of each, and a Check again button (POST /domains/{id}/verify). Resend's records:

  • DKIM (TXT at resend._domainkey): proves the mail is yours.
  • SPF (an MX and a TXT on the send subdomain): lets Resend send for you.
  • Receiving (an MX): where incoming mail goes. See the warning below.

Show statuses in words people understand: Verified, Waiting for DNS, Failed: check the value, with a colour and an icon, never colour alone.

2.3 The receiving MX: the trap that loses mail

A domain has one set of MX records. If you point the domain's MX at Resend, every email to that domain goes to Resend, and your old provider (Google Workspace, Zoho) gets nothing. People do this by accident and lose a day of mail.

Offer two safe ways, and check DNS live to warn about the unsafe one:

  1. A reply subdomain (in.yourcompany.com) with its own MX to Resend. Outgoing mail sets Reply-To: sales@in.yourcompany.com, so replies come to Resend, while @yourcompany.com keeps going to the old provider.
  2. Forwarding from the old provider: Resend gives each inbox a receiving address; the old provider forwards a copy there. Both keep the mail.
// What DNS says about a domain right now: which provider has its MX, and
// whether it is split (some Resend, some not) or all Resend.
var LookupMX = net.LookupMX
 
func domainMail(domain string) DomainMail {
    mx, _ := LookupMX(domain)
    out := DomainMail{Domain: domain}
    resend, other := 0, 0
    for _, m := range mx {
        isResend := strings.Contains(m.Host, "resend") || strings.Contains(m.Host, "rmta.net")
        out.MX = append(out.MX, MX{Host: m.Host, Priority: m.Pref, Resend: isResend})
        if isResend { resend++ } else { other++ }
    }
    out.Split = resend > 0 && other > 0      // mail goes to two places at random
    out.ResendOnly = resend > 0 && other == 0 // the old provider gets nothing
    return out
}

Show Split and ResendOnly as warnings, with the provider's name, on the setup page.

2.4 A setup checklist that ticks itself

People abandon setups they cannot see the end of. Show the steps with live state, never a box someone ticks:

  1. Resend connected: the key works.
  2. Domain verified: from GET /domains.
  3. A mailbox added.
  4. Every mailbox is an inbox in Resend.
  5. Mail reaches Resend: the MX check or forwarding.
  6. The webhook is set up and its secret saved.
  7. Resend has called you: LastWebhookAt.
  8. The first email received: LastReceivedAt.

Each step links to the part of the page that does it, and the next undone step is highlighted.


Part 3. Mailboxes as Resend inboxes

Give each mailbox an inbox in Resend. Find it by address or create it, keep its id and the receiving address Resend gives it:

func (s *EmailService) linkInbox(ctx context.Context, key string, m *models.Mailbox) error {
    var got resendInbox
    err := s.do(ctx, key, http.MethodGet, "/inboxes/"+url.PathEscape(m.Address), nil, &got)
    if isNotFound(err) {
        var made struct{ ID string `json:"id"` }
        err = s.do(ctx, key, http.MethodPost, "/inboxes", map[string]any{
            "email_address": m.Address, "name": m.Name, "from_name": m.Name, "forwarding": m.Forwarding,
        }, &made)
        if err == nil {
            err = s.do(ctx, key, http.MethodGet, "/inboxes/"+made.ID, nil, &got)
        }
    }
    if err != nil {
        m.ResendProblem = inboxWords(err) // shown on the mailbox, with a "Try again"
        return err
    }
    m.ResendInboxID, m.ReceivingAddress, m.ResendProblem = got.ID, got.ReceivingAddress, ""
    return nil
}

Who may use a mailbox is your rule, not Resend's: a shared box's members and anyone who manages the workspace; a personal box its owner. Apply it to every list, read and send.


Part 4. Receiving

4.1 One URL per workspace, signed

Resend posts webhooks to one URL you register. Put a random, secret token in it so you know which workspace it is for, and verify the signature with the webhook's signing secret before trusting a byte:

POST https://api.yourapp.com/api/v1/mail-inbound/{InboundToken}

Subscribe to:

  • email.received: mail received on a domain or subdomain
  • inbox.email.received: mail received into an inbox
  • inbox.email.sent: a message sent from the inbox (so you can match Resend's ids)

4.2 Verify the Svix signature

Resend signs with Svix. The signed content is id.timestamp.body, HMAC-SHA256 with the base64 secret after the whsec_ prefix. Compare in constant time, accept any of the space-separated v1, signatures, and refuse anything older than five minutes (replays).

const signatureTolerance = 5 * time.Minute
 
func verifyResendSignature(secret string, h http.Header, body []byte, now time.Time) error {
    id, ts, sigs := h.Get("svix-id"), h.Get("svix-timestamp"), h.Get("svix-signature")
    if id == "" || ts == "" || sigs == "" {
        return errors.New("the webhook is not signed")
    }
    sec, err := strconv.ParseInt(ts, 10, 64)
    if err != nil {
        return errors.New("the webhook's timestamp cannot be read")
    }
    if d := now.Sub(time.Unix(sec, 0)); d > signatureTolerance || d < -signatureTolerance {
        return errors.New("the webhook is too old or from the future")
    }
    key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
    if err != nil {
        return errors.New("the saved signing secret cannot be read")
    }
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(id + "." + ts + "." + string(body)))
    want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    for _, s := range strings.Fields(sigs) {
        if v, ok := strings.CutPrefix(s, "v1,"); ok && hmac.Equal([]byte(v), []byte(want)) {
            return nil
        }
    }
    return errors.New("the signature does not match the saved signing secret")
}

Read the raw body for this (io.ReadAll(c.Request.Body)), before any JSON binding: re-encoded JSON will not match the signature.

When verification fails, record the reason on the workspace (LastProblem) so the settings page can say the signature does not match the saved signing secret instead of mail silently not arriving.

4.3 The webhook is a notice, not the email

The webhook carries ids. Fetch the message itself:

// email.received (domain receiving)
s.call(ctx, key, "/emails/receiving/"+url.PathEscape(ev.Data.EmailID), &got)
 
// inbox.email.received (inbox receiving)
s.do(ctx, key, http.MethodGet,
    "/inboxes/"+ev.Data.InboxID+"/threads/"+ev.Data.ThreadID+"/emails/"+ev.Data.EmailID, nil, &e)

Attachments come from GET /emails/receiving/{id}/attachments, each with a download_url that expires (about an hour). Download each one straight away into your own storage, under a private key for the workspace, and keep only your keys on the message. Cap the total (we use 25 MB) and skip the rest.

4.4 Which mailbox, which thread

For each received message:

  1. Mailbox. The inbox the event names; else the recipient's local part on the reply domain (sales@in.yourcompany.com means the sales@ mailbox); else the mailbox whose address is in To or Cc.
  2. Thread. Resend's thread id when there is one; else the thread whose message has the Message-ID that this one's In-Reply-To (or any of its References) names; else a new thread.
  3. Dedupe on Message-ID: webhooks can arrive twice.
  4. Link to the CRM. Match the sender's address to a contact, and through the contact to a client, so the conversation shows on their pages.
  5. Mark the thread unread, update its snippet and LastMessageAt, and set LastReceivedAt.

Part 5. Sending

5.1 New emails and replies

Validate addresses, sanitise the HTML, fill variables, add the signature, then send through the mailbox's inbox:

// A reply in a thread Resend knows: it threads in the recipient's mail app.
s.do(ctx, key, http.MethodPost,
    "/inboxes/"+inbox+"/threads/"+thread+"/emails/"+lastEmail+"/reply", body, &out)
 
// A new email from an inbox: a draft, then send it.
s.do(ctx, key, http.MethodPost, "/inboxes/"+inbox+"/drafts", draft, &d)
s.do(ctx, key, http.MethodPost, "/inboxes/"+inbox+"/drafts/"+d.ID+"/send", nil, &out)

Without an inbox, send through POST /emails and thread it yourself. Generate a Message-ID on your domain, and on replies set In-Reply-To to the last message's Message-ID and References to its references plus that id. That is what makes Gmail and Outlook show it in the same conversation.

messageID := fmt.Sprintf("<%s.%s@%s>", id, randomID()[:8], domain)
headers := map[string]string{"Message-ID": messageID}
if last != nil {
    headers["In-Reply-To"] = last.MessageID
    headers["References"] = strings.TrimSpace(last.References + " " + last.MessageID)
}

Send first, then store. Save the outbound message only after Resend accepted it, in one transaction with the thread update. If the store fails after a successful send, say the email was sent but could not be kept rather than inviting a second send.

5.2 Variables

Fill {{first_name}}, {{name}}, {{company}}, {{email}}, {{my_name}}, {{mailbox}} per recipient at send time, from the CRM record the address matches. A missing first name becomes there, so Hello {{first_name}} never reads "Hello ,". HTML-escape every value you put into HTML.

5.3 Sanitise everything that leaves or is stored

Use an allow-list sanitiser (bluemonday in Go, DOMPurify in JS). Keep headings, paragraphs, lists, links, images and tables; drop scripts, event handlers, javascript: URLs and iframes. If your editor marks blocks with classes (a button, columns), allow exactly those classes and nothing else:

var mailPolicy = func() *bluemonday.Policy {
    p := bluemonday.UGCPolicy()
    p.AllowAttrs("class").Matching(regexp.MustCompile(`^email-(button|columns|col|social|spacer)$`)).
        OnElements("a", "table", "td", "p", "div")
    p.AllowAttrs("target").Matching(regexp.MustCompile(`^_blank$`)).OnElements("a")
    p.RequireNoReferrerOnLinks(true)
    return p
}()

5.4 Inline styles, and a layout that survives mail apps

Mail apps ignore most CSS, Outlook most of all. Style at send time, not in the editor: walk the tags and add inline styles (paragraph spacing, headings, a button in the brand colour, two columns as a table).

case name == "a" && class == "email-button":
    st = "display:inline-block;background:" + color + ";color:#ffffff;text-decoration:none;" +
        "font-weight:bold;font-size:15px;line-height:1;padding:14px 26px;border-radius:8px"
case name == "td" && class == "email-col":
    st = "width:50%;vertical-align:top;padding:0 8px" // plus class "col" to stack on phones

A branded layout is tables: a band of the brand colour, the logo centred, a white card 600 px wide, and a centred footer with the company's details and social links. One <style> block in the head makes it fit a phone (Gmail supports it):

<style>
  @media (max-width: 620px) {
    .card {
      width: 100% !important;
      border-radius: 0 !important;
    }
    .px {
      padding-left: 20px !important;
      padding-right: 20px !important;
    }
    .col {
      display: block !important;
      width: 100% !important;
      padding: 0 !important;
    }
  }
</style>

Replies go plain, like a personal email; new emails and templates can be branded. If you would rather not hand-write tables, MJML and React Email compile to the same kind of HTML.

5.5 Attachments when sending

Upload each file first (multipart, to your own storage, with a purpose like mail-attachments) and send only the references. At send time, read the files back, check they belong to the workspace's mail prefix, cap the total, and attach them as base64.

Two traps we fell into.

  1. Axios turns FormData into JSON if the instance has a default Content-Type: application/json. Set "Content-Type": "multipart/form-data" on the upload request explicitly.
  2. A web application firewall in block mode reads binary bodies as attacks, and many cap bodies at 1 MB. Uploads and every route that carries HTML (send, drafts, templates, preview) need to be excluded from WAF body inspection (they still pass auth, permissions, the sanitiser and rate limits). We found it as a 403 on a PNG, and a WAF log error invalid byte sequence for encoding "UTF8": 0x89, the first byte of every PNG.

5.6 Invoices and quotes as PDFs

When a message carries a record, draw its PDF on the server, store it like an uploaded attachment, and add the reference. The message then shows the PDF like any other file, and the recipient receives it.


Part 6. Reading email safely

6.1 A sandboxed frame, grown to fit

Received HTML is hostile until proven otherwise. Render it in an <iframe sandbox> without allow-scripts, with its own Content-Security-Policy, and grow the frame to its content so there is one scrollbar, the page's:

const doc = `<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${pictures ? `data: ${API_ORIGIN}` : "data:"}; style-src 'unsafe-inline'">
<base target="_blank">
<style>body{font:15px/1.6 system-ui;margin:0} img{max-width:100%!important;height:auto!important}
${pictures ? "" : "img:not([src^='data:']){display:none!important}"}</style></head><body>${body}</body></html>`;
 
<iframe
  sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
  scrolling="no"
  srcDoc={doc}
  ref={frame}
  style={{ height }}
/>;
// Follow the content as it lays out and as pictures load.
const fit = () =>
  setHeight(Math.max(d.documentElement.scrollHeight, d.body.scrollHeight) + 4);
new ResizeObserver(fit).observe(frame.current.contentDocument.body);

allow-same-origin without allow-scripts is safe and lets you measure the content. <base target="_blank"> with allow-popups opens links in a new tab.

6.2 Fold the quoted history

Replies quote everything before them. Hide it behind Show the earlier messages: remove .gmail_quote, blockquote[type=cite], .yahoo_quoted, Outlook's #divRplyFwdMsg (and everything after it), and the "On … wrote:" line before a Gmail quote. Trim empty lines at either end. For plain text, cut at the first line starting "On … wrote:" and drop > lines; for a chat view, also cut at the standard signature separator, \n-- \n.

6.3 Pictures: off by default, and through your own proxy

Remote pictures tell the sender you opened the email and from where. Keep them off until asked (Show pictures, Always from this sender), and on for your own outbound messages and for known contacts.

There is a second reason for a proxy. A srcdoc frame inherits the page's own Content-Security-Policy. If your app only allows images from itself and its storage (as it should), every outside picture in an email is blocked, and the email shows empty boxes. Do not loosen the app's policy. Rewrite each picture's address, when the message is opened, to your own proxy with an HMAC signature:

func proxied(raw string) string {
    return "/api/v1/mail-image?u=" + base64.RawURLEncoding.EncodeToString([]byte(raw)) + "&s=" + signImage(raw)
}

The proxy must not become an open relay or a way into your network:

  • Serve only addresses you signed (constant-time comparison).
  • Refuse private, loopback, link-local and metadata addresses on the connection itself (a dialer Control function), so a hostname that resolves to 169.254.169.254 is refused even if it resolved elsewhere a moment ago.
  • At most 8 MB, 10 seconds and three redirects.
  • Check the type from the bytes (http.DetectContentType), not the sender's header, and refuse SVG (it can carry script).
  • Answer with Content-Security-Policy: default-src 'none'; sandbox and Cross-Origin-Resource-Policy: cross-origin (the app is on another origin).
func publicOnly(_, address string, _ syscall.RawConn) error {
    host, _, _ := net.SplitHostPort(address)
    ip := net.ParseIP(host)
    if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast() {
        return ErrMailImage
    }
    if v4 := ip.To4(); v4 != nil && (v4[0] == 100 && v4[1]&0xc0 == 64 || v4[0] == 169 && v4[1] == 254) {
        return ErrMailImage // carrier-grade NAT and cloud metadata
    }
    return nil
}
 
var MailImageClient = &http.Client{
    Timeout:   10 * time.Second,
    Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 5 * time.Second, Control: publicOnly}).DialContext},
}

Part 7. The inbox

7.1 Folders and views as one query helper

Folders (Inbox, Starred, Drafts, Sent, Archived, Spam, Trash), people views (Assigned to me, Unassigned), built-in labels worked out from the mail (with attachments, from clients, new senders, invoices and payments) and the workspace's own labels all become one WHERE:

func applyFolderQuery(db *gorm.DB, folder, me string) *gorm.DB {
    switch {
    case folder == "starred":
        return db.Where("starred = ? AND folder <> ?", true, "trash")
    case folder == "sent":
        return db.Where("folder <> ? AND EXISTS (SELECT 1 FROM mail_messages mm WHERE mm.thread_id = mail_threads.id AND mm.direction = ?)", "trash", "outbound")
    case folder == "archived":
        return db.Where("archived = ? AND folder = ''", true)
    case folder == "spam", folder == "trash":
        return db.Where("folder = ?", folder)
    case folder == "assigned":
        return db.Where("assigned_to = ? AND folder = ''", me)
    case strings.HasPrefix(folder, "label:"):
        return db.Where("CAST(labels AS TEXT) LIKE ?", `%"`+strings.TrimPrefix(folder, "label:")+`"%`)
    }
    return db.Where("archived = ? AND folder = ''", false) // the inbox
}

Postgres and JSON columns. LIKE and LOWER do not work on json columns; CAST(col AS TEXT) first. Run new SQL against Postgres, not only the SQLite you test with.

Return the counts beside the folders (unread in the inbox and spam, starred, assigned, drafts) with the list, so the sidebar needs no extra requests. Page at 50, and break ties in ordering by id (time-ordered UUIDv7) so two messages in the same second never swap places.

When a thread moves folder or is read, tell Resend too (PATCH /inboxes/{id}/threads/{thread} with read and folder), so the state agrees in both places.

7.2 Drafts and sending later

Keep a draft three seconds after the last change (debounced), so closing the composer loses nothing; delete it when it is sent. A draft with SendAt goes by itself:

// Every minute, in every replica: claim each due draft with a conditional
// update, so one replica sends it, then send it as the person who wrote it.
claim := db.Model(&MailDraft{}).Where("id = ? AND send_at = ?", d.ID, d.SendAt).Update("problem", "sending")
if claim.RowsAffected == 0 { continue }
_, err := svc.Send(asUser(d.OrgID, d.CreatedBy), caller, SendInput{ /* from the draft */ DraftID: d.ID })
if err != nil {
    db.Model(&MailDraft{}).Where("id = ?", d.ID).Update("problem", "Not sent: "+err.Error())
}

Show a waiting message as a dashed bubble with its time and Cancel, and a failed one with its reason.

7.3 Unread, sound and badges

Poll unread threads every 20 seconds, in the background too (or use a websocket if you have one). When a thread's id:last_message_at appears that was not there before:

  • Play a short two-note chime made with the Web Audio API (no file to load), unless sound is off.
  • Show a desktop notification if the tab is hidden and permission was given.
  • Put the count in the tab title, (3) Outpost, and on the installed app's icon with navigator.setAppBadge(n).

7.4 A chat per client

On a client's page, gather every email with them (their billing address, the person, everyone at their company, across the mailboxes you may use) into one timeline, oldest first: theirs on the left, yours on the right, a line where the subject changes. Replying sends in the latest thread from the mailbox it is in, or as a new topic with a subject. They still use plain email.


Part 8. Composing

8.1 The editor

A contenteditable with a toolbar and a few rules gets you far:

  • Paste as plain text (e.preventDefault(); document.execCommand("insertText", false, text)): Word's styling never comes along.
  • Remember the selection on blur and restore it before a toolbar command, or the command lands at the start.
  • Blocks from an Insert button or by typing / (heading, lists, quote, picture, button, two columns, social links, spacer, divider, variables), filtered as you type, arrows and Enter to choose. Detect the slash from the input event's data, not the key: phone keyboards do not say which key it was.
  • Never render the editor's small forms (link, button) as <form> inside another form. Use a div and handle Enter.

Tiptap or Lexical give you more if you need it; the email-specific parts (inline styling, the sanitiser policy) stay the same.

8.2 Templates and signatures

Templates are HTML from the same editor, with the same variables, and a Preview that draws the email exactly as sent, at computer and phone width, from the server's own renderer. A picture signature (designed in Canva, say) is an https image with a width and an optional link, added under the message at send time.

8.3 Writing with AI

Ask the model for one JSON object, {"subject": "...", "body": "<p>...</p>"}, and give it:

  • What the business taught it: what it does, services and prices, tone, policies, questions people ask, what never to say.
  • Facts it learned: proposed from the team's sent mail, used only once a person approves each.
  • The team's recent sent emails, as examples of how they sound.
  • The thread, oldest first, quoted history removed.
  • The rule: never invent a price, date or promise; write it as a [placeholder].

Sanitise what comes back, put it in the editor with an Undo, and never send it by itself.


Part 9. Security checklist

  • Each workspace's API key encrypted at rest, never returned, never logged
  • Every query filtered by workspace; mailbox access checked on every list, read and send
  • Webhooks verified (Svix signature, five-minute window) on the raw body; failures recorded, not ignored
  • The inbound URL's token random and secret; one per workspace
  • Outgoing and stored HTML sanitised with an allow-list
  • Received HTML in a sandboxed frame with no scripts and its own CSP
  • Pictures through a signed proxy that refuses private addresses at connect time, SVG and oversized files
  • Attachments stored privately, served through an authorised download, checked to belong to the workspace before sending
  • The WAF stepped aside for uploads and HTML bodies only, everything else still inspected
  • Rate limits on sending and on the public inbound and image routes

Part 10. Testing without sending real mail

  • A fake Resend. An httptest server (or a small Python one for the browser) that answers /domains, /inboxes, /emails and keeps what was "sent", so tests can assert on the HTML that would have left. Point the client at it with an environment variable such as RESEND_API_URL.
  • Signed webhooks in tests. Sign test payloads with a known secret using the same scheme, and test the refusals: no signature, a wrong one, a stale timestamp.
  • Postgres in CI for every new query (JSON casts, SUM types, EXISTS).
  • A browser pass at 1440 and 375 px: an email from Gmail with quoted history and a picture signature, a reply with an attachment over 1 MB, a scheduled send, and the setup page with a domain whose MX points elsewhere.

Built for Outpost, November 2026. When Resend's API changes, the shapes in Parts 3 to 5 are the first place to check against the current reference.