Skip to main content

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.

  • .eml files in folders, openable in any mail client.
  • The index and workbook are rebuilt on demand from the .eml tree, 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:

  1. 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/nativeclient if you want the --manual paste 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.
  2. 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.env to any command. Real environment variables still win if set.

  3. 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

  • fetch pulls messages from Graph folders. --folders takes display names (case-insensitive), raw Graph folder ids, or all. --recursive walks subfolders. --sender filters by address. Re-running resumes where it left off and skips anything already archived.
  • import copies a directory of local .eml files into the archive and indexes them.
  • reindex rebuilds index.db from archive/'s own .eml files (moves/renames are picked up; Graph-fetched rows are untouched).
  • export generates an Excel workbook from the index, grouped by folder, year, or as one big sheet.
  • login runs 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; fetch exits non-zero if any failed.
  • Throttling: Graph 429 / Retry-After is 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
  • import and reindex commands for local .eml files
  • Single-job lock so overlapping cron runs can't corrupt the archive
  • Per-run log files under archive/logs/
  • --env-file for 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, with archive/logs/latest-<command>.log pointing at the most recent run of that command.
  • --quiet silences progress but keeps the one-line === done === summary on stdout (cron MAILTO stays 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)

Source distribution for graphmail 1.0.0
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)

Table of built distributions (wheels) for graphmail 1.0.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page