NewThe TypeScript SDK is on npm as an alpha. Try the quickstart

Blog

Sending mail as your users without breaking their mailbox.

A send is only half the job: the message also has to land in Sent once, stay in its thread, and keep a Message-ID you can match.

 · 6 min read

Sending a message from a user’s own mailbox looks like one API call. The recipient is only half of the job. The other half is the user’s mailbox: the message should appear in Sent exactly once, replies should land in the right conversation in Gmail and Outlook, and the Message-ID you recorded should be the one a bounce quotes back.

Each provider splits that work differently between server and client. This post covers what SMTP and the Gmail and Microsoft Graph send APIs do for you, what they leave to you, and how Workwae fills the gaps.

SMTP or the provider’s send API.

There are two ways to send as a user. SMTP submission (RFC 6409) works with any mail server. The recipients travel in the SMTP envelope, apart from the message (RFC 5321 section 2.3.1), so the client builds the envelope from To, Cc and Bcc and removes the Bcc header itself.

Provider send APIs take the message over HTTPS. Gmail’s messages.send takes the full RFC 2822 message, base64url-encoded, in the raw field (Gmail: create and send email). Graph’s sendMail takes either a JSON message or base64 MIME with Content-Type: text/plain (user: sendMail). With a MIME body, both providers send the bytes you composed, headers included, so one composer can serve every provider.

Who writes the Sent copy.

SMTP delivers the message and nothing more. Whether a copy lands in the user’s Sent folder depends on the server:

  • Microsoft Graph saves what sendMail sends in Sent Items; saveToSentItems defaults to true (user: sendMail).
  • Gmail copies mail sent through smtp.gmail.com to Sent on its own, and Google tells IMAP clients to stop saving their own copies (IMAP client settings for Gmail).
  • Most other IMAP servers leave it to the client, which uploads the message with APPEND (RFC 9051 section 6.3.12) into the mailbox marked \Sent (RFC 6154).

Get it wrong one way and the user’s Sent folder misses everything your product sent. Get it wrong the other way and every message shows up twice: once from the server, once from your APPEND. The rule has to be per account, because one product can serve both kinds of server.

Message-ID generation.

RFC 5322 says every message SHOULD carry a Message-ID, that its uniqueness is guaranteed by the host that generates it, and recommends a domain on the right of the @ (RFC 5322 section 3.6.4). Set it yourself rather than leaving it to the server: it is the key that links a bounce to the send, the Sent copy to your record, and a reply to its parent.

Do not assume it survives, either. Workwae has seen consumer gmail.com accounts replace the Message-ID of every message sent through the Gmail API with one of Gmail’s own, while custom headers come through unchanged. Recipients, and their bounces, then quote Gmail’s id, not yours.

Threading with In-Reply-To and References.

A reply’s In-Reply-To holds the parent’s Message-ID. Its References holds the parent’s References followed by the parent’s Message-ID (RFC 5322 section 3.6.4). Most clients thread on these headers.

Gmail asks for more. A sent message or draft joins a thread only when (Gmail: manage threads):

  1. the request names the thread’s threadId on the message or draft;
  2. References and In-Reply-To are set as RFC 2822 describes;
  3. the Subject matches.

So a reply through the Gmail API needs the parent’s Gmail thread id as well as its Message-ID, and a reply whose subject your code rewrote starts a new conversation.

Drafts live in the mailbox.

A draft your product creates is a real message in the user’s Drafts, visible and editable in Gmail or Outlook. Gmail’s drafts take a threadId on create and update too, and drafts.send sends the stored draft to its To, Cc and Bcc recipients (users.drafts.send). Because the provider sends what it stored, anything you would add at send time, such as a tracking pixel, has to be in the draft already or cannot be added.

How Workwae handles it.

Workwae composes every draft and send once, as RFC 5322 MIME, and hands the same bytes to the provider: Gmail’s raw upload, Graph’s sendMail with a base64 body, or SMTP for IMAP accounts, where it builds the envelope from the headers and strips Bcc.

  • Sent copies. IMAP accounts have an appendSent setting. It defaults to true, except on Gmail and Microsoft hosts, which file sent mail themselves; you can set it per account. The copy Workwae appends has no open or click tracking, so the user reading Sent never counts as an open.
  • Message-ID. Each send gets <outbox-id@sender-domain>: the outbox entry’s id on the left, the From address’s domain on the right. Every composed send also carries an X-Workwae-Outbox header. When a provider replaces the Message-ID, the sync links the Sent copy through that header and the entry’s internetMessageId becomes the one recipients got.
  • Threading. inReplyToMessageId takes the Workwae id of the message being answered. In-Reply-To and References are set from its Message-ID, Gmail sends and drafts carry its threadId, and an empty subject becomes “Re: <original>” so Gmail’s subject rule holds.
  • The outbox. outbox.create queues an entry and answers its id, your reference and undoUntil. With the account’s undoSendSeconds (0 to 60) set, the send waits that long, and cancelling the entry undoes it. send.sent, send.retrying and send.failed carry its outboxId, and the Sent copy indexed later carries it too.
  • Drafts. drafts.create, drafts.update and drafts.send take the same fields as a send, inReplyToMessageId included. A Gmail or Microsoft 365 draft is sent as stored, so asking to track it answers tracking_unsupported; an IMAP draft can be tracked.

A reply in a thread, with the SDK:

reply.ts
import { createClient } from "@workwae/sdk";

const workwae = createClient({ apiKey: process.env.WORKWAE_API_KEY });

// messageId: the Workwae id of the message being answered, e.g. from message.created
const original = await workwae.messages.get({ accountId, messageId });
const recipients =
  original.replyTo.length > 0 ? original.replyTo : original.from ? [original.from] : [];

const queued = await workwae.outbox.create({
  accountId,
  to: recipients.map(({ address }) => ({ address })),
  inReplyToMessageId: original.id,
  text: "Thursday at 10 works. I'll send an invite.",
  reference: "ticket-4821",
});
// No subject: it goes out as "Re: <original subject>".
// queued.internetMessageId is its Message-ID; queued.undoUntil is set while it can be cancelled.

Delivery is at least once. A retry first looks in the account’s index for a copy that already went out. When an SMTP server’s reply to the final dot never arrives, the entry fails with outcome_unknown instead of sending again, and your app decides.

Limits.

  • References carries only the parent’s Message-ID, not the parent’s own References chain that RFC 5322 describes. A Gmail reply still joins its thread through threadId, but a client that rebuilds threads from References alone sees less of the chain.
  • The duplicate check before a retry sees only what the account’s sync has indexed, so a crash shortly before a retry can still send a message twice.
  • On consumer Gmail, send.sent usually fires before the sync reads the Sent copy, so it carries the Message-ID Workwae composed. The copy’s message.created carries Gmail’s.
  • On Microsoft 365, a draft update replaces the draft with a new one, because MIME content cannot be updated in place. The API keeps the draft’s id across it.
  • @workwae/sdk is 0.1.0-alpha, so its surface can still change.

The sending guide lists every compose field and send event, and Mail covers sync, search and sending across Gmail, Microsoft 365 and IMAP.