Gmail CLI (co gmail)

September 8, 2026 · View on GitHub

Send and read email from your Gmail account right in the terminal — the same Gmail API access your agents get from the Gmail tool, as a command.

Quick Start

# Connect your Google account (one-time)
co auth google

# Check your inbox (the zero-arg default)
co gmail

# Read message #3 from the inbox list
co gmail read 3 --listing <listing-id>

# Create, review, and then explicitly send a draft
co gmail draft create alice@example.com "Hello" "Thanks for the meeting today!"
co gmail draft list               # choose the new draft's row from this listing
co gmail draft preview <draft-id>
co gmail draft review <draft-id> --json
co gmail draft send <draft-id> --confirm <review-token> --json

Use the row that matches your draft; create prints its full ID but does not assign it row 1. You can use that ID directly instead of listing again.

Setup

co gmail needs a connected Google account:

co auth google

This opens the Google OAuth flow and saves GOOGLE_* credentials (access token, refresh token, scopes, email) to the selected env file: global ~/.co/keys.env by default. Use co --env-file /absolute/project/.env auth google to select another file explicitly. Tokens auto-refresh — the access token is renewed at the start of every command, so you authorize once. Draft creation and editing need the gmail.modify scope; reconnect if an older token does not have it.

See Google Integration for the requested scopes.

Commands

co gmail — Show the inbox

With no subcommand, prints your most recent emails. Same as co gmail inbox.

co gmail                 # 10 most recent
co gmail inbox -n 25     # last 25
co gmail inbox -u        # unread only

Every listing prints a Listing: token. A number requires that token, for example co gmail read 3 --listing <listing-id>. Full IDs need no token. Listings are frozen for 15 minutes and bound to the provider-confirmed account. Opening another inbox, search, sent, or empty listing cannot change an earlier row. Up to 128 listings are retained; expired, evicted, corrupt, or mismatched listings fail with exit 1. Relist or use a full ID. Legacy last-listing caches are ignored; bare numbers now fail instead of choosing an ambiguous message.

A green ● marks unread.

co gmail read <#> — Read one email

co gmail read 3 --listing <listing-id>                    # by listing number
co gmail read 18f2c9d0a1b2c3d4     # by full message id
co gmail read 3 --listing <listing-id> --mark-read         # explicitly consume it

Prints headers in a panel and the body below it. Unread state is preserved by default; --mark-read opts into changing it with gmail.modify or the full-mail grant. A known read-only grant exits 1; missing local scope metadata lets the provider decide. The command never reports success when the requested mark-read action failed.

co gmail reply <#> <message> — Reply

co gmail reply 18f2c9d0a1b2c3d4 "Sounds good, see you then."
cat reply.txt | co gmail reply 18f2c9d0a1b2c3d4 -

Threaded — the reply goes back on the original conversation. A message of - reads the body from stdin.

co gmail draft — Build and review an unsent message

The draft workflow is the safe path when attachments or Drive files are involved. Creating, attaching, removing, replacing, and previewing do not send mail. draft review is the canonical final review; preview remains an inspection command for existing callers. Send requires a token from that exact review, or a real terminal's default-No confirmation. Piped yes cannot approve.

co gmail draft list
co gmail draft create alice@example.com "Report" "Please review."
co gmail draft list               # select the matching row before using a number
co gmail draft attach <draft-id> report.pdf
co gmail draft preview <draft-id>
co gmail draft review <draft-id> --json
co gmail draft send <draft-id> --confirm <review-token> --json

Draft numbers use --listing <listing-id> from co gmail draft list and the same account/expiry rules as messages. Message tokens cannot resolve drafts. A full Gmail draft ID works without a listing. Attachment numbers come from draft preview and still refer to that draft's current attachment manifest. Creating a draft prints its ID and does not change any frozen listing. The ID-only cache lives in the global config directory's gmail-listings/; it stores an account digest, not the account address or message contents. Answering no, ending input, or interrupting the confirmation prompt keeps the draft, exits 1, and prints its review command again.

Use local or Drive files without first copying Drive content to disk:

co gdrive list -n 20
co gmail draft attach <draft-id> 3 --drive --drive-listing <Drive-listing-id>          # attach Drive row 3 as bytes
co gmail draft attach <draft-id> 3 --drive --drive-listing <Drive-listing-id> --link   # append its Drive URL instead
co gmail draft remove <draft-id> 2
co gmail draft replace <draft-id> 1 corrected.pdf
co gmail draft replace <draft-id> 1 3 --drive --drive-listing <Drive-listing-id>
co gmail draft replace <draft-id> 1 <Drive-file-id> --drive --link

Google Docs, Sheets, Slides, and Drawings are exported with the same formats as co gdrive get. A Drive link does not change the file's sharing permissions; the recipient still needs access. The combined decoded attachment size must be at most 25,000,000 bytes; final MIME must fit 35,000,000 bytes. These are decimal MB, preserving the existing SDK constant. review --json includes the account, From, To/Cc/Bcc, body and digest, draft/thread IDs, source manifest, per-file and aggregate sizes, final MIME and base64 sizes, limits, warnings and review token. The items field combines files first and managed links second. Legacy attachments retains its file-only shape. Unknown link sizes are null.

Managed Drive records round-trip inside provider MIME; arbitrary body URLs never become managed items. Links retain unchanged sharing and unverified recipient access. Editing a managed link's text independently requires removing/replacing that item. Ordinary edits change the review token. Source metadata is removed from the outgoing message after it is included in the review hash.

Send re-fetches and rejects any changed token, then supplies the exact reviewed MIME with the draft ID in one Gmail drafts.send request. A late provider edit cannot substitute outgoing bytes, though consuming the draft can discard that edit. Gmail provides no draft edit compare-and-swap; avoid concurrent edits. Create/update preflight rejects invalid sources or oversized MIME before the provider write. A lost provider update response may still mean the update completed; inspect the draft before retrying.

A private account/draft-scoped send marker is persisted under the global gmail-send-attempts/ directory before submission. It stores hashes and receipt IDs, never message content. On an ambiguous response, another send command checks the deterministic Message-ID first. Gmail may rewrite that header, so new attempts also carry X-ConnectOnion-Send-Attempt in the reviewed MIME. If the Message-ID is missing, recovery inspects at most 100 sent-message metadata records since five minutes before submission and requires exactly one matching attempt header. A further result page, duplicate marker or missing match leaves the result uncertain; it does not resend. Older attempt records without this header remain guarded but cannot use the fallback. Do not remove that record to force a retry. A confirmed receipt can be returned even after Gmail has removed the draft. Explicit HTTP rejections allow a later deliberate attempt. This is a local retry guard, not a Gmail exactly-once guarantee across other clients or machines. Existing one-shot send/reply behavior remains separate.

The provider-preserved marker fallback shipped in 1.8.4a2 and is included in stable 1.8.4, tracked in #1460. Preview 1.8.4a1 itself still uses only Message-ID lookup. The live acceptance script checks provider-stored content and reports whether the inbox label was observed separately; mailbox routing does not change the send receipt, and this is not independent SMTP delivery proof.

co gmail send <to> <subject> <message> — Send immediately

co gmail send alice@example.com "Report" "See notes below."
co gmail send alice@example.com "Report" - < body.txt
co gmail send a@x.com,b@y.com "Update" "Shipping today" --cc lead@x.com
co gmail send alice@example.com "Report" "Attached" -a report.pdf -a chart.csv

Recipients are comma-separated. --cc and --bcc take the same form. A message of - reads the body from stdin. Repeat -a/--attach to attach several files; their combined size must be at most 25 MB. Because the human operator explicitly chooses these paths, the CLI may attach a file outside the current project. Agent-facing Gmail() tools remain limited to project files.

This command sends immediately and exists for backward compatibility and automation. Use co gmail draft when a human should inspect recipients, body, and the attachment manifest before delivery.

co gmail sent — Recently sent

co gmail sent
co gmail sent -n 25

Read-only listing with full message IDs and its own frozen listing token. Read a sent message directly by ID, or use its row with that token. Older inbox and search listings retain their original rows.

Takes full Gmail query syntax, not just plain words:

co gmail search "invoice"
co gmail search "from:alice@example.com is:unread"
co gmail search "subject:meeting after:2026/07/01" -n 25

Search results use the same full-ID and --listing rules as the inbox.

Piping

Structured pages and mailbox actions (1.8.4 candidate)

co gmail inbox --json
co gmail search "in:sent" -n 25 --json
co gmail search "in:sent" -n 25 --cursor <next-cursor> --json
co gmail read <message-id> --json
co gmail draft list --json
co gmail draft preview <draft-id> --json
co gmail mark <message-id> --read
co gmail mark <message-id> --unread
co gmail archive <message-id>
co gmail star <message-id> --remove
co gmail label list --json
co gmail label add <message-id> <label-name-or-id>
co gmail label remove <message-id> <label-name-or-id>
co gmail attachments <message-id> --json
co gmail download <message-id> --all --to ~/Downloads --json
co gmail download <message-id> --attachment <attachment-id> --to ~/Downloads --json
co gmail unanswered --within-days 30 --last 20 --json

Every new mailbox command supports --json, as do inbox, sent, search, read, draft list, preview, review and send. co gmail --json is the default inbox. JSON uses one schema-1 envelope: provider, account, operation, status, complete, data, error, next_command. It prints no prompts or extra stdout text. New commands put human hints on stderr. Operational/partial failures exit 1; invalid flags and missing arguments exit 2. A read with --mark-read fails when that mutation fails. Unknown scope metadata lets the provider decide.

data.next_cursor continues the same account/query/filter/limit for 15 minutes. data.truncated reports more candidate pages; complete means this requested page or operation completed, not that the entire mailbox was fetched. Messages and drafts allow 1–500 items; unanswered scans 1–100 threads per page and can return fewer matches. Provider counts are estimates. Unanswered's estimate is explicitly for candidate threads. Live pages may repeat/omit rows if mail changes. Human listings also report whether more provider results exist.

Unanswered selects the latest non-draft message by provider timestamp, checks that it is incoming and within the selected window, and includes conversations you started. Exact normalized mailbox identity and provider SENT evidence avoid substring matches and guessed aliases. --exclude-automated is optional and filters Auto-Submitted and bulk/list/junk headers. Sender names such as support, billing and invoice are not automatically filtered.

Incoming attachments include nested named files and explicitly inline parts. Provider attachment IDs and part:<partId> inline identifiers are stable within the message. Attached-message bytes are downloaded as one file; nested children are also listed when the provider supplies them as MIME parts. Downloads require an existing destination directory and exactly one selector: --all or --attachment ID. Names are sanitized; collisions gain suffixes. Existing files and symlinks are never overwritten. Private temporary files become visible only after base64 and size checks pass. Limits: 25 MB/file, 100 MB/invocation, 100 attachments, MIME depth 30/1,000 nodes, and 40 MB per streamed API response. Filesystems without atomic hard-link placement report a failure. Partial results retain completed files and include each path, hash or error; inspect before retry.

Actions need gmail.modify or the full-mail grant. Read/list/download operations need Gmail read access. No label creation, automatic reply, Drive sharing or scheduled sending is added. DD-068 records the transport and pagination decisions.

Human output

In a terminal you get a Rich table with truncated columns. When output is piped, you get the plain listing with full message ids instead, so scripts and agents never receive a truncated value:

co gmail inbox -n 50 | grep "ID:"
co gmail draft list | cat
co gmail draft preview <draft-id> | cat

The plain piped forms keep the literal next-command tip, including after failures, so the recovery step remains visible to scripts and agents.

Using it from an agent

The CLI wraps the same Gmail tool your agents use:

from connectonion import Agent, Gmail

agent = Agent("assistant", tools=[Gmail()])
agent.input("Any unread mail from Alice?")

Or call it directly:

gmail = Gmail()
gmail.list_inbox(last=10, unread=True)
gmail.list_search("from:alice@example.com")
gmail.send("alice@example.com", "Report", "See attached.")

draft = gmail.create_draft("alice@example.com", "Report", "Please review.")
gmail.add_draft_attachment(draft["id"], "report.pdf")
gmail.get_draft(draft["id"])

Troubleshooting

Exit / resultRecovery command
0, inbox or search resultco gmail read <full-message-id>
1, missing permissionco auth google
1, unknown message number or unreadable numbering cacheco gmail inbox
1, send/reply connection failureco gmail sent
1, declined or stale draft confirmationco gmail draft review <draft-id> --json
1, uncertain draft sendco gmail sent --json
2, missing read argumentco gmail read --help

Read the printed cause and next command even when piping output. Provider error bodies are omitted. A connection failure after sending can mean the response was lost after delivery: inspect sent mail before repeating the send or reply.

  • "Google account not connected" → run co auth google.
  • Missing Gmail scopes → run co auth google again to re-consent.
  • Unavailable draft listing → run co gmail draft list and use its full ID or its number with --listing.
  • Draft has no attachment #N → run co gmail draft preview <draft> and use the current manifest number.
  • A Drive link opens as access denied for the recipient → change sharing in Drive yourself, or attach the file bytes with --drive instead of --link.
  • Unavailable message listing → relist with co gmail inbox, then use a full ID or the new token. An account mismatch cannot fall back to another list.
  • An intentional project account is no longer selected → use co --env-file /absolute/project/.env gmail inbox. The 1.8.4 implementation defaults to global settings; see environment migration.

See also

  • co outlook — same surface for an Outlook mailbox
  • co email — your agent's own address, no OAuth needed
  • Gmail tool — the full method list for agents