Blog

Sending email from your users’ mailboxes: limits, bounces, tracking and merges.

Sending through a user’s own mailbox makes your product responsible for their sending limits, their domain’s authentication, bounces that arrive as mail, opt-outs and retries.

 · 15 min read

A CRM sequence, a recruiter’s outreach and a support reply have one thing in common: the message should come from a real person’s mailbox, land in the recipient’s inbox as mail from that person, and bring the reply back to them. Sending through the user’s own Gmail or Microsoft 365 account does that. It also makes your product responsible for things an email service provider would otherwise handle: the mailbox’s sending limits, its domain’s authentication, bounces that arrive as mail, opt-outs and the retries in between.

This guide covers each of those, with the providers’ published numbers and the RFCs behind them, and then what Workwae does at each step and where it stops. The message itself, its Sent copy, Message-ID and threading, is covered in Sending mail as your users.

When the user’s mailbox is the right sender.

Mail sent through the user’s mailbox is one-to-one mail that happens to be automated. The recipient sees the person, replies reach the person’s inbox, the conversation stays in their Sent folder, and the message carries the reputation of their domain rather than a shared sending service.

It is the wrong channel for a newsletter to fifty thousand subscribers. Microsoft says so in its own limits: Exchange Online customers who need to send legitimate bulk commercial email should use providers that specialize in it (Exchange Online limits). The rest of this guide assumes the middle ground: personal messages, sequences and merges in the hundreds per person per day.

Every mailbox has a daily budget.

Both providers cap what one user can send, over a rolling 24 hours rather than a calendar day. Google’s limits for a paid Google Workspace user (Gmail sending limits in Google Workspace):

  • Messages: 2,000 a day, 500 for a trial account.
  • Recipients: 10,000 a day in total, 3,000 of them external.
  • Unique recipients: 3,000 a day, 2,000 of them external. Five messages to the same address count once here and five times in the total.
  • Per message: 500 recipients through the Gmail API.

Mail sent from an alias or by a delegate counts against the user. A user who goes over cannot send for up to 24 hours, and Google says an account that sends spam can lose sending for good. The Gmail API adds a rate on top: messages.send costs 100 quota units, and a user may spend 6,000 units a minute in one Cloud project, which is 60 sends a minute (Gmail API quotas).

Exchange Online’s limits per mailbox, the same for every business and enterprise plan:

  • Recipients: 10,000 a day.
  • Messages: 30 a minute.
  • Per message: up to 1,000 recipients, which an administrator can lower.
  • Per tenant: a cap on external recipients for the whole organization that grows with its licenses, 5,000 a day for a trial tenant. Mail from the default onmicrosoft.com domain is held to 100 external recipients a day per organization and refused with 550 5.7.236 past that.

Sending from a shared mailbox through permissions counts against the delegate, not the shared mailbox. Two consequences for an integration: the budget belongs to a person your product does not control, since their own Outlook or Gmail use draws from it too, and the tenant-wide cap means one heavy user can stop everyone else in the organization from mailing outside it.

Authentication comes with the mailbox, mostly.

A receiver decides whether mail really comes from the domain in its From header with DMARC: the message passes when SPF or DKIM passes for a domain aligned with the From domain (Google: Email sender guidelines). Mail sent through Gmail or Exchange Online leaves from the provider’s own servers, which the domain’s SPF record lists if the domain uses that provider for mail at all, and the provider DKIM-signs it once the domain’s administrator has turned DKIM on. A domain set up that way passes with no work from your product and no DNS records to add, which is the main operational advantage over a sending service.

The exceptions are worth checking for:

  • DKIM never set up. Microsoft signs mail from the initial onmicrosoft.com domain automatically, but a custom domain must be configured, and DKIM only counts toward DMARC when the signing domain aligns with the From address (Microsoft: DKIM for your custom domain). Google’s equivalent is in Set up DKIM.
  • A send-as address on another domain. If the user sends as sales@brand.example from a mailbox on corp.example, the provider authenticates corp.example. Whether the message aligns depends on what brand.example publishes.
  • A user on another host. An IMAP and SMTP account sends through whatever server the user configured, with whatever authentication that server has.

The 5,000-a-day rules.

Gmail and Outlook.com hold large senders to stricter rules. Since February 1, 2024, a sender that sends more than 5,000 messages a day to Gmail accounts must authenticate with SPF, DKIM and DMARC with the From domain aligned, keep its spam rate as reported in Postmaster Tools below 0.3 percent, and give marketing and subscribed messages one-click unsubscribe plus a visible unsubscribe link (Email sender guidelines). Outlook.com applies the same 5,000 line: SPF and DKIM must pass and DMARC must be published at p=none or stricter, aligned with SPF or DKIM. Microsoft announced enforcement from May 5, 2025, with failing mail rejected as 550 5.7.515 (Outlook’s requirements for high-volume senders).

Outlook counts by sending domain, not by mailbox, so a sales team of fifty sending a hundred messages each on one domain is already past its line. Authentication usually comes from the mailbox, as above. One-click unsubscribe and a low complaint rate are your product’s job.

Refused now, or bounced later.

A send can fail in two places. The provider can refuse it while you are sending: an API error, or an SMTP reply in the 4xx or 5xx range. Or the provider accepts it, tries to deliver it, and later mails the sender a delivery status notification. The second kind arrives in the user’s inbox like any other message, so an integration that does not read the mailbox never sees it.

A well-formed notice is a multipart/report with a message/delivery-status part that has one block per recipient (RFC 3464). Action says what happened (failed, delayed, delivered, relayed or expanded), and Status carries an enhanced status code:

dsn-part.txt
Content-Type: multipart/report; report-type=delivery-status; boundary="b"

--b
Content-Type: text/plain

Your message to ada@example.org couldn't be delivered.

--b
Content-Type: message/delivery-status

Reporting-MTA: dns; mx.example.org

Final-Recipient: rfc822; ada@example.org
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 User unknown
--b--

The first digit of the code is its class: 2 is success, 4 a persistent transient failure that may succeed later, 5 a permanent failure (RFC 3463). The second is the subject. X.1.1 is a bad destination mailbox, X.2.2 a full mailbox and X.7.x a security or policy refusal, which often says more about the sender than the address. Only some permanent failures mean the address is gone:

  • Stop mailing it: 5.1.1, an address that does not exist, and 5.1.10, a domain that publishes a null MX and accepts no mail (RFC 7505).
  • Do not conclude anything about the address: 5.7.x, which is a verdict on the message or the sender, and 4.x.x, which may succeed on a later try.

Not every server sends a proper report. Plenty send plain text that says the message “couldn’t be delivered”, so a parser needs fallbacks, and anyone can mail a user a forged notice, so a bounce should only count against an address your product actually sent to.

Stop mailing addresses that cannot or should not receive.

A suppression list is the set of addresses an automated send skips. Three sources fill it: hard bounces, unsubscribes and complaints. For unsubscribes, RFC 8058 defines one-click: the message carries an HTTPS List-Unsubscribe URL and a List-Unsubscribe-Post header, and the recipient’s mail provider unsubscribes them with a POST, without a page to fill in (RFC 8058):

one-click unsubscribe
List-Unsubscribe: <https://t.example.com/u/3f9c2a>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

POST /u/3f9c2a HTTP/1.1
Host: t.example.com
Content-Type: application/x-www-form-urlencoded

List-Unsubscribe=One-Click

The POST has no cookie and no login, so the URL itself has to identify the recipient and the list, and it must not be guessable. Complaints come back through feedback loops as abuse reports in the format of RFC 5965; a complaint is the strongest signal there is, and mailing that address again is how a domain loses reputation.

Scope matters. A hard bounce applies to every list; an unsubscribe from a product update should not stop a reply to a support ticket the same person opened. One-to-one mail the user writes is usually not subject to a list at all.

Check addresses before the first send.

An imported list of a few thousand contacts will contain addresses that bounce, and a burst of hard bounces from one mailbox is exactly what providers watch for. Validation before the first send catches most of them: the syntax, whether the domain has mail servers, whether the server admits the mailbox when asked, and whether the domain is a known disposable-address service or the address is a role such as info@.

The answer is a probability, not a fact. Some domains accept every address at the SMTP stage and bounce later (catch-all), some servers refuse to answer, and a mailbox can disappear a day after it was checked. Validate imported and old addresses once; do not validate before every send, and treat “risky” and “unknown” as a decision for your product, not a block.

Opens are a weak signal; clicks are a better one.

Open tracking is a tiny remote image in the HTML; click tracking rewrites each link to pass through a redirect that records the click. Both still work mechanically. What they measure has changed. Apple’s Mail Privacy Protection “prevents senders from seeing if you’ve opened the email message they sent you” (Apple), and Gmail scans images before showing them, so a sender cannot use image loading to learn the reader’s computer or location (Gmail Help). Security gateways fetch links to check them before a person sees the message.

So an open is “a client or a proxy fetched the image”, and a click may be a scanner. Treat opens as a rough aggregate and clicks as evidence only once machine traffic is filtered out. Tracking also rewrites the message, adds a tracking host to every link, and records something about the recipient, so it should be a per-send decision, off unless the user wants it.

A mail merge is many single sends.

The tempting way to mail two hundred people is one message with two hundred Bcc recipients. It cannot be personalized, it counts as two hundred recipients against the budget anyway, and a bounce, an open or an unsubscribe cannot be traced to one person. A merge is two hundred messages, each with one recipient, its own content, its own Message-ID, its own tracking and its own unsubscribe link.

Spread it out. Exchange allows 30 messages a minute and the Gmail API about 60 a minute per user, and two hundred messages arriving at once from one person look like what they are. Give each message its own send time, keep the whole merge inside the daily budget with room for the user’s own mail, and make it cancellable until each message goes out.

Retry without sending twice.

A send has two points where a retry can duplicate it. The first is your call to the sending API: if it times out, you do not know whether it was queued. An idempotency key on the request solves that, as long as the retry carries the same key. The second is the delivery to the provider: SMTP accepts a message only when the server answers the end of the data, so a connection that drops after the message was written but before the answer leaves the outcome unknown. Sending again risks a duplicate; not sending risks a loss.

Retry only what is safe to retry: throttling and server errors, with a growing delay and the provider’s Retry-After respected. A daily limit is not a short throttle; the next attempt should wait for the window to roll, not thirty seconds. A permanent refusal should fail at once with the provider’s reason, so the person who wrote the message can act on it.

How Workwae handles it.

Every send goes through the outbox: you queue it, Workwae delivers it through the account’s provider (the Gmail API, Microsoft Graph or the account’s SMTP server), and the result arrives as webhooks. The examples below use the TypeScript and Python SDKs.

Queueing and delivery

send.tssend.py
import { createClient, isApiError } from "@workwae/sdk";

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

try {
  const queued = await workwae.outbox.create(
    {
      accountId,
      // from must be one of the account's identities
      from: { address: "sales@example.com", name: "Priya Shah" },
      to: [{ name: "Ada Lovelace", address: "ada@example.org" }],
      subject: "Contract for review",
      html: '<p>The contract: <a href="https://example.com/c/1042">review it</a></p>',
      reference: "deal-1042",
      sendAt: new Date("2026-11-05T14:00:00Z"),
      tracking: { opens: false, clicks: true },
    },
    // Your own key: a retry of this call after a timeout returns the first answer.
    { context: { idempotencyKey: "deal-1042-contract" } },
  );
  console.log(queued.id, queued.status, queued.undoUntil);
} catch (error) {
  if (isApiError(error, "plan_limit_sends")) console.log("daily send cap reached");
  else throw error;
}
import os
from datetime import datetime, timezone

from workwae import APIError, Workwae

workwae = Workwae(api_key=os.environ["WORKWAE_API_KEY"])

try:
    queued = workwae.outbox.create(
        account_id=account_id,
        # from_ must be one of the account's identities
        from_={"address": "sales@example.com", "name": "Priya Shah"},
        to=[{"name": "Ada Lovelace", "address": "ada@example.org"}],
        subject="Contract for review",
        html='<p>The contract: <a href="https://example.com/c/1042">review it</a></p>',
        reference="deal-1042",
        send_at=datetime(2026, 11, 5, 14, 0, tzinfo=timezone.utc),
        tracking={"opens": False, "clicks": True},
        # Your own key: a retry of this call after a timeout returns the first answer.
        request_options={"idempotency_key": "deal-1042-contract"},
    )
    print(queued.id, queued.status, queued.undo_until)
except APIError as error:
    if error.code == "plan_limit_sends":
        print("daily send cap reached")
    else:
        raise
  • The call. outbox.create takes up to 500 recipients in each of to, cc and bcc, up to 50 attachments with at most 25 MiB decoded, a fromfrom_ that must be one of the account’s identities, a sendAtsend_at and a reference echoed in every event for that send. It answers with the queued entry. The SDKs put an Idempotency-Key on every POST and reuse it across their own retries; a key is kept for 24 hours per workspace.
  • Delivery. One send per account is in flight at a time, and a Microsoft 365 account starts at most one send every 2 seconds, which keeps it under Exchange’s 30 a minute. The sendsPerMinute setting, for the workspace or one account, spaces sends further. When Exchange reports that the organization reached its daily cap on external recipients, the workspace’s other Microsoft 365 accounts on the same domain wait too, without using up attempts. Delivery is at least once: an attempt after the first looks for a copy that already went out before sending again. When an SMTP server took the message but its answer was lost, the entry fails with outcome_unknown and is never resent.
  • Retries. A throttled or unavailable provider is retried after a delay that doubles from 30 seconds to an hour, with jitter, never sooner than Retry-After. A provider’s sending limit is retried as sending_limit, an hour apart unless the provider names a time. An entry fails after 8 attempts, or when its next attempt would fall more than 24 hours after its sendAtsend_at, with send.failed carrying the last error. Every retry sends send.retrying with the next attempt’s time.
  • Plan caps. The workspace’s plan sets sends per day, counted as entries queued since midnight UTC. Past it, outbox.create answers 403 plan_limit_sends.

Bounces and suppression

  • Bounces. The sync reads delivery notices that reach an account’s inbox or junk folder within 7 days. It parses RFC 3464 reports first and falls back to the text formats of common servers. Each failed recipient is reported in message.bounced with its status code and diagnostic, and with the outboxIdoutbox_id and reference of the send when the notice matches one, by the X-Workwae-Outbox header or the Message-ID it returns. Delays and successes are not reported.
  • Lists. A send that names a listIdlist_id skips addresses on that list or the global one, checks again just before delivery, and carries one-click List-Unsubscribe headers; {{rcpt.unsubscribeUrl}} puts the same URL in the body. Such a send has exactly one recipient. An unsubscribe adds the address to that list.
  • Automatic entries. When a send bounces permanently, or an SMTP server refuses a recipient, with a code that says the address is gone (5.1.1, 5.1.10 and similar, never 5.7.x), the address goes on the global list, as long as it was one of the send’s recipients. So does a spam complaint that names the recipient of a list send, unless optOutHandlingopt_out_handling is off. Replies asking to stop, when AI reply classification is on, wait for review by default. Each address added this way sends suppression.created.
  • Sends without a list. By default they check no list, so a one-to-one reply reaches anyone. With suppressionCheck: “always”suppression_check=“always” they skip global-list addresses too: the send is refused with 400 suppressed, whose data lists the addresses, and checked again at delivery.
  • Validation. emailValidations.createemail_validations.create checks up to 10 addresses per call and returns safe, risky, invalid or unknown, with flags for catch-all, disposable and role addresses where the check can tell. suppressInvalid: truesuppress_invalid=True puts invalid addresses on the global list. Checks count against the plan’s validations and credits.

Tracking

Tracking is off unless a send, the account or the workspace turns it on. Opens add a 1×1 image; clicks rewrite absolute HTTP and HTTPS links, never mailto:, the unsubscribe URL or a link marked data-workwae-notrack. The account’s own Sent copy is the untracked version. Each hit is classified as human, proxy, prefetch, scanner or unknown, with the reasons, and message.opened and message.clicked carry isMachineis_machine. An optional honeypot, a hidden link no person can click, marks the client that follows it as a scanner, for that hit and later ones. The outbox entry counts human and machine opens and clicks separately. Links point to Workwae’s tracking host, or to the workspace’s own tracking domain once its CNAME is published and its certificate is active.

Mail merge and templates

merge.tsmerge.py
const start = Date.parse("2026-11-05T14:00:00Z");

// The template uses {{params.firstName}} and {{rcpt.unsubscribeUrl}}.
// listId skips suppressed addresses and adds one-click unsubscribe.
const merge = await workwae.mailMerges.create({
  accountId,
  template: templateId,
  listId: "product-updates",
  reference: "november-update",
  tracking: { clicks: true },
  entries: recipients.map((to, i) => ({
    to,
    params: { firstName: to.name },
    sendAt: new Date(start + i * 30_000), // one every 30 seconds
  })),
});

for (const result of merge.items) {
  if (result.success) await saveOutboxId(result.to.address, result.outboxId);
  // suppressed, plan_limit_sends, ...
  else console.warn(result.to.address, result.reason);
}

// Changed plans: cancel whatever has not gone out yet.
if (merge.id) await workwae.mailMerges.cancel({ accountId, mailMergeId: merge.id });
from datetime import datetime, timedelta, timezone

from workwae.types import MailMergeEntryParam

start = datetime(2026, 11, 5, 14, 0, tzinfo=timezone.utc)

entries: list[MailMergeEntryParam] = [
    {
        "to": to,
        "params": {"firstName": to.name or ""},
        "send_at": start + timedelta(seconds=30 * i),  # one every 30 seconds
    }
    for i, to in enumerate(recipients)
]

# The template uses {{params.firstName}} and {{rcpt.unsubscribeUrl}}.
# list_id skips suppressed addresses and adds one-click unsubscribe.
merge = workwae.mail_merges.create(
    account_id=account_id,
    template=template_id,
    list_id="product-updates",
    reference="november-update",
    tracking={"clicks": True},
    entries=entries,
)

for result in merge.items:
    if result.success is True:
        save_outbox_id(result.to.address, result.outbox_id)
    # suppressed, plan_limit_sends, ...
    else:
        print(result.to.address, result.reason)

# Changed plans: cancel whatever has not gone out yet.
if merge.id:
    workwae.mail_merges.cancel(account_id=account_id, mail_merge_id=merge.id)

mailMerges.createmail_merges.create queues up to 100 entries in one call, each an ordinary outbox entry with its own Message-ID, tracking, unsubscribe link and events. An entry that cannot be queued, because it is suppressed or over the day’s cap, fails alone and the rest go ahead. Each entry may set its own sendAtsend_at, which is how a merge is spread out. The merge is kept as an object: mailMerges.getmail_merges.get counts its entries by status, mailMerges.cancelmail_merges.cancel cancels every entry still queued, and each cancelled entry sends send.cancelled. Templates use Handlebars: {{params.firstName}} is escaped in HTML, {{{raw}}} is not, and content is rendered when the send is queued, so editing a template never changes mail already in the outbox.

Following the result

events.tsevents.py
import { parseWebhook } from "@workwae/sdk";

const SECRET = process.env.WORKWAE_WEBHOOK_SECRET!;

export async function handleWebhook(request: Request): Promise<Response> {
  const body = await request.text();
  const event = await parseWebhook(SECRET, request.headers, body);
  if (!event.known) return new Response(null, { status: 204 });

  switch (event.type) {
    case "send.failed":
      // outcome_unknown: the server may have it; never resend blindly.
      await markFailed(event.data.outboxId, event.data.error.code);
      break;
    case "message.bounced":
      for (const r of event.data.recipients)
        await markBounced(event.data.outboxId, r.address, r.status);
      break;
    case "message.clicked":
      if (!event.data.isMachine)
        await recordClick(event.data.outboxId, event.data.url);
      break;
  }
  return new Response(null, { status: 204 });
}
import os

from fastapi import Request, Response
from workwae import UnknownEvent, parse_webhook

SECRET = os.environ["WORKWAE_WEBHOOK_SECRET"]


async def handle_webhook(request: Request) -> Response:
    body = await request.body()
    event = parse_webhook(SECRET, request.headers, body)
    if isinstance(event, UnknownEvent):
        return Response(status_code=204)

    if event.type == "send.failed":
        # outcome_unknown: the server may have it; never resend blindly.
        mark_failed(event.data.outbox_id, event.data.error.code)
    elif event.type == "message.bounced":
        for r in event.data.recipients:
            mark_bounced(event.data.outbox_id, r.address, r.status)
    elif event.type == "message.clicked":
        if not event.data.is_machine:
            record_click(event.data.outbox_id, event.data.url)
    return Response(status_code=204)

send.sent carries the message’s Message-ID, on Gmail the provider’s message id, and rejectedRecipientsrejected_recipients: addresses an SMTP server refused while it accepted the message for the others. Email and calendar webhooks you can trust covers verifying, deduplicating and replaying these deliveries.

Limits.

  • Workwae does not know a mailbox’s budget. It cannot see how much of Google’s or Microsoft’s daily limit the user has already used, so it finds out when the provider refuses. Size merges to stay well under the limits above.
  • The organization cap is learned, not predicted. Workwae does not know an Exchange organization’s external recipient cap until Exchange refuses a send, and it groups accounts by email domain, so an organization with several domains is held per domain.
  • Bounces depend on the mailbox. A notice moved out of the inbox or junk folder, deleted by a rule, or older than 7 days is not read. On Microsoft 365 the sync sees no headers, so notices are recognized by their sender and subject, and an unusual one can be missed.
  • Gmail and Graph refuse whole messages. Only SMTP reports a refusal per recipient while sending, so on Gmail and Microsoft 365 a dead address is found from the later bounce.
  • No custom headers. A send cannot add headers of its own.
  • Tracking is per message. A message to several recipients has one set of counts. Use a merge to track per person. Gmail and Microsoft 365 drafts sent through Workwae are never tracked.
  • Validation is a sample, not a list cleaner. Ten addresses per call, and what can be known depends on the remote server.
  • Alpha SDKs. @workwae/sdk is at 0.1.0-alpha and workwae at 0.1.0a2.

The docs cover each piece in detail: sending, mail merge, templates, suppression lists, email validation, tracking and idempotency. Mail is the product overview.