
JB
Muke Johnbaptist
Go (Golang) expert · Creator of the Grit Framework
Building an Email Client into a Grit App with Resend
A battle-tested guide to building a full email client — read, reply, forward, attachments, drafts, search, labels, signatures, templates — inside a Grit app (Go/Gin/GORM API + React/TanStack) on Resend Inboxes, across several accounts and domains. Schema, per-domain API routing, encrypted credentials, webhook signature verification, the durable mirror, attachment expiry, and the email-HTML traps that break in Outlook and Gmail.
A working guide for putting a real mail client — read, reply, forward, attachments, drafts, search, labels, signatures, templates — inside a Grit application (Go/Gin/GORM API, React/TanStack frontend), backed by Resend Inboxes, across several Resend accounts and several domains.
It is written from an implementation that shipped, so it includes the decisions that turned out to matter and the three or four that look wrong until you know why. Read section 2 before you write any code; it is the part that determines the shape of everything else.
1. What you are building
Resend (one account per domain) Your app
┌──────────────────────────┐ ┌────────────────────────────┐
│ inbox: sales@a.com │ webhook → │ webhook receiver (public) │
│ inbox: support@a.com │ │ ↓ verify signature │
│ threads / emails │ ← API │ ↓ sync thread │
│ labels / drafts │ │ mirror (DB + object store) │
└──────────────────────────┘ │ access rules (yours alone) │
┌──────────────────────────┐ │ three-pane client UI │
│ inbox: hello@b.com │ └────────────────────────────┘
└──────────────────────────┘Resend holds the mail. Your app holds three things Resend has no concept of:
- Who may open which mailbox. Resend has no per-user access model at all — an API key sees every inbox on its account. Every access decision is yours.
- A durable copy. Attachment download URLs expire in about an hour, and a thread in trash is deleted after 30 days.
- The link to your domain objects — which booking, order, ticket or customer a conversation is about.
2. The five decisions to make first
These are cheap now and expensive later.
2.1 One API key per domain, routed at call time
A Resend API key is scoped to a team, and in practice you will run one
team (account) per domain. There is no key that can see two accounts. So an
EmailAccount row holds the domain and its key, and every call resolves the
client from the inbox's account:
func (s *EmailAccounts) ClientForInbox(ctx context.Context, inboxID string) (*resend.Client, *models.Inbox, error) {
var inbox models.Inbox
if err := s.db.WithContext(ctx).Preload("Access").First(&inbox, "id = ?", inboxID).Error; err != nil {
return nil, nil, err
}
client, err := s.ClientForAccount(ctx, inbox.AccountID)
if err != nil {
return nil, nil, err
}
return client, &inbox, nil
}Getting this wrong does not fail loudly. The wrong key simply reports the mailbox as missing.
2.2 Credentials in the database, encrypted — not in the environment
Operators add domains and rotate keys without a deploy. That means the key lives in a row, which means it must be encrypted, which means one key to encrypt it with — and that one cannot live in the database it protects.
Derive it from a secret the app already requires rather than inventing a new variable:
// After the usual JWT_SECRET validation:
if strings.TrimSpace(cfg.EmailEncryptionKey) == "" {
// The label gives the two uses separate key material from the same
// secret, so neither can stand in for the other.
cfg.EmailEncryptionKey = "app/email-credentials/v1|" + cfg.JWTSecret
} else {
cfg.EmailEncryptionDedicated = true
}Tell the operator the consequence on screen: rotating the JWT secret makes the stored API keys unreadable and they must be re-entered.
2.3 Access lives in one function, not in handlers
// AccessLevelFor returns the level this caller has on this inbox, or "".
//
// This is the whole access model, deliberately in one place. A Resend inbox
// is team-wide, so nothing on their side stops a junior reading sales@.
// This does, and it is the only thing that does.
func (i *Inbox) AccessLevelFor(userID, roleSlug string, isOwner bool) string {
if isOwner {
return InboxAccessManage // somebody must be able to recover a mailbox
}
best := ""
for _, grant := range i.Access {
if !grantApplies(grant, userID, roleSlug) {
continue
}
// A person can be granted directly and through a role at once.
// The higher wins — a named grant adds, never demotes.
if inboxAccessRank[grant.Level] > inboxAccessRank[best] {
best = grant.Level
}
}
return best
}
func grantApplies(grant InboxAccess, userID, roleSlug string) bool {
if grant.UserID != "" {
return grant.UserID == userID
}
if grant.RoleSlug != "" {
return strings.EqualFold(grant.RoleSlug, roleSlug)
}
// A grant naming neither must not become a grant to everybody.
return false
}Grant by role as well as by user — that is what makes "all managers see support@" survive the next hire.
Do not add a separate email.read permission. If opening the client
needs a permission and an inbox grant, then assigning somebody a mailbox
appears to do nothing until a second unrelated box is ticked. Gate the
workspace endpoints on authentication only and let the grant decide; keep a
permission for administration (creating inboxes, handing out access).
Force the check through one chokepoint so a handler cannot forget it:
// Handlers call this instead of loading an inbox themselves: the only way
// to get the row is to pass the check.
func (s *EmailAccounts) RequireInbox(ctx context.Context, inboxID, userID, roleSlug string, isOwner bool, level string) (*models.Inbox, error) {
var inbox models.Inbox
if err := s.db.WithContext(ctx).Preload("Access").First(&inbox, "id = ?", inboxID).Error; err != nil {
return nil, err
}
if !inbox.HasAtLeast(userID, roleSlug, isOwner, level) {
return nil, ErrNoAccess
}
return &inbox, nil
}Answer a refusal with 404, not 403. That sales@ exists but is not
yours is itself information about the organisation.
2.4 Mirror the mail, with a per-inbox switch
The reason is expiry, not performance:
- A received attachment is reachable only through a signed URL valid for about an hour. A passport scan sent in March cannot be opened in June unless you fetched it in March.
- A thread moved to trash is deleted after 30 days.
- Resend cannot join a thread to your
BookingorOrdertable.
Make it switchable per inbox (MirrorMessages bool, default on) so a
mailbox whose contents should not sit in your database can opt out.
2.5 Resend is authoritative for lists; your mirror for opened threads
- Thread list → live from Resend. One cheap call, and it knows the folder, labels and read state.
- Opening a thread → sync it, then serve your copy. One call out, and a durable copy made every time somebody reads their mail.
3. DNS, with domains on Cloudflare
Do this per domain, in the Resend account that owns it.
3.1 Verify the domain for sending
Add the domain in Resend → it prints the exact records. Copy them verbatim; do not retype from a guide (including this one — the values are per-domain). You will get roughly:
| Type | Name | Purpose |
|---|---|---|
| TXT | resend._domainkey | DKIM public key |
| TXT | send (subdomain) | SPF for the return path |
| MX | send (subdomain) | bounce handling |
| TXT | _dmarc | DMARC policy (recommended) |
Cloudflare specifics that cost people an afternoon:
- Turn off Cloudflare Email Routing on the domain, or at minimum know it is on. It installs its own MX records at the apex and will fight anything you add for receiving. Dashboard → the domain → Email → Email Routing.
- Proxy status must be DNS-only (grey cloud). Cloudflare will not let you proxy MX or TXT anyway, but if you add a CNAME for anything mail-related, grey-cloud it. An orange cloud returns Cloudflare's IPs and mail breaks.
- CNAME flattening at the apex can surprise you if you point the root at
anything. Keep mail records on their own names (
send,resend._domainkey) and you avoid it entirely. - Leave TTL on Auto. Cloudflare's Auto is 300s; a long TTL means a typo takes hours to correct.
- After adding, hit Verify in Resend. Cloudflare usually propagates in under a minute; if it fails, check for a duplicate SPF TXT record at the same name — two SPF records is itself a failure.
3.2 Receiving: two ways, and you want the second one first
(a) MX at Resend. Point the domain's MX at Resend's receiving host. Mail for every address on the domain arrives in the matching inbox. Clean, and a hard cutover — the moment you switch, the old provider stops receiving.
(b) forwarding: true on the inbox. Resend provisions its own receiving
address (something like 4ef6fc6e@xyz.resend.app) and you forward to it from
whatever runs the mail today. No MX change. Both systems run in parallel,
nothing has a gap, and you can cut over when you are ready.
resendID, err := client.CreateInbox(ctx, resend.CreateInboxRequest{
EmailAddress: "sales@example.com",
Name: "Sales",
FromName: "Example Sales",
Forwarding: true, // read receiving_address back with GetInbox
})Always build (b) first. Migration becomes "add a forward rule", which is reversible, instead of "change MX and hope".
With Cloudflare Email Routing you can do the forwarding there instead of at the old provider — a rule sending
sales@to the Resend receiving address. Be aware this makes Cloudflare your MX, so it is a cutover from the old provider even though it is not a cutover to Resend.
3.3 Several accounts, one app
The webhook URL carries the account id, so one endpoint serves every account and the path says which signing secret to verify with:
POST https://api.yourapp.com/api/webhooks/resend/{account_id}The account id does not exist until the operator adds the domain in your UI. So the order is: add domain → copy the URL your UI prints → create the webhook in that Resend account → paste its signing secret back.
4. Schema
// One Resend team, which in practice is one domain.
type EmailAccount struct {
ID string `gorm:"primarykey;size:36" json:"id"`
Domain string `gorm:"size:255;uniqueIndex;not null" json:"domain"`
Name string `gorm:"size:120" json:"name"`
// Never returned to a client: json:"-" plus the handler shapes the
// response by hand.
APIKeyEncrypted string `gorm:"type:text" json:"-"`
APIKeyHint string `gorm:"size:8" json:"api_key_hint,omitempty"` // last 4
WebhookSecretEncrypted string `gorm:"type:text" json:"-"`
Inboxes []Inbox `gorm:"foreignKey:AccountID" json:"inboxes,omitempty"`
// timestamps…
}
type Inbox struct {
ID string `gorm:"primarykey;size:36" json:"id"`
AccountID string `gorm:"size:36;index;not null" json:"account_id"`
ResendInboxID string `gorm:"size:64;index" json:"resend_inbox_id"`
EmailAddress string `gorm:"size:320;uniqueIndex;not null" json:"email_address"`
Name string `gorm:"size:120" json:"name"`
FromName string `gorm:"size:120" json:"from_name"`
Forwarding bool `gorm:"default:false" json:"forwarding"`
ReceivingAddress string `gorm:"size:320" json:"receiving_address,omitempty"`
MirrorMessages bool `gorm:"default:true" json:"mirror_messages"`
Access []InboxAccess `gorm:"foreignKey:InboxID" json:"access,omitempty"`
UnreadCount int64 `gorm:"-" json:"unread_count"` // computed, never stored
}
type InboxAccess struct {
ID string `gorm:"primarykey;size:36" json:"id"`
InboxID string `gorm:"size:36;index;not null" json:"inbox_id"`
// Exactly one of these.
UserID string `gorm:"size:36;index" json:"user_id,omitempty"`
RoleSlug string `gorm:"size:40;index" json:"role_slug,omitempty"`
Level string `gorm:"size:10;not null;default:'read'" json:"level"` // read|send|manage
}
type EmailThread struct {
ID string `gorm:"primarykey;size:36" json:"id"`
InboxID string `gorm:"size:36;index;not null" json:"inbox_id"`
ResendThreadID string `gorm:"size:64;index" json:"resend_thread_id"`
Subject string `gorm:"size:500" json:"subject"`
Snippet string `gorm:"size:500" json:"snippet"`
Folder string `gorm:"size:20;index;default:'inbox'" json:"folder"`
Unread bool `gorm:"index;default:true" json:"unread"`
Participants string `gorm:"type:text" json:"participants"`
LabelIDs string `gorm:"type:text" json:"label_ids,omitempty"`
MessageCount int `gorm:"default:0" json:"message_count"`
LastMessageAt time.Time `gorm:"index" json:"last_message_at"`
// The reason a mirror is worth having: Resend cannot do this.
BookingID string `gorm:"size:36;index" json:"booking_id,omitempty"`
AssignedTo string `gorm:"size:36;index" json:"assigned_to,omitempty"`
Messages []EmailMessage `gorm:"foreignKey:ThreadID" json:"messages,omitempty"`
}
type EmailMessage struct {
ID string `gorm:"primarykey;size:36" json:"id"`
ThreadID string `gorm:"size:36;index;not null" json:"thread_id"`
InboxID string `gorm:"size:36;index;not null" json:"inbox_id"`
ResendEmailID string `gorm:"size:64;index" json:"resend_email_id"`
Direction string `gorm:"size:10;index" json:"direction"` // inbound|outbound
FromAddress string `gorm:"size:320" json:"from_address"`
FromName string `gorm:"size:200" json:"from_name"`
ToAddresses string `gorm:"type:text" json:"to_addresses"`
CCAddresses string `gorm:"type:text" json:"cc_addresses,omitempty"`
Subject string `gorm:"size:500" json:"subject"`
HTML string `gorm:"type:text" json:"html,omitempty"` // only when mirroring
Text string `gorm:"type:text" json:"text,omitempty"`
SentBy string `gorm:"size:36;index" json:"sent_by,omitempty"` // your user
Attachments []EmailAttachment `gorm:"foreignKey:MessageID" json:"attachments,omitempty"`
SentAt time.Time `gorm:"index" json:"sent_at"`
}
// StorageKey is the point of this table — it does not expire.
type EmailAttachment struct {
ID string `gorm:"primarykey;size:36" json:"id"`
MessageID string `gorm:"size:36;index;not null" json:"message_id"`
Filename string `gorm:"size:255" json:"filename"`
ContentType string `gorm:"size:120" json:"content_type"`
Size int64 `json:"size"`
StorageKey string `gorm:"size:500" json:"storage_key,omitempty"`
ResendAttachmentID string `gorm:"size:64" json:"resend_attachment_id,omitempty"`
FetchError string `gorm:"size:255" json:"fetch_error,omitempty"`
}Two GORM notes for a Grit app:
- Optional foreign keys (
BookingID,UserID) must be stored NULL, not""or Postgres rejects the insert. Grit'somitEmptyForeignKeys(tx, …)helper inBeforeCreatehandles it. - Test migrations against SQLite with
?_pragma=foreign_keys(1), or the empty-string FK bug passes every test and fails in production.
5. Encrypting the credentials
AES-256-GCM, random nonce per value, versioned prefix:
const scheme = "v1:"
func New(passphrase string) (*Sealer, error) {
if strings.TrimSpace(passphrase) == "" {
return nil, ErrNoKey
}
// SHA-256, not a password KDF: this is a high-entropy secret from the
// environment, not a human-chosen password facing offline guessing.
sum := sha256.Sum256([]byte(passphrase))
block, err := aes.NewCipher(sum[:])
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Sealer{aead: aead}, nil
}
func (s *Sealer) Seal(plaintext string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
return scheme + base64.StdEncoding.EncodeToString(
s.aead.Seal(nonce, nonce, []byte(plaintext), nil)), nil
}
func (s *Sealer) Open(stored string) (string, error) {
if !strings.HasPrefix(stored, scheme) {
// A column holding a bare plain-text key is refused, not handed on.
return "", ErrNotEncrypted
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(stored, scheme))
if err != nil {
return "", err
}
size := s.aead.NonceSize()
if len(raw) < size {
return "", ErrNotEncrypted
}
// GCM authenticates as it decrypts: a tampered row fails here, not as a
// confusing 401 from Resend an hour later.
plaintext, err := s.aead.Open(nil, raw[:size], raw[size:], nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}Refuse to store a credential when no key is configured. "Store it in plain text for now" is not a degraded mode, it is a different product.
6. The Resend Inboxes API
Base https://api.resend.com, Authorization: Bearer <key>. {inbox} is
the inbox id or its email address — both work in the path.
| Purpose | Method & path |
|---|---|
| Create inbox | POST /inboxes |
| Get / list / update / delete inbox | GET|PATCH|DELETE /inboxes/{inbox}, GET /inboxes |
| Labels | POST|GET /inboxes/{inbox}/labels, PATCH|DELETE /inboxes/{inbox}/labels/{id} |
| List threads | GET /inboxes/{inbox}/threads |
| Search threads | GET /inboxes/{inbox}/threads/search |
| Get / update / delete thread | GET|PATCH|DELETE /inboxes/{inbox}/threads/{thread} |
| Set thread labels | PUT /inboxes/{inbox}/threads/{thread}/labels |
| List messages | GET /inboxes/{inbox}/threads/{thread}/emails |
| Get one message | GET /inboxes/{inbox}/threads/{thread}/emails/{email} |
| Reply | POST /inboxes/{inbox}/threads/{thread}/emails/{email}/reply |
| Forward | POST /inboxes/{inbox}/threads/{thread}/emails/{email}/forward |
| Drafts | POST|GET /inboxes/{inbox}/drafts, GET|PATCH|DELETE /inboxes/{inbox}/drafts/{id} |
| Send a draft | POST /inboxes/{inbox}/drafts/{id}/send |
| Received attachment | GET /emails/receiving/{email}/attachments/{id} |
Things worth knowing before you design around them:
GET …/threads/{thread}/emailsincludeshtmlandtext. One call gets every body in a thread — do not fetch per message.- The plain thread listing cannot filter by text. Anything with a search
box must use
/threads/search. - The thread's
readis true only when every message is read, so unread is!read. - Folders:
inbox,archive,spam,trash,sent. You may set the first four;sentis assigned automatically. Trash is deleted after 30 days. - There is no documented reply-all flag. Compute the Cc list yourself: everyone on the message, minus your own address and the sender. It is also better UX, because the user sees who it is going to before sending.
- Pagination is cursor-based (
after= last id), not offset. Idempotency-Keyis supported on every send. Use it.
6.1 A compact Go client
Generics keep the transport to one function:
func do[T any](ctx context.Context, c *Client, method, path string, body any, out *T) error {
return doWithKey(ctx, c, method, path, body, out, "")
}
func doWithKey[T any](ctx context.Context, c *Client, method, path string, body any, out *T, idempotencyKey string) error {
var reader io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("resend: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
apiErr := &APIError{Status: resp.StatusCode}
// A gateway in front of Resend may return HTML; the status is the
// useful part, so an unparseable body must not hide it.
_ = json.Unmarshal(payload, apiErr)
if apiErr.Status == 0 {
apiErr.Status = resp.StatusCode
}
return apiErr
}
if out == nil || len(payload) == 0 {
return nil
}
return json.Unmarshal(payload, out)
}Downloading an attachment is the one request that must not carry your key — the signed URL points at a CDN:
func (c *Client) FetchAttachment(ctx context.Context, downloadURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, err
}
resp, err := c.http.Do(req) // no Authorization header
…
}6.2 Sending a brand-new message
Use create draft → send draft, not POST /emails with the inbox as the
sender. Both deliver; only the first files the message in its thread and the
Sent folder, so the user can see what they sent.
draftID, err := client.CreateDraft(ctx, inbox.ResendInboxID, key+"-draft", resend.DraftRequest{
To: to, CC: cc, Subject: subject, HTML: html, Attachments: attachments,
})
if err != nil { return err }
sentID, err := client.SendDraft(ctx, inbox.ResendInboxID, draftID, key)
if err != nil {
// The draft exists and was not sent. Say so — it is recoverable from
// Drafts, and silently losing a typed message is worse.
return fmt.Errorf("%w — the message was saved to Drafts (%s)", err, draftID)
}7. The webhook: the only public endpoint
This is the whole of its security. Without verification, anyone who learns the URL can place a message in a staff mailbox that looks, in your UI, exactly like genuine mail from a customer. "Our bank details have changed" is a convincing thing to read in your own inbox.
Resend signs with Svix: headers svix-id, svix-timestamp,
svix-signature; HMAC-SHA256 over {id}.{timestamp}.{body}.
const (
secretPrefix = "whsec_"
tolerance = 5 * time.Minute
)
func VerifyWebhook(secret string, h WebhookHeaders, body []byte, now time.Time) error {
if h.ID == "" || h.Timestamp == "" || h.Signature == "" {
return ErrNoSignature
}
if strings.TrimSpace(secret) == "" {
// No secret means we cannot tell real from forged. Accepting
// "because it is probably fine" makes this an open door.
return errors.New("resend: no webhook signing secret configured")
}
if err := checkTimestamp(h.Timestamp, now); err != nil {
return err
}
// The bytes AFTER the whsec_ prefix are base64, and the decoded value is
// the key. Hashing the printable form is the classic way to build a
// verifier that never matches anything.
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, secretPrefix))
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(h.ID))
mac.Write([]byte("."))
mac.Write([]byte(h.Timestamp))
mac.Write([]byte("."))
mac.Write(body)
want := mac.Sum(nil)
// A space-separated list: during a rotation Resend signs with both the
// old and new secret. Any entry matching is a pass.
for _, part := range strings.Split(h.Signature, " ") {
version, encoded, ok := strings.Cut(part, ",")
if !ok || version != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
continue
}
if hmac.Equal(got, want) { // constant time
return nil
}
}
return ErrSignatureInvalid
}
func checkTimestamp(raw string, now time.Time) error {
seconds, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("unreadable webhook timestamp %q", raw)
}
drift := now.Sub(time.Unix(seconds, 0))
if drift < 0 {
drift = -drift
}
// Both directions: too old is a replay; too far ahead means a clock is
// wrong and the age check is meaningless.
if drift > tolerance {
return ErrSignatureExpired
}
return nil
}7.1 The handler — read the raw body first
This is the single easiest thing to get wrong. The signature covers the exact bytes. Bind the JSON first and re-encode it, and key order and whitespace change, and verification fails on every legitimate delivery.
func (h *EmailWebhookHandler) Receive(c *gin.Context) {
accountID := c.Param("account_id")
// Raw bytes first. Nothing has parsed this yet, and nothing must.
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
return
}
secret, err := h.Accounts.WebhookSecret(c.Request.Context(), accountID)
if err != nil {
log.Printf("email webhook: no signing secret for %s: %v", accountID, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "unverified"})
return
}
headers := resend.WebhookHeaders{
ID: c.GetHeader("svix-id"),
Timestamp: c.GetHeader("svix-timestamp"),
Signature: c.GetHeader("svix-signature"),
}
if err := resend.VerifyWebhook(secret, headers, body, time.Now()); err != nil {
// Logged with the reason, answered without it: a detailed error
// tells whoever is probing which part of the forgery to fix.
log.Printf("email webhook: rejected delivery for %s: %v", accountID, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "unverified"})
return
}
var event webhookEvent
if err := json.Unmarshal(body, &event); err != nil {
// Signed but unparseable — a retry sends the same bytes.
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
status, err := h.handle(c, event)
if err != nil {
// Non-2xx asks Resend to retry, which is what you want for a
// transient failure: a database blip must not cost you the message.
c.JSON(http.StatusInternalServerError, gin.H{"error": "retry"})
return
}
c.JSON(http.StatusOK, gin.H{"status": status})
}Pin the mistake with a test that would catch a future refactor:
func TestVerifyWebhook_RejectsReencodedBody(t *testing.T) {
// Semantically identical JSON must fail. If it passed, the check would
// not be covering the payload.
original := []byte(`{"a":1,"b":2}`)
reencoded := []byte(`{"b":2,"a":1}`)
headers := WebhookHeaders{ID: "msg_abc", Timestamp: stamp,
Signature: sign(t, testSecret, "msg_abc", stamp, original)}
if err := VerifyWebhook(testSecret, headers, reencoded, time.Now()); err == nil {
t.Fatal("re-encoded JSON accepted; signature is not covering the raw body")
}
}7.2 Events, and what they do not contain
Subscribe to inbox.*. The ones that matter:
inbox.email.received, inbox.email.sent, inbox.thread.created,
inbox.thread.folder.updated, inbox.thread.labels.updated.
The payload carries metadata only. data.email omits html and text,
and its attachments are {id, filename, size} — no URL. An arriving
webhook is a notification, not the content.
Route every one of those events through the same idempotent sync, so an event arriving twice, out of order, or not at all leaves the same state as a manual refresh:
func (h *EmailWebhookHandler) handle(c *gin.Context, event webhookEvent) (string, error) {
switch event.Type {
case "inbox.email.received", "inbox.email.sent", "inbox.thread.created",
"inbox.thread.folder.updated", "inbox.thread.labels.updated":
return h.syncThread(c, event)
case "inbox.deleted":
// Keep the local record: it holds your access grants and the
// mirrored mail, which exists precisely because Resend does not
// keep it.
return "noted", nil
default:
return "ignored", nil // 200 stops pointless retries
}
}7.3 Push to the right people only
// A broadcast would be simpler and wrong: the event carries a subject and a
// sender, so every connected client would see the contents of sales@.
recipients, err := h.Accounts.UsersWithAccess(c.Request.Context(), inbox)
h.Hub.SendToUsers(recipients, realtime.Event{Type: "email.received", Payload: payload})UsersWithAccess expands role grants to the people who hold that role now.
8. The mirror
One entry point, idempotent, used by the webhook, by "open a thread", and by any backfill:
func (m *EmailMirror) SyncThread(ctx context.Context, inbox *models.Inbox, threadID string) error {
client, err := m.accounts.ClientForAccount(ctx, inbox.AccountID)
if err != nil {
return err
}
thread, err := client.GetThread(ctx, inbox.ResendInboxID, threadID)
if err != nil {
return err
}
// Includes html and text, so one call gets every body in the thread.
emails, _, err := client.ListThreadEmails(ctx, inbox.ResendInboxID, threadID, 100, "")
if err != nil {
return err
}
local, err := m.upsertThread(ctx, inbox, thread, emails)
if err != nil {
return err
}
for _, email := range emails {
if err := m.upsertMessage(ctx, inbox, local, client, email); err != nil {
// One unreadable message must not abandon the thread.
log.Printf("email: mirror message %s: %v", email.ID, err)
}
}
return nil
}Use Save on a loaded row rather than Updates with a map, so your own
columns (BookingID, AssignedTo) are written straight back and a sync
never clears them.
8.1 Attachments — the hour that matters
func (m *EmailMirror) fetchAttachment(ctx context.Context, client *resend.Client,
inbox *models.Inbox, message *models.EmailMessage, row *models.EmailAttachment) error {
full, err := client.ReceivedAttachment(ctx, message.ResendEmailID, row.ResendAttachmentID)
if err != nil {
return err
}
row.ContentType = full.ContentType
if row.Size > attachmentSizeLimit {
return fmt.Errorf("attachment is %d bytes, over the limit", row.Size)
}
payload, err := client.FetchAttachment(ctx, full.DownloadURL) // valid ~1h
if err != nil {
return err
}
// The sender chose the filename, so sanitise before it decides where the
// file lands: a name containing ../ is a path traversal.
key := attachmentKey(inbox.ID, message.ID, row.Filename)
if err := m.storage.Upload(ctx, key, bytes.NewReader(payload), full.ContentType); err != nil {
return err
}
row.StorageKey = key
row.FetchError = ""
return nil
}Record a failure on the row (FetchError) rather than returning it — the
file existing and being unreadable is something the UI should say, and it
must not stop the message appearing.
Serve downloads through your API, checking access on the file itself, not only on the list that led to it — a link is a link anybody could paste anywhere. Fall back to a fresh signed URL when there is no local copy.
9. Frontend
9.1 Render received HTML in a sandboxed iframe
A message body is HTML written by a stranger. In your own document it runs inside an authenticated session.
export function readingFrame(html: string, showImages: boolean): string {
const policy = showImages
? "default-src 'none'; img-src https: data: cid:; style-src 'unsafe-inline'"
: "default-src 'none'; img-src data:; style-src 'unsafe-inline'";
return `<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${policy}">
<style>body{margin:0;font:14px/1.6 system-ui;overflow-wrap:break-word}
img,table{max-width:100%!important;height:auto}</style></head><body>${html}</body></html>`;
}<iframe title="Message" sandbox="" srcDoc={html} style={{ height }} />sandbox="" — no allow-scripts. Block remote images until asked: a
one-pixel image is a read receipt that tells the sender the address is live
and the message was opened. Resize the frame to its content on load, and
again ~350ms later, because images arrive after load and change the height.
9.2 Compose: write email HTML, not web HTML
Gmail strips <style> blocks, Outlook renders with Word's engine. Neither
can be relied on for flexbox, grid, or border-radius on a div. Everything
is inline styles on tables:
function buttonBlock(label: string, url: string): string {
return (
`<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:18px 0">` +
`<tr><td style="background:${ACCENT};border-radius:8px">` +
`<a href="${attr(url)}" style="display:inline-block;padding:11px 22px;font-family:${FONT};` +
`font-size:14px;font-weight:600;color:#ffffff;text-decoration:none">${esc(label)}</a>` +
`</td></tr></table>`
);
}For an accent edge use a table cell, not border-left on a div — Outlook
drops the border often enough to matter.
This is why a hand-written contentEditable beats an editor library here:
libraries emit clean semantic HTML for a browser, and all of it would need
rewriting on the way out.
// Bold should produce <b>, not <span style>. Clients handle tags far better.
document.execCommand("styleWithCSS", false, "false");
// Paste as plain text: pasting from Word otherwise drags in a page of
// foreign markup the sender cannot see and no client renders alike.
function onPaste(event: React.ClipboardEvent) {
event.preventDefault();
document.execCommand(
"insertText",
false,
event.clipboardData.getData("text/plain")
);
}Controlled contentEditable eats the caret unless you guard it:
const emitted = useRef(value);
useEffect(() => {
if (value !== emitted.current && body.current) {
body.current.innerHTML = value; // only when it came from outside
emitted.current = value;
}
}, [value]);Tag inserted blocks with data-block so they can be selected and removed,
then strip the marker on send — an attribute no client reads is still one a
spam filter can notice:
export function cleanForSend(html: string): string {
return html.replace(/ data-block="[^"]*"/g, "");
}9.3 Auto-save
Four seconds after typing stops, and again on close. Keep the id of the first save so the next one edits that draft instead of leaving a trail:
useEffect(() => {
if (!compose) return;
const content = draftFingerprint(compose);
if (!content || content === lastSaved.current) return;
const timer = window.setTimeout(() => {
lastSaved.current = content;
autoSave.mutate(compose);
}, 4000);
return () => window.clearTimeout(timer);
}, [compose]);Auto-save errors should be silent. An alert interrupting somebody mid-sentence is worse than a draft that is four seconds stale.
9.4 Layout
Give the client the whole window: hide the app's nav rail on that one route and summon it from a menu button. A page that is itself mostly navigation does not need a second rail beside it.
10. Signatures and templates
Render signatures on the server. The preview a person approves is then byte-for-byte what is sent, and two frontends cannot drift into two slightly different designs. Append on the send path, not in the textarea, where it gets half-deleted.
Resolution, most specific first: (user, inbox) → (user, any) →
(inbox, any user). That lets a shared mailbox have a house signature an
individual can override for themselves.
Empty columns stored as NULL mean the lookup must ask for
IS NULL, not= "", or one mailbox's house signature applies everywhere.
Offer an image signature too, but tell the truth about it on screen: most clients block remote images, so the alt text is frequently the entire signature. Size guidance that works: 300–600px wide (past ~650 forces sideways scrolling), 320–400 for phones, 150–200 tall, exported at 2× for retina. Set width as an attribute as well as a style — Outlook ignores the style and prints it at full pixel size.
Templates should resolve tokens from live data at insert time. A price typed into a template is correct on the day it is written and quietly wrong for two years — and it is quoted to customers.
// Lazy: the map holds functions, and only the tokens the template actually
// mentions are called, so a one-line acknowledgement reads no tables.
"{{room_catalogue}}": func() string { return s.roomCatalogue(ctx) },Leave an unresolvable token visible in the text and report it. Blanking it sends "Your booking is confirmed" — perfectly well-formed and nonsense. A token still sitting there is obvious before anybody presses send. Pass through tokens you do not recognise untouched.
11. Contact collection and consent
Filing every address you correspond with is easy. The part to get right is that having emailed somebody is not consent to market to them.
- Every row records its
source(sent/received/manual) and aSubscribedflag. - Recording a contact again never re-subscribes them. Someone who opted out and later writes in about their order has not changed their mind.
- An unsubscribe is kept, not deleted — a deleted row is recreated the
next time they write in, with
Subscribedback to true, silently undoing their decision. - The CSV export excludes opted-out contacts by default, because the easiest way to mail somebody who asked not to be is to hand their address to another system.
err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "address"}},
DoUpdates: clause.Assignments(map[string]any{
"last_seen_at": now,
"times_contacted": gorm.Expr("email_contacts.times_contacted + 1"),
}),
// Subscribed, Source and FirstSeenAt are deliberately absent: the first
// two are decisions, the third is a fact that does not change.
}).Create(&contact).ErrorCheck your jurisdiction's rules before sending a campaign to a list gathered this way; in several of them transactional correspondence does not imply marketing consent.
12. Order of work
- Foundation — schema, encryption, the per-account client router, webhook receiver with signature verification, attachment fetcher. Nothing visible; everything rests on it.
- The client — three panes, compose/reply/forward, attachments, drafts, search, read/unread, archive/trash, realtime.
- Administration — add accounts and keys in the UI, create addresses, assign to users and roles.
- Signatures.
- Templates with live tokens.
- Migration — forwarding mode first, MX later, so nothing has a gap.
Ship 1 and 3 together if an operator has to configure it themselves; the client is useless until an address exists and somebody is assigned to it.
13. The traps, collected
| Trap | What happens |
|---|---|
| Parsing the webhook body before verifying | Every legitimate delivery fails verification |
Hashing the whsec_ string instead of its decoded bytes | No signature ever matches |
| No timestamp tolerance check | A captured request replays forever |
| Broadcasting new-mail events | Subjects and senders leak to people without access |
| 403 instead of 404 on a refused mailbox | Confirms which mailboxes exist |
| Trusting webhook payload content | Bodies and attachment URLs are not in it |
| Not fetching attachments immediately | URLs expire in about an hour |
Updates instead of Save when syncing | Wipes your own columns on the row |
| Empty-string foreign keys | Postgres rejects the insert; SQLite tests pass |
| Semantic HTML in a message body | Renders correctly everywhere except email |
Rendering a body with dangerouslySetInnerHTML | Script execution in an authenticated session |
| Loading remote images by default | A read receipt for every sender |
| Signature in the textarea | Half-deleted, or sent twice |
| One API key for several domains | Mailboxes silently "not found" |
| Cloudflare Email Routing left on | Its MX records fight yours |
| Re-subscribing a contact on contact | Undoes an explicit opt-out |
14. What to test
Thin coverage is fine almost everywhere except these, where it is not:
- Signature verification — valid, tampered body, re-encoded JSON, wrong secret, replay, future timestamp, missing headers, rotation (two signatures in one header).
- Encryption — round trip, wrong key, tampered ciphertext, a value that was never encrypted, nil sealer.
- The access rule — every branch: direct grant, role grant, case differences, both at once (higher wins), a grant naming nobody, the owner exemption, and each level's boundary.
- Migrations — against SQLite with foreign keys enforced, asserting optional links store as NULL.
- Template rendering — live data in, unresolvable tokens reported, unknown tokens untouched.
- Signature field escaping — hostile input against every layout: tags,
attribute injection, a
javascript:URL in a website field.
A note on that last one: assert the tags did not survive (<script), not
that strings like onclick= are absent — properly escaped text legitimately
contains them and they are inert.


