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

Source: https://bettersupabase.com/docs/blocks/mail-sync

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](/docs/blocks/parties) whose addresses it
names (a person by email, a company by its people or its domain) and logs it
as an `email` [engagement](/docs/blocks/engagements) on them. Members can
also send from their mailbox, and the sent message shows on the thread
before the next sync.

```bash
better-supabase add mail-sync   # adds tenant, access and subjects as well
```

Install `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 [#providers-and-grants]

Every member signs in to their mail provider with OAuth, and the app keeps
the grant in a [credential provider](/docs/extending/credentials). 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`).

```ts title="lib/mail.ts"
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 [#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.

```ts
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](/docs/blocks/data-lifecycle).

## Run the sync [#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.

```ts title="app/api/cron/mail/route.ts"
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 [#threads-on-a-record]

```ts
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 [#send]

```ts
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 [#templates-and-tracking]

A send can come from a [templates](/docs/blocks/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.

```ts
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.

```ts title="app/api/mail/t/[...hit]/route.ts"
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 [#options]

```ts title="better-supabase.config.ts"
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 [#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](/docs/blocks/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](/docs/extending/events).