One index of every mailbox
Folders and labels with their roles (inbox, sent, drafts, trash, junk, archive), and every message in the sync window with its thread, direction, categories and priority. Lists answer from the index, not the provider.
workwae keeps one index of every mailbox and message in step with Gmail, Microsoft 365 and IMAP servers through provider push, and sends through an outbox that queues, retries and reports each message. Your writes go to the provider and show in the index at once.
// Unread, high-priority mail, newest first
const { items } = await workwae.messages.list({
accountId,
unread: true,
importance: "high",
});
// Body and attachments, stored or fetched
const message = await workwae.messages.get({
accountId,
messageId: items[0].id,
});
console.log(message.body.html, message.attachments);
// To the provider and the index at once
await workwae.messages.update({
accountId,
messageId: message.id,
isRead: true,
});
Gmail, Microsoft 365 (including GCC High and DoD) and any IMAP server answer through one typed SDK. The differences between them are handled here, not in your code.
Folders and labels with their roles (inbox, sent, drafts, trash, junk, archive), and every message in the sync window with its thread, direction, categories and priority. Lists answer from the index, not the provider.
Gmail through Cloud Pub/Sub, Microsoft 365 through Graph change notifications, IMAP through IDLE and NOTIFY. A scheduled poll catches any notification a provider drops.
By default bodies and attachments come from the provider on request. Turn on body storage to cache what is read, or keep every message, encrypted per tenant before it reaches the bucket.
Full-text search over subjects, names, addresses, snippets and stored bodies, best match first. Or pass the query to Gmail search, Microsoft KQL or IMAP SEARCH as written.
List conversations by latest activity with participants and unread counts, then mark, move or trash a whole thread in one call. Create, replace, delete and send drafts.
Every send is queued and delivered one at a time per account, retried with backoff,
and reported as send.sent,
send.retrying or
send.failed. Schedule it, undo it or move it.
Send as Gmail send-as addresses, Microsoft proxy addresses, Send As mailboxes or custom IMAP aliases, with a stored signature per identity, added per message or to every send.
Handlebars or Markdown templates, up to 100 personal messages per merge call, and one-click unsubscribe that feeds suppression lists, together with permanent bounces.
Opt-in open and click tracking, with every hit classified as a person, an image proxy, a prefetch or a link scanner, and counted apart.
workwae keeps each account's index in step with its provider, one sync at a time. These rules hold on every provider.
The first pass covers the sync window and announces nothing message by message. It
ends with one account.initialized, with mailbox and
message counts; only changes after it emit message.*
and mailbox.*.
The sync window is 90 days by default, set per tenant or per account, or the whole mailbox history. A provider search can still reach older mail.
Flag, move, delete or send through the API and the index changes in the same call. When the sync later sees that change at the provider, it announces nothing more.
A notification only wakes the account's sync. With push active, a backstop poll runs every 10 minutes; without push, accounts are polled every minute.
Every message carries a direction: outbound when it is
in Sent or comes from the account's address or one of its identities, so drafts and
notes to self count as outbound.
When a provider's change cursor expires, the mailbox or folder is read again and diffed against the index, so only real changes are announced.
The same calls work everywhere; this is how workwae gets the answer from each provider, and the limits it respects along the way.
| Gmail and Google Workspace | Microsoft 365 | IMAP | |
|---|---|---|---|
| Mailboxes |
Labels: a message can carry several. Inbox tabs (Social, Promotions, ...) become
categories. Gmail reports no counts per label.
|
Folders: a message is in exactly one. Immutable ids, so a move keeps the message id. | Folders from LIST, with counts from LIST-STATUS. |
| Finding changes |
history.list from the stored history id; one older
than about a week forces a full read, diffed against the index.
|
A message delta per folder plus a folder delta; an expired delta token re-reads that folder, diffed against the index. | QRESYNC (VANISHED and CHANGEDSINCE) where offered, else UID and flag comparison. Passes commit 1,000 messages at a time and resume where they stopped. |
| Push |
users.watch to a Cloud Pub/Sub topic, renewed
daily, with OIDC-verified push. Your own Google OAuth app can bring its own topic.
|
One change-notification subscription per account, renewed at half its lifetime; lifecycle notifications force a renewal. | IDLE on INBOX. Other mailboxes report through NOTIFY (RFC 5465) where the server offers it, else a STATUS check of Sent and Drafts every minute. |
| Backstop | The poll above. A refused watch leaves the account polled, says why in its runtime detail, and is asked again with backoff. | The poll above, for any notification Graph drops. | Every mailbox is read in full every 5 minutes on a second connection: at most two connections per account. |
| Provider search | Gmail search syntax, limited to 10 searches per account per minute to protect its quota. | KQL. | IMAP SEARCH TEXT. |
| Message identity | Gmail message ids. Attachments are addressed by MIME part. | Immutable ids; a draft keeps its id when its content is replaced. |
OBJECTID or X-GM-MSGID where the server has one;
otherwise a move made by another program is a delete plus a create.
|
| Sent copies | Gmail files them. Consumer Gmail replaces the Message-ID; workwae still links the copy to its send through its own header. | Exchange files them in Sent Items. | Appended to Sent unless the server files sent mail itself; the appended copy is the untracked one. |
| Worth knowing |
Accounts on workwae's Google app ask for gmail.modify,
so delete moves to Trash; permanent delete needs your own app with the full scope.
|
At most 4 requests in flight per mailbox, honoring
Retry-After. In GCC High and DoD a permanent delete
goes to Recoverable Items.
|
Servers are autodiscovered; private addresses and plaintext logins are refused. Google over IMAP needs your own Google OAuth app. |
bodyStorage is a tenant default with a per-account
override. The index always holds metadata; bodies, raw MIME and attachments depend on
the mode. Stored content is sealed per tenant in 64 KiB chunks and streamed back a
chunk at a time.
| Mode | What is stored | Where reads come from | What index search covers |
|---|---|---|---|
none (default) |
Metadata only. Switching to it drops stored content. | The provider, on every request. | Subject, sender and recipient names and addresses, snippet. |
cache |
What reads fetch, capped in size per workspace; content unread for 30 days is dropped. | Stored content, fetching and storing the raw message on a miss. | Also the text of bodies read so far. |
full |
Every indexed message, backfilled when you switch, without holding back new mail while it runs. | Stored content. Drafts are always read from the provider. | Also the text of every body. |
The index keeps a search vector of stored body text, never the text itself. Search
with source=auto (the default) asks the provider only
when the index cannot cover the request, such as a date before the sync window;
provider results the index does not hold come back with a null id.
outbox.create seals and queues the message, then answers
at once. The rest of its life arrives as events carrying your
reference.
undoSendSeconds (0 to 60) holds every send
back; undoUntil says until when cancelling takes it back.
sendAt for later, moved earlier or
later before the first attempt, announced as send.rescheduled.
send.failed with code
outcome_unknown, and your app decides.
outboxId
and reference, so bounces and replies match what recipients got.
priority: "high" or "low"
writes the headers Outlook, Thunderbird and Apple Mail read; every indexed message
carries its importance.
// From a send-as identity, tomorrow at 9:00 UTC
const send = await workwae.outbox.create({
accountId,
from: { address: "sales@example.com" },
to: [{ name: "Priya Shah", address: "priya@example.com" }],
subject: "Contract for review",
html: "<p>The contract is attached.</p>",
attachments: [
{
filename: "contract.pdf",
contentType: "application/pdf",
content: pdfBase64,
},
],
priority: "high",
sendAt: "2026-11-03T09:00:00Z",
reference: "deal_1042",
});
// Before the first attempt, move it earlier or later
await workwae.outbox.reschedule({
accountId,
outboxId: send.id,
sendAt: "2026-11-03T14:00:00Z",
});
// Queued, sending, sent, failed or cancelled
const entry = await workwae.outbox.get({
accountId,
outboxId: send.id,
});
console.log(entry.status, entry.attempt, entry.lastError);
Store a template once, then send one personal message per recipient. Name a list and each message carries one-click unsubscribe, and addresses that left are skipped.
{{rcpt.unsubscribeUrl}} for the body, served over HTTPS as Gmail and Yahoo
require.
global, checked when queued and again at delivery.
message.bounced. A bounce of a list send saying the address is gone
puts it on global, and abuse reports can suppress it on the send's list.
workwae templates push CLI renders
components locally into stored templates.
const template = await workwae.templates.create({
name: "renewal-reminder",
subject: "{{params.company}}: your renewal is due",
html:
"<p>Hi {{params.name}}, you renew on {{params.date}}.</p>" +
'<p><a href="{{rcpt.unsubscribeUrl}}">Unsubscribe</a></p>',
});
// One message per recipient, each addressed to them alone
const { items } = await workwae.mailMerges.create({
accountId,
template: template.id,
listId: "renewals",
entries: [
{
to: { address: "priya@example.com" },
params: { name: "Priya", company: "Acme", date: "Dec 1" },
},
{
to: { address: "sam@example.com" },
params: { name: "Sam", company: "Globex", date: "Dec 4" },
},
],
});
for (const result of items) {
if (!result.success) console.warn(result.to, result.reason);
}
// Your own opt-outs, on the same list
await workwae.suppressions.entries.add({
listId: "renewals",
addresses: ["former-customer@example.com"],
});
Tracking is off until you turn it on, per tenant, per account or per send. Every hit is classified, so image proxies, prefetching and link scanners don't count as readers.
mailto: links, unsubscribe links and links marked
data-workwae-notrack stay as written.
actor
(human, proxy, prefetch, scanner or unknown) with its reasons, and the
send counts human and machine hits apart.
const send = await workwae.outbox.create({
accountId,
to: [{ address: "jordan@example.com" }],
subject: "Proposal and next steps",
html: proposalHtml,
tracking: { opens: true, clicks: true },
reference: "deal_4821",
});
// Later: counts split by person and machine
const entry = await workwae.outbox.get({
accountId,
outboxId: send.id,
});
console.log(entry.humanOpenCount, entry.machineOpenCount);
// Each hit, newest first, with why it was classified that way
const { items } = await workwae.outbox.tracking({
accountId,
outboxId: send.id,
});
for (const hit of items) {
console.log(hit.kind, hit.actor, hit.isMachine, hit.reasons);
}
Signed per Standard Webhooks and filterable by event type, account and message criteria such as mailbox, sender, subject and direction.
| Event | When |
|---|---|
account.initialized |
The first mail sync finished, with mailbox and message counts. |
mailbox.created,
mailbox.updated,
mailbox.deleted
|
A folder or label appeared, was renamed, moved or given another role, or went away. Counts change silently. |
message.created |
A message was indexed: received, sent, drafted or moved in from outside the window. |
message.updated |
Read, flagged, moved, its priority changed, or linked to the send it is a copy
of; changes names the fields.
|
message.deleted |
A message was removed; carries it and its mailboxes as last indexed. |
send.sent,
send.retrying,
send.failed
|
The provider accepted a send, an attempt failed and the send waits for
nextAttemptAt, or it failed for good.
|
send.rescheduled |
A queued send got a new sendAt before its first attempt. |
message.bounced |
A delivery status notification arrived, with each failed recipient's status code
and diagnostic, and the outboxId when it answers
one of your sends.
|
message.opened,
message.clicked
|
A tracked send was opened or a tracked link followed, with the actor and its reasons. |
suppression.created |
An unsubscribe, bounce or complaint put an address on a suppression list. |
message.classified |
With AI features on, a reply to one of your sends was classified as a human reply, auto-reply, opt-out or bounce. |
Not by default. The index holds metadata (subjects, addresses, snippets, flags), and bodies and attachments are fetched from the provider when you ask. With body storage set to cache or full they are kept in object storage, encrypted per tenant, and switching back to none deletes them.
The sync window is 90 days by default and can be set per tenant or per account, or left open for the whole mailbox history. For older mail, a provider search still works; results the index does not hold have a null id and cannot be opened through the API.
Push only wakes the account's sync; the change itself is read from the provider. A backstop poll runs on a schedule while push is active, so a lost notification delays a change instead of losing it.
Delivery is at least once, but workwae works to avoid duplicates: a retry first looks
for a copy that already went out, provider requests are repeated only after an answer
that refused them, and an SMTP send whose outcome is unknown fails with
outcome_unknown instead of being retried.
The worldwide cloud (including GCC), GCC High and DoD, each with its own app registration. In 21Vianet, Graph has no message delta yet, so mail runs over IMAP and SMTP with Microsoft sign-in.
Gmail and Microsoft 365 keep the message they sent, pixel included, in Sent. Viewing
it with images on loads the pixel; on Gmail that hit comes through Gmail's image proxy
and is classified as proxy. IMAP accounts get the
untracked copy in Sent.
Log every rep's email to the right deal, and send from their own mailbox.
Turn shared inboxes into tickets and reply from the same address.
Read, draft and send on a person's behalf, with every change as an event.
Signed deliveries, filters, replays and an ordered change feed.
Google, Microsoft 365 and CardDAV address books next to the mail.
Hosted or headless sign-in for Gmail, Microsoft 365 and IMAP accounts.
workwae is in early access. Tell us what you are building, and read the docs in the meantime.