graphmail 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphmail-1.0.0/.gitignore +13 -0
- graphmail-1.0.0/LICENSE +21 -0
- graphmail-1.0.0/PKG-INFO +202 -0
- graphmail-1.0.0/README.md +157 -0
- graphmail-1.0.0/pyproject.toml +126 -0
- graphmail-1.0.0/src/graphmail/__init__.py +3 -0
- graphmail-1.0.0/src/graphmail/auth.py +411 -0
- graphmail-1.0.0/src/graphmail/cli.py +461 -0
- graphmail-1.0.0/src/graphmail/envfile.py +46 -0
- graphmail-1.0.0/src/graphmail/export.py +193 -0
- graphmail-1.0.0/src/graphmail/graph.py +318 -0
- graphmail-1.0.0/src/graphmail/importer.py +284 -0
- graphmail-1.0.0/src/graphmail/index.py +217 -0
- graphmail-1.0.0/src/graphmail/lock.py +132 -0
- graphmail-1.0.0/src/graphmail/pipeline.py +241 -0
- graphmail-1.0.0/src/graphmail/py.typed +0 -0
- graphmail-1.0.0/src/graphmail/runlog.py +123 -0
- graphmail-1.0.0/src/graphmail/text.py +42 -0
- graphmail-1.0.0/tests/conftest.py +28 -0
- graphmail-1.0.0/tests/fixtures/folder_inbox.json +8 -0
- graphmail-1.0.0/tests/fixtures/messages_page1.json +30 -0
- graphmail-1.0.0/tests/fixtures/messages_page2.json +16 -0
- graphmail-1.0.0/tests/fixtures/oauth_token_response.json +8 -0
- graphmail-1.0.0/tests/helpers.py +14 -0
- graphmail-1.0.0/tests/mockgraph.py +532 -0
- graphmail-1.0.0/tests/spec/examples.json +198 -0
- graphmail-1.0.0/tests/spec/graph_mail_v1.json +3068 -0
- graphmail-1.0.0/tests/test_auth.py +547 -0
- graphmail-1.0.0/tests/test_cli.py +655 -0
- graphmail-1.0.0/tests/test_e2e_local.py +232 -0
- graphmail-1.0.0/tests/test_envfile.py +73 -0
- graphmail-1.0.0/tests/test_export.py +272 -0
- graphmail-1.0.0/tests/test_helpers.py +114 -0
- graphmail-1.0.0/tests/test_importer.py +516 -0
- graphmail-1.0.0/tests/test_index.py +193 -0
- graphmail-1.0.0/tests/test_lock.py +230 -0
- graphmail-1.0.0/tests/test_mime.py +167 -0
- graphmail-1.0.0/tests/test_pagination.py +252 -0
- graphmail-1.0.0/tests/test_pipeline.py +370 -0
- graphmail-1.0.0/tests/test_runlog.py +156 -0
graphmail-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 graphmail contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
graphmail-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: graphmail
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Extract emails from Microsoft Graph and local .eml files into a local archive, with an Excel index.
|
|
5
|
+
Project-URL: Homepage, https://github.com/off-signal/graphmail
|
|
6
|
+
Author: graphmail contributors
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: archive,email,eml,microsoft-graph,outlook
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Topic :: System :: Archiving
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
26
|
+
Requires-Dist: openpyxl>=3.1
|
|
27
|
+
Requires-Dist: requests>=2.31
|
|
28
|
+
Requires-Dist: tqdm>=4.66
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: coverage>=7; extra == 'dev'
|
|
31
|
+
Requires-Dist: jsonschema>=4; extra == 'dev'
|
|
32
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
35
|
+
Requires-Dist: pyyaml>=6; extra == 'dev'
|
|
36
|
+
Requires-Dist: responses>=0.25; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
38
|
+
Requires-Dist: types-beautifulsoup4; extra == 'dev'
|
|
39
|
+
Requires-Dist: types-jsonschema; extra == 'dev'
|
|
40
|
+
Requires-Dist: types-openpyxl; extra == 'dev'
|
|
41
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
42
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
43
|
+
Requires-Dist: types-tqdm; extra == 'dev'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
# Graphmail
|
|
47
|
+
|
|
48
|
+
Archive your Outlook/Microsoft 365 mail into files you own.
|
|
49
|
+
|
|
50
|
+
`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.
|
|
51
|
+
|
|
52
|
+
- `.eml` files in folders, openable in any mail client.
|
|
53
|
+
- The index and workbook are rebuilt on demand from the `.eml` tree, so the files are the only source of truth.
|
|
54
|
+
- 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).
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- Python ≥ 3.10
|
|
59
|
+
- Any OS (pure Python)
|
|
60
|
+
- A Microsoft 365 / Outlook account and an Azure/Entra app registration
|
|
61
|
+
- [uv](https://docs.astral.sh/uv/) (if you're developing graphmail; see [Development](#development))
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install graphmail
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
One-time setup:
|
|
72
|
+
|
|
73
|
+
1. **Register an app** in the [Entra/Azure admin center](https://entra.microsoft.com/):
|
|
74
|
+
- **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).
|
|
75
|
+
- Optionally also add `https://login.microsoftonline.com/common/oauth2/nativeclient` if you want the `--manual` paste fallback.
|
|
76
|
+
- Leave it as a **public client** (no secret needed for desktop use).
|
|
77
|
+
- **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.
|
|
78
|
+
|
|
79
|
+
2. **Create a credentials file** (chmod 600):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# ~/.config/graphmail.env
|
|
83
|
+
GRAPHMAIL_CLIENT_ID=your-app-id
|
|
84
|
+
GRAPHMAIL_TENANT_ID=common # or your tenant id
|
|
85
|
+
# GRAPHMAIL_CLIENT_SECRET=... # only for confidential clients
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Pass `--env-file ~/.config/graphmail.env` to any command. Real environment variables still win if set.
|
|
89
|
+
|
|
90
|
+
3. **Log in** (opens your browser; the redirect is caught automatically):
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
graphmail --env-file ~/.config/graphmail.env login
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Then archive mail:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
graphmail fetch --folders Inbox
|
|
100
|
+
graphmail fetch --folders all --recursive
|
|
101
|
+
graphmail fetch --folders Inbox --sender someone@example.com
|
|
102
|
+
|
|
103
|
+
graphmail import ~/old-mail
|
|
104
|
+
|
|
105
|
+
graphmail export -o email_index.xlsx --group-by folder
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Features
|
|
109
|
+
|
|
110
|
+
- `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.
|
|
111
|
+
- `import` copies a directory of local `.eml` files into the archive and indexes them.
|
|
112
|
+
- `reindex` rebuilds `index.db` from `archive/`'s own `.eml` files (moves/renames are picked up; Graph-fetched rows are untouched).
|
|
113
|
+
- `export` generates an Excel workbook from the index, grouped by folder, year, or as one big sheet.
|
|
114
|
+
- `login` runs the OAuth flow or just refreshes the cached token, no fetching.
|
|
115
|
+
|
|
116
|
+
Behaviour worth knowing:
|
|
117
|
+
|
|
118
|
+
- **Ctrl-C** finishes the current message and stops; a second Ctrl-C aborts immediately.
|
|
119
|
+
- **Resume:** interrupted fetches pick up where they stopped. Failed messages are retried next run; `fetch` exits non-zero if any failed.
|
|
120
|
+
- **Throttling:** Graph 429 / `Retry-After` is respected automatically.
|
|
121
|
+
- **Dedup:** exact duplicates (same received time + content) are skipped.
|
|
122
|
+
|
|
123
|
+
## A note on stability
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
What's new since the refactor (i.e. not battle-tested the same way):
|
|
128
|
+
|
|
129
|
+
- System-browser login with PKCE (the old scripts only had the paste-the-code flow)
|
|
130
|
+
- SQLite index (`index.db`) replacing ad-hoc JSON caches
|
|
131
|
+
- `import` and `reindex` commands for local `.eml` files
|
|
132
|
+
- Single-job lock so overlapping cron runs can't corrupt the archive
|
|
133
|
+
- Per-run log files under `archive/logs/`
|
|
134
|
+
- `--env-file` for unattended jobs
|
|
135
|
+
- Excel export with relative, portable hyperlinks
|
|
136
|
+
- Cross-platform polish
|
|
137
|
+
|
|
138
|
+
If you hit a bug or a quirk, [open an issue](https://github.com/off-signal/graphmail/issues).
|
|
139
|
+
|
|
140
|
+
## Cron
|
|
141
|
+
|
|
142
|
+
Mutating commands (`fetch`, `import`, `reindex`) are safe unattended:
|
|
143
|
+
|
|
144
|
+
- 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.
|
|
145
|
+
- 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.
|
|
146
|
+
- `--quiet` silences progress but keeps the one-line `=== done ===` summary on stdout (cron `MAILTO` stays useful).
|
|
147
|
+
- Token refresh has a network timeout; a stalled endpoint can't hang the job forever.
|
|
148
|
+
|
|
149
|
+
Log in interactively **once** to seed `token.json`, then let cron use the refresh token from there on.
|
|
150
|
+
|
|
151
|
+
```cron
|
|
152
|
+
SHELL=/bin/sh
|
|
153
|
+
15 3 * * * cd /data/mail-archive && \
|
|
154
|
+
graphmail --env-file /etc/graphmail.env --quiet fetch --folders all \
|
|
155
|
+
>> /data/mail-archive/cron.out 2>&1
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Exit codes
|
|
159
|
+
|
|
160
|
+
| Code | Meaning |
|
|
161
|
+
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
162
|
+
| 0 | success |
|
|
163
|
+
| 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) |
|
|
164
|
+
| 2 | another graphmail job already holds the lock |
|
|
165
|
+
| 130 | aborted (Ctrl-C) |
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
## Files on disk
|
|
170
|
+
|
|
171
|
+
Everything below is relative to the directory you run graphmail from. (cd into your mail directory first)
|
|
172
|
+
|
|
173
|
+
| Path | What |
|
|
174
|
+
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
175
|
+
| `archive/<Folder>/*.eml` | the archive; fetch uses the Outlook folder name, import uses `Imported_<UTC timestamp>` |
|
|
176
|
+
| `archive/logs/<command>-<timestamp>.log` | one log per run; the filename says which command |
|
|
177
|
+
| `archive/logs/latest-<command>.log` | pointer to that command's most recent run |
|
|
178
|
+
| `archive/.graphmail.lock` | single-job lock; exists only while a job runs |
|
|
179
|
+
| `index.db` | SQLite index (rebuild with `reindex`) |
|
|
180
|
+
| `token.json` | cached OAuth token (keep private) |
|
|
181
|
+
| `email_index.xlsx` | Excel view, regenerated on demand |
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
Clone and sync the dev tooling (the `dev` extra; requires [uv](https://docs.astral.sh/uv/)):
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
git clone https://github.com/off-signal/graphmail && cd graphmail
|
|
189
|
+
uv sync --locked --extra dev
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The same checks CI runs:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
uv run ruff check src tests tools
|
|
196
|
+
uv run mypy
|
|
197
|
+
uv run pytest --cov=graphmail --cov-report=term --cov-fail-under=90
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT; see [LICENSE](https://raw.githubusercontent.com/off-signal/graphmail/main/LICENSE).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Graphmail
|
|
2
|
+
|
|
3
|
+
Archive your Outlook/Microsoft 365 mail into files you own.
|
|
4
|
+
|
|
5
|
+
`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.
|
|
6
|
+
|
|
7
|
+
- `.eml` files in folders, openable in any mail client.
|
|
8
|
+
- The index and workbook are rebuilt on demand from the `.eml` tree, so the files are the only source of truth.
|
|
9
|
+
- 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).
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Python ≥ 3.10
|
|
14
|
+
- Any OS (pure Python)
|
|
15
|
+
- A Microsoft 365 / Outlook account and an Azure/Entra app registration
|
|
16
|
+
- [uv](https://docs.astral.sh/uv/) (if you're developing graphmail; see [Development](#development))
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install graphmail
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
One-time setup:
|
|
27
|
+
|
|
28
|
+
1. **Register an app** in the [Entra/Azure admin center](https://entra.microsoft.com/):
|
|
29
|
+
- **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).
|
|
30
|
+
- Optionally also add `https://login.microsoftonline.com/common/oauth2/nativeclient` if you want the `--manual` paste fallback.
|
|
31
|
+
- Leave it as a **public client** (no secret needed for desktop use).
|
|
32
|
+
- **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.
|
|
33
|
+
|
|
34
|
+
2. **Create a credentials file** (chmod 600):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# ~/.config/graphmail.env
|
|
38
|
+
GRAPHMAIL_CLIENT_ID=your-app-id
|
|
39
|
+
GRAPHMAIL_TENANT_ID=common # or your tenant id
|
|
40
|
+
# GRAPHMAIL_CLIENT_SECRET=... # only for confidential clients
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Pass `--env-file ~/.config/graphmail.env` to any command. Real environment variables still win if set.
|
|
44
|
+
|
|
45
|
+
3. **Log in** (opens your browser; the redirect is caught automatically):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
graphmail --env-file ~/.config/graphmail.env login
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then archive mail:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
graphmail fetch --folders Inbox
|
|
55
|
+
graphmail fetch --folders all --recursive
|
|
56
|
+
graphmail fetch --folders Inbox --sender someone@example.com
|
|
57
|
+
|
|
58
|
+
graphmail import ~/old-mail
|
|
59
|
+
|
|
60
|
+
graphmail export -o email_index.xlsx --group-by folder
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- `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.
|
|
66
|
+
- `import` copies a directory of local `.eml` files into the archive and indexes them.
|
|
67
|
+
- `reindex` rebuilds `index.db` from `archive/`'s own `.eml` files (moves/renames are picked up; Graph-fetched rows are untouched).
|
|
68
|
+
- `export` generates an Excel workbook from the index, grouped by folder, year, or as one big sheet.
|
|
69
|
+
- `login` runs the OAuth flow or just refreshes the cached token, no fetching.
|
|
70
|
+
|
|
71
|
+
Behaviour worth knowing:
|
|
72
|
+
|
|
73
|
+
- **Ctrl-C** finishes the current message and stops; a second Ctrl-C aborts immediately.
|
|
74
|
+
- **Resume:** interrupted fetches pick up where they stopped. Failed messages are retried next run; `fetch` exits non-zero if any failed.
|
|
75
|
+
- **Throttling:** Graph 429 / `Retry-After` is respected automatically.
|
|
76
|
+
- **Dedup:** exact duplicates (same received time + content) are skipped.
|
|
77
|
+
|
|
78
|
+
## A note on stability
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
What's new since the refactor (i.e. not battle-tested the same way):
|
|
83
|
+
|
|
84
|
+
- System-browser login with PKCE (the old scripts only had the paste-the-code flow)
|
|
85
|
+
- SQLite index (`index.db`) replacing ad-hoc JSON caches
|
|
86
|
+
- `import` and `reindex` commands for local `.eml` files
|
|
87
|
+
- Single-job lock so overlapping cron runs can't corrupt the archive
|
|
88
|
+
- Per-run log files under `archive/logs/`
|
|
89
|
+
- `--env-file` for unattended jobs
|
|
90
|
+
- Excel export with relative, portable hyperlinks
|
|
91
|
+
- Cross-platform polish
|
|
92
|
+
|
|
93
|
+
If you hit a bug or a quirk, [open an issue](https://github.com/off-signal/graphmail/issues).
|
|
94
|
+
|
|
95
|
+
## Cron
|
|
96
|
+
|
|
97
|
+
Mutating commands (`fetch`, `import`, `reindex`) are safe unattended:
|
|
98
|
+
|
|
99
|
+
- 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.
|
|
100
|
+
- 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.
|
|
101
|
+
- `--quiet` silences progress but keeps the one-line `=== done ===` summary on stdout (cron `MAILTO` stays useful).
|
|
102
|
+
- Token refresh has a network timeout; a stalled endpoint can't hang the job forever.
|
|
103
|
+
|
|
104
|
+
Log in interactively **once** to seed `token.json`, then let cron use the refresh token from there on.
|
|
105
|
+
|
|
106
|
+
```cron
|
|
107
|
+
SHELL=/bin/sh
|
|
108
|
+
15 3 * * * cd /data/mail-archive && \
|
|
109
|
+
graphmail --env-file /etc/graphmail.env --quiet fetch --folders all \
|
|
110
|
+
>> /data/mail-archive/cron.out 2>&1
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Exit codes
|
|
114
|
+
|
|
115
|
+
| Code | Meaning |
|
|
116
|
+
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| 0 | success |
|
|
118
|
+
| 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) |
|
|
119
|
+
| 2 | another graphmail job already holds the lock |
|
|
120
|
+
| 130 | aborted (Ctrl-C) |
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
## Files on disk
|
|
125
|
+
|
|
126
|
+
Everything below is relative to the directory you run graphmail from. (cd into your mail directory first)
|
|
127
|
+
|
|
128
|
+
| Path | What |
|
|
129
|
+
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
130
|
+
| `archive/<Folder>/*.eml` | the archive; fetch uses the Outlook folder name, import uses `Imported_<UTC timestamp>` |
|
|
131
|
+
| `archive/logs/<command>-<timestamp>.log` | one log per run; the filename says which command |
|
|
132
|
+
| `archive/logs/latest-<command>.log` | pointer to that command's most recent run |
|
|
133
|
+
| `archive/.graphmail.lock` | single-job lock; exists only while a job runs |
|
|
134
|
+
| `index.db` | SQLite index (rebuild with `reindex`) |
|
|
135
|
+
| `token.json` | cached OAuth token (keep private) |
|
|
136
|
+
| `email_index.xlsx` | Excel view, regenerated on demand |
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
Clone and sync the dev tooling (the `dev` extra; requires [uv](https://docs.astral.sh/uv/)):
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git clone https://github.com/off-signal/graphmail && cd graphmail
|
|
144
|
+
uv sync --locked --extra dev
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The same checks CI runs:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
uv run ruff check src tests tools
|
|
151
|
+
uv run mypy
|
|
152
|
+
uv run pytest --cov=graphmail --cov-report=term --cov-fail-under=90
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT; see [LICENSE](https://raw.githubusercontent.com/off-signal/graphmail/main/LICENSE).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "graphmail"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Extract emails from Microsoft Graph and local .eml files into a local archive, with an Excel index."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "graphmail contributors" }]
|
|
13
|
+
keywords = ["microsoft-graph", "outlook", "email", "archive", "eml"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Communications :: Email",
|
|
27
|
+
"Topic :: System :: Archiving",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"requests>=2.31",
|
|
32
|
+
"openpyxl>=3.1",
|
|
33
|
+
"beautifulsoup4>=4.12",
|
|
34
|
+
"tqdm>=4.66",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8",
|
|
40
|
+
"pytest-cov",
|
|
41
|
+
"coverage>=7",
|
|
42
|
+
"responses>=0.25",
|
|
43
|
+
"ruff>=0.6",
|
|
44
|
+
"mypy>=1.11",
|
|
45
|
+
"types-requests",
|
|
46
|
+
"types-beautifulsoup4",
|
|
47
|
+
"types-openpyxl",
|
|
48
|
+
"types-tqdm",
|
|
49
|
+
"pyyaml>=6",
|
|
50
|
+
"types-PyYAML",
|
|
51
|
+
"jsonschema>=4",
|
|
52
|
+
"types-jsonschema",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[project.urls]
|
|
56
|
+
Homepage = "https://github.com/off-signal/graphmail"
|
|
57
|
+
|
|
58
|
+
[project.scripts]
|
|
59
|
+
graphmail = "graphmail.cli:main"
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.wheel]
|
|
62
|
+
packages = ["src/graphmail"]
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.sdist]
|
|
65
|
+
include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
66
|
+
exclude = ["**/__pycache__"]
|
|
67
|
+
|
|
68
|
+
# ruff
|
|
69
|
+
[tool.ruff]
|
|
70
|
+
line-length = 100
|
|
71
|
+
target-version = "py310"
|
|
72
|
+
src = ["src", "tests"]
|
|
73
|
+
extend-exclude = ["build", "dist"]
|
|
74
|
+
cache-dir = ".cache/ruff"
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = [
|
|
78
|
+
"E", # pycodestyle errors
|
|
79
|
+
"W", # pycodestyle warnings
|
|
80
|
+
"F", # pyflakes
|
|
81
|
+
"I", # isort
|
|
82
|
+
"UP", # pyupgrade
|
|
83
|
+
"B", # flake8-bugbear
|
|
84
|
+
"SIM", # flake8-simplify
|
|
85
|
+
"C4", # comprehensions
|
|
86
|
+
"PTH", # pathlib
|
|
87
|
+
"ARG", # unused arguments
|
|
88
|
+
"T20", # no stray prints in library code (CLI reports via cli layer)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint.per-file-ignores]
|
|
92
|
+
"src/graphmail/cli.py" = ["T20"]
|
|
93
|
+
"src/graphmail/pipeline.py" = ["T20"]
|
|
94
|
+
"src/graphmail/importer.py" = ["T20"]
|
|
95
|
+
"tests/**" = ["ARG", "T20"]
|
|
96
|
+
"tools/**" = ["T20"]
|
|
97
|
+
|
|
98
|
+
[tool.ruff.lint.isort]
|
|
99
|
+
known-first-party = ["graphmail"]
|
|
100
|
+
|
|
101
|
+
# mypy
|
|
102
|
+
[tool.mypy]
|
|
103
|
+
files = ["src", "tests", "tools"]
|
|
104
|
+
python_version = "3.10"
|
|
105
|
+
strict = true
|
|
106
|
+
warn_unreachable = true
|
|
107
|
+
pretty = true
|
|
108
|
+
show_error_codes = true
|
|
109
|
+
mypy_path = "src:tests"
|
|
110
|
+
explicit_package_bases = true
|
|
111
|
+
cache_dir = ".cache/mypy"
|
|
112
|
+
|
|
113
|
+
# pytest
|
|
114
|
+
[tool.pytest.ini_options]
|
|
115
|
+
testpaths = ["tests"]
|
|
116
|
+
addopts = "-q --strict-markers"
|
|
117
|
+
cache_dir = ".cache/pytest"
|
|
118
|
+
markers = [
|
|
119
|
+
"characterization: pins current behavior of the legacy script",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
[tool.coverage.run]
|
|
123
|
+
source = ["src"]
|
|
124
|
+
omit = ["tests/*"]
|
|
125
|
+
concurrency = ["thread"]
|
|
126
|
+
data_file = ".cache/coverage/.coverage"
|