JB logo
CoffeeyOUTUBE
JB's avatarUgandan flag

JB 

Muke Johnbaptist

Go (Golang) expert · Creator of the Grit Framework

Blog
Next

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:

  1. 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.
  2. A durable copy. Attachment download URLs expire in about an hour, and a thread in trash is deleted after 30 days.
  3. 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 Booking or Order table.

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:

TypeNamePurpose
TXTresend._domainkeyDKIM public key
TXTsend (subdomain)SPF for the return path
MXsend (subdomain)bounce handling
TXT_dmarcDMARC 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's omitEmptyForeignKeys(tx, …) helper in BeforeCreate handles 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.

PurposeMethod & path
Create inboxPOST /inboxes
Get / list / update / delete inboxGET|PATCH|DELETE /inboxes/{inbox}, GET /inboxes
LabelsPOST|GET /inboxes/{inbox}/labels, PATCH|DELETE /inboxes/{inbox}/labels/{id}
List threadsGET /inboxes/{inbox}/threads
Search threadsGET /inboxes/{inbox}/threads/search
Get / update / delete threadGET|PATCH|DELETE /inboxes/{inbox}/threads/{thread}
Set thread labelsPUT /inboxes/{inbox}/threads/{thread}/labels
List messagesGET /inboxes/{inbox}/threads/{thread}/emails
Get one messageGET /inboxes/{inbox}/threads/{thread}/emails/{email}
ReplyPOST /inboxes/{inbox}/threads/{thread}/emails/{email}/reply
ForwardPOST /inboxes/{inbox}/threads/{thread}/emails/{email}/forward
DraftsPOST|GET /inboxes/{inbox}/drafts, GET|PATCH|DELETE /inboxes/{inbox}/drafts/{id}
Send a draftPOST /inboxes/{inbox}/drafts/{id}/send
Received attachmentGET /emails/receiving/{email}/attachments/{id}

Things worth knowing before you design around them:

  • GET …/threads/{thread}/emails includes html and text. 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 read is true only when every message is read, so unread is !read.
  • Folders: inbox, archive, spam, trash, sent. You may set the first four; sent is 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-Key is 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.


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 a Subscribed flag.
  • 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 Subscribed back 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).Error

Check 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

  1. Foundation — schema, encryption, the per-account client router, webhook receiver with signature verification, attachment fetcher. Nothing visible; everything rests on it.
  2. The client — three panes, compose/reply/forward, attachments, drafts, search, read/unread, archive/trash, realtime.
  3. Administration — add accounts and keys in the UI, create addresses, assign to users and roles.
  4. Signatures.
  5. Templates with live tokens.
  6. 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

TrapWhat happens
Parsing the webhook body before verifyingEvery legitimate delivery fails verification
Hashing the whsec_ string instead of its decoded bytesNo signature ever matches
No timestamp tolerance checkA captured request replays forever
Broadcasting new-mail eventsSubjects and senders leak to people without access
403 instead of 404 on a refused mailboxConfirms which mailboxes exist
Trusting webhook payload contentBodies and attachment URLs are not in it
Not fetching attachments immediatelyURLs expire in about an hour
Updates instead of Save when syncingWipes your own columns on the row
Empty-string foreign keysPostgres rejects the insert; SQLite tests pass
Semantic HTML in a message bodyRenders correctly everywhere except email
Rendering a body with dangerouslySetInnerHTMLScript execution in an authenticated session
Loading remote images by defaultA read receipt for every sender
Signature in the textareaHalf-deleted, or sent twice
One API key for several domainsMailboxes silently "not found"
Cloudflare Email Routing left onIts MX records fight yours
Re-subscribing a contact on contactUndoes 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.