Mail sync
Sync each member's Gmail or Outlook mailbox through their own OAuth grant, match threads to people and companies, log them as email engagements and send replies.
The mail-sync block pulls each member's mailbox into Postgres and puts the
mail on the records it concerns. A sync job stores new messages by thread,
links each thread to the parties whose addresses it
names (a person by email, a company by its people or its domain) and logs it
as an email engagement on them. Members can
also send from their mailbox, and the sent message shows on the thread
before the next sync.
better-supabase add mail-sync # adds tenant, access and subjects as wellInstall parties and engagements too for the matching and the timeline;
without them, threads stay in the mailbox and can be linked by hand to any
registered subject.
| Permission | Lets a member | Default roles |
|---|---|---|
mail.read | read the shared threads on records they can read | member, admin |
mail.connect | connect their own mailbox | member, admin |
mail.send | send from their own mailbox | member, admin |
mail.manage | see every member's mailbox and its sync status | admin (mail.*) |
A mailbox's share decides who reads its threads. With records (the
default), a member with mail.read sees a thread when they can read one of
its linked records, and the thread is logged as an engagement. With
private, only the mailbox's owner sees its threads and nothing is logged.
Providers and grants
Every member signs in to their mail provider with OAuth, and the app keeps
the grant in a credential provider. The
block stores only the credentialRef that names the grant, with the
organization as its tenant, and asks the provider for a token as that
member each time it syncs or sends.
gmailMail() and outlookMail() call the Gmail REST API and Microsoft
Graph with fetch, so no Google or Microsoft SDK is installed. The first
sync of a mailbox reads the last 30 days (initialDays); later syncs read
Gmail's history or Graph's delta since the stored cursor. A provider of your
own implements MailProvider (apiVersion: 1, name, list, send).
import { vercelConnectCredentials } from "better-supabase/vercel-connect";
import {
createMailSync,
gmailMail,
outlookMail,
rpcTransport,
} from "better-supabase/blocks/mail-sync";
export const mailFor = (supabase: SupabaseClient) =>
createMailSync({
transport: rpcTransport(supabase),
credentials: vercelConnectCredentials(),
providers: [gmailMail(), outlookMail()],
});Connect a mailbox
After the OAuth flow, connect the member's mailbox. Connecting the same address again updates its grant and keeps the sync cursor when the provider stays the same.
const account = await mailFor(supabase)
.connect(organizationId, {
provider: "gmail",
address: "rep@acme.com",
credentialRef: {
provider: "vercel-connect",
connector: "gmail",
tenant: organizationId,
},
})
.orThrow();update(accountId, { share, paused }) switches the mailbox between
records and private, or pauses its sync. disconnect(accountId) stops
the sync; { purge: true } also deletes its threads and messages.
accounts(organizationId) lists the caller's mailboxes with their status,
lastSyncedAt and lastError. Deleting a member's account purges their
mailboxes and revokes their grants through
data lifecycle.
Run the sync
The sync runs as the service role, from a job or a cron route.
syncDue() syncs the active mailboxes not synced within staleAfter
(5 minutes), at most accounts (25) of them, reading up to pages (5)
pages of limit (50) messages each. A mailbox whose provider fails is
marked error with the message; a rate limit leaves it active for the
next run.
export async function GET() {
const report = await createMailSync({
transport: rpcTransport(admin),
credentials: vercelConnectCredentials(),
providers: [gmailMail(), outlookMail()],
})
.syncDue()
.orThrow();
return Response.json(report);
}report holds the number of mailboxes synced, the messages stored and the
mailboxes that failed. sync(account) syncs one mailbox. Storing is
idempotent by the provider's message id, and a sent message matches its
synced copy by its Message-ID, so a message is never stored twice.
Threads on a record
const mail = mailFor(supabase);
const threads = await mail
.threadsOf({ type: "person", id: personId })
.orThrow();
const thread = await mail.thread(threads[0].id).orThrow();threads(organizationId, { accountId, before, limit }) lists the threads
the caller can see, the latest first, and thread(id) returns one with its
messages, or undefined when the caller can't see it. A mailbox's owner
links a thread to records the matching missed with
link(threadId, [{ type, id }]) and removes one with unlink.
Send
await mail
.send(account.id, {
threadId: thread.id,
to: [{ address: "ada@client.com" }],
subject: "Re: Pricing",
text: "Here is our offer.",
})
.orThrow();With a threadId, the message replies to the thread's latest message, so
the provider keeps it in the same conversation. mailDraftSchema validates
the draft and mailAccountInputSchema what connect takes.
Templates and tracking
A send can come from a templates version and be
tracked. templateVersionId stores the version on the sent message, and
with tracking set on createMailSync, the HTML body's http(s) links go
through your tracking route and a 1x1 pixel records opens. The provider
sends the rewritten HTML; the database keeps the original, a SHA-256 of the
message's random token and its links, so the tracking URLs can't be
guessed from the data.
import { composeTemplateEmail } from "better-supabase/blocks/templates";
const mail = createMailSync({
transport: rpcTransport(supabase),
credentials: vercelConnectCredentials(),
providers: [gmailMail(), outlookMail()],
tracking: { url: "https://app.acme.com/api/mail/t" },
});
const { subject, text, html } = composeTemplateEmail(body, { person });
await mail
.send(account.id, {
to: [{ address: person.email }],
subject,
...(text === undefined ? {} : { text }),
...(html === undefined ? {} : { html }),
templateVersionId: version.id,
})
.orThrow();track: false sends one message untracked, and track: true without
tracking fails with MAIL_TRACKING_MISSING. The route answers each hit
with track(request) as the service role: an open (/o/<token>) returns
the pixel, a click (/c/<token>/<n>) redirects to the stored link, and
an unknown token gets a 404.
export async function GET(request: Request) {
const tracked = await mailFor(admin).track(request);
return tracked.ok ? tracked.data : new Response(null, { status: 503 });
}A sent message carries tracked, openCount, clickCount, openedAt and
clickedAt. templateStats(organizationId, { since }) returns, per
template version, the messages sent, opened, clicked and replied
(an inbound message on the thread after the sent one), over the mailboxes
the caller can read. Open counts are approximate: mail clients that block
images never load the pixel, and some (Apple Mail's privacy protection,
Gmail's image proxy) load it for the reader. Link scanners can click
links before the recipient does.
Options
blocks: {
"mail-sync": { options: { matchDomains: false } },
},| Option | Default | What it does |
|---|---|---|
engagementType | email | the engagement type a thread is logged as |
matchDomains | true | link a thread to the company whose domain one of its addresses has |
Events
The block writes mail_account.connected, mail_account.disconnected,
mail_message.received, mail_message.sent, mail_message.opened and
mail_message.clicked to the outbox. A message
event names the thread, the direction, the sender and the records the
thread is linked to, so a workflow can react to a reply from a contact. An
open is written once per message, on the first open; a click is written
every time, with its url and the templateVersionId. See
Events.
Last updated on
Engagements
Calls, emails, meetings, notes and tasks logged on one or more records, with an owner, a due time and an outcome, a record's timeline, its next step and a member's open tasks.
Segments
Save a filter over companies, people, deals or any registered subject, keep its members in Postgres and react when records enter or leave it.