Build a Python CLI named imap-agent-cli that lets agentic tools safely inspect email over generic IMAP and create email drafts without any ability to send, delete, move, archive, flag, mark read, or otherwise mutate existing messages.
The tool should follow the same broad product pattern as sql-agent-cli:
- local single-file execution via
uv run ./imap_agent_cli.py ...using PEP 723 inline script metadata - packaged execution via
uvx imap-agent-cli ... - agent-first stdout/stderr behavior with stable JSON output contracts
- profile-based configuration with a simple env-var path for the common single-account case
Primary audience:
- agentic coding tools such as Codex
Secondary audience:
- developers who need a deterministic local CLI for controlled email inspection and draft creation
License:
- MIT
- The CLI can search email by subject, sender, date range, folder, and folder scope.
- The CLI can enumerate folders with useful metadata such as selectable status, delimiter, special-use role, total count, and unread count when available.
- The CLI can read specific messages by
{profile, folder, uid}without marking them as read. - The CLI can list attachment metadata and download attachments only when explicitly requested.
- The CLI can create new draft messages by appending MIME messages to the configured or detected Drafts folder.
- The CLI can create reply drafts from an existing message, including
In-Reply-ToandReferencesheaders. - The CLI must never send email. SMTP is out of scope.
- The CLI must never delete, move, archive, label, flag, star, mark read, mark unread, or mutate existing messages.
- Stdout contains payload only. Diagnostics, warnings, progress, and errors go to stderr only.
- JSON is the only required output format in v1.
- The project is publishable so
uvx imap-agent-cli --helpworks. - The repo also keeps a root-level
imap_agent_cli.pywrapper foruv run.
- SMTP send support.
- Destructive or organizational mailbox actions.
- OAuth2 setup flows for Gmail or Microsoft 365.
- Provider-specific APIs such as Gmail API or Microsoft Graph.
- Interactive TUI behavior.
- Human-readable default output.
- Long-term local email indexing in v1.
- Background polling or notifications.
imap-agent-cli
The PyPI package name, GitHub repository name, and CLI command must match: imap-agent-cli.
imap-agent-cli/
pyproject.toml
README.md
imap_agent_cli.py
spec.md
src/
imap_agent_cli/
__init__.py
cli.py
config.py
errors.py
folders.py
imap_client.py
messages.py
mime.py
models.py
render.py
safety.py
search.py
tests/
__init__.py
test_config.py
test_search.py
test_mime.py
test_render.py
test_cli.py
- Publishable Python package via
pyproject.toml. - Console script entry point exposed as
imap-agent-cli. - Python
>=3.11. - Root-level
imap_agent_cli.pywrapper with PEP 723 metadata for local script execution. - The wrapper delegates into the packaged implementation instead of duplicating application logic.
Recommended implementation dependencies:
IMAPClientfor IMAP operations.- Python standard-library
emailpackage for parsing and composing MIME messages. beautifulsoup4for HTML cleanup and text extraction.bleachfor sanitized HTML output.markdownifyfor optional HTML-to-Markdown conversion.
Rationale:
IMAPClientprovides a higher-level Pythonic API over stdlibimaplib, handles UIDs cleanly, and parses many server responses into useful Python objects.- stdlib
emailis sufficient and appropriate for MIME parsing/composition. - HTML emails are untrusted content. Sanitization and conversion should be explicit implementation steps, not ad hoc string handling.
- Markdown conversion is feasible for agent consumption, but it is lossy. The default should preserve sanitized HTML while allowing
--body-format markdown.
The tool may use only these behavioral classes:
- connect/authenticate/logout
- list folders
- select or examine folders in read-only mode
- search for messages
- fetch message metadata, body parts, and attachments without setting
\Seen - append new messages to the Drafts folder
The tool must not expose or internally use behavior that mutates existing messages:
STORECOPYMOVEEXPUNGEDELETE- flag/star changes
- read/unread changes
- label changes
- archive operations
- folder create/delete/rename operations
Implementation requirement:
- Fetches must use peek/no-seen behavior. Reading a message must not mark it as read.
The only write operation allowed in v1 is creating a new message in the Drafts folder via IMAP APPEND.
Draft creation must:
- build a valid RFC 5322/MIME message
- append it to the resolved Drafts folder
- return metadata about the created draft when available
- never send the message
- never save a draft outside the Drafts folder unless the user explicitly configured that folder as the profile's drafts folder
Use a single user config file at:
~/.imap-agent-cli/config.toml
Config-writing commands create the directory and file if they do not exist.
The simplest single-account setup should work without a config file:
IMAP_AGENT_CLI_HOST=colo-mailin.sona-systems.com
IMAP_AGENT_CLI_PORT=993
IMAP_AGENT_CLI_USERNAME=paul@sona-systems.com
IMAP_AGENT_CLI_PASSWORD=...
IMAP_AGENT_CLI_TLS=true
Optional env vars:
IMAP_AGENT_CLI_DRAFTS_FOLDER=Drafts
IMAP_AGENT_CLI_CONNECT_TIMEOUT_SECONDS=15
IMAP_AGENT_CLI_READ_TIMEOUT_SECONDS=30
IMAP_AGENT_CLI_MAX_RESULTS=25
IMAP_AGENT_CLI_MAX_BODY_CHARS=12000
Recommended shape:
[defaults]
profile = "default"
format = "json"
default_folder = "INBOX"
max_results = 25
max_body_chars = 12000
connect_timeout_seconds = 15
read_timeout_seconds = 30
body_format = "html"
include_attachments = false
exclude_special_folders_from_all = ["junk", "spam"]
[profiles.default]
host = "colo-mailin.sona-systems.com"
port = 993
username = "paul@sona-systems.com"
password_env = "IMAP_AGENT_CLI_PASSWORD"
tls = true
ssl_mode = "required"
drafts_folder = ""
[profiles.personal]
host = "imap.example.com"
port = 993
username = "me@example.com"
password_env = "IMAP_AGENT_CLI_PERSONAL_PASSWORD"
tls = true
ssl_mode = "required"
drafts_folder = "Drafts"Notes:
[defaults].profileis an alias, not duplicated connection data.- Config should store non-secret fields and credential source names.
- Passwords should come from env vars or stdin, not plaintext config.
drafts_folder = ""means auto-detect.
Resolution order:
- explicit CLI flags such as
--host,--port,--username,--tls, and--ssl-mode - selected profile from
--profile NAME - default profile from
[defaults].profile - single-profile env vars
- fail with a clear configuration error
Credential resolution order:
--password-stdin- selected profile's
password_env - single-profile
IMAP_AGENT_CLI_PASSWORD - fail with a clear auth error
Recommended commands:
imap-agent-cli config init
imap-agent-cli config show
imap-agent-cli config set-default-profile NAME
imap-agent-cli config add-profile NAME --host HOST --port 993 --username USER --password-env ENV_NAME
imap-agent-cli config remove-profile NAME
imap-agent-cli profiles
Behavior requirements:
config initpre-populates~/.imap-agent-cli/config.toml.config initmay accept--from-envto seed non-secret fields fromIMAP_AGENT_CLI_*.config showdisplays effective settings with secrets redacted.config showindicates whether each profile is complete enough to connect.- Config-writing commands preserve unrelated existing settings where practical.
Provider presets are named connection templates, not provider-specific behavior layers.
Recommended v1 presets:
generic-ssl-993: port993, TLS enabled, SSL required.generic-starttls-143: port143, STARTTLS required.generic-plain-143: port143, TLS disabled, intended only for local test servers.
Possible later presets:
fastmailicloudyahoooutlook-imap
Preset behavior:
- Presets fill defaults only.
- Explicit CLI flags override preset values.
- Config values override preset values after initialization.
- Presets must not imply OAuth2 or provider API usage.
Examples:
imap-agent-cli config init --preset generic-ssl-993 --host colo-mailin.sona-systems.com --username paul@sona-systems.com
imap-agent-cli config add-profile work --preset generic-ssl-993 --host colo-mailin.sona-systems.com --username paul@sona-systems.com --password-env IMAP_AGENT_CLI_WORK_PASSWORD
All commands output JSON to stdout by default. Diagnostics and errors go to stderr.
List folders:
imap-agent-cli folders
imap-agent-cli folders --profile work
Example output:
{
"profile": "default",
"folders": [
{
"name": "INBOX",
"delimiter": "/",
"selectable": true,
"special_use": "inbox",
"total": 42,
"unread": 3,
"children": []
}
]
}Folder metadata should include provider flags and special-use roles where the server exposes them.
Primary search shape:
imap-agent-cli search --subject "invoice"
imap-agent-cli search --from "person@example.com"
imap-agent-cli search --since 2026-01-01 --before 2026-02-01
imap-agent-cli search --folder INBOX --subject "build failed"
imap-agent-cli search --folder Projects --recursive --from "person@example.com"
imap-agent-cli search --all-folders --subject "contract"
JSON query input:
imap-agent-cli search --json query.json
Get-Content query.json | imap-agent-cli search --json -
Recommended JSON query shape:
{
"profile": "default",
"folder": "INBOX",
"scope": "folder",
"subject": "invoice",
"from": "person@example.com",
"since": "2026-01-01",
"before": "2026-02-01",
"max_results": 25,
"include_body": false,
"include_attachments": false
}Folder scope values:
folder: selected folder onlyrecursive: selected folder and child foldersall: all selectable folders except Junk/Spam by default
Default search behavior:
- default folder is
INBOX - default scope is
folder - max results default to
25 - results sort newest first where practical
- all-folder search excludes folders with special-use
junkorspam - all-folder search may include Trash, Archive, Sent, and Drafts
Example search output:
{
"profile": "default",
"query": {
"folder": "INBOX",
"scope": "folder",
"subject": "invoice",
"max_results": 25
},
"results": [
{
"folder": "INBOX",
"uid": 12345,
"message_id": "<abc@example.com>",
"date": "2026-01-15T20:33:10Z",
"from": [{"name": "Example Sender", "email": "sender@example.com"}],
"to": [{"name": "Paul", "email": "paul@example.com"}],
"cc": [],
"subject": "Invoice for January",
"has_attachments": true,
"attachment_count": 1,
"size_bytes": 48291
}
],
"truncated": false
}Read by folder and UID:
imap-agent-cli read --folder INBOX --uid 12345
imap-agent-cli read --folder INBOX --uid 12345 --body-format markdown
imap-agent-cli read --folder INBOX --uid 12345 --body-format raw-html
imap-agent-cli read --folder INBOX --uid 12345 --include-attachments
JSON input:
imap-agent-cli read --json read.json
Get-Content read.json | imap-agent-cli read --json -
Body format values:
html: sanitized HTML if an HTML body exists, otherwise plain textmarkdown: Markdown converted from HTML if HTML exists, otherwise plain textplain: plain text body where available, otherwise text extracted from HTMLraw-html: unsanitized HTML, explicitly requested onlymetadata: no body
Default body behavior:
body_format = "html"- HTML output must be sanitized by default.
- If no HTML body exists, return the plain text body.
max_body_charsdefaults to12000.- Body truncation must be reported in JSON.
- Attachment content is never returned inline.
Sanitized HTML policy:
- remove scripts, event handlers, forms, iframes, objects, embedded active content, and unsafe URLs
- remove or neutralize remote resource references such as tracking pixels
- preserve basic formatting, links, tables, block quotes, and lists where practical
- include a
sanitized: truefield in the response
Markdown policy:
- Markdown conversion should use a maintained parser/converter such as
markdownify. - Markdown conversion is lossy and optional.
- Markdown should be useful for agent reasoning, not a pixel-perfect rendering of the original email.
Example read output:
{
"profile": "default",
"folder": "INBOX",
"uid": 12345,
"message_id": "<abc@example.com>",
"date": "2026-01-15T20:33:10Z",
"headers": {
"subject": "Invoice for January",
"from": [{"name": "Example Sender", "email": "sender@example.com"}],
"to": [{"name": "Paul", "email": "paul@example.com"}],
"cc": [],
"reply_to": []
},
"body": {
"format": "html",
"content": "<p>Hello...</p>",
"sanitized": true,
"truncated": false,
"max_chars": 12000
},
"attachments": [
{
"part_id": "2",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size_bytes": 39122,
"content_id": null,
"inline": false
}
]
}Attachment download requires an explicit destination:
imap-agent-cli attachments --folder INBOX --uid 12345
imap-agent-cli attachments download --folder INBOX --uid 12345 --part-id 2 --output-dir C:\tmp\email-attachments
imap-agent-cli attachments download --folder INBOX --uid 12345 --all --output-dir C:\tmp\email-attachments
Requirements:
- Attachment metadata can be listed without downloading content.
- Downloads require
--output-dir. - Filenames must be sanitized to avoid path traversal.
- Existing files must not be overwritten unless
--overwriteis provided. - Output JSON includes absolute saved paths, content types, sizes, and hashes.
- Inline attachments are excluded by default unless
--include-inlineis provided.
Create a new draft:
imap-agent-cli draft create --to person@example.com --subject "Hello" --body-file body.html --body-format html
imap-agent-cli draft create --json draft.json
Get-Content draft.json | imap-agent-cli draft create --json -
Recommended JSON input:
{
"profile": "default",
"to": [{"email": "person@example.com", "name": "Person"}],
"cc": [],
"bcc": [],
"subject": "Hello",
"body": "<p>Hello from Codex.</p>",
"body_format": "html",
"attachments": [
"C:/tmp/report.pdf"
]
}Create a reply draft:
imap-agent-cli draft reply --folder INBOX --uid 12345 --body-file reply.html --body-format html
imap-agent-cli draft reply --folder INBOX --uid 12345 --to override@example.com --body-file reply.md --body-format markdown
imap-agent-cli draft reply --json reply.json
Recommended reply JSON input:
{
"profile": "default",
"source": {
"folder": "INBOX",
"uid": 12345
},
"body": "<p>Thanks. I will take a look.</p>",
"body_format": "html",
"include_quoted_original": false,
"attachments": []
}Reply draft behavior:
- Read source message using no-seen fetch behavior.
- Default
TofromReply-Towhen present, otherwiseFrom. - Default
SubjecttoRe: <original subject>unless it already begins with a reply prefix. - Populate
In-Reply-Tofrom sourceMessage-ID. - Populate
Referencesfrom sourceReferencesplus sourceMessage-ID. - Allow explicit
--to,--cc,--bcc, and--subjectoverrides. - Do not include quoted original by default.
Draft output:
{
"profile": "default",
"drafts_folder": "Drafts",
"created": true,
"append_uid": 67890,
"message_id": "<generated@example.local>",
"subject": "Re: Invoice for January",
"to": [{"name": "Example Sender", "email": "sender@example.com"}],
"attachment_count": 0
}Draft folder resolution order:
- explicit CLI
--drafts-folder - selected profile
drafts_folder - folder with IMAP special-use
\Drafts - common folder names, in order:
Drafts,INBOX.Drafts,[Gmail]/Drafts - fail with a clear error that asks the user to configure
drafts_folder
The selected Drafts folder should be cached only in config when the user explicitly asks the tool to write it.
- Stdout is JSON payload only.
- Stderr is for diagnostics, warnings, progress, and errors.
- Logs must not include passwords or message bodies.
- Subjects, senders, recipients, and folder names may appear in payload JSON and should not be duplicated in logs by default.
On failure, stdout should be empty and stderr should contain a compact JSON error by default:
{
"error": {
"code": "auth_failed",
"message": "IMAP authentication failed for profile 'default'.",
"retryable": false
}
}Recommended error codes:
config_missingconfig_invalidauth_failedconnection_failedfolder_not_foundmessage_not_founddrafts_folder_not_foundinvalid_requestattachment_not_foundwrite_not_allowedserver_error
Recommended logging flags:
imap-agent-cli search --subject test --verbose
imap-agent-cli search --subject test --log-file C:\tmp\imap-agent-cli.log
Requirements:
- Default logs go to stderr only.
--log-filewrites diagnostics to the specified file.- Log files must redact secrets.
- Message bodies and attachment content must not be logged.
- Debug logging may include IMAP command names but must not include raw credentials.
Default limits:
max_results = 25max_body_chars = 12000connect_timeout_seconds = 15read_timeout_seconds = 30- attachment metadata included only when requested
- attachment content downloaded only by explicit command
- raw HTML and raw source disabled unless explicitly requested
Override examples:
imap-agent-cli search --subject invoice --max-results 100
imap-agent-cli read --folder INBOX --uid 12345 --max-body-chars 50000
imap-agent-cli read --folder INBOX --uid 12345 --body-format raw-html
Hard safety requirements:
- The CLI must not provide a
sendcommand. - The CLI must not expose a generic "run IMAP command" escape hatch.
- The CLI must not provide flags that modify existing message flags.
- The CLI must not auto-download attachments while reading/searching.
Unit tests should cover:
- config/env/profile resolution
- password source resolution
- folder scope expansion rules
- search criteria construction
- MIME parsing
- sanitized HTML output
- Markdown conversion
- attachment metadata extraction
- attachment filename sanitization
- draft MIME generation
- reply header generation
- JSON output contracts
- no-seen fetch behavior at the adapter boundary
Use pymap as the preferred repeatable local IMAP server for integration tests.
Recommended local test target:
uv run pymap --port 1143 --debug maildir .tmp/imap-maildir
pymap is useful because it is a lightweight Python IMAP server with pluggable backends such as Maildir. Tests can seed a temporary Maildir or use pymap administrative helpers, then verify imap-agent-cli behavior against IMAP without Docker or real work credentials.
Local test requirements:
- Tests must not require the user's real IMAP account.
- Local integration tests should run on Windows without Docker.
- Live provider tests must be gated behind explicit env vars such as
IMAP_AGENT_CLI_TEST_LIVE=1. - Live provider tests must not create or mutate anything except test drafts in the configured Drafts folder.
- The default test suite should run without network access where practical.
Against the user's current generic IMAP settings:
IMAP_AGENT_CLI_HOST=colo-mailin.sona-systems.com
IMAP_AGENT_CLI_PORT=993
IMAP_AGENT_CLI_USERNAME=paul@sona-systems.com
IMAP_AGENT_CLI_PASSWORD=...
IMAP_AGENT_CLI_TLS=true
uv run ./imap_agent_cli.py folders
uv run ./imap_agent_cli.py search --subject test --max-results 5
uv run ./imap_agent_cli.py read --folder INBOX --uid <uid>
uv run ./imap_agent_cli.py draft create --to paul@sona-systems.com --subject "Draft smoke test" --body "This is a draft only."
README should include:
- clear warning that the tool can create drafts but cannot send email
- quick start with env vars
- config file example with default and named profiles
- examples for folders, search, read, attachment download, draft create, and draft reply
- explanation of sanitized HTML vs Markdown vs raw HTML
- explanation of no-seen read behavior
- local
pymaptest instructions - troubleshooting for Drafts folder detection
- Whether v1 should support STARTTLS on port 143 or defer it until after SSL-on-993 works.
- Whether to expose
raw-sourceoutput for full RFC 822 messages, and if so under what flag name. - Whether draft creation should support inline images in v1 or only normal file attachments.
- Whether to add a
--since-days Nconvenience filter in addition to explicit date ranges.