Skip to main content
Glama
bdi-admin

mcp-mail-bridge

by bdi-admin
README.md
# mcp-mail-bridge

An MCP server that bridges an LLM tool client to a mailbox over IMAP and
SMTP. It exposes a small, deliberately read-mostly set of tools: health
checks, mailbox listing, message listing/fetching, and idempotent send/reply.
There is no message deletion, flag mutation, or archive manipulation of any
kind.

**Protocol requirements.** The implementation targets:

- IMAP4 (`imaplib.IMAP4_SSL`) for read operations, SMTP (`smtplib.SMTP_SSL`)
  for send/reply — both over **implicit TLS**, not STARTTLS.
- Username/password authentication via the standard IMAP `LOGIN` command and
  SMTP `AUTH` (through `login()`); no OAuth flow is implemented.
- Configurable IMAP and SMTP host/port, defaulting to the implicit-TLS
  convention (993 / 465).

It has been tested end-to-end against one real-world IMAP/SMTP mailbox
meeting those requirements. Any other provider that offers implicit-TLS
IMAP4 and SMTP with username/password login should work by protocol
conformance, but that broader interoperability is a design expectation, not
something independently verified here — a provider that only offers
STARTTLS on port 587, with no implicit-TLS option, will not work without
modifying `_smtp()`/`_imap()` in `core/mail_core.py`.

## Structure

```text
mcp_mail_bridge/
  core/
    types.py          frozen contract dataclasses and enums
    errors.py         typed, sanitized operation failures
    credentials.py    call-time protected-file credential provider
    idempotency.py    SQLite key -> SendResult bookkeeping only
    drafts.py         SQLite-backed persistent reply drafts
    mail_core.py      per-operation IMAP/SMTP, MIME, replies and send outcomes
  adapters/mcp/
    schema.py         MCP input models and JSON conversion
    server.py         six delegating tools and stdio entry point
tests/
  test_contract.py    fake IMAP / real smtplib with in-memory socket
  test_adapter.py     schema, conversion, errors and real stdio discovery
```

Core does not import MCP, HTTP or CLI frameworks. Only `mail_core.py` knows
`imaplib`/`smtplib`. The adapter never obtains a password. Connections use
verified TLS and close after each operation. Inbound selection is read-only
and every fetch uses `BODY.PEEK`; replies fetch only the threading/address
header fields. No STORE, APPEND, EXPUNGE, MOVE or mark-seen API exists.

## MCP capabilities

The V0 tools are `mail_health`, `mail_list_mailboxes`, `mail_list_messages`,
`mail_fetch_message`, `mail_send`, and `mail_reply`. V1 additionally provides
`mail_prepare_reply`, `mail_update_draft`, `mail_get_draft`, `mail_send_draft`,
and `mail_discard_draft`. Send and reply require an idempotency key. UIDVALIDITY is checked before fetching a `MessageRef`, so a
stale reference (for example, after a mailbox re-sync) fails closed instead of
silently fetching the wrong message.

`mail_prepare_reply` resolves the source message's recipient and threading
headers once using a read-only `BODY.PEEK` fetch, then stores an opaque draft
ID. A draft's source reference, recipients, subject, `Message-ID`,
`In-Reply-To`, and `References` are immutable. Only its text and HTML body can
be updated. `mail_send_draft` constructs the outgoing message entirely from
that persisted context and body; it does not re-fetch IMAP or reconstruct the
reply target. Draft bodies persist in the same local SQLite state file as the
idempotency results, so that file must be treated as confidential application
data. Sent and ambiguous drafts are retained and immutable; a definitively
failed draft remains pending for editing or a new-key retry. There are no
standalone persistent outbound drafts in V1: `mail_send` remains the direct
path for new mail.

## Credential handling model

Credentials are never held by the MCP adapter and never embedded in the
package, database, or logs. A `CredentialProvider` reads a password fresh from
a protected local file only at the moment a core operation needs it.

To be precise about what that guarantees: the code sets its local variable
holding the password to `None` immediately after use (see the `password =
None` sites in `core/mail_core.py`). That releases the application's own
reference so the value isn't held in scope longer than necessary and isn't
copied into the database or logs — it is **not** a claim that CPython
overwrites or wipes the underlying bytes in process memory, that garbage
collection is immediate, or that the string can't still exist in memory
(e.g. via string interning, swap, or a core dump) after the reference is
dropped. If you need actual memory-scrubbing guarantees, this package does
not provide them.

The default entry point (`mcp_mail_bridge/adapters/mcp/server.py`) is
configured entirely through environment variables, with no live values baked
in:

- `MAILBRIDGE_USERNAME` — mailbox login (default `user@example.com`)
- `MAILBRIDGE_CREDENTIAL_PATH` — path to a file containing only the password
  (default `/etc/mcp-mail-bridge/credential`, e.g. `/path/to/credential`)
- `MAILBRIDGE_IMAP_HOST` / `MAILBRIDGE_SMTP_HOST` — your provider's IMAP and
  SMTP hostnames (defaults `imap.example.com` / `smtp.example.com`, which do
  not resolve to a real server — you must set these)
- `MAILBRIDGE_IMAP_PORT` / `MAILBRIDGE_SMTP_PORT` — implicit-TLS ports
  (default `993` / `465`)
- `MAILBRIDGE_IDEMPOTENCY_PATH` — SQLite database path (default
  `/var/lib/mcp-mail-bridge/idempotency.sqlite`)
- `MAILBRIDGE_OPERATOR_NAME` — the party named in the send/reply tool
  descriptions and in the `AMBIGUOUS` outcome detail as the one who must
  externally verify an uncertain send before any retry (default `user`; set
  it to a specific name or role if your deployment wants the LLM client to
  address someone in particular)

The credential file's permissions and ownership are the deploying
environment's responsibility; this package does not create, chmod, or chown
it. The parent directory of the idempotency database must exist and be
writable by the execution identity.

`MailCore`'s host/port/operator values are ordinary constructor parameters,
not hardcoded anywhere in `core/`; `server.py`'s `main()` is simply one
choice of how to source them (environment variables).

## Read-only behavior

Listing and fetching never set the Seen flag or otherwise mutate mailbox
state. Listing returns newest UIDs first, up to the query limit, and
peek-fetches those messages transiently only to obtain accurate MIME
attachment metadata — no attachment bytes are persisted or exposed as
downloadable content. IMAP `SINCE` filtering is day-granular, matching the
IMAP protocol's own date resolution.

## Send/reply safety and idempotency

- `SENT`: the final DATA response confirmed SMTP acceptance (250), not
  recipient inbox delivery.
- `FAILED`: a typed AUTH, NETWORK_PRE_SUBMISSION, or SMTP_REJECTED result.
- `AMBIGUOUS`: acceptance may have occurred but definitive confirmation was
  lost. Callers must stop and verify the original attempt out of band (for
  example, by checking the Sent folder or the recipient) before any further
  send of that logical message. **A new idempotency key is never
  authorization to retry an AMBIGUOUS send.** This safeguard is not
  configurable away — only the wording of who to escalate to
  (`MAILBRIDGE_OPERATOR_NAME`) is.
- Every recorded result, including FAILED and AMBIGUOUS, is returned for a
  repeated key without opening a second SMTP connection. There are no
  automatic retries.
- Database records contain only `SendResult` fields — no mail content or
  credentials are ever written to the idempotency store.
- The idempotency store's get/put contract protects **recorded results**. It
  is not an exactly-once guarantee across process crashes, failed database
  writes, or simultaneous independent processes. Serialize callers for
  correct behavior; if a process or adapter fails around a send, stop and
  inspect before sending again.
- The stdio adapter processes synchronous core operations serially in its
  event loop. It creates no background mailbox worker or connection pool.
- On any recipient rejection, the send aborts before DATA, so a single
  SendResult never conceals a partial-recipient submission. Bcc recipients
  appear only in the SMTP envelope, never in message headers.
- Public mailbox names are Unicode, translated to/from IMAP modified UTF-7.
  `MessageContent` header keys are lowercase. SMTP envelope addresses are
  ASCII; Unicode subjects and MIME bodies are supported. SMTPUTF8 negotiation
  is not implemented.

## Installation / setup

Python 3.11 or later:

```sh
python -m venv .venv
.venv/bin/python -m pip install .
```

On Windows use `.venv\Scripts\python.exe`. Set the environment variables
above (or edit `main()` in `server.py` for your own wiring) and run the
`mcp-mail-bridge` console script, or `python -m
mcp_mail_bridge.adapters.mcp.server`, to serve MCP over stdio.

## Testing

```sh
.venv/bin/python -m unittest discover -s tests -v
```

Tests use synthetic data, temporary SQLite databases, and fake protocol
connections — no real IMAP/SMTP connection or email submission is made.
SMTP boundary tests exercise the standard library's DATA normalization and
dot-stuffing helpers plus its response parser against an in-memory socket.
DATA content and terminator are separate writes, so a body-write failure is
FAILED while a terminator-write failure is AMBIGUOUS. The stdio test
initializes/discovers tools and submits only an invalid draft that must fail
before any credential access.

## Limitations

- No message-list pagination: `mail_list_messages` returns up to `limit`
  newest messages per call with no cursor for paging further back.
- No SMTPUTF8 negotiation for non-ASCII envelope addresses.
- No exactly-once delivery guarantee across process crashes or concurrent
  callers (see idempotency notes above); a single serialized caller is
  required for the stated guarantees to hold.
- IMAP `SINCE` filtering is day-granular, not timestamp-granular.
- No STARTTLS support — only implicit TLS on the configured ports.
- No OAuth; only username/password login.

Implementation references: Python's [smtplib documentation](https://docs.python.org/3/library/smtplib.html)
and the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).

## License

[Apache License 2.0](LICENSE).