mail-mcp
Provides integration with Gmail accounts via IMAP/SMTP, including a Gmail provider preset and tools for managing Gmail labels alongside standard mailbox, message, draft, and send operations.
Provides integration with iCloud Mail accounts via IMAP/SMTP, enabling mailbox listing, message search and retrieval, flag changes, folder management, drafts, and sending mail through iCloud.
Provides integration with Proton Mail through the Proton Bridge provider preset, allowing IMAP/SMTP access to Proton Mail accounts for mailbox, message, draft, and send operations.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mail-mcpshow my unread emails from today"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mail-mcp
IMAP and SMTP mail client as a Model Context Protocol server. An MCP client can list mailboxes, search and read messages, change flags, manage folders and drafts, and send mail over the same accounts a desktop client would use.
The package name on npm is @koraykoska/mail-mcp. The executable is mail-mcp.
Node.js 22 or newer is required. On a machine with an older Node, bunx --bun @koraykoska/mail-mcp runs on Bun's runtime instead.
npx -y @koraykoska/mail-mcp --help
bunx --bun @koraykoska/mail-mcp --help
mail-mcp --versionQuick start
One account from the environment, stdio transport. With no config file, MAIL_ACCOUNT is the account id (otherwise default):
export MAIL_ACCOUNT=personal
export MAIL_IMAP_HOST=imap.example.com
export MAIL_SMTP_HOST=smtp.example.com
export MAIL_USER=you@example.com
export MAIL_PASSWORD=app-password
mail-mcpCheck the connection and exit:
mail-mcp --checkOptional streamable HTTP (loopback by default):
mail-mcp --http --port 3333Logs are written to stderr. Stdout is reserved for the protocol.
Related MCP server: postbus-mcp
MCP client configuration
{
"mcpServers": {
"mail": {
"command": "npx",
"args": ["-y", "@koraykoska/mail-mcp"],
"env": {
"MAIL_IMAP_HOST": "imap.example.com",
"MAIL_IMAP_PORT": "993",
"MAIL_IMAP_SECURE": "true",
"MAIL_SMTP_HOST": "smtp.example.com",
"MAIL_SMTP_PORT": "465",
"MAIL_SMTP_SECURE": "true",
"MAIL_USER": "you@example.com",
"MAIL_PASSWORD": "app-password"
}
}
}
}For several accounts, pass a file:
{
"mcpServers": {
"mail": {
"command": "mail-mcp",
"args": ["--config", "/home/you/.config/mail-mcp.jsonc"]
}
}
}What you can do
The catalog has 49 tools. With the default safety switches, 48 are registered: mail_expunge_mailbox is omitted until an account sets allowPermanentDelete. If every account is read-only, or send or delete is off for every account, the matching mutation tools are omitted too. Calls are still checked per account.
Tools cover accounts and identities, server capabilities, namespaces, quota, and ID; mailbox list, tree, status, counts, create, rename, delete, subscribe; message list, search, fetch, raw source, headers, MIME parts, attachments, and threads; flags, copy, move, delete, expunge, append, Gmail labels, and bulk updates, including a dry-run search bulk action; drafts, including a saved reply and a saved forward; SMTP send, reply, forward, and draft send; correspondents and search facets; and IDLE (with a polling fallback).
Every tool takes an optional account. List tools share one paging model (limit, cursor, order, items, nextCursor, total, truncated, snapshot). Mutating tools carry MCP annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint).
mail_list_accounts returns each account's identities. There is no separate identities tool.
Full argument lists: docs/TOOLS.md.
Paging
limit defaults to limits.pageSize (25). Summary pages are capped at the lower of 200 and limits.maxPageSize. Calls that include message bodies are capped at 20. The first page stores that limit in the cursor. Later pages reuse it when limit is omitted.
order is newest-first (default) or oldest-first when sort is omitted. When sort is set, order must agree with the first key. Ties break by date, then folder path (UTF-16 code units), then UID.
The first page records the highest UID that search returned for each folder. Later pages reuse that bound, so mail that arrives afterwards does not shift the walk and is not skipped or duplicated inside it. To see mail that arrived after the snapshot, call again without a cursor. The cursor also freezes the folder scope, the sort, and the page size. A uidValidity change returns CURSOR_INVALIDATED. A cursor from an older release returns INVALID_ARGUMENT. If the last row of a page was expunged, the next page starts at the first row that sorts after it. A page whose fetch comes back empty does not end the walk: the cursor advances through the frozen UID list. Attachment rows also store the part id, so the parts of one message stay distinct. mail_get_thread freezes the UID set from the first page.
snapshot.folders reports scanned, omitted, and truncated per folder when a cap cuts the scan.
Folder scope
There is no config key for a default folder list. The defaults live on the tools:
Call | When |
| The single |
| Every folder that contains the seed Message-ID, plus Sent, Archive, and All. Junk and Trash stay out. A mailbox and UID seed stays in that mailbox plus those special-use folders. |
folders may be one mailbox path, a list, a special-use flag such as \Sent, a glob such as Projects/*, the string all, or an object { all, mailboxes, specialUse, globs, exclude, includeTrash, includeJunk }. all skips Trash and Junk unless they are named, or includeTrash / includeJunk is true. Multi-folder reads run one mailbox at a time on one IMAP connection. maxPerFolder caps how many messages are scanned in a folder when the server cannot sort the whole snapshot. The default and the maximum are limits.maxSortCandidates (5000). A truncated folder reports truncated, omitted, and scanned. mail_list_correspondents, and the sender and attachment counts of mail_search_facets, ignore that window and read every match in batches of 200, up to 10000 messages per folder. Above that cap truncated and partial are true and the counts cover only the scanned prefix. Flag counts stay exact. mail_list_threads reports source: "server" only for a single folder whose THREAD tree was used. One threadKey identifies the conversation: the earliest message in the set that has no parent, by its Message-ID, or by mailbox, UIDVALIDITY, and UID when it has none.
Search
Criteria in one search object are sent as one IMAP SEARCH, with the keys side by side. That is how IMAP expresses AND, and it means the same thing on iCloud, Gmail, Dovecot, and GreenMail. A NOT of a UID set, a sequence set, or more than one criterion is not sent to the server. The client searches for the positive set and for every message in the folder, then returns the difference. A single text key or a single flag can still be negated on the server.
When the server answers NO or BAD, the tool error is PROTOCOL_ERROR and the message includes that response text. A connection that closes during connect, the greeting, login, or a command is CONNECTION_FAILED, not PROTOCOL_ERROR.
An ESEARCH ALL value that contains *, or any search response that is not a fully explicit UID list, is PROTOCOL_ERROR. The set is not decoded and no message is changed. Repeat the search. Two different UID lists in one response are refused the same way. An explicit list, which is what iCloud and Dovecot send, is used as-is. A CONDSTORE (MODSEQ n) modifier, and the ESEARCH MODSEQ, COUNT, MIN, and MAX items, are not matches.
Bulk changes
mail_set_flags, mail_copy_messages, mail_move_messages, mail_delete_messages, mail_set_labels, mail_bulk_modify, and a real mail_modify_by_search send UID sets (UID STORE, UID COPY, UID MOVE, UID EXPUNGE) in chunks of 25. They stop before the next chunk when limits.bulkBudgetMs elapses, the connection drops, or the client cancels the request. An in-flight chunk is allowed to finish only when its command completed. Chunks that finished are already applied. Call again with remaining and the same action to continue. Repeating an in-flight chunk can copy a message twice. It does not remove a message whose copy was not confirmed. Optional uidValidity is checked when the mailbox is selected. mail_delete_messages does not require it: a UID taken from an earlier list can name a different message if the mailbox was recreated and the caller omits uidValidity. Pass the UIDVALIDITY from that list. A move that copies and then expunges asks the server for UIDVALIDITY with STATUS immediately before UID EXPUNGE. RFC 9051 section 2.3.1.1 sends that value in a UIDVALIDITY response code at mailbox selection. Section 7.1 says that response code indicates the validity value. imapflow records UIDVALIDITY only while it handles SELECT and does not apply a later untagged UIDVALIDITY to the open mailbox, so the value stored at selection is not a later reading. RFC 3501 section 6.3.10 and RFC 9051 section 6.3.11 say a client SHOULD NOT use STATUS on the selected mailbox. RFC 9051 section 6.3.11 requires servers to be able to execute that command. Dovecot answers OK [CLIENTBUG]. When STATUS reports a different value, or no value while the session is still usable, the result is UIDVALIDITY_CHANGED: the originals stay and the copies may remain in the destination. A connection that closes during that read is CONNECTION_FAILED and does not expunge. The same STATUS check precedes any UID EXPUNGE of specific UIDs. mail_modify_by_search checks the UIDVALIDITY from its search before it moves.
Field | Meaning |
| False when some UIDs were not attempted. True for a dry run of |
| UIDs not attempted, in request order, including the in-flight chunk when the call stopped for the budget, a dropped connection, or cancellation. Empty when |
|
|
| True only when |
| One entry per attempted UID ( |
limits.bulkBudgetMs defaults to 20000 and cannot be set above 50000. The budget includes waiting for a connection and, for mail_modify_by_search, the folder scan, so the call returns inside a 60 second request limit. If the budget runs out before any chunk starts, complete is false, stopped is budget, and remaining lists every requested UID. A dropped connection sets stopped to connection and puts the in-flight chunk plus every unattempted UID into remaining. A UID is NOT_FOUND only after a search on a healthy, selected mailbox showed it was absent.
A move uses UID MOVE when the server advertises MOVE, or when IMAP4rev2 is active. IMAP4rev2 is active after ENABLE IMAP4REV2 takes effect, or on a session that advertises IMAP4rev2 and not IMAP4rev1. A bare IMAP4rev2 token next to IMAP4rev1 is not enough. Otherwise the client copies, then expunges only the UIDs named by UIDPLUS COPYUID, and UIDPLUS is decided the same way. A UID the server does not confirm stays in the source mailbox, with ok: false and a message that the original was left in place. A duplicate in the destination can remain. A server with neither MOVE nor UIDPLUS is refused with CAPABILITY_UNSUPPORTED. allowPermanentDelete does not permit expunging every \Deleted message in order to finish that move. Copy does not need COPYUID.
mail_modify_by_search still returns SIZE_LIMIT and changes nothing when the scan finishes and the match count is above maxBulk or a folder was truncated. The time budget covers that scan. When the scan stops before every folder, complete is false, stopped is budget, remaining is empty, and nothing is changed. Repeat the same search. A partial list is not returned, because resuming it would skip the folders that were not scanned. When the scan finishes and the budget stops before a chunk, remaining lists every match.
Configuration
Accounts come from a JSON or JSONC file, environment variables, or both. The checked-in schema is schema/config.schema.json (JSON Schema draft-07, generated from the zod schema in src/config/schema.ts). Regenerate it with npx tsx scripts/generate-config-schema.ts. A unit test fails when the file drifts. The same page is summarized in docs/CONFIGURATION.md.
File location and precedence
The file is whatever --config points at, otherwise MAIL_MCP_CONFIG. Both are paths, resolved from the process working directory. There is no default path. --config wins over MAIL_MCP_CONFIG.
The file may contain // and /* */ comments and trailing commas. Unknown keys are rejected.
Source | What it overrides |
File | Account fields, top-level |
Environment account variables | With no file, they create one account whose id is |
| With no file, the account id. With a file that omits |
| Used only when the file omits |
| TCP address of the default account, after the file is resolved. Other accounts keep their own |
| TCP address of |
| Wins over the file and |
| Which account |
There is no CLI flag for hosts, passwords, or safety switches. Those are file or environment values.
Environment substitution
Before validation, every string in the file is scanned:
Form | Result |
| The value of |
|
|
| An escape that becomes |
Numbers and booleans are not substituted. Put a number that comes from the environment in a string only where the schema expects a string. Ports and limits are JSON numbers.
Required keys
The root object may be empty of keys, but the process must end up with at least one account. defaultAccount is required when more than one account is configured and MAIL_ACCOUNT does not name one of them.
Each account needs an email (email or user) and a way to log in (a password, or auth). imap.host is required unless provider fills it. smtp is optional; send tools fail for that account until it is set. OAuth2 needs refreshToken, clientId, and clientSecret, plus tokenUrl when the provider preset has none. sendRateLimit needs both max and windowMs. An identity needs email. At most one identity may set default to true.
readOnly together with allowPermanentDelete is rejected at startup.
Top-level keys
Key | Type | Default | Required | Meaning |
| string | unset | no | JSON Schema URL. The loader ignores it. |
| string | the only account, or | when several accounts remain | Account id used when a call omits |
|
|
| no | Stderr log level. |
| integer 1–8 |
| no | IMAP connections kept per account. |
| string | unset | no | The only directory attachment |
| object | see below | no | Defaults for every account. An account |
| object of id → account | unset | no | Account ids are the object keys. |
Limits
Set on the root, on an account, or both. Account keys win. All values are positive integers.
Key | Default | Meaning |
| 25 | Page size when a call omits |
| 200 | Largest summary page. The effective cap is the lower of this and 200. Body pages are capped at 20 regardless. |
| 100000 | Text and HTML returned for one message. |
| 15728640 (15 MiB) | Largest attachment returned or accepted. |
| 36700160 (35 MiB) | Largest raw message fetched or appended. |
| 5000 | Default and maximum for |
| 200 | Most UIDs in one bulk flag, move, or copy, and the most matches |
| 20000 | Wall-clock budget for one bulk flag, copy, move, delete, label, or modify-by-search call, including the wait for a connection and the modify-by-search scan. The call stops before the next chunk of 25 and returns |
Accounts and provider presets
Key | Type | Default | Meaning |
|
|
| Fills IMAP and SMTP host, port, and |
| string |
| Mailbox address. Added to the identity list when missing. |
| string |
| IMAP and SMTP login name. |
| string | unset | Shorthand for |
| object | password | See Authentication. |
| object | provider preset | See Endpoints. SMTP may be omitted. |
| array | the account email | See Identities. |
| boolean | false | No mailbox or message changes and no send. |
| boolean | true |
|
| boolean | true |
|
| boolean | false |
|
| string array | empty | Empty allows every domain. Otherwise each SMTP recipient must match a listed domain or its subdomain. A leading |
| string array | empty | Empty means From must be a configured identity. When set, any address at these domains is also allowed. This does not change TLS checks. |
|
| unset | Sliding window of submissions for this process. |
|
|
|
|
| string | unset | Override special-use resolution. |
| partial limits object | the root limits | Per-account caps. |
Provider hosts, ports, and notes: docs/PROVIDERS.md.
Provider | IMAP | SMTP |
| host required, port 993, implicit TLS | host required, port 465, implicit TLS |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Endpoints (imap and smtp)
Key | Type | Default | Meaning |
| string | provider host | Name TLS verifies, unless |
| integer 1–65535 | provider port, else 993 or 465 when | Logical port. Also the TCP port when |
| string |
| TCP host when it differs, such as |
| integer 1–65535 |
| TCP port when it differs. |
| boolean | provider value, else |
|
|
|
|
|
| integer 1–50000 | 30000 | Connect and command budget. Values above 50000 are rejected so one call stays inside a 60 second client limit. |
| object | verify on | See below. |
TLS
Key | Type | Default | Meaning |
| boolean |
|
|
| string | unset | PEM file with one or more extra CA certificates. |
| string |
| SNI and certificate name when it should differ from |
The same TLS environment variables apply to both endpoints of the env-only account: MAIL_TLS_REJECT_UNAUTHORIZED, MAIL_TLS_CA_FILE, MAIL_TLS_SERVERNAME.
Authentication
| When it is chosen | Required fields |
| Default. Also chosen when |
|
|
|
|
|
|
|
This server exchanges a refresh token. It does not run a browser login flow. Client ids are created in the provider's developer console.
Identities
An identity is a From address for one mailbox. Several identities can share one account, for example an iCloud address and a custom domain that Apple sends from the same mailbox.
Key | Type | Default | Meaning |
| string |
| Stable id returned by |
| string | unset | Display name. |
| string | required | Address. Compared case-insensitively. |
| string | unset | Reply-To stored for that identity. |
| boolean | false | The address used when a send omits |
The account email is appended when it is not already in the list. Send, reply, reply-all, forward, draft save, draft update, and draft send take an optional from (a string or { name, address }) and an optional replyTo. Both are rejected unless the address is a configured identity or allowFromDomains contains its domain. replyTo is written from the parsed address list, so a display name that contains another address is not sent. When the caller omits replyTo, the selected identity's replyTo is used. A draft send does the same when the stored draft has no Reply-To. mail_list_accounts returns that value.
Custom headers are an allow-list. A name is canonicalized the way the composer does: characters in [\s\x00-\x20\x7f:] are not stripped and then accepted, they are rejected, and the remaining name must be a token. Allowed names are X-*, List-Unsubscribe, List-Unsubscribe-Post, Auto-Submitted, Precedence, Importance, X-Priority, Keywords, and Comments. An X- name is lowercased first and is never split on camelCase. The remainder after X- is split on - and _, and each token is checked with all digits removed, and again with look-alike digits mapped (0 as o, 1 as l and as i, 3 as e, 4 as a, 5 as s, 7 as t) and the other digits removed. Any of those variants rejects the name. A token that becomes from, to, cc, bcc, or recipient is rejected in any position, so X-From2-Not, X-From0, X-From0-Not, X-Fr0m, X-Envel0pe, X-Enve1ope, X-3nv3l0p3, X-B0unce, X-Bounc3, and X-B0unc3 are rejected. sender is rejected only as the last token or a compact suffix, so X-Sender-IP, X-Sender-IP2, X-Notification, X-Notification2, X-Photo, X-Photo2, and X-Original-Arrival-Time stay allowed. The look-alike pass is a best-effort guard: MTAs and Nodemailer do not read these names as routing headers. It is rejected when that token is to, from, sender, cc, bcc, or recipient, when the trailing tokens name an address-bearing header (reply-to, errors-to, return-path, delivered-to, apparently-to, apparently-from, original-from, original-sender, original-to, original-recipient, receipt-to, read-receipt-to, return-receipt-to, notification-to, disposition-notification-to, confirm-reading-to, reading-to, mdn-to, mail-followup-to, followup-to, mail-reply-to, or rcpt-to), or when any token is resent, envelope, rcpt, or bounce. The lowercased name with - and _ removed, 0 read as o, and other digits removed is always rejected when it ends with from, sender, recipient, replyto, errorsto, returnpath, deliveredto, receiptto, notificationto, readingto, mdnto, apparentlyto, followupto, originalto, or rcptto, or when it contains resent, envelope, rcpt, or bounce. X-Sender-IP, X-Notification, X-GitHub-Notification, and X-Photo stay allowed. X-Report-Abuse-To, X-Complaint-To, X-Authenticated-Sender, and X-Forwarded-To are rejected on purpose. Other X-* names stay allowed. In-Reply-To and References are set only by mail_reply_message and mail_create_reply_draft. They cannot be passed as custom headers. Everything else is rejected, including From, Sender, Reply-To, To, Cc, Bcc, Message-ID, Return-Path, every Resent-* field, Disposition-Notification-To, Return-Receipt-To, Mail-Reply-To, Mail-Followup-To, Errors-To, and Apparently-From. The error names the structured field to use instead (from, replyTo, to, cc, bcc). A headers.From that matches an identity is still rejected. Nothing in headers is dropped quietly.
mail_send_draft parses the stored draft on every send and rebuilds the message. From, Sender, Reply-To, To, Cc, and Bcc are emitted from the checked addr-specs. A display name is decoded, NFKC-normalized, and then dropped when it contains @ or <>, when it is still an encoded-word, or when it came from a comment. Send, reply, forward, and draft strip a parenthesized comment before parsing, so bob@example.com (Eve <eve@evil.test>) keeps bob@example.com. A display name that contains parentheses or another RFC 5322 special is quoted, so Bob (work) <bob@example.com> keeps (work). A second From, Sender, or Reply-To is PERMISSION_DENIED. So is a field name that is not an RFC 5322 field name, including whitespace before the colon (From :). Every address on From, Sender, Reply-To, every Resent-* address field, and the receipt and legacy reply headers must be an identity. Allow-listed headers (List-Unsubscribe, List-Unsubscribe-Post, Auto-Submitted, Precedence, Importance, Keywords, Comments, and other X-*) are copied from the raw header block with the original field name and value. Duplicate allow-listed headers stay separate fields. Recipient-domain and rate-limit rules apply. A failure is PERMISSION_DENIED and nothing is sent. Bcc and Resent-Bcc are removed from the submitted bytes. identityReason is stored-draft when from is omitted, and explicit when from is passed.
Replies and forwards pick an identity only when exactly one configured address appears verbatim in the original To or Cc. Zero matches and several matches both keep the default. Delivered-To and X-Original-To are not consulted. The result includes identity and identityReason: explicit, single-match, default-no-match, default-ambiguous, or stored-draft (draft send only, when from was omitted).
Reply and forward drafts
mail_reply_message and mail_forward_message send. mail_create_reply_draft and mail_create_forward_draft save the same composition and do not send. They do not require allowSend. They are blocked when the account is readOnly.
mail_create_reply_draft takes the source mailbox and uid (and optional uidValidity, replyAll, from, replyTo, text, html, subject, attachments). It sets In-Reply-To from the original Message-ID and References from the original References plus that Message-ID. The subject is Re: unless it already has a reply prefix. The prefixes are Re, Aw, Sv, and Antw, matched without regard to case, and a bracketed number is part of the prefix (Re[2]:, Antw[2]:). The original body is quoted. Recipients and the From identity follow mail_reply_message. The draft is APPENDed to the resolved Drafts mailbox with \Draft. draftsMailbox overrides that folder. The result includes the new UID. The original is not marked \Answered.
mail_create_forward_draft takes the source mailbox and uid, required to, and optional uidValidity, from, replyTo, cc, text, html, subject, attachments, includeAttachments (default true), and draftsMailbox. The subject is Fwd: unless it already has a forward prefix. The prefixes are Fwd, Fw, Wg, Tr, and Rv, matched without regard to case. The original text is quoted under a forwarded-message line. The draft attaches forwarded.eml and the original non-inline attachments under their original file names. Inline images are not copied. The original is not marked $Forwarded.
mail_update_draft reads the stored draft before it appends the replacement. Optional uidValidity is checked on that read. The UIDVALIDITY observed on that read is the value checked again before the old UID is removed, including when the caller omits uidValidity. A caller-supplied value must still match. The delete is skipped when that UID is the message just appended, so a recreated mailbox cannot make the new draft inherit the old UID number and then be deleted. In-Reply-To and References on that draft are copied onto the new copy and cannot be set or cleared by the call. If the stored draft cannot be read, nothing is appended and the old draft stays. ok is true only when the old UID was removed, and replacedUid is that UID. When the delete result is not a completed removal, or the delete throws, a later read checks the old UID. The warning says that UID is still in the mailbox only when that read succeeds and sees it, and then ok is false and replacedUid is null. A protocol error can be raised after the message was already moved, so the delete result alone never says the old UID is still there. If the later read fails, ok is false, replacedUid is null, and warning says the removal could not be confirmed. The new draft is kept in every one of those cases. It is never deleted to undo the replace: a duplicate is preferable to losing both copies. mail_send_draft copies those headers onto the submitted message. Passing In-Reply-To or References as custom headers is PERMISSION_DENIED.
mail_create_draft, mail_create_reply_draft, mail_create_forward_draft, and mail_update_draft refuse a built message over maxMessageBytes with SIZE_LIMIT before APPEND. The same check applies to send.
A Message-ID, In-Reply-To, or References id is one token. RFC 5322 limits a header line to 998 octets, and folding inside an id would leave a space in it when the line is unfolded. An id that cannot fit on one line next to its header name is omitted. An omitted Message-ID is replaced by the composer. An omitted In-Reply-To or References id is left off. Ids that fit are kept, and a list is folded on the spaces between them.
A Subject, Comments, or Keywords value with a token that cannot fit on one 998-octet line next to its header name is encoded as RFC 2047 encoded-words. Each word is at most 75 characters and words are separated by whitespace, so the line is folded between words and the decoded value is unchanged. A token that fits is left as written. Any other header line over 998 octets is rejected, and the error names that header.
An attachment filename is the name the recipient sees. When filename is omitted and path is set, the name is the basename of the path you passed, not the real path of a symlink. An explicit filename wins. A forwarded attachment keeps the original file name. A contentBase64 part with no filename is named by the composer (attachment-1, and so on).
attachmentsDir is created by mail-mcp when the key is set and the directory is missing, including after it is deleted while the server is running. The directory this process creates is mode 0700. Only that configured directory is created. A caller path or savePath never creates a directory. A missing file is NOT_FOUND and names the file. A missing directory inside attachmentsDir is NOT_FOUND and names that directory. A path that escapes attachmentsDir, including through a symlink, is PERMISSION_DENIED. If the directory cannot be created, startup and the call fail with CONFIG_ERROR naming attachmentsDir and the path.
MAIL_IDENTITIES is a comma-separated list for the env account. Names are allowed (Ada Lovelace <ada@example.com>). Commas inside <> are not separators. The first entry is the default.
Single-account environment
Variable | Purpose |
| IMAP endpoint. |
| SMTP endpoint. |
|
|
| Login and the account email. |
| Extra From addresses for the env account. |
| Preset id. |
|
|
| XOAUTH2 bearer token. |
| Refresh flow. |
| Booleans: |
| Comma-separated domains. |
| Rate limit. Both are required together. |
|
|
| TLS for both endpoints. |
|
|
| Connection pool size. |
| Account id when there is no file. Selects |
| Config file path when |
| Required to listen on a non-loopback |
Example
Three accounts. The iCloud mailbox sends as user@icloud.com and as me@example.com. Gmail uses OAuth. The work account is generic IMAP with STARTTLS submission and is read-only. Secrets come from the environment. The file is also examples/config.jsonc.
{
"$schema": "https://raw.githubusercontent.com/koraykoska/mail-mcp/main/schema/config.schema.json",
"defaultAccount": "icloud",
"logLevel": "info",
"poolSize": 2,
"attachmentsDir": "${MAIL_ATTACHMENTS_DIR:-/home/me/mail-attachments}",
"limits": {
"pageSize": 25,
"maxPageSize": 200,
"maxBodyChars": 100000,
"maxAttachmentBytes": 15728640,
"maxMessageBytes": 36700160,
"maxSortCandidates": 5000,
"maxBulk": 200,
"bulkBudgetMs": 20000,
},
"accounts": {
"icloud": {
"provider": "icloud",
"email": "user@icloud.com",
"user": "user@icloud.com",
"auth": {
"type": "password",
"password": "${ICLOUD_APP_PASSWORD}",
},
"identities": [
{
"id": "personal",
"name": "User",
"email": "user@icloud.com",
"default": true,
},
{
"id": "custom",
"name": "User",
"email": "me@example.com",
"replyTo": "me@example.com",
},
],
"readOnly": false,
"allowSend": true,
"allowDelete": true,
"allowPermanentDelete": false,
"recipientDomainAllowList": ["icloud.com", "example.com"],
"sendRateLimit": { "max": 20, "windowMs": 60000 },
"appendSent": true,
},
"gmail": {
"provider": "gmail",
"email": "user@gmail.com",
"user": "user@gmail.com",
"auth": {
"type": "oauth2",
"refreshToken": "${GMAIL_REFRESH_TOKEN}",
"clientId": "${GMAIL_CLIENT_ID}",
"clientSecret": "${GMAIL_CLIENT_SECRET}",
},
"allowSend": true,
"allowDelete": true,
"allowPermanentDelete": false,
"appendSent": "auto",
},
"work": {
"provider": "generic",
"email": "user@work.example",
"user": "user@work.example",
"password": "${WORK_IMAP_PASSWORD}",
"imap": {
"host": "imap.work.example",
"port": 993,
"secure": true,
"timeoutMs": 30000,
"tls": {
"rejectUnauthorized": true,
"caFile": "${WORK_CA_FILE:-/etc/ssl/certs/ca-certificates.crt}",
},
},
"smtp": {
"host": "smtp.work.example",
"port": 587,
"secure": false,
"starttls": "required",
},
"readOnly": true,
"allowSend": false,
"allowDelete": false,
"allowPermanentDelete": false,
},
},
}Connecting through a tunnel (manual connect address)
Some networks block outbound IMAP and SMTP. You can still reach the real servers by forwarding those ports through SSH to a small VM that is allowed to connect, while TLS keeps verifying the server's real hostname. The TCP socket opens to the local forward. SNI, the certificate check, and SMTP STARTTLS all use the logical host (host, or tls.servername when that is set). Certificate verification stays on. The SMTP EHLO name stays mail-mcp and is not rewritten to the tunnel address.
Forward iCloud IMAP and submission. ssh -N does not open a shell. The local ports are arbitrary; 1993 and 1587 stay off the privileged range:
ssh -N -L 1993:imap.mail.me.com:993 -L 1587:smtp.mail.me.com:587 user@vmautossh restarts the forward when the SSH session drops. -M 0 uses server-alive probes instead of a monitor port:
autossh -M 0 -N \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-L 1993:imap.mail.me.com:993 -L 1587:smtp.mail.me.com:587 \
user@vmA user systemd unit does the same job:
[Unit]
Description=Mail port forwards
After=network-online.target
[Service]
ExecStart=/usr/bin/ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 1993:imap.mail.me.com:993 -L 1587:smtp.mail.me.com:587 user@vm
Restart=on-failure
[Install]
WantedBy=default.targetMatching account. provider: "icloud" still supplies the logical hosts. Only the TCP address changes. With a provider preset you can set connectHost and connectPort and leave host and port unset:
{
"accounts": {
"icloud": {
"provider": "icloud",
"email": "user@icloud.com",
"user": "user@icloud.com",
"password": "${ICLOUD_APP_PASSWORD}",
"imap": { "connectHost": "127.0.0.1", "connectPort": 1993 },
"smtp": { "connectHost": "127.0.0.1", "connectPort": 1587 },
},
},
}The same override for the default account, without editing the file:
export MAIL_IMAP_CONNECT=127.0.0.1:1993
export MAIL_SMTP_CONNECT=127.0.0.1:1587
mail-mcp --config /home/you/.config/mail-mcp.jsonc --checkCLI flags win over those variables. --account chooses which account they apply to; omitted, they apply to the default account. Other accounts keep the connectHost from the file.
mail-mcp --config /home/you/.config/mail-mcp.jsonc --account icloud \
--imap-connect 127.0.0.1:1993 --smtp-connect 127.0.0.1:1587 --checkIPv6 literals must be bracketed: MAIL_IMAP_CONNECT=[::1]:1993 or --imap-connect "[2001:db8::1]:993". An unbracketed address with several colons is rejected, and a value with no port is rejected.
The same fields cover other cases where the dial address and the certificate name differ:
A corporate proxy or jump host. Add
-J user@jump(orProxyJumpin~/.ssh/config) to thessh -Lcommand. The mail config still dials127.0.0.1and verifies the real hostname.A staging server whose certificate uses the production name. Keep
hostas the name on the certificate and setconnectHostto the staging address.An IPv6 literal on the far side of the forward, or as
connectHostitself ("::1"in the file,[::1]:1993in the environment).
What --check prints
mail-mcp --check and mail_verify_connection report the logical endpoint and the TCP target. host and port are what TLS verifies. connectHost and connectPort are where the socket connects. servername is the SNI name. When no override is set, connectHost equals host and connectPort equals port.
{
"ok": true,
"account": "icloud",
"imap": {
"ok": true,
"host": "imap.mail.me.com",
"port": 993,
"secure": true,
"connectHost": "127.0.0.1",
"connectPort": 1993,
"servername": "imap.mail.me.com"
},
"smtp": {
"ok": true,
"host": "smtp.mail.me.com",
"port": 587,
"secure": false,
"connectHost": "127.0.0.1",
"connectPort": 1587,
"servername": "smtp.mail.me.com"
}
}Tunnel errors
What you see | What it means |
|
|
| Nothing is listening on the local port. The SSH session exited, or |
|
|
| The forward is up. The app password or OAuth token is wrong. |
SMTP |
|
|
|
Safety
Per account you can set readOnly, allowSend, allowDelete, allowPermanentDelete (off by default), a recipient domain allow-list, an extra From-domain list, and a send rate limit. Delete moves to Trash unless permanent delete is explicitly enabled. See docs/SECURITY.md.
Providers
Presets: Gmail, Outlook / Microsoft 365, iCloud, Fastmail, Yahoo, Proton Bridge, and generic IMAP/SMTP. Password, app passwords, XOAUTH2 access tokens, and OAuth refresh tokens are supported. Details: docs/PROVIDERS.md.
Troubleshooting
Symptom | What to try |
| App password or OAuth, not the website password. |
| Implicit TLS vs STARTTLS. For a tunnel, compare |
|
|
| The folder was recreated. List it again. |
| The folder's UIDVALIDITY changed, or the cursor was reused with a different scope, sort, or page size. Start again without a cursor. |
| A safety switch blocked the call, or |
| Narrow the search, lower |
Empty SMTP send | The account needs an |
Development
npm ci
npm test
npm run checkBehavior that must stay true is listed under Principles in CONTRIBUTING.md. See docs/TESTING.md and docs/ARCHITECTURE.md.
License
MIT, copyright Koray Koska.
This server cannot be deployed
Maintenance
Related MCP Connectors
Email infrastructure for AI agents — send, receive, search, and reply to email over MCP.
Hosted email MCP for your own Gmail, Outlook.com, Microsoft 365, iCloud or IMAP inbox: read, search, draft, reply in thread, forward and file mail. It moves or flags up to 500 messages in one call, and a send leaves exactly one copy in Sent. A calendar is a separate connection, and connecting one adds diary and scheduling tools.
Email inboxes for AI agents: send, receive, reply, search, and manage threaded email over MCP.
Your own IMAP mailbox in any MCP client: search, read, flag, move; send and delete need confirm.
Related MCP Servers
- AlicenseBqualityBmaintenanceEnables users to manage email accounts via IMAP/SMTP, including reading, searching, sending emails with attachments and calendar invites, all through natural language interactions with MCP-compatible clients.137 PyPI4MIT
- AlicenseNot gradedqualityBmaintenanceEnables searching, reading full conversations, and sending email across multiple IMAP/SMTP mailboxes from any MCP client, with multi-user support and per-user API tokens.MIT
- AlicenseNot gradedqualityCmaintenanceEnables MCP clients to check, list, search, read, send, draft, and trash email in a single IMAP mailbox using standard IMAP and SMTP settings, with sending and trash moves enabled only when explicitly allowed.19 npmMIT
- AlicenseNot gradedqualityAmaintenanceEnables AI assistants to read, search, send, manage, schedule, and analyze email across multiple IMAP/SMTP accounts through MCP, including drafts and templates, provider-aware labels, bulk operations, real-time IMAP IDLE watching with AI triage, verification-code capture, calendar extraction, and analytics.17 npmISC