rename: mail-mcp -> Carrier

Sulkta naming convention. Carrier (the carrier pigeon — single-
purpose, reliable, comes back every time) sits alongside Aldabra
(giant tortoise), Hawk (eBay-resale eyes), Skald (Norse storyteller),
cWHO (monitoring), Cauldron (meal planning), Clawdforge (build), tny
(URL shortener), ktra (cargo registry).

The full audit/cleanup/Phase-A-B-C arc happened under the mail-mcp
name; this commit just renames the identity:

- Cargo workspace + crate package name: mail-mcp -> carrier
- Binary name: mail-mcp -> carrier
- USER_AGENT header: mail-mcp/<ver> -> carrier/<ver>
- Config env var: MAIL_MCP_CONFIG -> CARRIER_CONFIG
- Default config path: ~/.config/mail-mcp/config.toml -> ~/.config/carrier/config.toml
- ServerHandler instructions reference: 'mail-mcp' -> 'Carrier'
- README + repo URL refs updated
- Workspace path: mail-mcp -> carrier
- Git remote: mail-mcp -> carrier

Tool names stay mail_* (mail_send, mail_inbox_list, mail_reply, etc.)
because they describe the email domain — same convention as Aldabra
keeps wallet_*/chain_*/dao_*/escrow_*. The server-level identity is
Carrier; the tools it carries are mail.
This commit is contained in:
Sulkta 2026-05-21 11:19:09 -07:00
parent bcf8a46713
commit 6b751558fb
10 changed files with 101 additions and 70 deletions

View file

@ -1,18 +1,25 @@
# mail-mcp
# Carrier
Rust MCP server for Sulkta-hosted email. SMTP send + IMAP read with RFC-correct headers, multipart/alternative when HTML is included, multipart/mixed for attachments, threading via `In-Reply-To`/`References`.
a Rust MCP email server. SMTP send + IMAP read with RFC-correct headers, multipart/alternative when HTML is included, multipart/mixed for attachments, threading via `In-Reply-To`/`References`. Named after the carrier pigeon — single-purpose, reliable, comes back every time.
Replaces the `a legacy mail CLI` CLI path that lived in `a private workspace` since 2026-04-23.
10 MCP tools. Multi-account. Attachment-safe. Replaces the `a legacy mail CLI` CLI path that lived in `a private workspace` since 2026-04-23.
## Why a server, not a CLI
`a legacy mail CLI` shipped without `Date` or `Message-ID` headers until a 2026-05-18 patch — exactly the kind of header-discipline regression a typed Rust server prevents at compile time. The "no spam bin" framing is mostly upstream of any client (the upstream relay handles DKIM signing; mail-tester scored 10/10 and port25 SpamAssassin 7.31 on 2026-05-20), but a correct client doesn't trip filters with bad MIME structure, broken threading, or missing headers.
## Tools (v0.1)
## Tools
- `mail_send` — send mail. Args: `account?`, `to`, `cc[]?`, `bcc[]?`, `subject`, `body`, `body_html?`, `attachments[]?`, `in_reply_to?`, `references[]?`. Returns `{message_id, sent_at}`.
- `mail_inbox_list` — list folder messages newest-first. Args: `account?`, `since?` (YYYY-MM-DD), `unread_only?`, `limit?` (default 50, max 500), `folder?` (default INBOX). Uses `BODY.PEEK` so it does not toggle `\Seen`.
- `mail_inbox_read` — fetch one message by UID. Args: `account?`, `uid`, `folder?`, `format?` (`text`|`html`|`raw_eml`). Attachment payloads are not inlined — only filename/mime_type/size metadata.
- `mail_send``account?`, `to`, `cc[]?`, `bcc[]?`, `subject`, `body`, `body_html?`, `attachments[]?`, `in_reply_to?`, `references[]?`. Returns `{message_id, sent_at}`.
- `mail_inbox_list` — newest-first listing. `account?`, `since?` (YYYY-MM-DD), `unread_only?`, `limit?` (default 50, max 500), `folder?` (default INBOX). Uses `BODY.PEEK` so it does not toggle `\Seen`.
- `mail_inbox_read` — fetch one message by UID. `account?`, `uid`, `folder?`, `format?` (`text`|`html`|`raw_eml`). Attachment payloads NOT inlined — only `{filename, mime_type, size}` metadata. RFC822.SIZE pre-flight rejects messages > 20 MB.
- `mail_folder_list``account?`. Enumerates IMAP mailboxes. Returns `[{name, delimiter, attributes, selectable}]`.
- `mail_search` — raw IMAP SEARCH passthrough. `account?`, `query`, `folder?`, `limit?`. CR/LF + `{N}` literal-form rejected.
- `mail_thread``account?`, `message_id`, `folder?`, `limit?`. Seed Message-ID → matches seed + any message whose `References` / `In-Reply-To` contains the seed. Oldest-first.
- `mail_move``account?`, `uid`, `from_folder?`, `to_folder`. UID MOVE (RFC 6851) with COPY + STORE + EXPUNGE fallback.
- `mail_mark``account?`, `uid`, `action` (`read`/`unread`/`flagged`/`unflagged`/`trash`/`archive`), `folder?`. Toggles `\Seen` / `\Flagged` via UID STORE; `trash` MOVEs to Trash; `archive` errors (no canonical Archive folder on stock Dovecot — use `mail_move`).
- `mail_attachment_get``account?`, `uid`, `attachment_index`, `folder?`. Fetches the N-th attachment (index matches `mail_inbox_read.attachments[]`) as base64.
- `mail_reply``account?`, `uid`, `body`, `body_html?`, `attachments?`, `reply_all?`, `to_override?`, `folder?`. Pulls original to build `In-Reply-To` + `References` + `Re: ` subject prefix.
## Headers we guarantee on outbound
@ -20,44 +27,57 @@ Replaces the `a legacy mail CLI` CLI path that lived in `a private workspace` si
- `Message-ID``<UUIDv4@<from_addr_domain>>` — own-domain, never the container hostname
- `From``name <addr>`
- `MIME-Version: 1.0`
- `User-Agent: mail-mcp/<version>`
- `User-Agent: carrier/<version>`
- `In-Reply-To` + `References` when threading args present
- `Content-Type` correct for the body shape (text-only / alternative / mixed)
- `Content-Type` correct for body shape (text-only / alternative / mixed)
DKIM-Signature is applied by the relay, not the client.
## Safety
`mail_inbox_read` returns attacker-controlled bytes. **Do NOT auto-fetch URLs found in inbound mail** — web beacons confirm read and links may be phishing. Default deny on every URL; wait for explicit per-link authorization. Authorized fetches route through a sandboxed headless browser, never `WebFetch` or `curl` from the host. `mail_attachment_get` bytes get the same treatment — don't execute, render, or open them blindly.
The Carrier `ServerHandler.instructions` payload and `mail_inbox_read` description both surface this rule so any MCP introspection picks it up before reading a message.
## Build
```bash
cargo build --release
```
Binary lands at `target/release/mail-mcp`.
Binary lands at `target/release/carrier`.
## Config
```bash
mkdir -p ~/.config/mail-mcp
cp config.example.toml ~/.config/mail-mcp/config.toml
chmod 600 ~/.config/mail-mcp/config.toml
mkdir -p ~/.config/carrier
cp config.example.toml ~/.config/carrier/config.toml
chmod 600 ~/.config/carrier/config.toml
```
Edit accounts as needed. Passwords are NEVER inline:
Or override with `CARRIER_CONFIG=/path/to/config.toml`.
1. Looked up from the env var named in `password_env`
2. Falling back to `password_file` (shell-format: `KEY=VALUE` per line)
3. Hard-failing with a vault-pointer hint if neither resolves
Passwords are NEVER inline:
1. env var named in `password_env`
2. fallback to `password_file` (shell-format: `KEY=VALUE` per line)
3. hard fail with a vault-pointer hint if neither resolves
Vault canonical: `your-vault``alice@example.com — IMAP/SMTP`.
Config file must be `mode & 0o077 == 0` (0600 strict). Carrier refuses to start on loose perms — same posture as ssh-keygen on a private key.
## MCP wiring (your MCP client)
```json
{
"mcpServers": {
"mail-mcp": {
"command": "/usr/local/bin/mail-mcp",
"args": []
"carrier": {
"command": "/usr/local/bin/carrier",
"args": [],
"env": {
"CARRIER_CONFIG": "/path/to/carrier-config.toml",
"CARRIER_PRIMARY_PASS": "<from vault: alice@example.com IMAP/SMTP>"
}
}
}
}
@ -65,9 +85,10 @@ Vault canonical: `your-vault` → `alice@example.com — IMAP/SMTP`.
Logging is stderr-only — stdout is the JSON-RPC transport.
## Future phases
## Deferred / future
- **Phase B** (~200 LOC): multi-account routing across all configured `[accounts.*]`, plus `mail_thread` and `mail_search`.
- **Phase C** (~150 LOC): `mail_mark` (read/unread/flag/trash/archive), `mail_attachment_get`, `mail_reply` helper.
- **Session pool** — single TCP+TLS+LOGIN reused across same-account tool calls (~200ms saved per call). Substantial state-management work; deferred until usage patterns justify.
- **BODYSTRUCTURE-driven partial body fetch** — fetch only the text/html leaf for `mail_inbox_read text/html` instead of the full RFC822. The 20 MB raw_eml cap already prevents OOM; remaining win is bandwidth on large messages.
- **Typed address shape**`mail_inbox_list` / `_read` currently return `Vec<String>` for `from`/`to`/`cc` in `Name <addr>` shape. A `Vec<{name, addr}>` would let `mail_reply` skip the parse/re-parse dance.
Full locked spec: `a private workspace``memory/spec-mail-mcp.md`.
Spec at `a private workspace``memory/spec-carrier.md`.