Graphmail
Archive your Outlook/Microsoft 365 mail into files you own.
graphmail pulls messages from Microsoft Graph (or imports local .eml files) and writes each one as a plain .eml file into a folder tree, indexed in SQLite, with an optional Excel workbook.
.emlfiles in folders, openable in any mail client.- The index and workbook are rebuilt on demand from the
.emltree, so the files are the only source of truth. - Excel hyperlinks use relative paths, so the workbook keeps working as long as you move or sync the workbook and
archive/together. Keep them side by side (links are resolved relative to the workbook's own folder, so moving only one of the two breaks them).
Requirements
- Python ≥ 3.10
- Any OS (pure Python)
- A Microsoft 365 / Outlook account and an Azure/Entra app registration
- uv (if you're developing graphmail; see Development)
Installation
pip install graphmail
Quick start
One-time setup:
-
Register an app in the Entra/Azure admin center:
- Authentication: add platform Mobile and desktop apps -> add redirect URI
http://localhost(the port is ignored; this enables browser login). - Optionally also add
https://login.microsoftonline.com/common/oauth2/nativeclientif you want the--manualpaste fallback. - Leave it as a public client (no secret needed for desktop use).
- API permissions -> Microsoft Graph delegated:
Mail.Read,User.Read,offline_access. Admin-consent if your tenant requires it.
- Authentication: add platform Mobile and desktop apps -> add redirect URI
-
Create a credentials file (chmod 600):
# ~/.config/graphmail.env GRAPHMAIL_CLIENT_ID=your-app-id GRAPHMAIL_TENANT_ID=common # or your tenant id # GRAPHMAIL_CLIENT_SECRET=... # only for confidential clients
Pass
--env-file ~/.config/graphmail.envto any command. Real environment variables still win if set. -
Log in (opens your browser; the redirect is caught automatically):
graphmail --env-file ~/.config/graphmail.env login
Then archive mail:
graphmail fetch --folders Inbox
graphmail fetch --folders all --recursive
graphmail fetch --folders Inbox --sender someone@example.com
graphmail import ~/old-mail
graphmail export -o email_index.xlsx --group-by folder
Features
fetchpulls messages from Graph folders.--folderstakes display names (case-insensitive), raw Graph folder ids, orall.--recursivewalks subfolders.--senderfilters by address. Re-running resumes where it left off and skips anything already archived.importcopies a directory of local.emlfiles into the archive and indexes them.reindexrebuildsindex.dbfromarchive/'s own.emlfiles (moves/renames are picked up; Graph-fetched rows are untouched).exportgenerates an Excel workbook from the index, grouped by folder, year, or as one big sheet.loginruns the OAuth flow or just refreshes the cached token, no fetching.
Behaviour worth knowing:
- Ctrl-C finishes the current message and stops; a second Ctrl-C aborts immediately.
- Resume: interrupted fetches pick up where they stopped. Failed messages are retried next run;
fetchexits non-zero if any failed. - Throttling: Graph 429 /
Retry-Afteris respected automatically. - Dedup: exact duplicates (same received time + content) are skipped.
A note on stability
This tool was refactored from a handful of battle-tested one-off scripts I created last year. The core behaviours were pinned with characterization tests before the refactor, so the everyday fetch/dedup/resume/export paths are safe to use.
What's new since the refactor (i.e. not battle-tested the same way):
- System-browser login with PKCE (the old scripts only had the paste-the-code flow)
- SQLite index (
index.db) replacing ad-hoc JSON caches importandreindexcommands for local.emlfiles- Single-job lock so overlapping cron runs can't corrupt the archive
- Per-run log files under
archive/logs/ --env-filefor unattended jobs- Excel export with relative, portable hyperlinks
- Cross-platform polish
If you hit a bug or a quirk, open an issue.
Cron
Mutating commands (fetch, import, reindex) are safe unattended:
- Single-job lock: a second run exits immediately with code 2 (vs 1 = "ran but some mail failed"). Stale locks from crashed processes are cleaned up.
- Run log per job under
archive/logs/<command>-<timestamp>.log, witharchive/logs/latest-<command>.logpointing at the most recent run of that command. --quietsilences progress but keeps the one-line=== done ===summary on stdout (cronMAILTOstays useful).- Token refresh has a network timeout; a stalled endpoint can't hang the job forever.
Log in interactively once to seed token.json, then let cron use the refresh token from there on.
SHELL=/bin/sh
15 3 * * * cd /data/mail-archive && \
graphmail --env-file /etc/graphmail.env --quiet fetch --folders all \
>> /data/mail-archive/cron.out 2>&1
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | failed: fetch had one or more failed messages (retried next run); import/reindex failed on every file; or a setup error (auth, missing source/index) |
| 2 | another graphmail job already holds the lock |
| 130 | aborted (Ctrl-C) |
Note the asymmetry: fetch exits 1 if anything failed, but import and reindex only exit 1 when nothing was imported, a partially successful run still exits 0 on reindex.
Files on disk
Everything below is relative to the directory you run graphmail from. (cd into your mail directory first)
| Path | What |
|---|---|
archive/<Folder>/*.eml |
the archive; fetch uses the Outlook folder name, import uses Imported_<UTC timestamp> |
archive/logs/<command>-<timestamp>.log |
one log per run; the filename says which command |
archive/logs/latest-<command>.log |
pointer to that command's most recent run |
archive/.graphmail.lock |
single-job lock; exists only while a job runs |
index.db |
SQLite index (rebuild with reindex) |
token.json |
cached OAuth token (keep private) |
email_index.xlsx |
Excel view, regenerated on demand |
Development
Clone and sync the dev tooling (the dev extra; requires uv):
git clone https://github.com/off-signal/graphmail && cd graphmail
uv sync --locked --extra dev
The same checks CI runs:
uv run ruff check src tests tools
uv run mypy
uv run pytest --cov=graphmail --cov-report=term --cov-fail-under=90
License
MIT; see LICENSE.
Metadata
Release files for graphmail 1.0.0
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 |
|---|---|---|---|---|
| graphmail-1.0.0.tar.gz | 84.8 kB | With provenance | Details for graphmail-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| graphmail-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for graphmail-1.0.0-py3-none-any.whl |
Total release size: 121.0 kB
Release files / graphmail-1.0.0.tar.gz
| Download URL | graphmail-1.0.0.tar.gz |
|---|---|
| Size | 84.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6454fc321871a2bee82e8fab19d9edfe6eea5189c4f3825a0b6996ad1a064168
|
|
BLAKE2b-256 checksum How to use checksums |
71bd5222af833209e60366d5b53108241ce306af3de19761135a0b513447110f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 25, 2026.
Transparency logRelease files / graphmail-1.0.0-py3-none-any.whl
| Download URL | graphmail-1.0.0-py3-none-any.whl |
|---|---|
| Size | 36.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ce95a8a0926fe50a3130a3003389c18ec200f22e4201644faed2a0a3079c4fc9
|
|
BLAKE2b-256 checksum How to use checksums |
78e1306407539b73cc538a5ebbe7922d1b4e4900fba23a021922665530c1b175
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 25, 2026.
Transparency logSecurity for graphmail 1.0.0
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 graphmail-1.0.0.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 25, 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.