Metadata-Version: 2.5
Name: graphmail
Version: 1.0.0
Summary: Extract emails from Microsoft Graph and local .eml files into a local archive, with an Excel index.
Project-URL: Homepage, https://github.com/off-signal/graphmail
Author: graphmail contributors
License: MIT
License-File: LICENSE
Keywords: archive,email,eml,microsoft-graph,outlook
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Email
Classifier: Topic :: System :: Archiving
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: beautifulsoup4>=4.12
Requires-Dist: openpyxl>=3.1
Requires-Dist: requests>=2.31
Requires-Dist: tqdm>=4.66
Provides-Extra: dev
Requires-Dist: coverage>=7; extra == 'dev'
Requires-Dist: jsonschema>=4; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: responses>=0.25; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: types-beautifulsoup4; extra == 'dev'
Requires-Dist: types-jsonschema; extra == 'dev'
Requires-Dist: types-openpyxl; extra == 'dev'
Requires-Dist: types-pyyaml; extra == 'dev'
Requires-Dist: types-requests; extra == 'dev'
Requires-Dist: types-tqdm; extra == 'dev'
Description-Content-Type: text/markdown

# 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](https://docs.astral.sh/uv/) (if you're developing graphmail; see [Development](#development))

## Installation

```bash
pip install graphmail
```

## Quick start

One-time setup:

1. **Register an app** in the [Entra/Azure admin center](https://entra.microsoft.com/):
   - **Authentication:** add platform [*Mobile and desktop apps*](https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-app-configuration) -> add redirect URI `http://localhost` ([the port is ignored](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url); 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`](https://learn.microsoft.com/en-us/graph/permissions-reference), `User.Read`, `offline_access`. Admin-consent if your tenant requires it.

2. **Create a credentials file** (chmod 600):

   ```bash
   # ~/.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):

   ```bash
   graphmail --env-file ~/.config/graphmail.env login
   ```

Then archive mail:

```bash
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](https://github.com/off-signal/graphmail/issues).

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

```cron
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](https://docs.astral.sh/uv/)):

```bash
git clone https://github.com/off-signal/graphmail && cd graphmail
uv sync --locked --extra dev
```

The same checks CI runs:

```bash
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](https://raw.githubusercontent.com/off-signal/graphmail/main/LICENSE).
