Your email archive, with a web interface for desktop and mobile.
Keep your mail as standard .eml files on your own drive. Search across accounts,
browse labels, read HTML messages, and preview attachments in your browser.
Start or schedule downloads in the web interface. The command line also handles
downloads, imports, and archive maintenance.
The goal is to own your mail and remove server copies once they are no longer needed by your mail clients. Read Owning your mail for the product philosophy, Active ownership, and server-cleanup limits.
Development version: This README describes
0.4.0-devonmaster. PyPI currently provides 0.3.0, which has an older interface. Use the GitHub installation below for the features described here.
- Search and navigate: Full-text search, sender links, labels, attachment indicators, sorting, and pagination across your saved mail.
- Read on desktop or phone: A collapsible sidebar becomes a navigation drawer on smaller screens. Add the site to your phone's Home Screen for app-style access.
- Read messages and attachments: View sanitized HTML, expand message details, print a message, and preview supported attachment formats or download the originals.
- Choose your appearance: Light, dark, or system theme, with adjustable desktop message and sidebar density, dates, time zone, and page size. In Settings, choose Comfortable, Standard (the default), or Compact spacing; density is saved automatically in your browser.
- Control remote content: Load remote images, fonts, audio, and video for a message or remember a trusted sender. Blocking is enabled by default.
- Manage saved mail: Select messages, move them to the archive's Trash, and restore them. Mobile selection supports a long press.
Your .eml files and JSON label sidecars preserve the archive; SQLite provides
its rebuildable search index. Downloads are incremental and resumable with Ctrl-C.
You can also import .eml exports from other mail tools.
For message bodies labelled Latin-1, Ownmail prefers UTF-8 when the complete payload decodes as readable UTF-8; otherwise it retains Latin-1. Other usable charset declarations keep their precedence. Undeclared bodies use best-effort recovery, including short Korean text and text after long HTML preambles.
Requires Python 3.10+, Git, and pipx. For the web interface, also install a supported Node.js LTS release with npm. Connecting a mail provider requires a working system keychain.
pipx install "git+https://github.com/clee704/ownmail.git@master"master follows ongoing development and can change between installations.
To switch an existing pipx installation from PyPI to this version:
pipx install --force "git+https://github.com/clee704/ownmail.git@master"To refresh an installation made from GitHub:
pipx reinstall ownmailReinstallation uses the original source and resolves dependencies again. It also
picks up new commits when the development version number has not changed.
After installing or reinstalling, run ownmail setup --web before ownmail serve
so the web interface's sanitizer dependencies match the new version.
For a source checkout or editable installation, see Contributing.
To use the published 0.3.0 release with its web dependencies:
pipx install "ownmail[web]==0.3.0"Follow the 0.3.0 README for that version's behavior and requirements.
Run these commands from a directory where you want to keep config.yaml:
# Connect a mail provider and choose where to store the archive
ownmail setup
# Download eligible mail
ownmail download
# Open the archive in your browser
ownmail serveSetup creates the configuration and stores credentials in your system keychain. Choose IMAP for a provider that accepts an app password, or Gmail API with OAuth for read-only Gmail access. See provider setup and configuration. Setup then installs the HTML sanitizer's Node.js dependencies for the web interface, which needs npm and internet access.
The browser opens at http://127.0.0.1:8080. ownmail serve never installs
packages. If the sanitizer dependencies are missing or outside the versions this
ownmail requires, for example after an upgrade, the server refuses to start and
asks you to run ownmail setup --web. That command installs or refreshes only
those dependencies, without credential prompts. With them installed, local
reading and search work without internet; keep ownmail serve running while you
browse. A phone also needs a connection to that server. See
browser and mobile access.
Open Settings → Downloads and choose Download now to run the same download
as ownmail download, using all configured sources and their existing filters.
The page shows the current source, archived and Active-refreshed message counts,
and failures with a brief reason. Counts cover the current run across all sources;
incomplete Active refreshes remain marked incomplete when capture finishes.
Command-line and web downloads cannot overlap for the same archive.
For automatic downloads, choose an interval and Save schedule. Available
intervals are 15 minutes, 30 minutes, 1 hour, 6 hours, and 1 day; Off disables
the schedule. The default is Off, and the saved interval survives server restarts.
Each interval starts after a download finishes or the server restarts.
Keep ownmail serve running for scheduled downloads; the browser can be closed.
The interval is stored as web.download_interval_minutes in config.yaml.
Downloads use the original incremental archiver by default. Inbox, Drafts, Trash, and Spam are excluded by the default download filters.
Active mail is opt-in. Set active_downloads: true on a source in
config.yaml to cache Inbox, Drafts, and observed unfinished mail for reading
and search. Active copies remain server-owned. Filed and Sent mail is archived
after a fresh eligibility check; unknown states remain uncaptured.
Remove that setting or set it to false to restore the original incremental
download path. Existing Active copies stay readable with a disabled/stale status;
downloads do not open or update their cache. Owned mail is preserved.
Opted-in sources use targeted Active checks and incremental capture by default;
ordinary retained Archive mail needs no manual exclusion. Optional
active_exclude_folders: [Archive] (IMAP) or active_exclude_labels: [Saved]
(Gmail API) also suppresses Active tracking for matching messages. New eligible
mail in those folders or labels still gets archived. See
download filters and provider limits.
Preview which archived messages could be moved to server Trash:
ownmail cleanup --source personalCleanup verifies the owned contents and capture metadata, the Gmail account, and current message and thread activity. It reports held messages and failures with reasons. Local contents and labels stay unchanged; IMAP cleanup remains held.
To enable apply for an existing Gmail API source, explicitly authorize cleanup:
ownmail authorize-cleanup --source personalThis opens Google consent for gmail.modify, a broad permission that allows
reading, changing, and sending mail. Ownmail uses cleanup access to verify and
move eligible server copies to Trash. Its credential is stored separately from
the read-only login used for downloads and previews.
Then explicitly request Trash moves:
ownmail cleanup --source personal --applyApply uses the saved cleanup credential and never opens consent automatically. See cleanup checks, authorization, retry behavior, and provider limits.
Use the search box in the web interface or the same query syntax in the terminal:
ownmail search "invoice"
ownmail search "from:example.com"
ownmail search "subject:receipt"
ownmail search "attachment:pdf"
ownmail search 'label:"Receipts, 2026"'The web interface includes search help with supported operators and examples.
For scripts, JSON mode searches the existing archive and cached Active view:
ownmail search 'invoice' --json --limit 50 --offset 0
ownmail search --json -- '-is:active'Stdout is one JSON document; configuration and search diagnostics go to stderr.
Success contains results and pagination. Each result has id, sender,
subject, date, snippet, has_attachments, archived, and active. Indexed
headers can be null. Snippets are capped at 200 Unicode characters; file paths
and full bodies are omitted. active is null for archive-only messages, otherwise
it contains complete, checked_at, content_at, reason, and refresh_error.
An archived copy can also have Active status; incomplete freshness stays explicit.
--limit defaults to 50 and accepts 1–500. JSON-only --offset accepts integers
from 0 to 2147483647. Pagination repeats these values and reports has_more plus
next_offset (null on the last page), using one lookahead row rather than an
expensive total. Ordering and Trash/date exclusions match ordinary search. Each
invocation takes its own read snapshots, so later pages can change if mail changes.
Empty results succeed with exit status 0. Query/FTS errors, bad pagination, missing
prerequisites, and unexpected failures return {"error":{"code":"...","message":"..."}}
with codes invalid_query, invalid_pagination, unavailable, or search_failed
and exit status 1; Ctrl-C returns search_failed and status 130. CLI syntax/help
handling retains the normal argparse behavior.
JSON search requires existing compatible indexes. It opens live SQLite data
read-only, including committed WAL contents; normal WAL/SHM coordination files
may be updated. It does not initialize or migrate databases, rebuild the Active
index, modify substantive archive/cache/configuration/credential contents, or
contact mail servers. A missing or inconsistent index reports unavailable
instead of repairing it. Concurrent Active changes also report unavailable so
the command can be retried. The human-readable default is unchanged.
Use the exact id from JSON search to read its locally available message:
ownmail read 'MESSAGE_ID'
ownmail read 'MESSAGE_ID' --body-limit 5000The command always emits one JSON document. Success includes id, decoded
sender, subject, combined recipients (To/Cc/Bcc), date, body,
attachment_names, archived, and active. Ownership and Active freshness use
the same fields as search; an owned copy can also have an Active server copy.
Reading an ID resolves that local copy directly, even if it no longer matches
the earlier query. Missing or changed cached content reports an error; no server
fetch is attempted. Each command takes a new snapshot.
Body text prefers plain text and otherwise extracts text from HTML without
loading images or other remote content. Attachment contents, including attached
messages, are excluded; names remain separate list items even when they contain
commas. Valid declared UTF-8 is decoded strictly, preserving all Unicode text;
other declarations and malformed encodings use the existing recovery helpers.
The body defaults to at most 20000 Unicode characters; --body-limit accepts
1–100000. body_limit repeats the requested cap and truncated explicitly says
whether body text was omitted. The cap applies to body text, not headers or the
attachment-name list. Full message bytes must be locally readable to parse MIME.
original contains the selected message's local file: URI and media type
message/rfc822, so it can be opened in a mail client. This explicit reference
includes its local archive/cache path; cached files may disappear after refresh.
No attachment content or HTML is returned. Existing compatible indexes are
required under the same live read-only contract as JSON search: normal SQLite
WAL/SHM coordination writes are permitted, but substantive data is unchanged and
no initialization, migration, cache rebuild or credential access occurs.
Success exits 0. Errors use the same error.code/error.message envelope and
exit 1: invalid_id for an empty ID, invalid_limit for a bad body cap,
not_found for an unknown ID, unavailable for missing/unreadable files or
prerequisites, parse_failed when MIME parsing fails, and read_failed for an
unexpected failure. Ctrl-C returns read_failed and exit 130. Diagnostics go to
stderr; CLI syntax/help retains normal argparse behavior.
Export owned archive messages to a new mbox file that other mail clients can import:
ownmail export --format mbox --output backup.mbox
ownmail export --output receipts.mbox --query 'from:billing@example.com after:2024-01-01'
ownmail export --output filed.mbox --query='-is:active'
ownmail export --format pdf --output receipt-pdfs --query 'from:billing@example.com'Export uses the existing search syntax without a result-count limit. As in ordinary
archive search, Trash and messages without parsed dates are excluded. Use scan
to register untracked mail and rebuild to repair indexed dates first. Active-only
cached messages are not exported. Ownership filters such as is:active can select
archived copies with verified existing Active membership, but only the archived
bytes and labels are used. Export never downloads mail.
The default mbox format retains original headers, MIME bodies and attachments,
with standard mbox envelope lines and From escaping. Sidecar labels are not
added as headers.
PDF exports create a new directory containing 000001.pdf, 000002.pdf, and so
on, in date-ascending message order. Each document prints the subject, sender,
recipients, date, plain-text or sanitized HTML body, and an attachment inventory
on A4 pages; wide HTML is scaled to fit the page width. A message's own page
setup, such as a page size or running header, is ignored. Attachment payloads
are not embedded in the PDF; safe inline CID images remain visible. Remote
content and local-file loads are blocked. An empty selection creates an empty
directory.
PDF is optional and requires available Node.js, the already-declared Playwright package, and Chromium. These are checked only when PDF is selected. If unavailable, the command fails clearly before creating output. Ownmail never installs or downloads renderer tooling, and mbox export does not require it.
The output parent directory must exist. Existing files, directories and symlinks are refused. Unreadable message files are skipped and reported; the output contains the readable matches, and the command exits unsuccessfully to flag the partial export. Handled renderer/write errors or Ctrl-C remove the new incomplete export so the same command can be rerun. Output can be visible while being published; abrupt process termination can leave partial output. Source mail is never changed.
| Command | Purpose |
|---|---|
setup |
Connect a mail source, store credentials, and install web interface dependencies; --web installs only the dependencies |
download |
Refresh Active mail and archive eligible filed and Sent mail |
cleanup --source NAME |
Preview server cleanup; --apply requests eligible Gmail Trash moves |
authorize-cleanup --source NAME |
Grant separate Gmail cleanup access through explicit browser consent |
serve |
Open the web interface |
search "query" |
Search from the terminal |
export --output PATH |
Export matching archived messages as mbox or PDF |
import <path> |
Import external .eml files |
scan |
Index .eml files already in the archive |
stats |
Show archive statistics |
verify |
Check hashes, files, and index integrity |
sync-check |
Compare the archive with its mail sources |
trash |
View and manage the archive's Trash |
update-labels |
Backfill missing labels |
relabel |
Repair IMAP folder labels from the server |
reconcile |
Review archived mail against legacy role and folder filters |
rebuild |
Rebuild the index or selected metadata |
reset-sync |
Reset download progress for a rescan |
list-unknown |
List messages with unparseable dates |
sources list |
List configured mail sources |
Run ownmail <command> --help for options. See
archive storage, verification, and repairs for maintenance.
download ends with an overall summary of downloaded emails, errors, and the
current archive size. With --source, download and error counts cover that
source; the archived total still covers the whole archive.
Credentials are stored in the system keychain. Messages, attachments, label sidecars, the search index, and account configuration remain on your filesystem and can contain sensitive information. Use encrypted storage and protect your backups; ownmail does not encrypt the archive itself.
The web interface sanitizes message HTML with DOMPurify. Form controls in a message are displayed but cannot submit anything. Remote content blocking is enabled by default. It stops every request a message makes for remote images, fonts, audio, and video, including CSS and responsive images and fonts from services such as Google Fonts. Loading remote content explicitly can reveal your request to the servers that host it.
To print a message, choose Print from the reader's message actions menu. It opens the message alone in a new tab, laid out like a PDF export with the reader's remote-content setting, and opens the print dialog, which sets the paper size. Cmd+P (Ctrl+P on Windows and Linux) in the reader does the same. Printing the reader through the browser's menu prints the message details and, for plain-text messages, the body; for HTML messages it prints a note that points to the Print action, because browsers do not split the reader's message frame across pages.
The server listens on localhost by default and has no built-in login. If you
make it reachable from other devices, control access through a trusted network
or an authenticated proxy, and list the names and addresses browsers use in
web.allowed_hosts; requests addressed to any
other host get 400 Bad Request. Home Screen access uses the same server; it
does not store an offline archive on the phone.
Changes made through the web interface, such as trash, labels, and settings,
need an allowed origin and a CSRF token from a page the server rendered, so
another website cannot make them through your browser. Requests without both,
including scripts and other clients without a browser, get 403 Forbidden.
Behind an HTTPS proxy, list the browser's origin in
web.allowed_origins.
The Home Screen manifest uses the configured app name, root navigation scope, app icons, and the existing theme colors. A loaded reader saves only its local URL, message-file version, and vertical scroll offset in session storage. A reload restores that position only for the same unchanged message; browser Back/Forward restoration and new user movement take precedence. No message body or attachment is cached for offline reading.
While an app page is open, navigation and message-action requests stop showing pending feedback after 30 seconds. Retry opens the requested page again, or reloads the current page after an unconfirmed action. Check its current state before repeating an action: a lost response does not prove the server did nothing. Retry never automatically repeats a mutation. A cold launch while the server is unavailable still uses the browser's connection-error screen.
A future cached offline fallback requires a trusted HTTPS origin for phone/LAN access (the localhost secure-context exception does not cover a LAN address). Its intended scope is static app-shell/reconnection guidance, not private messages, search results, or attachments. No service worker or offline cache is installed yet. Actual iPhone Home Screen, keyboard, cutout, and background/resume verification remains tracked in TASK-50; desktop browser emulation does not complete those checks.
See CONTRIBUTING.md for development setup and repository rules, and AGENTS.md for AI-agent instructions. Planned and in-flight work lives in the backlog.