
JB
Muke Johnbaptist
Go (Golang) expert · Creator of the Grit Framework
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
| Resend | You |
|---|---|
| Sending through its API, from a verified domain | Who may send from which address |
| Receiving mail for a domain or subdomain, delivered as webhooks | Storing 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 time | Keeping them in your own storage |
Reference docs you will keep open:
- API reference: resend.com/docs/api-reference/introduction
- Sending an email: emails/send-email
- Domains and DNS: dashboard/domains/introduction
- Receiving email: dashboard/receiving/introduction
- Webhooks, and verifying them: webhooks/introduction, verify-webhooks-requests
- Svix's manual verification (the scheme Resend signs with): docs.svix.com/receiving/verifying-payloads/how-manual
- The inbox endpoints (
/inboxes, threads, drafts, replies) are in the API reference. Check the current shape there before coding: Resend evolves these quickly.
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 NULLboolean column needsdefault:false, or adding it to a table with rows fails. And GORM skips zero values onCreate, so a struct fieldfalsewithdefault:truestorestrue. Set false booleans with an explicitUpdateafter create, or useSelect(...)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
sendsubdomain): 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:
- A reply subdomain (
in.yourcompany.com) with its own MX to Resend. Outgoing mail setsReply-To: sales@in.yourcompany.com, so replies come to Resend, while@yourcompany.comkeeps going to the old provider. - 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:
- Resend connected: the key works.
- Domain verified: from
GET /domains. - A mailbox added.
- Every mailbox is an inbox in Resend.
- Mail reaches Resend: the MX check or forwarding.
- The webhook is set up and its secret saved.
- Resend has called you:
LastWebhookAt. - 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 subdomaininbox.email.received: mail received into an inboxinbox.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:
- Mailbox. The inbox the event names; else the recipient's local part on the reply domain (
sales@in.yourcompany.commeans thesales@mailbox); else the mailbox whose address is inToorCc. - Thread. Resend's thread id when there is one; else the thread whose message has the
Message-IDthat this one'sIn-Reply-To(or any of itsReferences) names; else a new thread. - Dedupe on
Message-ID: webhooks can arrive twice. - 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.
- Mark the thread unread, update its snippet and
LastMessageAt, and setLastReceivedAt.
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 phonesA 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.
- Axios turns
FormDatainto JSON if the instance has a defaultContent-Type: application/json. Set"Content-Type": "multipart/form-data"on the upload request explicitly.- 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
403on a PNG, and a WAF log errorinvalid 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
Controlfunction), so a hostname that resolves to169.254.169.254is 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'; sandboxandCross-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.
LIKEandLOWERdo not work onjsoncolumns;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 withnavigator.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'sdata, 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 adivand 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
httptestserver (or a small Python one for the browser) that answers/domains,/inboxes,/emailsand 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 asRESEND_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,
SUMtypes,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.


