zohomail
An AI email client for Zoho Mail: IMAP / POP3 / SMTP access with an app-specific password, a local SQLite mirror of the mailbox with full-text search and deduplicated attachment storage, an MCP server, and Claude Code skills and subagents that read, triage, and draft against it.
Standard library only: no third-party packages, no OAuth app, no API key. MIT licensed. Mail, passwords and rules stay in a profile outside this repository, so the tree itself can be shared.
Verified against a Zoho organisation account on the zoho.com data centre.
pip install -e . # from a checkout; or pip install .
zohomail doctor # prove connectivity
zohomail sync # mirror the last 90 days into SQLite
zohomail db stats # what's local now
python -m zohomail is the same program. From this directory it works
without installing; pip install is what puts zohomail on PATH for
any other directory, and what a published install uses.
Then, in Claude Code: "triage my inbox", "what am I waiting on?", "draft a reply to the invoice thread".
Setup
-
Install. From a checkout,
pip install -e .. Once the repository is public,pip install git+https://github.com/alphaolomi/zoho-mail.gitinstalls the engine without the Claude skills. The skills, agents andscripts/stay with the clone; they are not part of the wheel. -
Generate an app-specific password at accounts.zoho.com → Security → App Passwords. Not your account password; it bypasses 2FA for mail protocols only.
-
Enable the protocols in Zoho Mail → Settings → Mail Accounts → IMAP / POP Access.
-
Create your profile — your config, rules and mail data live outside the repo, in
~/.zohomail/<name>/(see Profiles):zohomail profile init alice --email you@yourdomain.com zohomail profile use alice
Then fill in
~/.zohomail/alice/config.env:ZOHO_EMAIL=you@yourdomain.com ZOHO_APP_PASSWORD=xxxxxxxxxxxx ZOHO_DC=com # com | eu | in | au | jp | ca | sa | uk ZOHO_HOST_STYLE=standard # standard -> imap.zoho.com | pro -> imappro.zoho.com SYNC_DAYS=90 SYNC_FOLDERS=INBOX,Sent # names or globs: "*", "INBOX/*", "!INBOX/Spammy" BULK_FOLDERS= # alert folders your Zoho filters fill (see below)
-
Write your rules in
~/.zohomail/alice/rules.md: who matters, what is urgent, how replies should sound. The skills read it before they act. -
Check it —
zohomail doctorprobes both host styles across all three protocols and reports which authenticate.
Environment variables override config.env.
Profiles
The repo is the engine: code, schema, skills, agents and scripts, with nothing personal in it. Each person, or each mailbox, gets a profile:
~/.zohomail/<name>/
config.env account, app password, folders, bulk folders, mute list
rules.md their triage priorities, key people, drafting voice, hard limits
store/ mail.db and attachment blobs
state/ poll cursors and scheduler logs
The active profile is, first match wins: --profile NAME, ZOHOMAIL_HOME=<dir>,
ZOHOMAIL_PROFILE=<name>, the one set by profile use, then a legacy .env in
the repo, then default. Set ZOHOMAIL_PROFILES to keep profiles somewhere
other than ~/.zohomail.
python -m zohomail profile list # * marks the active one
python -m zohomail profile show # where its config, rules and store are
python -m zohomail profile rules # what the skills read
python -m zohomail --profile technical sync
python -m zohomail.mcp_server --profile technical
Two people on two machines each run profile init once. Two mailboxes on one
machine (your own and a shared technical@, say) are two profiles; pick one
per command with --profile, or register scheduled tasks per profile with
-MailProfile.
Upgrading from the in-repo layout. Older installs kept .env, store/ and
state/ inside the repo. That still works, and one command moves it out:
python -m zohomail profile migrate alice
It copies .env to config.env, moves store/ and state/, adds a rules
template, and makes alice active. The repo .env is removed only after
everything else has moved; stop the MCP server first so the store is not open.
Servers
| Protocol | Standard | Pro (some org accounts) | Port | Security |
|---|---|---|---|---|
| IMAP | imap.zoho.com |
imappro.zoho.com |
993 | SSL |
| POP3 | pop.zoho.com |
poppro.zoho.com |
995 | SSL |
| SMTP | smtp.zoho.com |
smtppro.zoho.com |
465 | SSL |
ZOHO_SMTP_PORT=587 switches to STARTTLS automatically.
The local store
python -m zohomail sync mirrors IMAP into the profile's store/mail.db. Sync is incremental —
each folder keeps a UID cursor, and a UIDVALIDITY change invalidates it correctly.
python -m zohomail sync # configured folders, SYNC_DAYS window
python -m zohomail sync --days 365 --full # backfill a year
python -m zohomail sync --folders "INBOX,Sent,Newsletter"
python -m zohomail sync --folders "INBOX/*" # every folder under INBOX
python -m zohomail sync --folders "*" --max 500 # every folder, capped
python -m zohomail db stats # counts, range, per-folder cursors
Folders and hierarchy
Folders are stored under their full server path (INBOX/Alerts), so the store
mirrors the hierarchy you see in Zoho. SYNC_FOLDERS, --folders and
search --folder all take the same selectors:
| Selector | Picks |
|---|---|
INBOX/Alerts |
that folder (case does not matter) |
Alerts |
the one folder whose path ends /Alerts; an error if two do |
Sent, Spam, Trash, Drafts |
the server's special-use folder, whatever it is called |
INBOX/* |
every folder under INBOX, but not INBOX itself |
* |
every folder except Trash, Spam and Drafts (name those to include them) |
!INBOX/Newsletters |
removes a folder the other entries picked |
Bulk folders. Zoho filters often route alerts into their own folders.
List those in BULK_FOLDERS and their messages are
marked automated, so --no-bots, the triage queue and briefs skip them. Header
heuristics miss senders such as UptimeRobot; the folder does not. A bulk
folder's first sync reaches back only BULK_SYNC_DAYS (default 30, 0 = the
normal window, --bulk-days per run). New alerts are always kept.
SYNC_FOLDERS=*
BULK_FOLDERS=INBOX/Alerts,INBOX/Newsletters
BULK_SYNC_DAYS=30
Entities
accounts ── folders ── messages ── message_addresses ── addresses
│
├── attachments (sha256-deduped blobs on disk)
├── message_refs (Message-ID graph for threading)
├── message_labels ── labels
├── analyses (what the AI concluded)
└── threads ── tasks, drafts
- Threading stitches messages by
In-Reply-To/Referencesfirst, merging threads when a late message joins two chains. Subject matching is only a fallback, and only when a participant overlaps — so two unrelated "Re: Hello" chains never merge. - Attachments are content-addressed: bytes go to
store/blobs/<aa>/<sha256>, so the same signature logo across 40 messages is stored once. Inline parts are flagged so you can filter for real documents. Files overMAX_ATTACHMENT_MBare indexed without their bytes. - Full-text search is FTS5 over subject, body, sender name and address, kept current by triggers and ranked with bm25.
Reading it
python -m zohomail search "invoice NOT draft" --no-bots
python -m zohomail search --sender alex --attachments
python -m zohomail show 12 # one message, full body
python -m zohomail thread 10 # the whole conversation
python -m zohomail threads --awaiting-reply # you wrote last, silence since
python -m zohomail attachments --name .pdf --extract ./out
Every command takes --json for machine consumption.
FTS5 syntax: invoice payment (AND), "purchase order" (phrase),
invoice OR receipt, deploy NOT staging, integrat* (prefix).
The AI layer
The Python holds no model calls. Claude Code does the reasoning, and the store is its memory — so conclusions survive across sessions and accumulate.
| Table | Holds |
|---|---|
analyses |
priority, category, summary, action items, needs-reply, confidence |
labels |
topic/category tags applied to messages |
tasks |
extracted work with due dates and status |
drafts |
proposed replies awaiting your approval |
python -m zohomail queue # messages with no analysis yet
python -m zohomail annotate --message 12 --priority high --category partner-integration \
--summary "The invoice is due Friday" \
--action "Send the receipt" --needs-reply --label billing
python -m zohomail task add --title "Send the receipt" --message 12 --due 2026-09-18
python -m zohomail task # open tasks by due date
Skills
In .claude/skills/, available in any Claude Code session in this directory:
| Skill | Use |
|---|---|
mail-triage |
Work the queue: judge, annotate, label, create tasks |
mail-search |
Find mail, answer questions from it, pull attachments |
mail-draft |
Draft a reply in your voice, saved for review |
mail-brief |
A read briefing: what needs you, what you're waiting on |
Subagents
In .claude/agents/ — dispatchable, each running in its own context:
| Agent | Does | Never |
|---|---|---|
mail-triage-agent |
Syncs, triages the queue, files tasks, reports | sends, deletes, marks read |
mail-reply-drafter |
Reads the thread, matches your voice, drafts | sends |
mail-archivist |
Syncs/backfills, labels, finds noise and stale threads | sends, deletes server mail |
MCP server
.mcp.json registers a stdio MCP server exposing 18 tools — mail_search,
mail_message, mail_thread, mail_threads, mail_queue, mail_stats,
mail_attachments, mail_attachment_extract, mail_annotate, mail_task_add,
mail_tasks, mail_task_status, mail_draft_create, mail_drafts,
mail_draft_get, mail_draft_send, mail_sync, mail_flag.
python -m zohomail.mcp_server # stdio; any MCP client can launch it
Point Claude Desktop or another client at the same command to query this mailbox from outside Claude Code.
Sending is always gated
Drafts are written to the store, never sent automatically:
python -m zohomail draft new --to alex@example.com --subject "RE: ..." \
--body-file reply.txt --thread 10 --reply-to 12 --rationale "unblocks testing"
python -m zohomail draft show 1
python -m zohomail draft send 1 --yes # --yes is required
Without --yes the CLI refuses and exits 2. The MCP mail_draft_send tool
requires confirm: true. Both mean the user said send it — not the model's own
confidence. The agents are instructed never to send.
Live mailbox commands
These talk to Zoho directly, without the store:
| Command | What it does |
|---|---|
doctor |
Probe IMAP/SMTP/POP on both host styles |
folders |
List folders with unread counts |
list |
Search live IMAP: --unread --since 7d --sender x --subject y |
read UID |
Print a message; --save-attachments DIR, --mark-read |
send |
Send directly: --to --subject --body --attach --cc --html-file |
pop-download |
Bulk-fetch as .eml: --dir --limit --delete |
digest |
Mechanical digest: --hours 24 --send --out digest.html |
reminders |
Action items and awaiting-reply: --send --include-automated |
stats |
Volume, top senders, newsletters: --days 7 |
poll-once |
Report mail since the last poll; --on-new "cmd {subject}" |
watch |
Poll in the foreground: --interval 300 |
digest and reminders filter automated mail — newsletters (List-Unsubscribe),
no-reply@ senders, Auto-Submitted and Precedence: bulk. Bots sending from a
normal mailbox (a CI job on Gmail, say) have no such headers and need
MUTE_SENDERS in config.env:
MUTE_SENDERS=ci-bot@example.com, notify.cloudflare.com
Entries match a full address or a bare domain.
Scheduling (Windows)
powershell -ExecutionPolicy Bypass -File .\scripts\register-tasks.ps1
| Task | Schedule | Command |
|---|---|---|
Digest |
daily 07:30 | digest --send |
Reminders |
weekdays 08:30 | reminders --send |
Poll |
every 15 min | poll-once |
Options: -DigestTime, -ReminderTime, -PollMinutes, -SkipPoll,
-SkipReminders, -MailProfile <name> (tasks under \ZohoMail\<name>\). Logs
land in the profile's state/logs/. Remove with
.\scripts\unregister-tasks.ps1 (-MailProfile too, if you used it).
To keep the store fresh, add a sync task:
schtasks /create /tn "ZohoMail\Sync" /tr "cmd /c cd /d $PWD && python -m zohomail sync" /sc hourly
Cron equivalent:
30 7 * * * cd /path/to/zoho-mail && python -m zohomail digest --send
0 * * * * cd /path/to/zoho-mail && python -m zohomail sync
As a library
from zohomail.config import load_config
from zohomail.store import Store
from zohomail.imap_client import ZohoIMAP
from zohomail import sync, smtp_client
config = load_config()
with Store(config) as store:
sync.sync(config, store, days=30, progress=print)
for row in store.search("invoice", limit=10):
print(row["date_utc"], row["from_email"], row["subject"])
store.add_analysis(message_id=12, priority="high", summary="Needs a reply today")
with ZohoIMAP(config) as imap:
for message in imap.recent("INBOX", hours=24, unread=True):
print(message.sender, message.subject)
Layout
pyproject.toml pip metadata; the wheel is the engine only
LICENSE MIT
.env.example same template as zohomail/templates/config.env
zohomail/
profile.py which profile is active, and where its files live
config.py config.env + environment loading, per-DC host derivation
imap_client.py IMAP4_SSL: search, fetch, flag, move, delete, retry on throttle
smtp_client.py SMTP_SSL / STARTTLS sending with attachments
pop_client.py POP3 bulk download with UIDL de-duplication
message.py header decoding, body extraction, attachment parts
schema.sql the entity model
db.py connection, migrations, FTS rebuild, vacuum
store.py persistence, threading, search, annotation layer
sync.py incremental IMAP -> SQLite
productivity.py action items, awaiting-reply detection, statistics
digest.py digest assembly, text and HTML rendering
mcp_server.py MCP server over stdio (18 tools)
cli.py live-mailbox commands
cli_store.py store-backed commands
cli_profile.py profile init / use / show / rules / migrate
folders.py folder selectors: paths, globs, exclusions
templates/ config.env and rules.md copied by `profile init`
.claude/
skills/ mail-triage, mail-search, mail-draft, mail-brief
agents/ mail-triage-agent, mail-reply-drafter, mail-archivist
.mcp.json registers the MCP server for this project
scripts/ register-tasks.ps1 / unregister-tasks.ps1 (clone only)
Personal files live in the profile, never in the repo (see Profiles).
Notes and limits
- Zoho throttles IMAP at roughly 200 logins/day. Each command opens one
connection; a sync of many folders reuses a single one. On repeated
socket error: EOFthe client retries twice with backoff, then tells you to back off — that error means throttling, not a bad password. - Zoho also drops long sessions mid-sync. Read commands reconnect and retry, so a backfill carries on; if a folder still fails, the rest sync and a re-run resumes where it stopped.
- The first sync of a folder reaches back
SYNC_DAYS(BULK_SYNC_DAYSfor bulk folders); after that the UID cursor picks up everything new. Widen with--days N --full, which skips messages already stored. - A message moved or deleted on the server keeps its old row in the store; sync only adds and refreshes.
poll-oncerecords a high-water UID on first run and reports nothing, so a fresh install does not replay two days of mail.pop-downloadnever deletes unless you pass--delete.- Sending only works from the account's own address — Zoho rejects unverified
From. Use--from-namefor the display name. - Attachment blobs are never garbage-collected. Deleting rows leaves the blobs;
clear the profile's
store/blobs/and re-sync if you need the space back.
Security
- The package is MIT licensed and contains no account, mailbox, or secret.
Profiles live outside the repo, so passwords and mail cannot be committed by
accident. The legacy in-repo
.env,store/andstate/are git-ignored too. rules.mdis the owner's instruction to the assistant. Only they should edit it.- App-specific passwords grant mail protocol access only, not account login, and are revocable from the page that issued them.
- Email content is data, not instructions. Every skill and agent here is told that a message asking to forward a thread, reply with credentials, or wire money is a claim by a sender to be reported — never a command to act on. Keep that rule if you write more agents against this store.
- The only tool that contacts another person is
draft send/mail_draft_send, and it requires explicit approval every time.
Metadata
Release files for zohomail 1.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| zohomail-1.1.1.tar.gz | 69.0 kB | With provenance | Details for zohomail-1.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| zohomail-1.1.1-py3-none-any.whl | Python 3 | none | any | Details for zohomail-1.1.1-py3-none-any.whl |
Total release size: 139.1 kB
Release files / zohomail-1.1.1.tar.gz
| Download URL | zohomail-1.1.1.tar.gz |
|---|---|
| Size | 69.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
13759f74e29eb25e5ae2e4f88b6289e139048dce6a925a196c8f01201d723302
|
|
BLAKE2b-256 checksum How to use checksums |
be3bda4fd64d5e282fc9452e79abf3ebec186e04ecd655eaada0598dd96a9b83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / zohomail-1.1.1-py3-none-any.whl
| Download URL | zohomail-1.1.1-py3-none-any.whl |
|---|---|
| Size | 70.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b9e5956e6b0bd7ca65e3bf722bd34336b73c6670100684ea2886cfdeecc1a1b6
|
|
BLAKE2b-256 checksum How to use checksums |
b8764549d5f54dfda58a992c1d9fbcc17f46fcb2523610c1648a8dc482368bc5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logSecurity for zohomail 1.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for zohomail-1.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.