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
sendMailsends in Sent Items;saveToSentItemsdefaults to true (user: sendMail). - Gmail copies mail sent through
smtp.gmail.comto 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):
- the request names the thread’s
threadIdon the message or draft; - References and In-Reply-To are set as RFC 2822 describes;
- 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
appendSentsetting. 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 anX-Workwae-Outboxheader. When a provider replaces the Message-ID, the sync links the Sent copy through that header and the entry’sinternetMessageIdbecomes the one recipients got. - Threading.
inReplyToMessageIdtakes the Workwae id of the message being answered. In-Reply-To and References are set from its Message-ID, Gmail sends and drafts carry itsthreadId, and an empty subject becomes “Re: <original>” so Gmail’s subject rule holds. - The outbox.
outbox.createqueues an entry and answers its id, yourreferenceandundoUntil. With the account’sundoSendSeconds(0 to 60) set, the send waits that long, and cancelling the entry undoes it.send.sent,send.retryingandsend.failedcarry itsoutboxId, and the Sent copy indexed later carries it too. - Drafts.
drafts.create,drafts.updateanddrafts.sendtake the same fields as a send,inReplyToMessageIdincluded. A Gmail or Microsoft 365 draft is sent as stored, so asking to track it answerstracking_unsupported; an IMAP draft can be tracked.
A reply in a thread, with the SDK:
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.sentusually fires before the sync reads the Sent copy, so it carries the Message-ID Workwae composed. The copy’smessage.createdcarries 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/sdkis 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.