@ajmarquez99/gmail-cli
v1.0.0
Published
Gmail CLI with a fail-closed recipient allowlist — send (markdown, attachments, dry-run, threading, send log) plus IMAP read, label, and mark.
Maintainers
Readme
gmail-cli
A Gmail CLI with a fail-closed recipient allowlist.
Sends over Gmail SMTP and reads over Gmail IMAP, both using an App Password — no OAuth, no
service accounts. gmail read show surfaces the Message-ID of any message, which you can pass
directly to gmail send --in-reply-to for a full read-and-reply loop from the terminal or an
agent — no external connector needed. v1.0.0 adds draft/reply/forward composition, organize
actions (archive, move, trash, label management), a local rules engine with Gmail filter export,
and per-profile capability scoping.
Install
Global (recommended)
npm install -g @ajmarquez99/gmail-cli
gmail --versionNo install (npx)
npx @ajmarquez99/gmail-cli send --to [email protected] --subject "Hi" --body "Hello."One-liner installer
curl -fsSL https://raw.githubusercontent.com/AJMarquez99/gmail-cli/main/install.sh | shFrom source
git clone https://github.com/AJMarquez99/gmail-cli
cd gmail-cli
npm install
npm install -g .Requires Node.js ≥ 20.
gmail: command not found after install?
A global npm install drops the gmail executable into npm's global bin directory — it does not
edit your shell config. If that directory isn't on your PATH, the command won't be found. Print
the directory and confirm it's on your PATH:
npm prefix -g # global install root; the bin dir is <that>/bin
echo "$PATH" | tr ':' '\n' | grep "$(npm prefix -g)/bin" || echo "not on PATH"If it's missing, add it to your shell config (~/.zshrc, ~/.bashrc, etc.) and restart your shell:
export PATH="$(npm prefix -g)/bin:$PATH"Most Node setups (official installer, Homebrew, nvm, fnm, volta) already put this directory on
PATH, so this is only needed for non-standard installs.
Quickstart
All setup steps are now command-driven — no hand-editing JSON required (though hand-editing the JSON files still works if you prefer).
Set up credentials — prompts for your Gmail address and App Password (hidden):
gmail loginThis writes
~/.config/gmail-cli/credentials.jsonatchmod 600. See Security notes for guidance on App Passwords.Optional flags:
--user [email protected](skip the email prompt);--forceto overwrite existing credentials.Add an allowed recipient (with an optional short alias):
gmail allow add [email protected] --alias aliceSet non-secret preferences (optional):
gmail config set fromName "Your Name"Verify and send (doctor now checks both SMTP and IMAP):
gmail doctor gmail send --to alice --subject "Hello" --body "Hi there." gmail read list # list inbox once IMAP is enabled on the account
Getting an App Password: requires 2FA on the account. Visit https://myaccount.google.com/apppasswords — pick "Mail" / "Other"; you get a 16-character code shown as
xxxx xxxx xxxx xxxx.
Multiple accounts (optional): see Profiles to manage more than one Gmail account from the same CLI. The single-account setup above keeps working unchanged — profiles are opt-in.
Reading mail (IMAP)
Prerequisite
IMAP must be enabled on each Gmail account before any read, label, or mark command will
work. In Gmail, go to Settings → See all settings → Forwarding and POP/IMAP → Enable IMAP.
The same App Password you set up for sending works for IMAP too — no second credential needed.
gmail doctor checks both the SMTP and IMAP connections and reports the result for each.
Commands
# List the 20 most-recent messages in INBOX (newest first)
gmail read list
# Limit to 10, or only unread messages
gmail read list --limit 10
gmail read list --unread
# List from a different mailbox/label
gmail read list --mailbox "[Gmail]/Sent Mail"
# Search with Gmail's query syntax — same queries as the Gmail search box
gmail read search "from:[email protected] has:attachment"
gmail read search "newer_than:7d is:unread" --limit 50
gmail read search "subject:invoice" --mailbox "[Gmail]/All Mail"
# Show a full message by UID (from `read list` output) or by Message-ID.
# Table output prints the plain-text body; the HTML body (when present) is always
# available via --format json (the `html` field).
gmail read show 1234
gmail read show "<[email protected]>"
gmail read show 1234 --format json
# Show all messages in a thread (oldest first; defaults to [Gmail]/All Mail)
gmail read thread <thread-id>
gmail read thread <thread-id> --mailbox INBOX
# List all labels/folders on the account
gmail label list
# Add or remove a label on a message (identified by UID)
gmail label add 1234 Work
gmail label remove 1234 Work
# label add/remove default to INBOX; override with --mailbox if the message is elsewhere
gmail label add 1234 Archived --mailbox "[Gmail]/All Mail"
# removing a label from within that label's own mailbox (e.g. --mailbox Work; case-insensitive) moves the
# message to All Mail. In a rule, later actions no longer see it: a later archive/move/trash is reported
# as an error, while a later label/star/important/mark:read is unverified and still reports applied
# Mark a message as read or unread
gmail mark 1234 --read
gmail mark 1234 --unread
# Override the mailbox if the message is not in INBOX
gmail mark 1234 --read --mailbox "[Gmail]/Sent Mail"Read-and-reply loop
read show returns the messageId field (the RFC 822 Message-ID), which you can feed straight
into gmail send --in-reply-to to thread a reply:
# Get the Message-ID of the message you want to reply to
gmail read show 1234 --format json | grep messageId
# Thread a reply
gmail send --to [email protected] \
--subject "Re: Original subject" \
--body "Got it, thanks." \
--in-reply-to "<[email protected]>"Privacy note
Read content is displayed in the terminal but is never written to the send log — the log remains send-only. HTML bodies are fetched but kept in memory only; nothing is persisted to disk.
Profile awareness
All read commands (read, label, mark) are profile-aware: use --profile <name> or set
GMAIL_PROFILE to select an account. Per-profile IMAP host/port overrides can be set in
config.json under profiles.<name>.imap. IMAP connection timeouts (in milliseconds) can also
be overridden per profile: imap.connectionTimeout (default 15000), imap.greetingTimeout
(default 10000), and imap.socketTimeout (default 120000):
{
"profiles": {
"work": {
"imap": {
"host": "imap.example.com",
"port": 993,
"connectionTimeout": 15000,
"greetingTimeout": 10000,
"socketTimeout": 120000
}
}
}
}Profiles
Profiles let you use multiple Gmail accounts from the same CLI. Each profile owns its own credentials, allowlist, send log, and identity settings. Without any profiles configured, the single-account setup works exactly as before — profiles are purely opt-in.
What each profile owns
| Resource | Single-account path | Profile path |
|---|---|---|
| Credentials | credentials.json | credentials-<name>.json |
| Allowlist | allowlist.json | allowlist-<name>.json |
| Send log | sent.jsonl | sent-<name>.jsonl |
| Identity (fromName, replyTo, signature) | top-level config keys | profiles.<name>.* |
| Allowlist enforcement | allowlist.enforce | profiles.<name>.allowlist.enforce |
| Attachment root | attachRoot (default: current directory) | profiles.<name>.attachRoot |
| Fan-out cap | maxRecipients (default: 10) | profiles.<name>.maxRecipients |
File paths and settings can be set for any profile with a fully-qualified key
(gmail config set profiles.work.credentialsPath ~/secrets/work-creds.json) or a bare key plus
--profile (gmail config set fromName "Work Me" --profile work). The profile must already
exist (gmail profile add).
Profile resolution
When a command runs, the profile is selected by the first match in this ladder:
--profile <name>flagGMAIL_PROFILEenvironment variableconfig.defaultProfile(set bygmail profile use <name>)- Auto-select if exactly one profile is configured
- Legacy single-account mode (no profiles key in config)
- Error — multiple profiles configured and none selected: pass
--profile <name>or set a default
gmail profile commands
| Command | Description |
|---|---|
| gmail profile add <name> | Register a new profile. The first profile added automatically becomes the default. |
| gmail profile list | List all profiles, marking the default. |
| gmail profile use <name> | Set a profile as the default. |
| gmail profile remove <name> | Unregister a profile from config. Its credential/allowlist/log files are left on disk. |
Per-account setup flow
# 1. Register the profile (first one added becomes the default)
gmail profile add work
# 2. Set up credentials for that profile
gmail login --profile work
# 3. Add allowed recipients
gmail allow add [email protected] --alias colleague --profile work
# 4. Set identity preferences
gmail config set fromName "Work Name" --profile work
# 5. Send
gmail send --to colleague --subject "Hi" --body "Hello." --profile workThe --profile flag (or GMAIL_PROFILE env var) works on every command: send, doctor,
login, allow, log, config, and profile subcommands.
Multi-account example
gmail profile add personal # first → default
gmail profile add work # default unchanged (still personal)
gmail login --profile personal # stores credentials-personal.json
gmail login --profile work # stores credentials-work.json
gmail profile use work # switch default to work
gmail doctor # checks work (the new default)
gmail doctor --profile personal # checks personal explicitly
gmail send --to colleague --subject "Hi" --body "Hello." --profile work
GMAIL_PROFILE=personal gmail send --to friend --subject "Hey" --body "Hi."Fail-closed allowlist
gmail sendonly delivers to addresses on the allowlist (plus the configured account itself). With no or empty allowlist, the first send to anyone other than yourself fails immediately with exit code3. This is intentional — populate the allowlist before sending to others.
Use gmail allow add / gmail allow remove to manage it from the CLI, or edit
~/.config/gmail-cli/allowlist.json directly (override path with GMAIL_ALLOWLIST):
{
"recipients": [
{ "email": "[email protected]", "aliases": ["alice", "a"] },
{ "email": "[email protected]" },
{ "email": "[email protected]" }
]
}aliasesare optional. Address by alias and it expands to the real email:gmail send --to alice …sends to[email protected].- Enforcement covers
--to,--cc, and--bcc. If any recipient is not permitted, the whole send is rejected (nothing is sent) and the command exits3. --dry-runreports would-be-blocked recipients without throwing — useful for pre-flight checks. Two things still throw even under--dry-run, because they're hard refusals rather than previewable denials: exceeding the recipient cap (config.maxRecipients, exit2) and a locked-boundary allowlist bypass (exit3).- Matching is case-insensitive.
gmail allow listshows the current entries;gmail doctorreports the count and enforcement status.
Disabling enforcement ("let the agent run free")
Enforcement can be turned off to allow sending to any recipient without maintaining the allowlist. Default is always ON (fail-closed). Aliases still expand when enforcement is off — only the block is lifted.
Per-send (flag):
gmail send --to [email protected] --subject "Hi" --body "Hello." --no-allowlistA stderr warning is emitted on every real send with enforcement off:
warn: allowlist enforcement disabled — sending to any recipient (re-enable via config allowlist.enforce or drop --no-allowlist).Persistent (config):
gmail config set allowlist.enforce falseThe --no-allowlist flag overrides the config for a single send regardless of what the config says. To re-enable, run gmail config set allowlist.enforce true (or gmail config unset allowlist.enforce) and drop --no-allowlist.
Sealing the boundary (locked mode)
By default, anything that can run gmail can also edit its own boundary (gmail allow add,
gmail config set allowlist.enforce false, --no-allowlist, …). Locking the boundary makes the
CLI refuse those boundary-widening commands. A human turns it on per environment or persistently:
export GMAIL_CLI_LOCKED=1 # or "true"; set it in the environment the agent runs in
# or, persistently (a global key — always written at the top level of config.json,
# never per profile, whatever profile is active):
gmail config set locked true # equivalent to hand-editing config.json: { "locked": true }locked accepts only true or false; any other value (1, yes, ...) is rejected with exit 2
and nothing is written.
While locked, these all refuse with exit 3 (nothing is written or sent):
gmail allow add/gmail allow removegmail logingmail config set|unsetof a boundary key —allowlist.*,allowlistPath,credentialsPath,attachRoot,maxRecipients,capabilities,deny,profiles, andlockeditself (also when written as a fully-qualifiedprofiles.<name>.<key>)gmail profile add/gmail profile remove/gmail profile caps --allow|--deny- any send with enforcement off:
--no-allowlist, or a profile whoseallowlist.enforceisfalse
Everything else (reading, enforced sends, drafts, profile use, non-boundary config such as
fromName) works normally. Unlocking is a human action: clear GMAIL_CLI_LOCKED and set
locked to false in config.json by hand.
What the lock does not cover. The lock only governs changes made through the CLI. An agent
that can set environment variables (e.g. GMAIL_CLI_SETTINGS, GMAIL_ALLOWLIST,
GMAIL_CLI_CONFIG, GMAIL_PROFILE, GMAIL_USER/GMAIL_APP_PASSWORD, or GMAIL_CLI_LOCKED itself)
or write the config directory can defeat it — by pointing the CLI at different files, or by editing them directly. For a real seal,
fix the environment in the agent's launcher (so the agent cannot change it) and make
~/.config/gmail-cli non-writable by the agent. See SECURITY.md.
Permissions & capabilities
Each profile can be scoped to a least-privilege set of capabilities — so you can attach, say, a business email that may read, organize, and draft but can never send. Scoping is fail-closed and layered on top of the recipient allowlist.
The five buckets
| Bucket | Grants |
|---|---|
| read | read list/search/show/thread/count/download, label list |
| organize | label add/remove, label create/delete/rename, mark (read/star/important), archive, move, rules apply |
| draft | draft create, draft delete, reply --draft |
| send | send, reply, forward, draft send |
| delete | trash, delete |
Always-allowed (never gated): init, login, doctor, config, profile, allow, whoami,
log, and rules add/list/remove/export-xml (defining or exporting rules never touches the mailbox
— only rules apply does).
Allowlist vs denylist (pick one)
Configure a profile with either an allowlist or a denylist — never both:
// allowlist (recommended): only the listed buckets are granted, fail-closed on growth
"business": { "capabilities": ["read", "organize", "draft"] }
// denylist: everything except the listed buckets
"vendor": { "deny": ["send", "delete"] }- Absent both keys → unrestricted (full back-compat; existing and legacy single-account configs are unaffected, no migration needed).
- Both keys present → config error (exit
2). - An unknown bucket name → config error (exit
2).
Managing & inspecting
# Set a profile's scope (writes config.json; validates buckets)
gmail profile caps business --allow read,organize,draft
gmail profile caps vendor --deny send,delete
# Show the current effective scope
gmail profile caps business
# Show the resolved profile, account, mode, and granted buckets
gmail whoami
# doctor also prints each profile's capabilities alongside SMTP/IMAP/allowlist checks
gmail doctorA command whose bucket the active profile lacks is rejected before it runs with exit code 4
(CAPABILITY_DENIED) — distinct from 3 (recipient blocked by the allowlist), so agents can branch on
which boundary stopped them.
The transmission boundary
The allowlist is enforced only at the moment of transmission (send, draft send, a real
reply/forward) — never on draft creation or reply --draft. So a send-denied,
draft-capable profile can compose and stage outreach for human review but physically cannot
transmit it.
Usage
Output is JSON by default; add --format table for a human-readable summary.
# Verify credentials + SMTP connection
gmail doctor
# Send plain text
gmail send --to [email protected] --subject "Report" --body "All green."
# Multiple recipients (repeat the flag or comma-separate), cc/bcc, reply-to
gmail send --to [email protected] --to "[email protected],[email protected]" \
--cc [email protected] \
--subject "Update" --body "Done." --reply-to [email protected]
# Send HTML explicitly
gmail send --to [email protected] --subject "Hi" --html "<b>Hello.</b>"
# Render body as Markdown → HTML (with inline email styles), plain-text fallback auto-generated
gmail send --to [email protected] --subject "Report" --body "# Report\n\nAll green." --markdown
# Markdown without the inline styler (raw marked HTML)
gmail send --to [email protected] --subject "Report" --body "# Report" --markdown --no-style
# Pipe the body in (handy for agents / long content); --markdown works with piped input too
generate-report | gmail send --to [email protected] --subject "Nightly report" --markdown
# Attach files (repeatable; comma-separated ok; hard limit 25MB total, warning at 20MB).
# Attachments are confined to config.attachRoot (default: the current directory) — set it once,
# then pass paths relative to it (or absolute paths under it).
gmail config set attachRoot ~/docs
gmail send --to [email protected] --subject "Invoice" --body "See attached." \
--attach invoice.pdf --attach receipt.pdf
# Thread a reply (sets In-Reply-To and References)
gmail send --to [email protected] --subject "Re: Question" --body "Sure!" \
--in-reply-to "<[email protected]>"
# Set a display name on the From header
gmail send --to [email protected] --subject "Hi" --body "Hello." \
--from-name "Your Name"
# Dry-run: assemble and preview without sending or logging; denied recipients are reported, not blocked
gmail send --to [email protected] --subject "Test" --body "Hello." --dry-run
# Suppress the configured signature for this send
gmail send --to [email protected] --subject "Quick note" --body "Short one." --no-signature
# Skip logging this send
gmail send --to [email protected] --subject "Quiet" --body "Shh." --no-log
# Include the body in the log entry for this send
gmail send --to [email protected] --subject "Report" --body "Details." --log-body
# View recent sent mail (newest first, default 20)
gmail log
gmail sent --limit 5Compose: draft, reply, forward
Drafts
# Create a draft (same flags as `gmail send`): saved to [Gmail]/Drafts. No allowlist check —
# nothing is transmitted, so a send-denied profile can still draft.
gmail draft create --to [email protected] --subject "Proposal" --body "Draft for review"
# Send a stored draft by UID: enforces the allowlist, transmits, then deletes the draft and logs it.
gmail draft send 42
# Discard a draft by UID
gmail draft delete 42There is no
draft list/draft show— usegmail read list --mailbox '[Gmail]/Drafts'andgmail read show <uid> --mailbox '[Gmail]/Drafts'.
Notes: draft send transmits to the recipients stored in the draft at create time (editing
the draft elsewhere is honored; the allowlist is checked against those stored recipients on send). A
Bcc saved into a draft is not visible in Gmail's draft UI but is sent when the draft is sent.
Reply
# Threaded reply by UID (sets In-Reply-To/References, derives the Re: subject and recipient).
# Quotes the original by default. Body via flag or stdin.
gmail reply 17 --body "Sounds good — shipping today."
# Reply-all (cc all original recipients minus yourself)
gmail reply 17 --all --body "Looping everyone in."
# Stage the reply as a draft instead of sending (capability: draft, not send)
gmail reply 17 --draft --body "Hold for review"
# Suppress the quoted original
gmail reply 17 --no-quote --body "..."reply is send by default and draft with --draft (a dynamic capability). Other flags:
--html, --markdown/--no-style, --from-name, --no-signature, --attach, --no-allowlist,
--no-log, --mailbox (source mailbox, default INBOX).
Forward
# Forward a message, re-attaching the original attachments; --to is required.
gmail forward 17 --to [email protected] --body "FYI — see below."Flags: --to (repeatable/comma-separated), --body (optional intro), --markdown/--no-style,
--from-name, --no-signature, --no-allowlist, --no-log, --mailbox.
Organize
# Archive (remove from inbox; keeps the message in All Mail)
gmail archive 17
# Move to another mailbox/label
gmail move 17 "Saved"
# Star / important toggles (alongside --read/--unread)
gmail mark 17 --star # or --unstar
gmail mark 17 --important # or --unimportant
# --unstar in "[Gmail]/Starred" / --unimportant in "[Gmail]/Important" moves the message to All Mail
# Label taxonomy management
gmail label create "Outreach/Acme"
gmail label rename "Outreach/Acme" "Clients/Acme"
gmail label delete "Clients/Acme"
# Counts + attachment download
gmail read count --mailbox INBOX
gmail read download 17 --dir ./attachments
# Trash (recoverable) vs permanent delete
gmail trash 17
gmail delete 17 --permanent # refuses without --permanent; points you to `trash`mark requires exactly one action flag. archive/move/mark/label create|delete|rename need
the organize capability; trash/delete need delete; read count/read download need read. Permanent delete always requires
--permanent on top of the delete capability — there is no interactive confirmation (it would
not fit the JSON/agentic model), so --permanent is the explicit intent guard.
Rules engine (local filters)
A stateless, App-Password-only rules engine: define match → actions rules per profile, then
apply them on demand over IMAP (pair with cron or a loop for cadence). It is not a
server-side filter — it only runs when you invoke it — but you can also export importable Gmail
filter XML for true always-on server-side rules.
Define rules
# Add a rule (always allowed — defining a rule never touches the mailbox)
gmail rules add --match "from:acme.com newer_than:7d" --label "Outreach/Acme" --archive
# With a custom id, star + mark read, or move/trash
gmail rules add --match "from:[email protected]" --id news --mark read --star
gmail rules add --match "subject:invoice" --move "Finance"
gmail rules add --match "from:spammy.example" --trash
gmail rules list
gmail rules remove newsRules live in rules-{profile}.json (default ~/.config/gmail-cli/rules.json; override with
GMAIL_RULES):
{
"rules": [
{
"id": "outreach-acme",
"match": "from:acme.com newer_than:7d", // Gmail search query (same syntax as read/search)
"actions": ["label:Outreach/Acme", "archive"],
"mailbox": "INBOX" // search scope
}
]
}Actions: label:<name>, unlabel:<name>, archive, mark:read, star, important,
move:<mailbox>, trash. Rules cannot permanently delete — trash (recoverable) is the only
destructive action.
Apply rules
# Apply all rules now (gated: needs the `organize` capability)
gmail rules apply
# Preview without mutating anything
gmail rules apply --dry-run
# Apply only one rule, and/or cap matches per rule
gmail rules apply --rule outreach-acme --limit 50rules apply is gated by the organize capability as a baseline, plus per-action checks: an
action whose bucket the profile lacks (e.g. a trash action under a profile without delete) is
skipped and reported, not executed. Apply is idempotent (re-running is safe; there is no state
file). A non-positive or non-numeric --limit is ignored (no cap).
Export to Gmail filters
# Emit Gmail "Settings → Filters → Import" XML, then import it once by hand in the Gmail UI
gmail rules export-xml --format table > filters.xmlThe match query maps to Gmail's "Has the words" criterion; archive/mark:read/star/
important/trash map to the corresponding filter properties; move:<mbox> maps to label +
archive. Caveats: Gmail filters act on incoming mail going forward (not your existing inbox);
relative-date operators like newer_than behave differently server-side; unlabel has no
server-side equivalent and is omitted from the export.
Commands
| Command | Description |
|---|---|
| gmail init | Scaffold ~/.config/gmail-cli/ (allowlist.json + config.json) and print setup steps. |
| gmail login | Guided credential setup — prompts for email and App Password (hidden), writes credentials.json at chmod 600. |
| gmail send | Send an email (text/HTML/Markdown, to/cc/bcc, attachments, threading, dry-run). Enforces the allowlist. |
| gmail draft create | Save a new draft to Drafts (same flags as send; no allowlist — nothing is sent). |
| gmail draft send <uid> | Send a stored draft (enforces the allowlist), then delete it. |
| gmail draft delete <uid> | Discard a draft by UID. |
| gmail reply <uid> | Threaded reply (--all, --no-quote, --draft). Enforces the allowlist on send. |
| gmail forward <uid> --to <addr> | Forward a message, re-attaching original attachments. |
| gmail doctor | Check credentials, verify Gmail SMTP and IMAP connections, report allowlist size. |
| gmail read list | List recent messages (newest first). Options: --mailbox, --limit, --unread. |
| gmail read search <query> | Search with a Gmail query string (same syntax as the Gmail search box). Options: --mailbox, --limit. |
| gmail read show <uid\|message-id> | Show a full message by UID or Message-ID. Options: --mailbox. (HTML body is in the html field of --format json output.) |
| gmail read thread <thread-id> | Show all messages in a thread (oldest first). Options: --mailbox. |
| gmail read count | Count total + unread messages in a mailbox. Options: --mailbox. |
| gmail read download <target> | Download a message's attachments to a directory. Options: --mailbox, --dir. |
| gmail archive <uid> | Archive a message (remove it from the inbox). Options: --mailbox. |
| gmail move <uid> <destination> | Move a message to another mailbox/label. Options: --mailbox. |
| gmail trash <uid> | Move a message to Trash (recoverable). Options: --mailbox. |
| gmail delete <uid> --permanent | Permanently delete a message (requires --permanent). Options: --mailbox. |
| gmail label list | List all labels/folders on the account. |
| gmail label add <uid> <name> | Add a label to a message by UID. Options: --mailbox. |
| gmail label remove <uid> <name> | Remove a label from a message by UID. Options: --mailbox. |
| gmail label create <name> | Create a new label. |
| gmail label delete <name> | Delete a label. |
| gmail label rename <name> <newName> | Rename a label. |
| gmail mark <uid> --read\|--unread\|--star\|--unstar\|--important\|--unimportant | Mark a message. Options: --mailbox. Exactly one action flag is required. |
| gmail allow list | List allowed recipients and their aliases (read-only). |
| gmail allow add <email> | Add a recipient to the allowlist (idempotent; merges aliases if entry already exists). |
| gmail allow remove <email\|alias> | Remove a recipient by email address or alias. |
| gmail config set <key> <value> | Set a config preference (dotted key; true/false coerced to boolean). A fully-qualified profiles.<name>.<key> targets that profile directly, regardless of the active one. |
| gmail config get [key] | Show one config key, or the whole config if no key is given. |
| gmail config unset <key> | Remove a config key. |
| gmail log | Show recent sent-mail log entries, newest first (alias: gmail sent). |
| gmail profile add <name> | Register a new profile (first one added becomes the default). |
| gmail profile list | List all profiles, marking the default. |
| gmail profile use <name> | Set a profile as the default. |
| gmail profile remove <name> | Unregister a profile from config (files left on disk). |
| gmail profile caps <name> | Show or set a profile's capability scope (--allow b,b or --deny b,b). |
| gmail whoami | Show the resolved profile, account, capability mode, and granted buckets. |
| gmail rules add | Define a rule (--match + actions: --label/--archive/--mark read/--star/--important/--move/--trash). Always allowed. |
| gmail rules list | List defined rules. |
| gmail rules remove <id> | Remove a rule by id. |
| gmail rules apply | Apply rules over IMAP (--dry-run, --rule <id>, --limit <n>). Gated: organize + per-action checks. |
| gmail rules export-xml | Emit importable Gmail filter XML to stdout. Always allowed. |
Exit codes: 0 ok · 1 send/network/IMAP failure · 2 user-fixable config (missing creds, bad input, conflicting/unknown capability config) · 3 blocked by the boundary (recipient not in the allowlist, or a sealed/locked boundary refusing a bypass/edit) · 4 capability denied (command's bucket not granted to the profile).
Note on exit
4: the shared CLI-family contract defines0/1/2/3.gmail-cliadds4for capability-denied as a deliberate, documented extension so an orchestrator can distinguish "this profile may not do that" from "that recipient is blocked" (3). Consumers coded to the 4-code family should treat4like a2-class refusal.
--dry-run always exits 0 for allowlist denials (reported in the output, not the exit code) —
except exceeding the recipient cap (config.maxRecipients), which exits 2, and a
locked-boundary allowlist bypass, which exits 3; both are refused outright even under --dry-run.
gmail login options reference
| Flag | Description |
|---|---|
| --user <email> | Gmail address — skips the interactive email prompt |
| --force | Overwrite existing credentials without error |
Security: the App Password prompt has echo off — the password is never echoed to the terminal,
never written to logs, and never passed as a CLI flag. It flows directly from the prompt to
credentials.json. Verify the setup afterward with gmail doctor.
gmail allow commands reference
| Command | Description |
|---|---|
| gmail allow add <email> [--alias <name>] | Add a recipient. --alias is repeatable. If the email already exists, new aliases are merged in; duplicate aliases are silently skipped. An alias that already maps to a different email is refused. |
| gmail allow remove <email\|alias> | Remove the entry whose email or alias matches the target (case-insensitive). Exits 2 if not found. |
| gmail allow list | Read-only view of all entries. |
gmail config commands reference
| Command | Description |
|---|---|
| gmail config set <key> <value> | Write a value. Dotted keys (signature.text) create/update nested objects. true/false strings are coerced to booleans. Unknown keys are written with a warning. A fully-qualified profiles.<name>.<key> writes to that profile's settings verbatim (the profile must already exist); a bare key with --profile <name> scopes to that profile the same way. |
| gmail config get [key] | Print a single key's value, or the whole config if [key] is omitted. |
| gmail config unset <key> | Delete a key (and its subtree if dotted). |
Known config keys
| Key | Effect | Override flag |
|---|---|---|
| fromName | Display name on the From header | --from-name |
| replyTo | Default Reply-To address | --reply-to |
| signature.text | Plain-text signature appended after a blank line | --no-signature |
| signature.html | HTML signature appended after a blank line | --no-signature |
| sendLog.enabled | false disables the send log globally | --no-log (per-send) |
| sendLog.logBody | true includes body text in every log entry | --log-body (per-send) |
| allowlist.enforce | false disables allowlist enforcement globally (default: true) | --no-allowlist (per-send) |
| attachRoot | Directory --attach paths are confined to (default: the current directory); ~ expands to $HOME. Refused outright if it resolves to the filesystem root. | none — set per-send paths relative to it instead |
| maxRecipients | Hard cap on to+cc+bcc combined for a single transmission (default: 10); refused with exit 2 if exceeded. Enforced centrally, so it covers send, reply, forward, and draft send alike. Must be an integer >= 1 — gmail config set maxRecipients <n> rejects a non-integer, zero, or negative value with exit 2 and writes nothing; a value that reaches this state anyway (e.g. a hand-edited config file) fails closed with exit 2 the next time a profile is resolved, rather than silently disabling the cap. | none — split the send, or raise the cap |
gmail send options reference
| Flag | Description |
|---|---|
| --to <addr> | Recipient (repeatable; comma-separated ok) |
| --cc <addr> | CC recipient (repeatable) |
| --bcc <addr> | BCC recipient (repeatable) |
| --subject <text> | Subject line |
| --body <text> | Plain-text body (or pipe it on stdin) |
| --html <html> | HTML body (mutually exclusive with --markdown) |
| --markdown | Render --body (or stdin) as Markdown → HTML with inline email styles; plain-text fallback is the raw Markdown |
| --no-style | With --markdown: skip the inline email styler (raw marked HTML output) |
| --attach <path> | File attachment (repeatable; comma-separated ok); confined to config.attachRoot (default: the current directory) — a path resolving outside it, or an attachRoot that resolves to the filesystem root, is refused; hard limit 25MB total, warning at 20MB |
| --from-name <name> | Display name on the From header (overrides config.fromName) |
| --reply-to <addr> | Reply-To address (overrides config.replyTo) |
| --in-reply-to <messageId> | In-Reply-To header; threads the email in Gmail |
| --references <id> | References header entry (repeatable; comma-separated ok) |
| --no-signature | Do not append the configured signature |
| --dry-run | Assemble and preview the message without sending or logging; reports would-be-blocked recipients |
| --log-body | Include body text in this send's log entry (overrides config.sendLog.logBody) |
| --no-log | Do not append this send to the send log |
| --no-allowlist | Disable allowlist enforcement for this send — sends to any recipient; aliases still expand |
Configuration (non-secret preferences)
Optional settings live at ~/.config/gmail-cli/config.json (override path with
GMAIL_CLI_SETTINGS). Missing file is fine — all fields are optional, and CLI flags always
win over config values.
{
"fromName": "Your Name",
"replyTo": "[email protected]",
"signature": {
"text": "--\nYour Name",
"html": "<p>--<br>Your Name</p>"
},
"sendLog": {
"enabled": true,
"logBody": false
},
"allowlist": {
"enforce": true
},
"attachRoot": "~/docs",
"maxRecipients": 10
}Use gmail config set/get/unset to manage these without hand-editing. Hand-editing the JSON
file directly also works.
Send log
Every successful send appends a metadata-only JSONL entry to
~/.config/gmail-cli/sent.jsonl (override path with GMAIL_SEND_LOG). Body text is
excluded by default (use --log-body or config.sendLog.logBody: true to include it).
Use --no-log to skip logging for a single send; set config.sendLog.enabled: false to
disable globally.
gmail log # show last 20 entries (newest first)
gmail sent # alias for gmail log
gmail log --limit 5 # show last 5Credential resolution
GMAIL_USER+GMAIL_APP_PASSWORDenv vars (take precedence)GMAIL_CLI_CONFIGpath, else~/.config/gmail-cli/credentials.json
Environment variables
| Variable | Purpose |
|---|---|
| GMAIL_USER | Gmail address (takes precedence over credentials.json) |
| GMAIL_APP_PASSWORD | App Password (takes precedence over credentials.json) |
| GMAIL_CLI_CONFIG | Path to credentials JSON (default: ~/.config/gmail-cli/credentials.json) |
| GMAIL_CLI_SETTINGS | Path to non-secret config JSON (default: ~/.config/gmail-cli/config.json) |
| GMAIL_SEND_LOG | Path to sent-mail JSONL log (default: ~/.config/gmail-cli/sent.jsonl) |
| GMAIL_ALLOWLIST | Path to allowlist JSON (default: ~/.config/gmail-cli/allowlist.json) |
| GMAIL_RULES | Path to the rules JSON (default: ~/.config/gmail-cli/rules.json; per-profile: rules-{name}.json) |
Security notes
gmail loginuses a hidden prompt — the App Password is never echoed to the terminal, never passed as a CLI flag, and never written to logs. It goes directly from the prompt tocredentials.json.- The App Password is a long-lived secret with SMTP-send access to the account. Keep
credentials.jsonatchmod 600(gmail logindoes this automatically); never commit it. - Every file this CLI writes under
~/.config/gmail-cli/—config.json,allowlist*.json,rules*.json, andsent*.jsonl(not justcredentials.json) — is written owner-only (chmod 600) and re-tightened to600on every subsequent write/append, since a file's mode only applies at creation. An install upgrading from an older version whose files were left world-readable self-heals the next time each file is touched (config set,allow add,profile add,rules add, sending), or immediately via a no-opgmail init(safe to re-run; it never overwrites existing content, only permissions). - Scope is send-only by construction (SMTP). Revoke anytime at https://myaccount.google.com/apppasswords.
- Outbound recipients are constrained by the fail-closed allowlist (see above), so the blast radius of a misused App Password is limited to addresses you've explicitly approved.
- A per-send fan-out cap (
config.maxRecipients, default 10, must be an integer >= 1) further bounds blast radius: any single transmission (send, reply, forward, or draft send) with more than the cap combined across to/cc/bcc is refused outright with exit2, before the allowlist is even consulted — even under--dry-run. The cap fails closed: a non-numeric or out-of-range value is rejected atconfig settime (exit2, nothing written) and, if one reaches the config file another way, at profile-resolution time (exit2), rather than silently disabling the cap.
MCP server
gmail-cli ships a stdio Model Context Protocol server — the portable counterpart to the CLI, so MCP-aware clients (Claude Code, Claude Desktop) can drive Gmail as structured tools without shell invocations.
Install
npm install -g . installs both the gmail CLI and the gmail-mcp MCP server binary.
Register with Claude Code
claude mcp add gmail -- gmail-mcpThe server reads the same ~/.config/gmail-cli/ credentials, config, and allowlist files as the
CLI (including profiles via the optional profile argument). No extra setup needed.
Tools
The MCP surface is the operational verbs only — send, mailbox reads, label/mark triage, and
read-only diagnostics:
| Tool | Description |
|---|---|
| gmail_send | Send an email. Gated by the fail-closed allowlist; supports dry_run. |
| gmail_read_list | List recent messages from a mailbox. |
| gmail_read_search | Search messages with a Gmail query string. |
| gmail_read_show | Show a single message by UID or Message-ID. |
| gmail_read_thread | Show all messages in a thread. |
| gmail_label_list / gmail_label_add / gmail_label_remove | List labels; add/remove a label on a message. |
| gmail_mark | Mark a message read/unread. |
| gmail_allow_list | Show the recipient allowlist (read-only). |
| gmail_log | Show recent sent-mail log entries (metadata). |
| gmail_doctor | Verify credentials over SMTP + IMAP; report allowlist + enforcement. |
Safety
Every tool delegates directly to the same run*(opts, deps) command functions used by the CLI, so
the fail-closed recipient allowlist, dry-run, and send log all apply unchanged. A blocked recipient
returns an MCP error result (isError: true) rather than crashing the server — proven end-to-end: a
gmail_send to a non-allowlisted address is rejected before any SMTP call.
By design, the MCP surface cannot move its own safety boundary or touch secrets: there is no
gmail_login/gmail_init, no gmail_allow_add/remove, no gmail_config_*, and gmail_send
exposes no no_allowlist (bypass) or no_log (accountability) argument. Widening the allowlist
or disabling enforcement remains a deliberate human edit to the config files. See the umbrella
.ai/guidelines/safety-spec.md §5.8 for the full rationale.
Related tools
gmail-cli is one of a small family of personal, fail-closed CLIs built for AI coding agents — each a
focused wrapper around a single service, in the spirit of gh:
- dot-ai — the agent-agnostic
.ai/project-intelligence convention these tools are documented with.
Develop
npm run test:run # run tests once
npm test # vitest watch modeArchitecture: ESM, commander, dependency-injected command handlers (src/commands/*.js)
for testability, JSON-default output. Credentials never get committed (.gitignore + creds
live in ~/.config).
