1. Features
  2. Mail
Mail

Every mailbox, in sync and ready to send.

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.

inbox.ts
// 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,
});
What you get

A mail API that behaves the same on every provider

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.

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.

Push first, polling as a backstop

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.

Bodies stored only if you want

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.

Search the index or the provider

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.

Threads and drafts

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.

An outbox with a paper trail

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.

Identities and signatures

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.

Templates, merges and lists

Handlebars or Markdown templates, up to 100 personal messages per merge call, and one-click unsubscribe that feeds suppression lists, together with permanent bounces.

Tracking that knows a bot

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.

How it works

The sync contract

workwae keeps each account's index in step with its provider, one sync at a time. These rules hold on every provider.

  • A quiet first sync

    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.*.

  • A window you choose

    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.

  • Read your own writes

    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.

  • Push wakes, the poll backs it up

    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.

  • Inbound or outbound

    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.

  • Recovers without a flood

    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.

Provider by provider

What happens under the hood

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.
Bodies and search

Choose what workwae keeps

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.

Build it

Sends you can schedule, undo and account for

outbox.create seals and queues the message, then answers at once. The rest of its life arrives as events carrying your reference.

  • Undo window. undoSendSeconds (0 to 60) holds every send back; undoUntil says until when cancelling takes it back.
  • Scheduled sends. sendAt for later, moved earlier or later before the first attempt, announced as send.rescheduled.
  • Retries. Up to 8 attempts within 24 hours of the send time. A retry first looks for a copy that already went out.
  • No silent duplicates. When an SMTP server accepted the whole message and the reply never came, workwae does not retry: send.failed with code outcome_unknown, and your app decides.
  • Linked copies. The Sent copy gets the entry's outboxId and reference, so bounces and replies match what recipients got.
  • Retention. Sealed content is dropped once an entry finishes, and finished entries are deleted after 30 days.
  • Priority. priority: "high" or "low" writes the headers Outlook, Thunderbird and Apple Mail read; every indexed message carries its importance.
outbox.ts
// 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);
Build it

Templates, mail merge and lists people can leave

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.

  • Handlebars or Markdown templates, shared by the tenant or bound to one account. Content is rendered when the request is taken, so editing a template never changes queued mail.
  • Merges of up to 100 entries per call, each with its own params, Message-ID, send time, events, tracking and bounce matching. A bad entry fails alone.
  • One-click unsubscribe per RFC 8058, with {{rcpt.unsubscribeUrl}} for the body, served over HTTPS as Gmail and Yahoo require.
  • Suppression lists. A list send skips addresses on that list or on global, checked when queued and again at delivery.
  • Bounces. Delivery failures are matched to the send and announced as 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.
  • react-email. The workwae templates push CLI renders components locally into stored templates.

Read the suppression lists guide

merge.ts
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"],
});
Build it

Opens and clicks, with the machines sorted out

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.

  • Only the HTML part changes: a 1x1 pixel and absolute http(s) links. Text parts, mailto: links, unsubscribe links and links marked data-workwae-notrack stay as written.
  • Classified hits. Each open or click gets an actor (human, proxy, prefetch, scanner or unknown) with its reasons, and the send counts human and machine hits apart.
  • A host of its own. Tracking and unsubscribe links are served from a separate host that answers nothing else, so a blocklisted or crawled tracking host never affects the API.
  • Per message, not per recipient: everyone on a send gets the same MIME. For per-recipient tracking, use a merge.
  • Know the edges. Gmail and Microsoft 365 draft-sends go out as stored and are never tracked. Gmail and Microsoft 365 keep the tracked copy in Sent, so a sender viewing it with images on counts as an open.
tracking.ts
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);
}
Events

Every change, as a webhook and in the change feed

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.
FAQ

Questions about mail

Does workwae store message bodies?

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.

How far back does sync go?

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.

What if a provider drops a push notification?

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.

Can a send go out twice?

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.

Which Microsoft clouds are supported?

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.

Why did my own open show up in tracking?

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.

Add every inbox to your product

workwae is in early access. Tell us what you are building, and read the docs in the meantime.