Agent API¶
/api/v1/agent is a read-mostly HTTP API for wiring an AI agent, script, or
integration up to a user's mailboxes: search, fetch a message and its
attachments, hand off to IMAP directly, and trigger or poll a sync. It is the
only versioned, externally contracted surface in MFB — everything under
/api/restore is the web UI's private contract and follows the UI's own
shapes instead.
This page is aimed at whoever is wiring an agent up to it. For how the authentication and scope model is built, see the Security Model.
The same tokens also work over MCP, for a client that speaks that protocol instead of raw HTTP.
Creating a token¶
On the Profile page, under Access tokens, create a token with a name and one or more scopes:
| Scope | Grants |
|---|---|
imap |
IMAP login only (Dovecot, Roundcube). No access to this HTTP API. |
mail:read |
The read endpoints below: mailboxes, search, message and attachment fetch, IMAP-coords lookup. |
sync:trigger |
Both sync endpoints — queuing a sync and reading a job's status. |
Pick mail:read for an agent that only searches and reads mail, and add
sync:trigger if it should also be able to ask for a fresh sync or poll one
it queued. A token holding only imap is a valid IMAP password but gets a
403 from every route on this API — that separation is intentional, since
imap is the scope the IMAP-only skills use and it shouldn't imply anything
more.
The token is shown exactly once, at creation, as mfb_<prefix>_<secret>.
Store it then; it can't be recovered later, only revoked and replaced.
Authenticating¶
Send the token as a standard bearer header:
curl -H "Authorization: Bearer mfb_abc123..._def456..." \
https://mfb.example.com/api/v1/agent/mailboxes
The scheme name (Bearer) is matched case-insensitively. If the header names
the bearer scheme at all, the request is treated as a token attempt end to
end: a token that turns out to be revoked, expired, or malformed — or whose
owning user is disabled or is being migrated to a different mail store —
gets a plain 401, and the request never falls back to a browser session
cookie that might be attached to the same connection.
Endpoints¶
GET /mailboxes¶
The mailboxes this token's user can search, with what is actually indexed in
each — indexed_messages and folders come from the search index, not the
provider, so they answer "what can I search right now" rather than "how many
messages does the provider report".
[
{
"account_id": "8f2b...",
"name": "Work Gmail",
"email_address": "andrea@example.com",
"provider": "google",
"last_sync_at": "2026-08-20T09:14:00Z",
"indexed_messages": 41823,
"folders": ["INBOX", "Sent", "[Gmail]/All Mail"]
}
]
Requires mail:read.
POST /search¶
Indexed search across every mailbox the token's user can see.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"query": "invoice fiscozen", "deep": false, "page_size": 20}' \
https://mfb.example.com/api/v1/agent/search
{
"results": [
{
"account_id": "8f2b...",
"message_id": "<abc@mail.example.com>",
"message_id_hash": "a1b2c3...",
"subject": "Invoice #4471",
"from_addr": "billing@fiscozen.it",
"from_name": "Fiscozen",
"to_addrs": ["andrea@example.com"],
"date_sent": "2026-07-02T10:03:00Z",
"folder_path": "INBOX",
"alive_in_live": true,
"snapshots": ["2026-07-03T00:00:00Z"],
"has_attachments": true,
"attachments": [
{"filename": "invoice-4471.pdf", "ext": "pdf", "size_bytes": 88213, "part_index": 2}
],
"body_matched": null
}
],
"total": 1,
"page": 1,
"page_size": 20,
"partial": false
}
Request fields: query, account_ids (omit for "every visible mailbox"),
range_start / range_end, include_deleted (default true),
snapshot_id, deep, page, page_size (max 200). There is no
include_all field — see What this API does not
do.
Setting deep: true additionally runs a live body search over Dovecot,
bounded by a server-side timeout. body_matched is true/false per hit
only when deep was requested; it is null when it wasn't, so a caller
can't confuse "didn't match" with "wasn't checked". If the deep pass times
out partway through, the response still comes back with whatever it found
so far and "partial": true — treat that as "more may exist, not all
folders were reached" rather than as an error.
Requires mail:read.
POST /search-attachments¶
Search attachments by filename, and by extracted text when the optional Tika integration is enabled.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"query": "imponibile", "include_content": true, "exts": ["pdf"]}' \
https://mfb.example.com/api/v1/agent/search-attachments
{
"results": [
{
"account_id": "8f2b...",
"message_id": "<abc@mail.example.com>",
"message_id_hash": "a1b2c3...",
"part_index": 2,
"filename": "invoice-4471.pdf",
"ext": "pdf",
"size_bytes": 88213,
"content_snippet": "...imponibile € 1.200,00...",
"subject": "Invoice #4471",
"from_addr": "billing@fiscozen.it",
"folder_path": "INBOX",
"date_sent": "2026-07-02T10:03:00Z",
"alive_in_live": true,
"snapshots": []
}
],
"total": 1,
"page": 1,
"page_size": 50,
"content_search_available": true
}
content_search_available says whether include_content means anything on
this deployment — without it, a caller can't tell "no matches" apart from
"content search is switched off here". Request fields: query,
account_ids, exts, min_size / max_size, include_content,
range_start / range_end, page, page_size.
Requires mail:read.
GET /messages/{account_id}/{message_id_hash}¶
Headers, a body snippet, and the attachment list for one message —
message_id_hash and part_index come straight out of a search hit, so
finding a message and fetching it is a two-call sequence with no
re-derivation in between.
curl -H "Authorization: Bearer $TOKEN" \
https://mfb.example.com/api/v1/agent/messages/8f2b.../a1b2c3...
{
"subject": "Invoice #4471",
"from_addr": "billing@fiscozen.it",
"from_name": "Fiscozen",
"to_addrs": ["andrea@example.com"],
"date_sent": "2026-07-02T10:03:00Z",
"folder_path": "INBOX",
"alive_in_live": true,
"source": "live",
"body_snippet": "Please find attached your invoice...",
"attachments": [
{"filename": "invoice-4471.pdf", "ext": "pdf", "size_bytes": 88213, "part_index": 2}
]
}
source is "live" when served from the current Maildir, or
"snapshot:<id>" when the message is gone from the live mailbox and was
served from the newest snapshot that still holds it.
Requires mail:read.
GET /messages/{account_id}/{message_id_hash}/attachments/{part_index}¶
Raw attachment bytes — the part_index from a search hit or from
GET /messages/{account_id}/{message_id_hash} addresses exactly one
attachment, so a search result carries everything needed to download it
without a separate lookup step.
curl -H "Authorization: Bearer $TOKEN" \
https://mfb.example.com/api/v1/agent/messages/8f2b.../a1b2c3.../attachments/2 \
-o invoice-4471.pdf
The response is always application/octet-stream with
X-Content-Type-Options: nosniff, regardless of the attachment's real type —
a hostile HTML or SVG attachment downloads as a file, it never renders on
MFB's origin. Every download is audited the same way the UI's own attachment
download is.
Requires mail:read.
POST /imap-coords¶
Resolves Message-IDs (as returned by search) to live IMAP folder keys and
UIDs, so an agent can hand off to an existing IMAP client instead of
re-fetching bytes over HTTP — search here, then SELECT and FETCH there.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"account_id": "8f2b...", "message_ids": ["<abc@mail.example.com>"]}' \
https://mfb.example.com/api/v1/agent/imap-coords
{
"resolved": {
"Work Gmail (andrea@example.com)/INBOX": ["1523"]
},
"missing": [],
"imap_unavailable": false
}
resolved keys are namespace-prefixed folder names exactly as Dovecot
publishes them — SELECT them as-is, a bare folder name will not match.
Values are real IMAP UIDs. Message-IDs beyond the first 200 in a single
request are silently ignored (neither resolved nor reported missing) — cap
each request to 200 IDs or fewer.
imap_unavailable: true means Dovecot itself could not be reached, so every
ID that would otherwise have been resolved landed in missing for that
reason rather than because it was checked and not found. Treat that case as
"retry", not as "these messages don't exist" — a single folder failing to
resolve on an otherwise-reachable connection does not set this flag, it
just adds those IDs to missing as ordinary non-matches.
Requires mail:read.
POST /sync/{account_id}¶
Queue a sync for one mailbox.
{
"job_id": "d4e5...",
"account_id": "8f2b...",
"status": "pending",
"source": "agent",
"already_queued": false,
"requested_at": "2026-08-20T09:00:00Z",
"started_at": null,
"completed_at": null,
"failure_kind": null
}
If a sync for this mailbox is already pending or running, that existing job
is returned instead, with "already_queued": true — this is not an error, it
means the mailbox is already covered. A polling agent needs no special case
for it; it's the ordinary "nothing further to do" outcome, not a failure.
Requires sync:trigger.
GET /sync/jobs/{job_id}¶
Status of one sync job.
{
"job_id": "d4e5...",
"account_id": "8f2b...",
"status": "completed",
"source": "agent",
"already_queued": false,
"requested_at": "2026-08-20T09:00:00Z",
"started_at": "2026-08-20T09:00:03Z",
"completed_at": "2026-08-20T09:01:47Z",
"failure_kind": null
}
status is one of the ordinary job states; failure_kind (when set) is one
of throttled / budget_paused / transient / interrupted / error —
only error is a real failure, the others are self-recovering pauses that
will resolve on their own.
This route needs the sync:trigger scope, not mail:read, even though it
only reads a job's status — deliberately, so a read-only agent can search and
fetch mail without also being able to find out whether a sync it didn't
trigger is running.
The search-then-fetch flow¶
A typical agent session looks like:
POST /search(or/search-attachments) with a query.- Take a hit's
account_id+message_id_hash, and, for a specific file, the attachment'spart_index. GET /messages/{account_id}/{message_id_hash}for the headers and a body snippet (capped at 2048 characters — see the endpoint reference above), or go straight toGET /messages/{account_id}/{message_id_hash}/attachments/{part_index}for the file.
That pairing — message_id_hash plus part_index — is what lets an agent go
from "find the invoice" to "download the PDF" in one pass, with nothing to
look up in between. If the agent instead wants to keep working through an
existing IMAP client rather than downloading over HTTP, POST /imap-coords
takes the same search hits' Message-IDs and turns them into folder keys and
UIDs that client can SELECT/FETCH directly.
Failure modes an agent must handle¶
| Condition | Response | Meaning |
|---|---|---|
| Missing, malformed, expired, or revoked token — or a token whose owning user is disabled or is being migrated to a different mail store | 401 |
Not authenticated. Never falls back to a session. |
| Token valid, but missing the scope the route requires | 403 |
Authenticated, but not authorized for this action. |
| A mailbox, message, attachment, or job the caller cannot see | 404 |
Never 403 — the API does not confirm that something exists if the caller isn't allowed to see it. |
message_id_hash is not valid hex |
400 |
Malformed request, not an authorization outcome. |
Deep search (deep: true) hits its timeout |
200 with "partial": true |
Results so far, not everything checked. Not an error. |
POST /imap-coords can't reach Dovecot |
200 with "imap_unavailable": true |
The IDs in missing were never checked — retry, don't conclude the mail is gone. |
POST /sync/{account_id} on a mailbox already syncing |
200 with "already_queued": true |
The existing job is returned; nothing further needed. |
POST /sync/{account_id} on a suspended or migrating account, or a self-recovering pause |
409 |
Refused, naming the reason. Unlike the web UI, this route never overrides a pause on the caller's behalf — an agent cannot weigh burning the provider's daily quota the way a human triggering it manually can. |
| The attachment behind a download exceeds the internal extraction cap | 502 with "attachment too large to extract" |
The message bytes were found but are too large to safely parse for that one part; not an authentication or authorization outcome. |
What this API does not do¶
- No writes and no sending. Every route is a read, a search, or a sync trigger — there is no endpoint that modifies, deletes, or sends mail.
- No access to another user's mail, ever — including for an admin.
include_alldoes not exist as a field on any request model here. A token minted by an admin user sees exactly that admin's own mailboxes, the same as any other user's token would. mail:readdoes not implyimap. A token needs theimapscope separately to use as an IMAP/Roundcube password;mail:readonly unlocks this HTTP API's read endpoints.
Model Context Protocol¶
MFB also exposes the same mailbox operations as an MCP server, for clients that talk MCP instead of HTTP — an agent framework's MCP client, an IDE, or any tool that lets you add a remote MCP server by URL. It's the same tokens, the same scopes, and (mostly) the same operations as the REST API above, wrapped as MCP tools instead of routes.
Enabling it¶
MCP is off by default. A deployment needs two settings:
MAILFALLBACK_MCP_ENABLED=trueMAILFALLBACK_MCP_PUBLIC_URL— the externally-reachable base URL MFB is served at (e.g.https://mfb.example.com, no path, no query string).
Setting only the first does not half-start the server: at boot, if
mcp_enabled is true but mcp_public_url is empty, MFB logs an error and
does not mount /mcp at all — there is no sensible default to guess for a
public URL, so it refuses to build a server that would advertise the wrong
one.
mcp_public_url is not cosmetic. It feeds the MCP SDK's transport-security
settings on two axes:
- Issuer/resource metadata — it's the URL MFB claims to be, in the protected-resource metadata a client fetches during discovery.
- The host allowlist — the SDK's DNS-rebinding protection rejects any
request whose
Host(orOrigin) doesn't match an allowed value. Get the public URL wrong (or leave it as an internal/Docker-network hostname) and every request gets rejected with421or403— which looks exactly like an authentication failure but isn't one; the token is never even reached.
Disabling this (MAILFALLBACK_MCP_DNS_REBINDING_PROTECTION=false) skips more
than the Host check: it also skips the SDK's Origin check, and a reverse
proxy that validates Host does not validate Origin for you — the two are
independent headers a client controls independently. So a Host-validating
proxy in front of MFB does not make disabling this setting fully safe by
itself. It remains practically low-risk here specifically because MCP
authenticates with a static bearer header rather than a cookie: a
browser-based attacker exploiting a DNS-rebinding or cross-origin request has
no way to attach the token, so it cannot authenticate even with both checks
off. Leave the protection enabled unless you have a specific reason not to.
Connecting¶
Point an MCP client at https://<host>/mcp/ (streamable HTTP transport,
with the trailing slash) with the token as a bearer header, same as the
REST API:
The app is mounted at the path, so the endpoint is the trailing-slash form; a
POST to https://<host>/mcp (no slash) gets a 307 redirect to the
slash form. Configure the slash form directly — not every HTTP client
follows a redirect on POST, and some that do drop the request body, so a
client pointed at the bare path can fail with nothing in the response to
suggest the URL, rather than the token, is the problem.
The scheme name is matched case-insensitively (bearer, Bearer, BEARER
all work), same as the REST surface — and same as the REST surface, a
different scheme is rejected outright rather than silently ignored.
MFB is not an OAuth 2.1 resource server, and that's a real limitation.
The MCP specification's authorization model expects a remote server to
support OAuth 2.1 discovery and token issuance. MFB doesn't do that — it
authenticates MCP the same way it authenticates IMAP and the REST API: one
static bearer token, created once on the Profile page, used everywhere. That
means one credential model to reason about instead of three, but it also
means an MCP client that insists on driving a full OAuth discovery-and-grant
flow before it will talk to a server will not connect here. A client that
lets you configure a static Authorization header for a remote MCP server
works fine.
Scopes and tools¶
Same three scopes as the REST API (imap, mail:read, sync:trigger) —
see Creating a token above. A token holding only imap
reaches the server (authentication succeeds) but every tool call on it is
refused, because none of the eight tools accept the bare imap scope. That's
intentional: imap is the scope the IMAP-only skills use, and it shouldn't
imply anything more here either.
Eight tools, mirroring the REST endpoints above:
| Tool | Arguments | Scope | Read-only |
|---|---|---|---|
list_mailboxes |
(none) | mail:read |
yes |
search_mail |
query, account_ids, range_start, range_end, include_deleted, snapshot_id, deep, page, page_size |
mail:read |
yes |
search_attachments |
query, account_ids, exts, min_size, max_size, include_content, range_start, range_end, page, page_size |
mail:read |
yes |
get_message |
account_id, message_id_hash |
mail:read |
yes |
download_attachment |
account_id, message_id_hash, part_index |
mail:read |
yes |
imap_coords |
account_id, message_ids |
mail:read |
yes |
sync_now |
account_id |
sync:trigger |
no |
sync_status |
job_id |
sync:trigger |
yes |
Seven tools are annotated read-only (read_only_hint: true), so an MCP
client that surfaces that hint can auto-approve them without a
confirmation prompt. sync_now is the only one that changes state — it
queues a sync job — and is annotated accordingly.
The arguments and response shapes match their REST counterparts one for
one (search_mail is POST /search, imap_coords is POST /imap-coords,
and so on) — see the endpoint reference above for request fields, response
fields, and the failure-mode table. The same rules apply here: deep: true
on search_mail can come back with partial: true rather than an error,
sync_now on an already-syncing mailbox returns the existing job with
already_queued: true instead of failing, and sync_now refuses outright
(rather than overriding, the way the web UI may) on a suspended, migrating,
or self-recovering-paused account. Admin role does not travel with a
token here either: a token minted by an admin sees only that admin's own
mailboxes, exactly like every other token.
Two tools carry MCP-specific notes worth calling out on their own:
download_attachmentreturns the attachment as base64 incontent_base64, inline in the tool result. A part over 5 MiB is refused rather than served — base64 inflates the payload by a third and the whole thing rides inside one JSON-RPC response, so the cap exists to keep that response bounded. Read it as a route, not a dead end: the error names the message's folder and points the caller atimap_coordsto resolve IMAP coordinates and fetch the same attachment directly over IMAP instead.imap_coordsreturns namespace-prefixed IMAP folder keys (exactly as Dovecot publishes them —SELECTthem as-is) and real IMAP UIDs, the bridge from an MCP search hit to fetching over an existing IMAP connection.imap_unavailable: truemeans Dovecot itself could not be reached, so every id that landed inmissingwas never actually checked — the right response is to retry, not to conclude the mail is gone.