---
title: Mailboxes
description: Connect an existing Gmail or Outlook account over OAuth — from a hosted page, a plain link, or the API — keep it synced, and put your own brand on the consent screen.
sidebar:
  icon: user
---

A **mailbox** is an existing Gmail or Outlook account, connected once over OAuth. Its mail keeps flowing through Google or Microsoft exactly as before; AI Inbx syncs it, and can send and receive as that address.

That's the difference from a [domain](/guides/domains), which needs DNS access and takes over mail for every address on it. A mailbox needs nothing but the account holder clicking approve, changes nothing about their existing setup, and covers one address.

Reach for it when the address already has an inbox somewhere — a rep's work account, a founder's inbox — and the mail should read as coming from that person.

## Connecting a mailbox

Three ways in, ordered by how much of the flow you own. They all end the same way: the person approves at Google or Microsoft, the mailbox lands in your workspace, and [`mailbox.connected`](/webhooks/events/mailbox-connected) fires.

| | You write | Your users see | Needs |
| --- | --- | --- | --- |
| [Hosted page](#hosted-page) | Nothing — share a link | A page in your brand, then "you're connected" | Your own [OAuth app](#your-own-oauth-app) |
| [Connect link](#connect-link) | A URL on a button | Only the provider's consent screen, then your page | Your own OAuth app, plus a registered return URL |
| [API](#api) | A server-side call per connection | Only the provider's consent screen, then your page | A `full` API key |

### Hosted page

Every OAuth app of yours has a page at its `connect_url` — `https://aiinbx.com/connect/<slug>` — wearing your name, logo, accent and support address, with nothing of AI Inbx on it. Send people there, or put it in an email; it's a permanent link, not a one-time one.

```
https://aiinbx.com/connect/acme?ref=user_8812
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `ref?` | `string` | - | Your own identifier for the person, up to 200 characters. Echoed on mailbox.connected. |
| `return_to?` | `string` | - | One of the app's registered return URLs. Adds a "Continue to <your app>" button to the page they end on, carrying the same query as a connect link does. |

Brand the page from the app's settings in the console, or through `oauthApps.create` — `accent`, `tagline`, `support_email`, `logo_url`.

### Connect link

The same handoff with no page of ours in it. The person clicks a button on your site, lands on the consent screen, and comes back to a page of yours. Build the URL yourself; there's nothing to call first:

```
https://aiinbx.com/connect/<slug>/start?return_to=<url>&ref=<id>
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `return_to` | `string` | - | Where they land afterwards. Must match one of the app's return_urls on origin and path; the query is yours to fill. |
| `ref?` | `string` | - | Your own identifier for the person, up to 200 characters. Echoed on mailbox.connected. |

1. **Register where the link may return to**

    A connect link is opened with no credential, so it will only send someone back to a page you've listed on the app — the same idea as the redirect URIs you register with Google. Add them in the console, or on the app:

    ```ts
    await aiinbx.oauthApps.update(app.id, {
      return_urls: [
        "https://app.example.com/settings/mailboxes",
        "http://localhost:3000/settings/mailboxes",
      ],
    })
    ```

    Entries are matched on origin and path, so one covers every query string that page is opened with. `https` only, except on `localhost`. An unregistered `return_to` fails with [`return_url_not_registered`](/reference/errors#return_url_not_registered) before anyone reaches the provider.

2. **Put the link on a button**

    ```tsx React
    const connectUrl = new URL(`https://aiinbx.com/connect/acme/start`)
    connectUrl.searchParams.set("return_to", "https://app.example.com/settings/mailboxes")
    connectUrl.searchParams.set("ref", user.id)

    <a href={connectUrl.href}>Connect your Gmail</a>
    ```

    ```html HTML
    <a href="https://aiinbx.com/connect/acme/start?return_to=https%3A%2F%2Fapp.example.com%2Fsettings%2Fmailboxes&ref=user_8812">
      Connect your Gmail
    </a>
    ```

3. **Read the outcome off the query**

    They come back to `return_to` with the result appended:

    | Query | When | Value |
    | --- | --- | --- |
    | `mailbox` | Connected | The new mailbox's `mbx_…` id |
    | `connected` | Connected | The address they authorized |
    | `error` | Declined or failed | What went wrong, e.g. `access_denied` |

    Fine for showing a "connected" state on the page. Not proof — see the last step.

4. **Wait for mailbox.connected**

    The webhook is the authoritative signal, and carries your `ref`. The browser landing on `return_to` only says it came back; a person who closes the tab at the consent screen never triggers it.

    ```ts
    if (event.type === "mailbox.connected") {
      await markMailboxReady(event.data.ref, event.data.mailbox_id)
      // `reconnected` is true when this replaced an existing authorization.
    }
    ```

### API

For when your server should decide — the region, how much history to import, or the shared AI Inbx app rather than your own. The URL it returns is single-use and short-lived: redirect to it, don't email it.

```ts
const { url } = await aiinbx.mailboxes.connect({
  provider: "google",
  return_to: "https://app.example.com/settings/mailboxes",
  ref: "user_8812",
  backfill_days: 30,
})
// Send the customer to `url`.
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `provider` | `"google" \| "microsoft"` | - | Which provider to authorize against. |
| `return_to` | `string` | - | Where the customer lands after approving or declining. Any URL — the call is authenticated, so it isn't held to the app's return_urls. |
| `ref?` | `string` | - | Your own identifier, echoed back on mailbox.connected so you know which user finished. |
| `app_id?` | `string` | - | Use one of your own OAuth apps instead of the shared AI Inbx app — see below. |
| `space_id?` | `string` | - | The space the mailbox lands in, and with it everything synced from it. Omit for the workspace itself. |
| `region?` | `"eu-central-1" \| "us-east-1"` | - | Where this mailbox's mail is processed and stored. Defaults to eu-central-1. |
| `backfill_days?` | `number` | - | How much history to import on first sync, 0–90 days. Omit to start from the connection forward. |

The return carries the same query as a connect link, and the same rule applies: wait for `mailbox.connected` rather than trusting the redirect. A stale URL fails with [`expired_state`](/reference/errors#expired_state). Creating connection URLs requires a `full` [scope](/authentication#scopes) key.

`space_id` is the reason a platform uses this path: it is the only one of the three that can put the mailbox in a [space](/guides/spaces). The hosted page and a connect link are opened with no credential, so they cannot take a space — anyone could file a mailbox into another customer's. A mailbox lands in a space only through this call, which binds the space inside the encrypted OAuth state of the URL it returns. So a platform mints the connect URL server-side, per customer, and never hands out the bare hosted link.

## Mailbox state

```ts TypeScript
const mailbox = await aiinbx.mailboxes.retrieve("mbx_...")

console.log(mailbox.address, mailbox.state, mailbox.last_sync_at)
```

```python Python
mailbox = client.mailboxes.retrieve("mbx_...")
print(mailbox["address"], mailbox["state"])
```

| State | Meaning | What to do |
| --- | --- | --- |
| `active` | Syncing normally. | Nothing. |
| `needs_reauth` | The grant expired or was revoked — password change, admin policy, manual revocation. | Prompt the customer through the connect flow again. |
| `disconnected` | Removed, by you or by them. | Reconnect if they want it back. |

`state_reason` carries the provider's explanation when there is one. Both transitions are also webhooks — [`mailbox.needs_reauth`](/webhooks/events/mailbox-needs-reauth) and [`mailbox.disconnected`](/webhooks/events/mailbox-disconnected) — which is the right place to trigger a re-authorization prompt rather than discovering it on the next failed send. The workspace's owners and admins also get an email when a mailbox needs re-authorization, one per batch rather than per mailbox — each of them at the level they picked under Settings → Notifications.

Sending from a mailbox that isn't `active` fails with `409` [`mailbox_inactive`](/reference/errors#mailbox_inactive).

## Syncing

Mail arrives on its own — Gmail through push notifications, Outlook through Graph subscriptions. `sync` forces a catch-up when you have a reason to think something was missed:

```ts TypeScript
await aiinbx.mailboxes.sync("mbx_...") // 202, queued
```

```python Python
client.mailboxes.sync("mbx_...")
```

```bash curl
curl -X POST https://api.aiinbx.com/api/v2/mailboxes/mbx_.../sync \
  -H "Authorization: Bearer $AI_INBX_API_KEY"
```

It returns `202` with `{ "status": "queued" }` — the work happens in the background, and the messages surface as ordinary `email.received` events. It isn't a way to poll for mail; it's a repair tool.

## Disconnecting

```ts
const mailbox = await aiinbx.mailboxes.disconnect("mbx_...")
console.log(mailbox.state) // "disconnected"
```

Sync stops and the stored credentials are dropped. Messages already received stay on their threads.

## Your own OAuth app

By default the consent screen says AI Inbx. Register your own Google or Microsoft app and it says your product's name instead — which is usually a requirement, not a preference, once you're asking a business customer for mailbox access.

```ts
const app = await aiinbx.oauthApps.create({
  provider: "google",
  name: "Acme Assistant",
  slug: "acme",
  client_id: process.env.GOOGLE_CLIENT_ID!,
  client_secret: process.env.GOOGLE_CLIENT_SECRET!,
  tagline: "Let Acme handle your inbox",
  support_email: "support@acme.com",
  return_urls: ["https://app.acme.com/settings/mailboxes"],
})

console.log(app.redirect_uri) // register this with the provider
console.log(app.connect_url) // the hosted page; + "/start?return_to=…" is the connect link
console.log(app.push_endpoint) // Google only — the Pub/Sub push endpoint
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `provider` | `"google" \| "microsoft"` | - | Which provider this app is registered with. |
| `name` | `string` | - | Shown to the customer during consent. |
| `slug` | `string` | - | 3–64 characters, unique across AI Inbx; forms the hosted connect URL. |
| `client_id` | `string` | - | From the provider's console. |
| `client_secret` | `string` | - | Stored encrypted; never returned. |
| `tenant?` | `string` | - | Microsoft only — restrict to a single Entra tenant. |
| `pubsub_topic?` | `string` | - | Google only — your own Pub/Sub topic for push notifications. |
| `accent?` | `string` | - | Accent color for the hosted connect page. |
| `tagline?` | `string` | - | One line shown under the app name. |
| `support_email?` | `string` | - | Where a confused customer should write. |
| `return_urls?` | `string[]` | - | Pages of yours a connect link may return to, up to 20. Matched on origin and path. |

1. **Register the app with the provider**

    A Google Cloud OAuth client, or an Entra app registration.

2. **Create it here and read back redirect_uri**

    Add that exact URI to the provider's allowed redirect list.

3. **Google only: create a Pub/Sub topic**

    Grant publish rights, then set `pubsub_topic`. Push notifications go to the
    returned `push_endpoint`. A bring-your-own app needs its own topic — it
    cannot share the shared app's.

4. **Connect through it**

    Share the hosted `connect_url`, put a [connect link](#connect-link) on your
    site, or pass `app_id` to `mailboxes.connect`.

`slug` is claimed globally; a taken one fails with `409` [`slug_taken`](/reference/errors#slug_taken). A workspace configured to require its own app returns [`shared_apps_disabled`](/reference/errors#shared_apps_disabled) if you try to connect without an `app_id`.

Secrets can be rotated with `oauthApps.update`; the old value is replaced immediately, so update the provider first.

## Domains or mailboxes?

Not a question of whose address it is — either one can be yours or your customer's. A domain you add on a customer's behalf, walking them through the DNS records, is an ordinary domain. What differs is what you need from them and what you get.

| | [Domain](/guides/domains) | Mailbox |
| --- | --- | --- |
| Needs | Access to the domain's DNS | One person clicking approve |
| Covers | Every address on the domain | One existing inbox |
| Mail is handled by | AI Inbx | The existing Google or Microsoft account, synced |
| Setup | Publish records, wait for DNS | OAuth consent, live in seconds |
| Receiving | The `INBOUND` MX record | Provider sync |
| Breaks when | The records change or lapse | The grant is revoked |

The deciding question is usually whether the address already receives mail somewhere. An address on a live Google Workspace or Microsoft 365 domain has an inbox already, and pointing the MX record at AI Inbx would take that over for the whole domain — connect the mailbox instead. A domain with nowhere for its mail to go yet, or a subdomain created for the purpose, is the domain case.

Plenty of products use both, and often both on behalf of the same customer: a subdomain for anything the product sends, connected mailboxes for anything written from a person's own inbox.
