Skip to main content
Glama
erikknave

multi-mail-mcp

by erikknave
README.md
# multi-mail-mcp

An MCP server that gives an AI agent access to **several mailboxes at once** —
Gmail / Google Workspace and Microsoft 365 / Outlook side by side, plus Slack
for reading and writing chat. Searching and
reading mail, reading and updating calendars, reading and writing Google Drive,
editing Sheets and Docs in place, and moving attachments in and out.

Mail and calendar work identically whichever service a mailbox is on: the same
Gmail query syntax, the same label names, the same recurrence rules. The rest is
whatever the provider actually has:

| | Google | Microsoft | Slack |
|---|:---:|:---:|:---:|
| Mail | ● | ● | — |
| Calendar | ● | ● | — |
| Drive, Sheets, Docs | ● | — | — |
| Chat | — | Teams ● | ● |
| Sign-in | ● | — | — |

Every account reports its own `capabilities` from `list_accounts`, and a tool
called against a provider that lacks the capability answers `NOT AVAILABLE`
rather than a permission error — because no consent screen would fix it.

Runs as a single Node process with a SQLite database and a small web UI.

---

## How it fits together

```
┌──────────────────────────────────────────────────────┐
│ multi-mail-mcp        (one process, port 8456)       │
│                                                      │
│   /mcp                       MCP endpoint (bearer key)     │
│   /                          web UI — mailboxes, keys      │
│   /oauth/google/callback     Google sign-in + consent      │
│   /oauth/microsoft/callback  Microsoft mailbox consent     │
│   /oauth/slack/callback       Slack account consent        │
│   /reauth/<token>            one-click access renewal      │
│   /files/attachment/…        signed attachment download    │
│   /files/upload/…            signed attachment upload      │
│                                                            │
│   SQLite: users, mailboxes, keys, staged uploads           │
└────────────────────────────────────────────────────────────┘
             │                              │            │
             ▼                              ▼            ▼
   Gmail / Calendar / Drive         Microsoft Graph     Slack Web API
   (per Google mailbox)             (per Microsoft      (per Slack account,
                                       mailbox)            user token)
```

Mail is **not** mirrored locally. Every search goes straight to Gmail, so results
are always current and there is no index to maintain. Searching several mailboxes
runs the query against each in parallel and merges the results newest-first.

---

## Getting started

```bash
npm install
npm run build
npm start
```

Then open your `PUBLIC_BASE_URL` in a browser and **sign in with Google**.

1. **Sign in.** The account you sign in with is connected as your first mailbox
   automatically — one flow does both.
2. **Connect the other mailboxes** from the dashboard, Google or Microsoft.
3. **Create an API key.** The dashboard then shows the key once, alongside a
   ready-to-run registration command with the key already filled in, and a copy
   button for each:

   ```bash
   claude mcp add --transport http multi-mail \
     https://your-host.example.com/mcp \
     --header "Authorization: Bearer <your-key>"
   ```

   Keys are 37 characters (`mmcp_` plus 192 bits of randomness) and are stored
   only as a SHA-256 hash, so a key that isn't copied at creation time is gone.

### Sign-in rules

There are no passwords, and **Google is the only way in** — a Microsoft account
can be connected as a mailbox but never used to sign in. That keeps one
authentication path to reason about, and one place where the allowlist applies.

Which user you become follows three rules, checked in order:

1. The address is a known user → sign in as them.
2. The address is already connected as a **mailbox** on some user → sign in as
   **that user**. This is what makes all of your addresses work as ways into one
   account instead of creating a separate user per address.
3. The address is on the allowlist (`ALLOWED_LOGIN_EMAILS`, then managed in the
   admin UI) → create a new user. The first user created becomes administrator.

Anything else is refused. The service is on a public URL, so this matters.

---

## Renewing access

Google expires refresh tokens after **7 days** while the OAuth app is in
*Testing* mode, so this will happen often at first. Microsoft's refresh tokens
roll forward as they are used and typically last months, so Microsoft mailboxes
rarely need this at all. The whole flow is built
around making it a single click:

- Any tool call that hits a dead grant returns an **`ACTION REQUIRED`** message
  containing a ready-made link. The agent can hand that link straight to you.
- Multi-account operations (`search_messages`, `list_events`, `find_free_time`)
  don't fail outright when one mailbox is stale — they return results from the
  working mailboxes and list the broken ones with their renewal links.
- `list_accounts` always shows current status, and `get_reauth_url` produces a
  link on demand.
- Opening the link takes you straight to the right provider's consent screen for
  that specific address — the link carries an account id, not a provider, so it
  works the same for both. No prior sign-in needed; the link itself is the
  authorisation.

Links are HMAC-signed and valid for 24 hours. The callback refuses to proceed if
you sign in as a different Google account than the one the link was issued for.

**To stop the weekly expiry**, publish the OAuth app: Google Cloud Console →
*APIs & Services* → *OAuth consent screen* → **Publish app**. You will get an
"unverified app" warning screen once per consent (click *Advanced* → *Go to…*),
but refresh tokens then stop expiring. Full verification is only needed to go
past 100 users.

---

## Connecting Microsoft mailboxes

Microsoft support is optional and switches itself on once an app registration is
configured. Without `MICROSOFT_CLIENT_ID` the dashboard simply does not offer it.

In **Azure Portal → Microsoft Entra ID → App registrations → New registration**:

1. **Supported account types** — *Accounts in any organizational directory
   (multitenant)* to allow any work or school tenant. Use a single tenant id in
   `MICROSOFT_AUTHORITY` to restrict it to your own, or `common` to also allow
   personal outlook.com accounts.
2. **Redirect URI** — type *Web*, exactly
   `<PUBLIC_BASE_URL>/oauth/microsoft/callback`.
3. **Certificates & secrets → New client secret** — copy the *Value*, not the
   Secret ID. It is shown once, and it expires; note the date.
4. **API permissions → Microsoft Graph → Delegated**: `offline_access`, `openid`,
   `email`, `profile`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`,
   `Calendars.ReadWrite`, `Chat.ReadWrite`, `Chat.Create`. Then
   **Grant admin consent** —
   depending on the tenant's settings an ordinary user may not be able to
   consent alone.

**Re-grant admin consent whenever the permission list changes.** The grant
covers the exact set of permissions it was clicked for, so adding one to the app
registration without pressing *Grant admin consent* again leaves it unconsented.
Admins do not notice — they can approve for themselves at sign-in — but every
non-admin user is stopped by "Need admin approval" and cannot connect their
mailbox at all. One click on that button fixes it for the whole tenant, and
users then see no consent screen whatsoever.

`Mail.ReadWrite` is deliberate in the same way `gmail.modify` is: it covers
reading, moving, flagging and drafting, but not permanent deletion. `Mail.Send`
is separate so sending is its own explicit grant. `Chat.ReadWrite` authorises
reading and posting in the chats the user belongs to, and `Chat.Create` starting
new ones — `ChatMessage.Send` would add nothing, and the *application* chat
permissions, which are gated and metered by Microsoft, are not used at all.

### What differs from Google

Everything below is handled by the server; this is what is happening underneath.

| Gmail idea | Outlook equivalent |
|---|---|
| Labels | Folder, plus read/flagged state and categories |
| `INBOX` / `SENT` / `DRAFT` / `TRASH` / `SPAM` / `ARCHIVE` | The matching well-known folders |
| Archive (remove `INBOX`) | Move to Archive |
| `UNREAD`, `STARRED` | `isRead`, flag status |
| A user label | A folder move, or a category |
| Thread id | Conversation id |
| Query syntax | Translated to KQL or an OData filter |
| RRULE recurrence | Translated to an Outlook recurrence pattern |
| Google Meet | Teams meeting |

Five honest limits:

- **`bcc:` cannot be searched.** Outlook does not index it. A query using it
  comes back with a `queryNotes` entry saying so, rather than quietly returning
  results that ignored the term.
- **`sendUpdates` is not honoured.** Outlook always notifies attendees; a
  calendar write that asked for silence says so in `notes`.
- **`find_free_time` takes addresses, not calendar ids.** Graph's free/busy is
  per mailbox — `"primary"` means the account's own address, and any colleague
  or room address can be added.
- **Drive, Sheets and Docs are Google-only.** Called against a Microsoft mailbox
  they answer `NOT AVAILABLE`, which is not a permission problem and cannot be
  fixed by re-authenticating.
- **Moving a message changes its id.** Outlook reissues a message under a new
  id when it changes folder, so `modify_labels` returns the id to use from now
  on — with `previousMessageId` and a note when it moved. Gmail ids are
  unaffected by labelling and come back unchanged.
- **`list_events` spans at most five years per call.** Outlook caps an expanded
  calendar view at 1825 days; Google has none. Asking wider is refused with the
  limit named, rather than passed through as Graph's "greater than the allowed
  range", which reads like a quota.

Two notes on ordering, both learned the hard way against a live mailbox:

- When a query needs full-text matching, Graph's `$search` forbids `$orderby`,
  so the server fetches a wider page and sorts by date itself. Queries built
  only from filterable terms (`is:unread`, date ranges, `has:attachment`) take
  the exact, server-ordered path instead.
- On that filtered path, Exchange rejects `$orderby=receivedDateTime` unless the
  filter *leads* with `receivedDateTime` — `is:starred` and
  `is:unread has:attachment` both failed with "The restriction or sort order is
  too complex for this operation" until an open-ended lower bound was prepended.
  Dropping `$orderby` instead would have been worse: the server would then be
  free to return any N matches, quietly turning "the 20 newest unread" into "20
  unread, in some order".

Three more Graph behaviours worth not rediscovering:

- `mailFolder.wellKnownName` exists in the Graph **beta** endpoint but not in
  v1.0, and naming an unknown property in `$select` fails the entire request
  rather than returning null for it. The well-known folders are resolved by
  name through a single `$batch` instead.
- **A write response ignores the `Prefer: outlook.timezone` header.** A created
  or patched event comes back in the zone it was submitted in, while reads come
  back in UTC as asked. Stamping that wall clock with `Z` reported a 09:00
  Stockholm meeting as 09:00Z — two hours out, in a value an agent would repeat
  to a human as fact. Times are now converted from whatever zone the response
  declares, IANA or the Windows names some tenants still use.
- **Junk and Deleted Items are excluded in the query, not from the results.**
  Graph searches every folder by default while Gmail does not, and dropping the
  unwanted ones after fetching `$top=N` lets deleted mail eat the result slots —
  a one-result search returned nothing at all when the newest message happened
  to be in the bin, which reads as "no mail" rather than "the newest is
  deleted". `parentFolderId ne` does it server-side. The `$search` path cannot
  combine with `$filter`, so it still sifts afterwards and over-fetches to
  compensate.
- **A sent message is not in Sent Items immediately.** A lookup fired the moment
  `/send` returns finds nothing, so `send_message` retries once and then reports
  `messageId: null` with a note to find it through `get_thread` — rather than an
  empty string the caller would pass to `get_message`.

### Teams chat

`list_chats`, `read_chat`, `start_chat` and `send_chat_message` cover the chats
the connected account itself belongs to — one-to-one, group and meeting chats —
including **federated** ones where someone from another organisation is writing
to you. The same four tools serve a connected Slack account (see below).

Creating and sending are separate tools, which is not an accident: a chat with no
messages in it is invisible to the other people, so `start_chat` notifies nobody
and only `send_chat_message` is outward-facing. Teams allows one one-to-one chat
per pair, so asking for one that exists returns the existing conversation with
its history and says `alreadyExisted` — group chats have no such rule, and
calling twice really does create two.

Two things it deliberately does not do:

- **Guest chats in another tenant.** If you were invited as a guest and switch
  organisation in the Teams client, those conversations belong to *that* tenant,
  and an access token is always for exactly one tenant. Reaching them means
  connecting that tenant as its own account, with its own consent.
- **Downloading chat attachments.** Files posted in a chat live in the sender's
  OneDrive or a SharePoint site, and the server holds no file permissions for
  Microsoft accounts. Attachments come back as a name and a link to hand to the
  user.

Everyone in a chat is checked against the connected account's own tenant, and
anyone outside it is flagged — as is anyone whose tenant cannot be determined,
plus bots and anonymous meeting participants. The flag errs towards "external"
on purpose: mistaking a colleague for an outsider costs one extra question,
while mistaking an outsider for a colleague means replying to them without
asking at all. `read_chat` names who triggered the flag so it can be checked
rather than taken on trust.

Sending is immediate and has no draft state and no unsend, which the tool
description says plainly so an agent confirms the wording first.

---

## Connecting Slack accounts

Slack support is optional like Microsoft's: it appears once `SLACK_CLIENT_ID`
and `SLACK_CLIENT_SECRET` are set, and without them the dashboard does not offer
it. A Slack account is **chat-only** — it adds the four chat tools and nothing
else.

In **api.slack.com/apps → Create New App → From scratch** (any name, your
workspace as the development workspace — an app used only in its own workspace
needs no distribution review):

1. **OAuth & Permissions → Redirect URLs** — add exactly
   `<PUBLIC_BASE_URL>/oauth/slack/callback`.
2. **OAuth & Permissions → User Token Scopes** (not Bot Token Scopes):
   `channels:read`, `groups:read`, `im:read`, `mpim:read`,
   `channels:history`, `groups:history`, `im:history`, `mpim:history`,
   `chat:write`, `im:write`, `mpim:write`, `users:read`, `users:read.email`.
3. **Leave token rotation off.** The token this server stores is the user token
   itself, which does not expire; enabling rotation would mint refresh tokens
   the server has no path to use, and the access token would silently die.
4. Copy **Client ID** and **Client Secret** (Basic Information → App
   Credentials) into `.env`, rebuild and restart.

There is no "Install to Workspace" step — a user-scope-only app has no bot to
install. The dashboard's **Connect Slack** button starts the authorisation and
the token comes back to the callback. Re-authorising later is the same flow, via
the re-auth links the tools hand out.

The token is a **user token (xoxp)**: the agent reads exactly the conversations
the member can read and posts as them, mirroring how Teams chat access is
delegated. No bot token is requested, and no scope for creating or renaming
channels.

### What differs on Slack

- **Threads are read and answered, not flattened away.** `read_chat` fetches a
  thread's replies and flattens them in after the parent — replies carry
  `inThreadOf`, the parent `replyCount`, and `threadTruncated` says when more
  exist than the 20 fetched per thread. `send_chat_message` replies into a
  thread with `threadTs`. Teams has no threads at all.
- **Bodies are mrkdwn, not HTML.** `isHtml` is refused on Slack rather than
  converted — converting would silently drop link targets and formatting.
- **Channels have no participant list.** A channel's name is its identity;
  listing every member of every channel would be an unbounded fan-out for no
  decision an agent needs to make. DMs and group DMs list their people.
- **`isExternal` means Slack Connect.** Conversations shared with an outside
  organisation are flagged, but which organisations they are cannot be named
  without member calls the server does not make.
- **One workspace per member email.** The account is keyed on the Slack profile
  address; authorising a second workspace whose profile email matches replaces
  the first.
- **Attachments are permalinks**, like Teams attachments — a name and a link to
  hand to the user, no bytes.

---

## Tools

Mail, calendar and account tools work with both providers. Drive, Sheets and Docs
are Google-only; chat works on Microsoft (Teams) and Slack.

**Only the tools your accounts can use are registered.** A tool that could
only ever answer "not available for this account" is not worth the context it
costs to describe, and a shorter list is one a model chooses from more
accurately. So the Drive, Sheets and Docs tools appear once a Google mailbox is
connected, the chat tools once a Microsoft or Slack one is, and a user with
nothing connected sees only the three account tools — which is what tells them
to connect an account. In tokens, the full surface is around 17k; a
Microsoft-only user sees about 8.7k, and a user with no mailboxes about 0.8k.

The endpoint is stateless, so the list is recomputed on every request and needs
no cache invalidating. A client that caches `tools/list` may need to reconnect
before newly connected mailboxes bring their tools into view.

**Accounts** — `list_accounts`, `check_account`, `get_reauth_url`

**Mail** — `search_messages`, `get_message`, `get_thread`, `list_labels`,
`modify_labels`, `send_message`, `create_draft`, `get_attachment_url`

**Calendar** — `list_calendars`, `list_events`, `get_event`, `create_event`,
`update_event`, `delete_event`, `respond_to_event`, `find_free_time`,
`find_rooms`

**Chat** (Microsoft and Slack) — `list_chats`, `read_chat`, `start_chat`,
`send_chat_message`

**Drive** — `search_drive`, `read_drive_file`, `list_drive_folder`,
`get_drive_download_url`, `upload_to_drive`, `write_drive_file`,
`create_drive_folder`, `update_drive_file`, `copy_drive_file`,
`trash_drive_file`, `restore_drive_file`, `get_drive_permissions`,
`share_drive_file`, `unshare_drive_file`, `get_drive_usage`

**Sheets** — `list_sheet_tabs`, `duplicate_sheet_tab`, `add_sheet_tab`,
`rename_sheet_tab`, `delete_sheet_tab`, `reorder_sheet_tab`, `read_sheet_range`,
`write_sheet_range`, `append_sheet_rows`, `clear_sheet_range`,
`format_sheet_range`, `auto_resize_sheet_columns`, `set_sheet_layout`,
`create_spreadsheet`

**Docs** — `read_doc`, `append_to_doc`, `append_doc_heading`, `replace_doc_text`,
`create_doc`

**Attachments** — `create_upload_url`, `list_uploads`

Most tools take an optional `account` argument naming the mailbox. Omit it when
only one mailbox is connected; with several, the tool asks you to name one rather
than guessing — sending from the wrong address is not worth being clever about.

`search_messages` uses Gmail query syntax — against both providers — and returns
compact summaries **without bodies**, so a broad search doesn't flood the context
window. Follow up with `get_message` or `get_thread` for the text and the
attachment list.

Search results deliberately carry no attachment flag: Gmail's `metadata` format
returns headers but not the MIME part tree, so any such field could only ever be
wrong. Filter with the `has:attachment` operator in the query instead — it runs
server-side and costs nothing.

### Drive

Drive search uses **Drive query syntax**, which is not Gmail's:

```
name contains 'invoice' and mimeType = 'application/pdf'
fullText contains 'quarterly report'
modifiedTime > '2026-01-01T00:00:00'
'<folderId>' in parents
```

Three behaviours worth knowing:

- **Google Docs, Sheets and Slides hold no bytes.** They are exported on the
  way out — to Markdown, CSV and plain text when read as text, and to .docx,
  .xlsx and .pptx when downloaded. Asking for the "raw" content of a Doc is not
  a thing that exists.
- **Shared drives are included.** Every call opts in explicitly; without that
  the API quietly pretends team-drive content does not exist and a search
  returns a confident, wrong "no results".
- **Nothing is deleted permanently.** `trash_drive_file` bins the file, Drive
  keeps it 30 days, and `restore_drive_file` brings it back.

Sharing is deliberately limited: files can be shared with **named people only**.
The server cannot create "anyone with the link" access, because a mistakenly
public file is hard to notice afterwards. It *can* remove such a permission —
`get_drive_permissions` flags a publicly reachable file and `unshare_drive_file`
revokes it.

### Changing an existing invitation

A meeting room is an **attendee**, not the `location` field — it is booked by
adding its resource address, and `location` is free text that books nothing.
Events report their rooms separately (`rooms`) and flag each attendee with
`isResource` and a `displayName`, so a room is never just an opaque
`c_188…@resource.calendar.google.com`.

`update_event` merges guest changes rather than replacing them:

- `addAttendees` / `removeAttendees` change only the people you name. Everyone
  else keeps their RSVP, and any booked room stays booked.
- `addOptionalAttendees` invites as optional, or flips an existing required
  guest to optional without touching their RSVP — `create_event` takes
  `optionalAttendees` for the same thing at creation.
- `setAttendees` replaces the whole list, uninviting anyone omitted — including
  rooms. It exists for when that is genuinely the intent.

The response says what actually changed, including when a room booking was
released or taken.

`find_rooms` lists the rooms you have used before, most frequent first, with the
address needed to book one. Enumerating every room in an organisation needs
Workspace admin rights, which this server does not have — but the rooms you have
already met in are the ones you want again.

For a repeating event, changes apply to the single occurrence unless you pass
`applyTo: "series"`.

### Sheets and Docs

**Never edit a spreadsheet or document through `write_drive_file`.** Drive treats
a file as one opaque blob, so writing to it replaces the whole thing — on a
workbook that destroys every other tab. Structured edits go through the Sheets
and Docs APIs instead:

- `write_sheet_range` writes cells and leaves the rest of the workbook alone.
- `duplicate_sheet_tab` copies a tab with its formatting, formulas, conditional
  formatting, column widths and frozen rows intact — the right way to start a
  new month from the last one. Clear the carried-over values with
  `clear_sheet_range`, which keeps the formatting.
- `append_to_doc` and `replace_doc_text` edit a document in place.

Both APIs are authorised by the **same Drive scope**, so enabling them needs no
new consent. They do need switching on once in the Cloud project:
[Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com) ·
[Docs API](https://console.cloud.google.com/apis/library/docs.googleapis.com).
Until then the tools say exactly that, with the link — they do not send anyone
through a pointless re-authentication.

Ranges use A1 notation (`'Jul 2026'!B2:F40`). A bare name such as `Jun 2026` or
`Sheet1` means the whole tab; `A1` means that cell.

### Attachments

**Downloading** — messages come back with a signed `downloadUrl` per attachment.
The URL streams from Gmail on demand; nothing is stored on the server. Valid for
one hour.

**Uploading** — `create_upload_url` reserves a slot and returns a URL to PUT to:

```bash
curl -X PUT --upload-file ./report.pdf "<uploadUrl>"
```

Then pass the returned `uploadId` in the `uploadIds` array of `send_message` or
`create_draft`. Staged files are deleted when the slot expires.

---

## Admin CLI

```bash
npm run cli -- allow <email>            # add to the sign-in allowlist
npm run cli -- users                    # list users
npm run cli -- accounts <user-email>    # mailboxes and their health
npm run cli -- key <user-email> [name]  # issue an API key
npm run cli -- keys <user-email>        # list keys
npm run cli -- audit [n]                # recent audit log
```

---

## Configuration

All settings live in `.env` (see `.env.example`). The ones that matter:

| Variable | Purpose |
|---|---|
| `PORT` | Listen port, e.g. `8456` behind a reverse proxy or tunnel. |
| `PUBLIC_BASE_URL` | Public origin. Used to build the OAuth redirect and every signed URL. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | OAuth client, type *Web application*. |
| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | Entra ID app registration. Blank disables Microsoft mailboxes. |
| `MICROSOFT_AUTHORITY` | `organizations` (default), a tenant id, or `common` to allow personal accounts. |
| `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` | Slack app credentials. Blank disables Slack chat. |
| `ALLOWED_LOGIN_EMAILS` | Comma-separated addresses allowed to create a user. |
| `ENCRYPTION_KEY` | 32 random bytes, base64. Encrypts refresh tokens at rest. |
| `URL_SIGNING_SECRET` | 32 random bytes, base64. Signs download, upload and renewal links. |
| `MAX_UPLOAD_BYTES` | Attachment size cap, default 25 MB (Gmail's own ceiling). |

The redirect URIs must match exactly: `<PUBLIC_BASE_URL>/oauth/google/callback`
in Google Cloud Console, `<PUBLIC_BASE_URL>/oauth/microsoft/callback` in
Entra ID, and `<PUBLIC_BASE_URL>/oauth/slack/callback` in the Slack app's
OAuth & Permissions.

**Rotating `ENCRYPTION_KEY` or `URL_SIGNING_SECRET` invalidates existing data**:
a new encryption key makes every stored refresh token unreadable, so every
mailbox has to be reconnected.

---

## Security notes

- **Refresh tokens are encrypted at rest** with AES-256-GCM, for both providers.
  Access tokens are cached encrypted too and refreshed ahead of expiry. Microsoft
  rotates its refresh token on every renewal, and the new one is persisted — the
  grant would otherwise die at the following renewal.
- **The two OAuth flows cannot be crossed.** Each callback signs its state with a
  distinct kind, so an authorization code obtained for one provider cannot be
  replayed at the other's callback.
- **Microsoft is not a sign-in method.** Its callback has no path that creates a
  user; it requires an established session or a signed renewal link.
- **API keys are stored as SHA-256 hashes.** The plaintext is shown once, at
  creation, and is not recoverable.
- **Every tool is bound to the authenticated user.** A fresh MCP server is built
  per request with that user closed over, so no argument can reach another user's
  mailboxes.
- **Signed URLs are typed and time-limited.** A download token cannot be replayed
  as an upload token, and a tampered token fails HMAC verification.
- **Uploaded filenames never touch the filesystem path.** Files are stored under
  a random name; the display name lives only in the database.
- **Chat access is delegated, never application-wide.** `Chat.ReadWrite` reaches
  only the chats the signed-in user is already in — there is no path to reading
  the organisation's messages, and the gated application permissions that would
  allow it are not requested. Slack is the same stance with a user token: it
  reads and posts as the member who authorised it, holds no bot token, and
  requests no scope for creating or renaming channels. The token is stored
  AES-256-GCM-encrypted like every other credential.
- **`gmail.modify` is requested, not `gmail.full`**, and `Mail.ReadWrite` rather
  than anything wider — an agent can archive, label and trash, but cannot
  permanently delete mail. Drive follows the same line: the server never calls
  `files.delete`, only trash and restore.
- **Capabilities are checked before the call.** An account connected before a
  capability existed is detected from its stored scopes and told to extend its
  permission, rather than failing with an opaque 403 from inside the API.
- Anyone holding an API key can read, send and delete mail in every connected
  mailbox. Treat keys as you would the mailbox passwords themselves, and revoke
  them from the dashboard when a client is retired.

---

## Running it as a service

Run it under systemd, not from a shell. A process started with `nohup ... &` is
a child of that shell's process group and dies with it — which looks exactly
like a crash, except the log ends with a clean `Received SIGTERM`.

A **user** service is enough; no root required:

```ini
# ~/.config/systemd/user/multi-mail-mcp.service
[Unit]
Description=multi-mail-mcp — mail and calendar MCP server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/path/to/multi-mail-mcp
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=5
# Give SQLite a moment to flush its WAL on stop.
TimeoutStopSec=15
SyslogIdentifier=multi-mail-mcp

[Install]
WantedBy=default.target
```

```bash
systemctl --user daemon-reload
systemctl --user enable --now multi-mail-mcp

# Required so the service keeps running when you log out, and starts at boot:
sudo loginctl enable-linger "$USER"
```

Day to day:

```bash
systemctl --user status multi-mail-mcp
systemctl --user restart multi-mail-mcp        # after a rebuild
journalctl --user -u multi-mail-mcp -f         # follow the log
```

`WorkingDirectory` is what lets the process find `.env` and `data/`, so it must
point at the checkout.

---

## Development

```bash
npm run dev        # watch mode
npm test           # unit tests
npm run typecheck  # types only
```

Tests cover the parts that are worth testing without live credentials: MIME
assembly, Gmail payload parsing, calendar mapping for both providers, the
Gmail→Graph query translation, RRULE conversion, capability checks, chat
participant and message mapping (through a stubbed Graph), HTML entity decoding,
token signing and expiry, encryption round-trips, and filename sanitisation.

The provider split lives in three places: `src/mail/types.ts`,
`src/calendar/types.ts` and `src/chat/types.ts` define what a mailbox must be
able to do, `src/google/*` and `src/microsoft/*` implement it, and
`src/service.ts` picks between them. Tools never see a provider-specific client.

Which provider offers what is stated once, in `SCOPE_FOR` and `GRAPH_SCOPE_FOR`
in `src/config.ts`. A `null` there means "this provider has no such thing", and
everything downstream — the refusal message, the `capabilities` list, which
accounts a fan-out tool skips — follows from it.