Skip to main content
Glama
koraykoska

mail-mcp

by koraykoska

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 --version

Quick 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-mcp

Check the connection and exit:

mail-mcp --check

Optional streamable HTTP (loopback by default):

mail-mcp --http --port 3333

Logs 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 folders is omitted

mail_list_messages, mail_search_messages, mail_list_threads, mail_count, mail_list_attachments, mail_list_correspondents, mail_search_facets, mail_modify_by_search

The single mailbox argument, or INBOX when that is omitted too. folders replaces mailbox when set.

mail_get_thread

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.

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

complete

False when some UIDs were not attempted. True for a dry run of mail_modify_by_search that finished its scan.

remaining

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 complete is true. On mail_modify_by_search each entry is { mailbox, uid }, in folder order.

stopped

budget when the time budget stopped the call before the next chunk, cancelled when the client aborted, connection when the connection failed or the session was unusable during a chunk, or null when every requested UID was attempted. A command that fails after some UIDs succeeded also leaves this null: those UIDs are in results and the rest are in remaining.

ok

True only when complete is true and every per-UID result succeeded.

results

One entry per attempted UID (uid, ok, and code / message when it failed). Copy and move include value, the destination UID when UIDPLUS returned one. mail_modify_by_search returns this list, empty on a dry run.

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 logLevel, poolSize, limits, attachmentsDir, defaultAccount.

Environment account variables

With no file, they create one account whose id is MAIL_ACCOUNT or default. With a file, they merge into the account id default only when MAIL_IMAP_HOST is set. They do not overlay any other account.

MAIL_ACCOUNT

With no file, the account id. With a file that omits defaultAccount, the default account when that id exists. A file defaultAccount wins.

MAIL_LOG_LEVEL, MAIL_POOL_SIZE

Used only when the file omits logLevel or poolSize.

MAIL_IMAP_CONNECT, MAIL_SMTP_CONNECT

TCP address of the default account, after the file is resolved. Other accounts keep their own connectHost.

--imap-connect, --smtp-connect

TCP address of --account, or of the default account. Applied after the environment, so the flags win.

--log-level

Wins over the file and MAIL_LOG_LEVEL.

--account

Which account --check and the connect flags use. Tool calls still pass their own 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

${VAR}

The value of VAR. A missing or empty variable is a startup error that names VAR and the path.

${VAR:-default}

VAR when it is set and non-empty, otherwise the literal default.

$${

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

$schema

string

unset

no

JSON Schema URL. The loader ignores it.

defaultAccount

string

the only account, or MAIL_ACCOUNT when that id exists

when several accounts remain

Account id used when a call omits account.

logLevel

trace, debug, info, warn, error, fatal, silent

info

no

Stderr log level.

poolSize

integer 1–8

2

no

IMAP connections kept per account.

attachmentsDir

string

unset

no

The only directory attachment path reads and savePath writes may use. When set, mail-mcp creates it at startup and again before use if it is missing, mode 0700. An existing directory keeps its mode. A subdirectory of a caller path is never created. A missing file, or a missing directory inside this one, is NOT_FOUND and names the path. Paths are realpath-checked. A path outside this directory, including through a symlink, is PERMISSION_DENIED. Unset refuses path reads and savePath. If creation fails, the error is CONFIG_ERROR and names this key and the directory.

limits

object

see below

no

Defaults for every account. An account limits object overrides per key.

accounts

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

pageSize

25

Page size when a call omits limit.

maxPageSize

200

Largest summary page. The effective cap is the lower of this and 200. Body pages are capped at 20 regardless.

maxBodyChars

100000

Text and HTML returned for one message.

maxAttachmentBytes

15728640 (15 MiB)

Largest attachment returned or accepted.

maxMessageBytes

36700160 (35 MiB)

Largest raw message fetched or appended.

maxSortCandidates

5000

Default and maximum for maxPerFolder. A folder that exceeds it is truncated and reports omitted and scanned.

maxBulk

200

Most UIDs in one bulk flag, move, or copy, and the most matches mail_modify_by_search will change when dryRun is false. A real run that did not load every match, or that truncated a folder, fails with SIZE_LIMIT and changes nothing.

bulkBudgetMs

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 remaining. Maximum 50000, so the call finishes inside a 60 second request limit.

Accounts and provider presets

Key

Type

Default

Meaning

provider

generic, gmail, microsoft, icloud, fastmail, yahoo, proton-bridge

generic

Fills IMAP and SMTP host, port, and secure when those fields are omitted, and the OAuth token URL for Gmail and Microsoft.

email

string

user

Mailbox address. Added to the identity list when missing.

user

string

email

IMAP and SMTP login name.

password

string

unset

Shorthand for auth.type: "password".

auth

object

password

See Authentication.

imap, smtp

object

provider preset

See Endpoints. SMTP may be omitted.

identities

array

the account email

See Identities.

readOnly

boolean

false

No mailbox or message changes and no send.

allowSend

boolean

true

false blocks every SMTP submission.

allowDelete

boolean

true

false blocks delete, move, and mailbox deletion.

allowPermanentDelete

boolean

false

true allows expunge and permanent UID delete, and registers mail_expunge_mailbox.

recipientDomainAllowList

string array

empty

Empty allows every domain. Otherwise each SMTP recipient must match a listed domain or its subdomain. A leading @ is stripped.

allowFromDomains

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.

sendRateLimit

{ max, windowMs }

unset

Sliding window of submissions for this process. max and windowMs are positive integers.

appendSent

true, false, "auto"

"auto"

true always APPEND to Sent. false never does. "auto" skips Gmail, which stores Sent itself.

sentMailbox, trashMailbox, draftsMailbox

string

unset

Override special-use resolution.

limits

partial limits object

the root limits

Per-account caps.

Provider hosts, ports, and notes: docs/PROVIDERS.md.

Provider

IMAP

SMTP

generic

host required, port 993, implicit TLS

host required, port 465, implicit TLS

gmail

imap.gmail.com:993

smtp.gmail.com:465

microsoft

outlook.office365.com:993

smtp.office365.com:587, STARTTLS

icloud

imap.mail.me.com:993

smtp.mail.me.com:587, STARTTLS

fastmail

imap.fastmail.com:993

smtp.fastmail.com:465

yahoo

imap.mail.yahoo.com:993

smtp.mail.yahoo.com:465

proton-bridge

127.0.0.1:1143, STARTTLS

127.0.0.1:1025, STARTTLS

Endpoints (imap and smtp)

Key

Type

Default

Meaning

host

string

provider host

Name TLS verifies, unless tls.servername is set. Also the TCP host when connectHost is omitted.

port

integer 1–65535

provider port, else 993 or 465 when secure, else 143 or 587

Logical port. Also the TCP port when connectPort is omitted.

connectHost

string

host

TCP host when it differs, such as 127.0.0.1 for a tunnel. Certificate checks still use host.

connectPort

integer 1–65535

port

TCP port when it differs.

secure

boolean

provider value, else true

true is implicit TLS (typically 993 or 465). false is cleartext that may upgrade with STARTTLS.

starttls

required, optional, disabled

disabled when secure is true; otherwise required

required refuses a server that does not offer STARTTLS. optional stays on cleartext when the server does not offer it and logs a warning. disabled is cleartext and must be explicit. secure: true forces disabled.

timeoutMs

integer 1–50000

30000

Connect and command budget. Values above 50000 are rejected so one call stays inside a 60 second client limit.

tls

object

verify on

See below.

TLS

Key

Type

Default

Meaning

tls.rejectUnauthorized

boolean

true

false skips certificate verification. Unsafe outside a trusted local bridge. A tunnel does not turn this off.

tls.caFile

string

unset

PEM file with one or more extra CA certificates.

tls.servername

string

host

SNI and certificate name when it should differ from host.

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

auth.type

When it is chosen

Required fields

password

Default. Also chosen when password is set and no token is set.

auth.password or the account-level password. Use an app password for iCloud, Gmail, Yahoo, and Fastmail.

xoauth2

auth.accessToken is set and type is omitted or xoauth2.

accessToken, a bearer token you refresh yourself.

oauth2

type is oauth2, or refreshToken is set.

refreshToken, clientId, clientSecret. tokenUrl unless the provider preset supplies it (Gmail, Microsoft). Optional scope and accessToken.

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

id

string

identity-1, identity-2, …

Stable id returned by mail_list_accounts and by send results.

name

string

unset

Display name.

email

string

required

Address. Compared case-insensitively.

replyTo

string

unset

Reply-To stored for that identity.

default

boolean

false

The address used when a send omits from. Exactly one identity is default. If none is marked, the first becomes the default.

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

MAIL_IMAP_HOST, MAIL_IMAP_PORT, MAIL_IMAP_SECURE, MAIL_IMAP_STARTTLS, MAIL_IMAP_TIMEOUT_MS

IMAP endpoint.

MAIL_SMTP_HOST, MAIL_SMTP_PORT, MAIL_SMTP_SECURE, MAIL_SMTP_STARTTLS, MAIL_SMTP_TIMEOUT_MS

SMTP endpoint.

MAIL_IMAP_CONNECT, MAIL_SMTP_CONNECT

host:port or [ipv6]:port. Port required. Overrides the default account.

MAIL_USER, MAIL_PASSWORD, MAIL_FROM or MAIL_EMAIL

Login and the account email.

MAIL_IDENTITIES

Extra From addresses for the env account.

MAIL_PROVIDER

Preset id.

MAIL_AUTH_TYPE

password, xoauth2, or oauth2.

MAIL_ACCESS_TOKEN

XOAUTH2 bearer token.

MAIL_OAUTH_REFRESH_TOKEN, MAIL_OAUTH_CLIENT_ID, MAIL_OAUTH_CLIENT_SECRET, MAIL_OAUTH_TOKEN_URL, MAIL_OAUTH_SCOPE

Refresh flow.

MAIL_READONLY, MAIL_ALLOW_SEND, MAIL_ALLOW_DELETE, MAIL_ALLOW_PERMANENT_DELETE

Booleans: true/false, 1/0, yes/no, on/off.

MAIL_RECIPIENT_DOMAINS, MAIL_ALLOW_FROM_DOMAINS

Comma-separated domains.

MAIL_SEND_RATE_MAX, MAIL_SEND_RATE_WINDOW_MS

Rate limit. Both are required together.

MAIL_APPEND_SENT

true, false, or auto.

MAIL_TLS_REJECT_UNAUTHORIZED, MAIL_TLS_CA_FILE, MAIL_TLS_SERVERNAME

TLS for both endpoints.

MAIL_LOG_LEVEL

trace through silent.

MAIL_POOL_SIZE

Connection pool size.

MAIL_ACCOUNT

Account id when there is no file. Selects defaultAccount when a file omits it.

MAIL_MCP_CONFIG

Config file path when --config is absent.

MAIL_HTTP_TOKEN

Required to listen on a non-loopback --host.

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@vm

autossh 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@vm

A 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.target

Matching 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 --check

CLI 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 --check

IPv6 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 (or ProxyJump in ~/.ssh/config) to the ssh -L command. The mail config still dials 127.0.0.1 and verifies the real hostname.

  • A staging server whose certificate uses the production name. Keep host as the name on the certificate and set connectHost to the staging address.

  • An IPv6 literal on the far side of the forward, or as connectHost itself ("::1" in the file, [::1]:1993 in 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

TLS_FAILED, hostname mismatch

host is 127.0.0.1 or some other name that is not on the certificate. Put the certificate name in host (or tls.servername) and the forward in connectHost / connectPort.

CONNECTION_FAILED or a timeout

Nothing is listening on the local port. The SSH session exited, or -L published a different port than connectPort.

CONFIG_ERROR about a port or brackets

MAIL_IMAP_CONNECT / --imap-connect is missing :port, or an IPv6 literal is unbracketed. Use [::1]:1993.

AUTH_FAILED while connectHost is the tunnel

The forward is up. The app password or OAuth token is wrong.

SMTP TLS_FAILED on port 587

secure is true, so the client speaks implicit TLS on a STARTTLS port. iCloud submission is secure: false with STARTTLS. The preset already does this.

servername equals 127.0.0.1

host was set to the tunnel. TLS is verifying the wrong name.

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

AUTH_FAILED

App password or OAuth, not the website password. mail-mcp --check.

TLS_FAILED

Implicit TLS vs STARTTLS. For a tunnel, compare host and connectHost in --check. Pin tls.caFile, or set tls.rejectUnauthorized to false only for a trusted local bridge.

CAPABILITY_UNSUPPORTED

mail_get_capabilities. SORT, THREAD, MOVE, and IDLE have fallbacks; Gmail labels and MODSEQ do not.

UIDVALIDITY_CHANGED

The folder was recreated. List it again.

CURSOR_INVALIDATED

The folder's UIDVALIDITY changed, or the cursor was reused with a different scope, sort, or page size. Start again without a cursor.

PERMISSION_DENIED

A safety switch blocked the call, or from is not a configured identity.

SIZE_LIMIT

Narrow the search, lower limit, or raise limits in the config. Summary pages cannot exceed 200.

Empty SMTP send

The account needs an smtp block or a provider preset that includes one.

Development

npm ci
npm test
npm run check

Behavior that must stay true is listed under Principles in CONTRIBUTING.md. See docs/TESTING.md and docs/ARCHITECTURE.md.

License

MIT, copyright Koray Koska.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    B
    maintenance
    Enables 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.
    1
    37 PyPI
    4
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables 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
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables 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 npm
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables 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 npm
    ISC