Gmail pushes new mail through Cloud Pub/Sub, Microsoft Graph wants subscriptions you renew every few days, and Exchange speaks EWS or a long-lived IMAP connection. Building sync against all three natively means three push systems, three data models, and three sets of folder names. This recipe shows the sync architecture that collapses them into one pipeline: one grant per account, a single backfill, and webhook-driven incremental updates on one schema.
This page is about the sync engine, not the screen. If you want to merge several accounts into a single list and sort them, the unified inbox recipe covers the view layer. Here you’ll wire the grant model, the List Messages endpoint, and message.created and message.updated webhooks into a pipeline that stays correct across Gmail, Outlook, Exchange, Yahoo, iCloud, and generic IMAP.
What’s multi-provider email sync?
Section titled “What’s multi-provider email sync?”Multi-provider email sync is the practice of keeping a local, single-shape copy of mail from accounts on different email systems such as Gmail, Outlook, and Exchange. Each account becomes a grant, you back it up once, then apply incremental updates as they arrive so your database mirrors every connected mailbox.
The architecture has three moving parts that you build once and reuse across all 6 providers. A grant represents one connected account and gives you a stable grant_id to address it. A one-time backfill imports the existing mailbox right after connection. A webhook listener applies every change after that. The win is that none of these three pieces branch on provider. A Gmail message and an Exchange message arrive with the same id, subject, from, date, and folders fields, so one code path and one table handle every account.
Why use one grant per account instead of native APIs?
Section titled “Why use one grant per account instead of native APIs?”A grant is the single connection abstraction that replaces Gmail OAuth, Microsoft Graph permissions, and EWS credentials with one identifier. Each connected account becomes one grant with a unique grant_id, and every sync request targets that ID with the same call shape, so your pipeline never branches on which email system the account belongs to.
Without this model, a sync engine carries three clients. Gmail needs a Cloud project and the Gmail API, Microsoft Graph needs an Azure app registration with admin-approved permissions, and Exchange needs EWS or IMAP handling. Each has its own token lifecycle and its own message schema. With grants, you authenticate once through Nylas hosted auth, and the API hands back a grant_id per account. The same GET /v3/grants/{grant_id}/messages request reads a Gmail mailbox, an Outlook mailbox, or an on-premises Exchange mailbox. One request shape covers all 6 providers, and OAuth token refresh happens automatically, so your sync loop never handles a 401 from an expired Google or Microsoft token.
To list the grants your application can sync, send GET /v3/grants. The response returns each connected account with its id, provider, and grant_status, which is the set you iterate over to schedule backfills and confirm webhook coverage. The request below pages through every grant 50 at a time.
curl --request GET \ --url 'https://api.us.nylas.com/v3/grants?limit=50' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'const grants = await nylas.grants.list({ queryParams: { limit: 50 },});
grants.data.forEach((grant) => { console.log(grant.id, grant.provider, grant.grantStatus);});grants = nylas.grants.list(query_params={"limit": 50})
for grant in grants.data: print(grant.id, grant.provider, grant.grant_status)The provider field tells you whether an account is Google, Microsoft, EWS, IMAP, Yahoo, or iCloud, which you store for reporting. Your sync code still treats them identically. For the deeper Outlook and Exchange tradeoffs this replaces, see the Microsoft Graph alternative and connect Exchange over EWS.
How do you do the initial backfill per account?
Section titled “How do you do the initial backfill per account?”Run a one-time import the moment a grant is created. Page through GET /v3/grants/{grant_id}/messages with a limit of 200, follow the next_cursor until it stops, and write each batch to your store. This captures everything that already existed in the mailbox, because old mail predates the connection and fires no webhook.
The backfill is a bounded batch job you run exactly once per grant, then mark complete and never repeat. The List Messages endpoint returns up to 200 messages per page, so a mailbox of 20,000 messages finishes in 100 sequential requests at the maximum page size. Because the response carries the full object, including subject, sender, body, and folders, one pass populates your database without a second fetch step. Keep this job separate from your live listener: the backfill is bursty and can pause and resume, while the webhook handler stays a lightweight always-on process. The dedicated backfill recipe covers resuming a stopped run, throttling, and reaching mail older than the 90-day IMAP cache with query_imap.
The loop below imports one grant to completion and checkpoints its cursor so a restart resumes where it stopped.
import requests
def backfill_account(grant_id, api_key, store): url = f"https://api.us.nylas.com/v3/grants/{grant_id}/messages" headers = {"Authorization": f"Bearer {api_key}"} cursor = store.get_cursor(grant_id) total = 0
while True: params = {"limit": 200} if cursor: params["page_token"] = cursor
resp = requests.get(url, headers=headers, params=params) resp.raise_for_status() body = resp.json()
store.save_batch(grant_id, body["data"]) cursor = body.get("next_cursor") store.set_cursor(grant_id, cursor) # checkpoint for resume total += len(body["data"])
if not cursor: break
store.mark_backfilled(grant_id) return totalHow do you keep accounts in sync with webhooks?
Section titled “How do you keep accounts in sync with webhooks?”Subscribe one webhook to the message.created and message.updated triggers. Nylas fires message.created within seconds of new mail landing on any connected account and message.updated when a message changes, such as a read-state or folder change. One subscription covers all 6 providers, so you never wire Gmail Pub/Sub or Graph subscriptions yourself.
This is the incremental half of the pipeline, and it’s where the grant model pays off most. Native push differs on every system: Gmail wants a Cloud Pub/Sub topic, Microsoft Graph wants per-resource subscriptions you renew every few days, and IMAP gives you only a connection you keep open. A single POST /v3/webhooks/ registers one HTTPS endpoint for both triggers across every account at once. Your endpoint must return 200 OK within 10 seconds, and Nylas verifies the URL with a challenge handshake before the first event. The same handler updates your local store whether the change came from Gmail, Outlook, or Exchange. For the handshake, signature verification, and retry behavior, see real-time webhooks.
The request below creates one destination subscribed to both message triggers. Swap in your public HTTPS webhook_url, and the notification_email_addresses receive alerts if delivery starts failing.
curl --request POST \ --url 'https://api.us.nylas.com/v3/webhooks/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data-raw '{ "trigger_types": ["message.created", "message.updated"], "description": "Multi-provider email sync", "webhook_url": "<WEBHOOK_URL>", "notification_email_addresses": ["[email protected]"] }'import Nylas, { WebhookTriggers } from "nylas";
const nylas = new Nylas({ apiKey: "<NYLAS_API_KEY>" });
const webhook = await nylas.webhooks.create({ requestBody: { triggerTypes: [WebhookTriggers.MessageCreated, WebhookTriggers.MessageUpdated], webhookUrl: "<WEBHOOK_URL>", description: "Multi-provider email sync", },});
console.log("Subscribed:", webhook.data.id);from nylas import Clientfrom nylas.models.webhooks import WebhookTriggers
nylas = Client("<NYLAS_API_KEY>")
webhook = nylas.webhooks.create( request_body={ "trigger_types": [ WebhookTriggers.MESSAGE_CREATED, WebhookTriggers.MESSAGE_UPDATED, ], "webhook_url": "<WEBHOOK_URL>", "description": "Multi-provider email sync", })
print("Subscribed:", webhook.data.id)When a message.created notification arrives, its payload carries the grant_id and the message object, so your handler writes the row keyed on message id without a follow-up request. Make the write idempotent on that id so a redelivered event doesn’t create a duplicate.
Things to know about syncing across providers
Section titled “Things to know about syncing across providers”One schema and one pipeline don’t erase every provider difference. The points below cover the gotchas that surface once you run sync across Gmail, Outlook, and Exchange in the same code path, past the first two test accounts.
Folder semantics differ even though the field is shared. The folders field is consistent in shape, but the values aren’t. Gmail uses labels, so one message can sit in INBOX and IMPORTANT at the same time, while Outlook and Exchange use single folders like inbox and sentitems. Map both into your own “inbox” and “sent” concepts rather than filtering on raw provider names, which some organizations rename. See Microsoft’s mail folder reference for the internal names Graph exposes.
IMAP-based accounts cache 90 days. Gmail and Microsoft expose the entire mailbox through their native APIs, so a standard cursor loop reaches mail from years ago. Yahoo, iCloud, and generic IMAP work differently: only the most recent 90 days sync into the cache, and a plain list call stops at that boundary. Add query_imap=true during backfill to reach older mail, as the backfill recipe details.
Rate limits apply per grant, not per app. Google throttles at the per-user and per-project level, and Microsoft throttles per mailbox. A backfill of one account can’t slow another, but a fleet of simultaneous backfills can. Cap concurrent grants with a worker pool and pace pages with a delay of roughly 200 ms so one import never saturates a shared quota.
Webhooks reduce sync to near-zero polling. Once the backfill completes, the webhook listener handles every change, so you don’t poll any mailbox on a timer. That matters at scale: a 1,000-user app where each person connects 3 accounts is 3,000 grants, and polling all of them every minute would be 3,000 requests a minute doing mostly nothing. Webhooks push only the deltas.
Treat backfill and live sync as two jobs. Run the backfill as a background queue and the webhook handler as an always-on web process. Keeping them apart means a large historical import never delays a live message.created event, and either can fail and recover without touching the other.
What’s next
Section titled “What’s next”- Build a unified inbox merges these synced accounts into one sorted view.
- Backfill historical email covers resuming, throttling, and
query_imapfor old mail. - Get real-time updates with webhooks details the handshake, signatures, and retries.
- Microsoft Graph API alternative compares the Outlook and Exchange setup this replaces.
- Messages API reference lists every list parameter and the full response schema.