Skip to content

Guides

Version scope: Managed-mode workflows in this page are Local Email App V2 behavior. See Version availability before using them with a PyPI installation.

These examples cover common configurations that need more control than the basic UI provides.

IMAP-only accounts

Remove SMTP configuration when an account must not send email:

[[emails]]
account_name = "archive"
full_name = "Archive Reader"
email_address = "[email protected]"

[emails.incoming]
user_name = "[email protected]"
password = "your-password"
host = "imap.example.com"
port = 993
use_ssl = true
start_ssl = false
verify_ssl = true

Or configure one through environment variables without MCP_EMAIL_SERVER_SMTP_HOST:

{
  "mcpServers": {
    "mcp-email-server": {
      "command": "uvx",
      "args": ["mcp-email-server@latest", "stdio"],
      "env": {
        "MCP_EMAIL_SERVER_ACCOUNT_NAME": "archive",
        "MCP_EMAIL_SERVER_EMAIL_ADDRESS": "[email protected]",
        "MCP_EMAIL_SERVER_PASSWORD": "your-password",
        "MCP_EMAIL_SERVER_IMAP_HOST": "imap.example.com"
      }
    }
  }
}

send_email and forward_email remain in the static MCP tool list, but calling either for this account fails its SMTP capability check before provider access — for a forward, before the source message is read. IMAP mutation tools remain available, so this is not a strict read-only mode. To limit mutations, also constrain which MCP tools the client may call or run the server with an account whose provider permissions are read-only.

Safe delete and move behavior

Message-scoped deletion never uses mailbox-wide IMAP EXPUNGE, which would remove every message already marked \Deleted, including messages selected by another email client. The server uses UID EXPUNGE only when the provider advertises the RFC 4315 UIDPLUS capability.

If a provider lacks UIDPLUS, delete_emails reports the requested messages as failed before changing their flags. When the provider also lacks native MOVE, move_emails rejects its COPY-and-delete fallback before copying anything. Use the provider's own client or an IMAP server that supports MOVE or UIDPLUS.

ProtonMail Bridge and self-signed TLS

Local bridges commonly expose IMAP through STARTTLS with a locally issued certificate. A typical environment configuration is:

{
  "mcpServers": {
    "mcp-email-server": {
      "command": "uvx",
      "args": ["mcp-email-server@latest", "stdio"],
      "env": {
        "MCP_EMAIL_SERVER_ACCOUNT_NAME": "protonmail",
        "MCP_EMAIL_SERVER_EMAIL_ADDRESS": "[email protected]",
        "MCP_EMAIL_SERVER_PASSWORD": "bridge-password",
        "MCP_EMAIL_SERVER_IMAP_HOST": "127.0.0.1",
        "MCP_EMAIL_SERVER_IMAP_PORT": "1143",
        "MCP_EMAIL_SERVER_IMAP_SSL": "false",
        "MCP_EMAIL_SERVER_IMAP_START_SSL": "true",
        "MCP_EMAIL_SERVER_IMAP_VERIFY_SSL": "false",
        "MCP_EMAIL_SERVER_SMTP_HOST": "127.0.0.1",
        "MCP_EMAIL_SERVER_SMTP_PORT": "1025",
        "MCP_EMAIL_SERVER_SMTP_SSL": "false",
        "MCP_EMAIL_SERVER_SMTP_START_SSL": "true",
        "MCP_EMAIL_SERVER_SMTP_VERIFY_SSL": "false"
      }
    }
  }
}

Equivalent TOML:

[[emails]]
account_name = "protonmail"
full_name = "John Doe"
email_address = "[email protected]"

[emails.incoming]
user_name = "bridge-username"
password = "bridge-password"
host = "127.0.0.1"
port = 1143
use_ssl = false
start_ssl = true
verify_ssl = false

[emails.outgoing]
user_name = "bridge-username"
password = "bridge-password"
host = "127.0.0.1"
port = 1025
use_ssl = false
start_ssl = true
verify_ssl = false

Use the exact credentials and ports shown by the local bridge. Disable certificate verification only for a bridge running on a trusted local endpoint.

Separate IMAP and SMTP credentials

Some providers or bridges issue separate credentials. With environment variables, keep the required shared password and override each protocol:

MCP_EMAIL_SERVER_EMAIL_ADDRESS='[email protected]'
MCP_EMAIL_SERVER_USER_NAME='[email protected]'
MCP_EMAIL_SERVER_PASSWORD='required-shared-fallback'
MCP_EMAIL_SERVER_IMAP_USER_NAME='imap-user'
MCP_EMAIL_SERVER_IMAP_PASSWORD='imap-password'
MCP_EMAIL_SERVER_IMAP_HOST='imap.example.com'
MCP_EMAIL_SERVER_SMTP_USER_NAME='smtp-user'
MCP_EMAIL_SERVER_SMTP_PASSWORD='smtp-password'
MCP_EMAIL_SERVER_SMTP_HOST='smtp.example.com'

The generic MCP_EMAIL_SERVER_PASSWORD currently remains required to create an environment-provided account, even when both protocol-specific passwords are set.

In TOML, set user_name and password independently in the incoming and outgoing tables.

Save messages to a custom Sent folder

If Sent folder auto-detection does not select the provider's folder, set it explicitly:

[[emails]]
account_name = "work"
save_to_sent = true
sent_folder_name = "INBOX.Sent"

Before choosing a value, call list_mailboxes and inspect the returned names and flags. Set save_to_sent = false if the provider already saves SMTP mail and a second IMAP append would create duplicates.

Save a draft

Call save_to_mailbox with the account and message fields. The default mailbox is Drafts, and the default flags are \Draft and \Seen.

Conceptual MCP call:

await save_to_mailbox(
    account_name="work",
    recipients=["[email protected]"],
    subject="Project update",
    body="Draft content",
    mailbox="Drafts",
)

Mailbox names vary by provider. Use list_mailboxes first when Drafts is not the correct name. If any address or thread-header identifier requires internationalized syntax, the IMAP endpoint must support RFC 6855 ENABLE/UTF8=ACCEPT; otherwise the save fails before mailbox selection with utf8-append-unsupported and is not retried.

Reply with proper threading

First fetch the original message and read its RFC thread headers:

result = await get_emails_content(
    account_name="work",
    email_ids=["123"],
)
original = result.emails[0]

Build the ancestor chain from the returned references value and the immediate parent's message_id, then send the reply:

references = " ".join(
    value
    for value in (original.references, original.message_id)
    if value
) or None

await send_email(
    account_name="work",
    recipients=[original.sender],
    subject=f"Re: {original.subject}",
    body="Thank you for your email.",
    in_reply_to=original.message_id,
    references=references,
)

in_reply_to and references are nullable because not every message belongs to a thread or has a valid Message-ID. Simple Message-IDs may be supplied bare or inside angle brackets; the compose path adds missing brackets to each simple ID and preserves already bracketed values. The server returns references as one whitespace-normalized string rather than guessing how to tokenize malformed or historical header syntax. Treat both values as untrusted observations: compose validation rejects malformed values containing control characters, and unusual legacy syntax is preserved rather than partially rewritten.

Forward a message with its attachments

Locate the message with list_emails_metadata, then forward it by UID. The subject and the quoted content are derived from the source message, so the caller supplies only the note that goes above them:

await forward_email(
    account_name="work",
    email_id="123",
    source_mailbox="INBOX",
    recipients=["[email protected]"],
    body="Forwarding this for your records; the signed contract is attached.",
)

The delivered subject becomes Fwd: <original subject>, and the original's attachments are re-attached with their MIME types and parameters preserved. Pass include_attachments=False to forward only the text.

The quoted block is rebuilt from the parsed plain-text body, so an HTML-heavy original arrives without its formatting. Nothing is silently truncated: the composed body, note included, is bounded at 1 MiB, and a forward that exceeds it is rejected rather than trimmed. When the recipient needs the message exactly as it was sent, save the parts with download_attachment and compose the message yourself with send_email.

Forwarding requires SMTP, so it fails its capability check for an IMAP-only account. If the source message cannot be read, including when a sender allowlist hides it, the call fails before any SMTP session is opened, so a forward is never sent without the attachments it was supposed to carry.

Read a long message in chunks

get_emails_content returns at most max_body_length characters for each message. If the body ends with ...[TRUNCATED], request the next window:

first = await get_emails_content(
    account_name="work",
    email_ids=["123"],
    body_offset=0,
    max_body_length=20000,
)

second = await get_emails_content(
    account_name="work",
    email_ids=["123"],
    body_offset=20000,
    max_body_length=20000,
)

Keep the mailbox argument consistent with the mailbox used to obtain the email_id.

Import legacy accounts into a managed catalog

Create the destination while keeping legacy runtime selected, preview the effective legacy source, and apply only after reviewing every action:

mcp-email-server config init \
  --database ~/.config/mcp-email-server/catalog.sqlite3
mcp-email-server config import-legacy
mcp-email-server config import-legacy --apply
# Review the displayed plan, then type IMPORT at the prompt.
mcp-email-server account list
mcp-email-server account test work incoming
# Restart MCP clients when config status reports restart_required=true.

The source uses the TOML file selected by MCP_EMAIL_SERVER_CONFIG_PATH plus the same complete environment-account replacement/addition and policy override precedence as legacy runtime. Preview displays endpoint, TLS, user, save-to-sent, policy, credential source class, and exact target revision details without accessing credential values or the keyring, so it can still show actions while the legacy keyring is locked. --apply prints that complete plan before accepting the interactive IMPORT confirmation; a no-op plan does not prompt. Apply then fails safely if a required current TOML, environment, or keyring credential cannot be read. A full successful import automatically selects managed mode. Any failure keeps legacy selected; unsupported provider account types are reported and prevent automatic cutover.

A conflict means the destination already has a different account with that normalized name or retains a soft-removal tombstone. Import never overwrites that row. Resolve it deliberately by choosing a fresh catalog or reconciling the account manually. An exact repeat is unchanged; an interrupted matching import can report resume_credentials and install only missing bindings. The source is left untouched in every case.

Optional Codex and Claude Code plugin

The repository publishes one optional plugin through the Codex and Claude Code marketplace manifests. Both manifests reference the same root .mcp.json, which starts uvx --from mcp-email-server@latest mcp-email-server-plugin as a local bundled MCP server, plus the canonical safe-email-operations skill. The plugin-only entry point is absent from legacy releases, so they fail closed instead of exposing their older MCP catalog. This local stdio integration is for Codex, ChatGPT desktop, and Claude Code hosts that support local plugin processes; it is not a remote ChatGPT web connector.

Plugin and Python application releases are independent. The plugin version changes only when the bundled manifest, MCP declaration, skill, or related plugin content changes. @latest deliberately resolves the current published Python application, so first use can require network access and the running application can advance without a plugin update. Installing the plugin does not store email credentials, but enabling it allows the host to resolve the package and start the server locally. uvx must already be available on PATH.

The MCP surface contains mail operations, not account or credential management. The skill allows only the bounded @latest version check plus config status --json and config doctor --json, validates the JSON schema/command fields, and hands all account creation or credential entry back to the user-operated terminal or local browser. JSON availability on another command does not grant the agent permission to run it. The plugin must never launch the UI, copy its bootstrap URL, edit the catalog directly, or accept a secret in chat.

Before installation, inspect the official repository source, both marketplace manifests, both plugin manifests, .mcp.json, and the canonical skill. Installation, update, enablement, and removal are explicit user actions. The complete commands and source-verification checklist live in the plugin's references/installation.md; never curl and execute an installer or put repository credentials in a marketplace URL.

If a user asks an agent to add an account or rotate a password, the safe handoff is: run uvx mcp-email-server@latest ui locally and complete secret entry in the browser, or use the documented masked interactive CLI in the user's terminal. Do not paste credentials or the one-time UI URL into chat. If no user-controlled terminal or browser is available, setup cannot be completed safely through the plugin.

Containers and CI

For non-interactive environments:

  • Supply account settings through environment variables or mount a protected TOML file and set MCP_EMAIL_SERVER_CONFIG_PATH.
  • Use credential_storage = "plaintext" only when the mounted secret file is appropriately protected, or provide a functional keyring backend.
  • Expect auto to fall back to plaintext when no D-Bus keyring session exists.
  • Bind HTTP transports to the required interface and configure explicit allowed hosts and origins.
  • Mount only the directories needed for attachment upload or download.

See Security and Transports before exposing the service outside a local development environment.