graphmail 1.0.0__py3-none-any.whl
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/__init__.py +3 -0
- graphmail/auth.py +411 -0
- graphmail/cli.py +461 -0
- graphmail/envfile.py +46 -0
- graphmail/export.py +193 -0
- graphmail/graph.py +318 -0
- graphmail/importer.py +284 -0
- graphmail/index.py +217 -0
- graphmail/lock.py +132 -0
- graphmail/pipeline.py +241 -0
- graphmail/py.typed +0 -0
- graphmail/runlog.py +123 -0
- graphmail/text.py +42 -0
- graphmail-1.0.0.dist-info/METADATA +202 -0
- graphmail-1.0.0.dist-info/RECORD +18 -0
- graphmail-1.0.0.dist-info/WHEEL +4 -0
- graphmail-1.0.0.dist-info/entry_points.txt +2 -0
- graphmail-1.0.0.dist-info/licenses/LICENSE +21 -0
graphmail/cli.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Command-line entry point for graphmail.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
fetch pull messages from Microsoft Graph folders into the archive
|
|
5
|
+
import copy local .eml files into the archive and index them
|
|
6
|
+
reindex rebuild index.db from the archive's own .eml files
|
|
7
|
+
export rebuild the Excel index from index.db
|
|
8
|
+
login run the OAuth flow (or refresh) without fetching
|
|
9
|
+
|
|
10
|
+
Credentials come from environment variables (never flags; keep secrets out
|
|
11
|
+
of shell history), optionally loaded from an ``--env-file``:
|
|
12
|
+
|
|
13
|
+
GRAPHMAIL_CLIENT_ID, GRAPHMAIL_CLIENT_SECRET, GRAPHMAIL_TENANT_ID
|
|
14
|
+
|
|
15
|
+
Mutating commands (fetch/import/reindex) take a single-job lock and write a
|
|
16
|
+
timestamped run log under ``archive/``. ``index.db``, ``token.json`` and the
|
|
17
|
+
workbook live in the working directory; ``cd`` there and everything resolves
|
|
18
|
+
relative to it (there is no archive-root flag).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import signal
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from graphmail import __version__, auth, export, graph, importer, pipeline
|
|
32
|
+
from graphmail.auth import OAuthConfig
|
|
33
|
+
from graphmail.envfile import EnvFileError, load_env_file
|
|
34
|
+
from graphmail.index import ArchiveIndex
|
|
35
|
+
from graphmail.lock import ArchiveLock, LockHeldError
|
|
36
|
+
from graphmail.runlog import RunLog
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("graphmail")
|
|
39
|
+
|
|
40
|
+
ENV_CLIENT_ID = "GRAPHMAIL_CLIENT_ID"
|
|
41
|
+
ENV_CLIENT_SECRET = "GRAPHMAIL_CLIENT_SECRET"
|
|
42
|
+
ENV_TENANT_ID = "GRAPHMAIL_TENANT_ID"
|
|
43
|
+
|
|
44
|
+
EXIT_LOCKED = 2 # distinct from 1 (failures) so schedulers can tell "still
|
|
45
|
+
# running" from "ran and some mail failed".
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _config(args: argparse.Namespace) -> OAuthConfig:
|
|
49
|
+
"""Build the OAuth config from env vars, honoring --token-path."""
|
|
50
|
+
missing = [
|
|
51
|
+
name
|
|
52
|
+
for name in (ENV_CLIENT_ID, ENV_TENANT_ID)
|
|
53
|
+
if not os.environ.get(name)
|
|
54
|
+
]
|
|
55
|
+
if missing:
|
|
56
|
+
raise SystemExit(
|
|
57
|
+
"Missing required environment variable(s): "
|
|
58
|
+
+ ", ".join(missing)
|
|
59
|
+
+ "\nSet them with your Azure app credentials, e.g.:\n"
|
|
60
|
+
f" export {ENV_CLIENT_ID}=...\n"
|
|
61
|
+
f" export {ENV_TENANT_ID}=common"
|
|
62
|
+
)
|
|
63
|
+
return OAuthConfig(
|
|
64
|
+
client_id=os.environ[ENV_CLIENT_ID],
|
|
65
|
+
client_secret=os.environ.get(ENV_CLIENT_SECRET, ""),
|
|
66
|
+
tenant_id=os.environ[ENV_TENANT_ID],
|
|
67
|
+
token_path=Path(args.token_path),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _authenticate(args: argparse.Namespace) -> auth.AuthSession | None:
|
|
72
|
+
"""Run auth.authenticate with flow flags from the parsed args."""
|
|
73
|
+
return auth.authenticate(
|
|
74
|
+
_config(args),
|
|
75
|
+
flow="manual" if args.manual else "loopback",
|
|
76
|
+
auth_port=args.auth_port,
|
|
77
|
+
timeout=args.auth_timeout,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_folders(
|
|
82
|
+
selectors: list[str], folders: list[dict[str, str]]
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
"""Map user folder selectors to Graph folder ids.
|
|
85
|
+
|
|
86
|
+
A selector matches a display name case-insensitively; anything else is
|
|
87
|
+
passed through as a raw folder id (Graph rejects unknown ids with a clear
|
|
88
|
+
error). The special selector ``all`` selects every top-level folder.
|
|
89
|
+
"""
|
|
90
|
+
by_name = {f["displayName"].casefold(): f["id"] for f in folders}
|
|
91
|
+
ids: list[str] = []
|
|
92
|
+
for selector in selectors:
|
|
93
|
+
if selector.casefold() == "all":
|
|
94
|
+
ids.extend(f["id"] for f in folders)
|
|
95
|
+
continue
|
|
96
|
+
folder_id = by_name.get(selector.casefold(), selector)
|
|
97
|
+
if folder_id not in ids:
|
|
98
|
+
ids.append(folder_id)
|
|
99
|
+
return ids
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _expand_subfolders(
|
|
103
|
+
access_token: str, folder_ids: list[str]
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
"""Add every descendant of the given folders (breadth-first, deduped).
|
|
106
|
+
|
|
107
|
+
Uses a dict as an ordered set: Python string hashing is randomized, so
|
|
108
|
+
iteration order of a ``set`` is not insertion order.
|
|
109
|
+
"""
|
|
110
|
+
seen = dict.fromkeys(folder_ids)
|
|
111
|
+
queue = list(folder_ids)
|
|
112
|
+
while queue:
|
|
113
|
+
for child in graph.get_subfolders(access_token, queue.pop(0)):
|
|
114
|
+
if child["id"] not in seen:
|
|
115
|
+
seen[child["id"]] = None
|
|
116
|
+
queue.append(child["id"])
|
|
117
|
+
return list(seen)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _install_stop_handler() -> graph.StopFlag:
|
|
121
|
+
"""First Ctrl-C: finish the current message, then stop. Second: exit."""
|
|
122
|
+
flag = graph.StopFlag()
|
|
123
|
+
|
|
124
|
+
def _handler(*_args: object) -> None:
|
|
125
|
+
if flag.stopped:
|
|
126
|
+
raise KeyboardInterrupt
|
|
127
|
+
print("\n⏹️ Finishing current message (Ctrl-C again to abort now)")
|
|
128
|
+
flag.trigger()
|
|
129
|
+
|
|
130
|
+
signal.signal(signal.SIGINT, _handler)
|
|
131
|
+
return flag
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_fetch(args: argparse.Namespace, archive_root: Path) -> tuple[int, dict[str, int]]:
|
|
135
|
+
"""Authenticate, resolve folders, and archive; returns exit code + counters."""
|
|
136
|
+
session = _authenticate(args)
|
|
137
|
+
if session is None:
|
|
138
|
+
print("Authentication failed.", file=sys.stderr)
|
|
139
|
+
return 1, {"saved": 0, "duplicates": 0, "failed": 0}
|
|
140
|
+
|
|
141
|
+
folders = graph.list_mail_folders(session.access_token)
|
|
142
|
+
if not folders:
|
|
143
|
+
print("No mail folders found (or listing failed).", file=sys.stderr)
|
|
144
|
+
return 1, {"saved": 0, "duplicates": 0, "failed": 0}
|
|
145
|
+
folder_ids = resolve_folders(args.folders, folders)
|
|
146
|
+
if not folder_ids:
|
|
147
|
+
print("No folders selected.", file=sys.stderr)
|
|
148
|
+
return 1, {"saved": 0, "duplicates": 0, "failed": 0}
|
|
149
|
+
if args.recursive:
|
|
150
|
+
folder_ids = _expand_subfolders(session.access_token, folder_ids)
|
|
151
|
+
|
|
152
|
+
stop = _install_stop_handler()
|
|
153
|
+
totals = {"saved": 0, "duplicates": 0, "failed": 0}
|
|
154
|
+
with ArchiveIndex(Path(args.index)) as index:
|
|
155
|
+
for folder_id in folder_ids:
|
|
156
|
+
if stop():
|
|
157
|
+
break
|
|
158
|
+
summary = pipeline.process_folder(
|
|
159
|
+
session.access_token,
|
|
160
|
+
folder_id,
|
|
161
|
+
sender=args.sender or "",
|
|
162
|
+
archive_index=index,
|
|
163
|
+
output_root=archive_root,
|
|
164
|
+
should_stop=stop,
|
|
165
|
+
)
|
|
166
|
+
totals["saved"] += summary.saved
|
|
167
|
+
totals["duplicates"] += summary.duplicates
|
|
168
|
+
totals["failed"] += summary.failed
|
|
169
|
+
if totals["failed"]:
|
|
170
|
+
print(
|
|
171
|
+
f"⚠️ {totals['failed']} message(s) failed and will be retried on the next run."
|
|
172
|
+
)
|
|
173
|
+
return 1, totals
|
|
174
|
+
print("📥 Fetch complete.")
|
|
175
|
+
return 0, totals
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_fetch(args: argparse.Namespace) -> int:
|
|
179
|
+
archive_root = pipeline.OUTPUT_ROOT
|
|
180
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
with (
|
|
182
|
+
ArchiveLock(archive_root, "fetch"),
|
|
183
|
+
RunLog(archive_root, "fetch", quiet=args.quiet) as runlog,
|
|
184
|
+
):
|
|
185
|
+
code, totals = _run_fetch(args, archive_root)
|
|
186
|
+
runlog.finish(code, **totals)
|
|
187
|
+
return code
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
191
|
+
index_path = Path(args.index)
|
|
192
|
+
if not index_path.exists():
|
|
193
|
+
print(
|
|
194
|
+
f"Index not found: {index_path} (run `graphmail fetch` first)",
|
|
195
|
+
file=sys.stderr,
|
|
196
|
+
)
|
|
197
|
+
return 1
|
|
198
|
+
with ArchiveIndex(index_path) as index:
|
|
199
|
+
out = export.export_to_excel(
|
|
200
|
+
index,
|
|
201
|
+
Path(args.output),
|
|
202
|
+
group_by=args.group_by,
|
|
203
|
+
)
|
|
204
|
+
count = index.count()
|
|
205
|
+
print(f"📊 Wrote {out} ({count} messages)")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
210
|
+
session = _authenticate(args)
|
|
211
|
+
if session is None:
|
|
212
|
+
print("Authentication failed.", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
print("✅ Logged in. Token cached.")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _print_stats(verb: str, stats: importer.ImportStats) -> None:
|
|
219
|
+
print(
|
|
220
|
+
f"{verb} {stats.imported} file(s), "
|
|
221
|
+
f"{stats.duplicates} duplicate(s), {stats.errors} error(s)"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _default_import_folder() -> str:
|
|
226
|
+
"""One folder per import batch, stamped in UTC so batches never merge.
|
|
227
|
+
|
|
228
|
+
The stamp keeps the name out of the way of a real Outlook folder that
|
|
229
|
+
happens to be called "Imported" (fetch would archive that under
|
|
230
|
+
``archive/Imported`` too).
|
|
231
|
+
"""
|
|
232
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
233
|
+
return f"Imported_{stamp}"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cmd_import(args: argparse.Namespace) -> int:
|
|
237
|
+
source = Path(args.source)
|
|
238
|
+
if not source.is_dir():
|
|
239
|
+
print(f"Source directory not found: {source}", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
archive_root = pipeline.OUTPUT_ROOT
|
|
242
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
folder = args.folder or _default_import_folder()
|
|
244
|
+
with (
|
|
245
|
+
ArchiveLock(archive_root, "import"),
|
|
246
|
+
RunLog(archive_root, "import", quiet=args.quiet) as runlog,
|
|
247
|
+
ArchiveIndex(Path(args.index)) as index,
|
|
248
|
+
):
|
|
249
|
+
stats = importer.import_directory(
|
|
250
|
+
source,
|
|
251
|
+
archive_root=archive_root,
|
|
252
|
+
folder_name=folder,
|
|
253
|
+
index=index,
|
|
254
|
+
)
|
|
255
|
+
_print_stats("📥 Imported", stats)
|
|
256
|
+
code = 1 if stats.errors and not stats.imported else 0
|
|
257
|
+
runlog.finish(
|
|
258
|
+
code,
|
|
259
|
+
folder=archive_root / folder,
|
|
260
|
+
imported=stats.imported,
|
|
261
|
+
duplicates=stats.duplicates,
|
|
262
|
+
errors=stats.errors,
|
|
263
|
+
)
|
|
264
|
+
return code
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_reindex(args: argparse.Namespace) -> int:
|
|
268
|
+
root = pipeline.OUTPUT_ROOT
|
|
269
|
+
if not root.is_dir():
|
|
270
|
+
print(f"Archive root not found: {root}", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
with (
|
|
273
|
+
ArchiveLock(root, "reindex"),
|
|
274
|
+
RunLog(root, "reindex", quiet=args.quiet) as runlog,
|
|
275
|
+
ArchiveIndex(Path(args.index)) as index,
|
|
276
|
+
):
|
|
277
|
+
stats = importer.reindex_archive(root, index=index)
|
|
278
|
+
_print_stats("🗂️ Reindexed", stats)
|
|
279
|
+
code = 1 if stats.errors and not stats.imported else 0
|
|
280
|
+
runlog.finish(
|
|
281
|
+
code,
|
|
282
|
+
imported=stats.imported,
|
|
283
|
+
duplicates=stats.duplicates,
|
|
284
|
+
errors=stats.errors,
|
|
285
|
+
)
|
|
286
|
+
return code
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
290
|
+
parser = argparse.ArgumentParser(
|
|
291
|
+
prog="graphmail",
|
|
292
|
+
description="Archive Microsoft Graph mail into .eml files + an Excel index.",
|
|
293
|
+
)
|
|
294
|
+
parser.add_argument(
|
|
295
|
+
"--version", action="version", version=f"graphmail {__version__}"
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument(
|
|
298
|
+
"-v",
|
|
299
|
+
"--verbose",
|
|
300
|
+
action="count",
|
|
301
|
+
default=0,
|
|
302
|
+
help="-v for INFO, -vv for DEBUG",
|
|
303
|
+
)
|
|
304
|
+
parser.add_argument(
|
|
305
|
+
"-q",
|
|
306
|
+
"--quiet",
|
|
307
|
+
action="store_true",
|
|
308
|
+
help="suppress progress on stdout (run log still written; "
|
|
309
|
+
"the final === done === line and errors remain)",
|
|
310
|
+
)
|
|
311
|
+
parser.add_argument(
|
|
312
|
+
"--env-file",
|
|
313
|
+
metavar="PATH",
|
|
314
|
+
help="dotenv-style KEY=VALUE file (real env vars win over the file)",
|
|
315
|
+
)
|
|
316
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
317
|
+
|
|
318
|
+
fetch = subparsers.add_parser(
|
|
319
|
+
"fetch", help="download messages from Graph folders"
|
|
320
|
+
)
|
|
321
|
+
fetch.add_argument(
|
|
322
|
+
"--folders",
|
|
323
|
+
nargs="+",
|
|
324
|
+
required=True,
|
|
325
|
+
metavar="ID_OR_NAME",
|
|
326
|
+
help="folder display names, raw Graph folder ids, or 'all'",
|
|
327
|
+
)
|
|
328
|
+
fetch.add_argument(
|
|
329
|
+
"--recursive",
|
|
330
|
+
action="store_true",
|
|
331
|
+
help="also fetch all subfolders of the selected folders",
|
|
332
|
+
)
|
|
333
|
+
fetch.add_argument(
|
|
334
|
+
"--sender", help="only messages from this sender address"
|
|
335
|
+
)
|
|
336
|
+
fetch.add_argument(
|
|
337
|
+
"--token-path",
|
|
338
|
+
default="token.json",
|
|
339
|
+
help="OAuth token cache file (default: token.json)",
|
|
340
|
+
)
|
|
341
|
+
fetch.add_argument(
|
|
342
|
+
"--index",
|
|
343
|
+
default="index.db",
|
|
344
|
+
help="archive index path (default: index.db)",
|
|
345
|
+
)
|
|
346
|
+
_add_auth_flow_args(fetch)
|
|
347
|
+
fetch.set_defaults(func=cmd_fetch)
|
|
348
|
+
|
|
349
|
+
export_parser = subparsers.add_parser(
|
|
350
|
+
"export", help="build the Excel index from index.db"
|
|
351
|
+
)
|
|
352
|
+
export_parser.add_argument(
|
|
353
|
+
"-o",
|
|
354
|
+
"--output",
|
|
355
|
+
default=str(export.DEFAULT_EXPORT_FILE),
|
|
356
|
+
help=f"workbook path (default: {export.DEFAULT_EXPORT_FILE})",
|
|
357
|
+
)
|
|
358
|
+
export_parser.add_argument(
|
|
359
|
+
"--index",
|
|
360
|
+
default="index.db",
|
|
361
|
+
help="archive index path (default: index.db)",
|
|
362
|
+
)
|
|
363
|
+
export_parser.add_argument(
|
|
364
|
+
"--group-by",
|
|
365
|
+
choices=["folder", "year", "none"],
|
|
366
|
+
default="folder",
|
|
367
|
+
help="sheet grouping (default: folder)",
|
|
368
|
+
)
|
|
369
|
+
export_parser.set_defaults(func=cmd_export)
|
|
370
|
+
|
|
371
|
+
login = subparsers.add_parser(
|
|
372
|
+
"login", help="authenticate (or refresh the cached token)"
|
|
373
|
+
)
|
|
374
|
+
login.add_argument(
|
|
375
|
+
"--token-path",
|
|
376
|
+
default="token.json",
|
|
377
|
+
help="OAuth token cache file (default: token.json)",
|
|
378
|
+
)
|
|
379
|
+
_add_auth_flow_args(login)
|
|
380
|
+
login.set_defaults(func=cmd_login)
|
|
381
|
+
|
|
382
|
+
import_parser = subparsers.add_parser(
|
|
383
|
+
"import", help="copy local .eml files into the archive and index them"
|
|
384
|
+
)
|
|
385
|
+
import_parser.add_argument("source", help="directory containing .eml files")
|
|
386
|
+
import_parser.add_argument(
|
|
387
|
+
"--folder",
|
|
388
|
+
default=None,
|
|
389
|
+
help="archive folder to copy into "
|
|
390
|
+
"(default: Imported_<UTC timestamp>, one folder per batch)",
|
|
391
|
+
)
|
|
392
|
+
import_parser.add_argument(
|
|
393
|
+
"--index", default="index.db", help="archive index path (default: index.db)"
|
|
394
|
+
)
|
|
395
|
+
import_parser.set_defaults(func=cmd_import)
|
|
396
|
+
|
|
397
|
+
reindex = subparsers.add_parser(
|
|
398
|
+
"reindex", help="rebuild index.db from the archive's own .eml files"
|
|
399
|
+
)
|
|
400
|
+
reindex.add_argument(
|
|
401
|
+
"--index", default="index.db", help="archive index path (default: index.db)"
|
|
402
|
+
)
|
|
403
|
+
reindex.set_defaults(func=cmd_reindex)
|
|
404
|
+
|
|
405
|
+
return parser
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _add_auth_flow_args(parser: argparse.ArgumentParser) -> None:
|
|
409
|
+
"""Shared interactive-auth flags for login/fetch."""
|
|
410
|
+
parser.add_argument(
|
|
411
|
+
"--manual",
|
|
412
|
+
action="store_true",
|
|
413
|
+
help="paste the authorization code by hand (legacy flow; "
|
|
414
|
+
"default is loopback on localhost)",
|
|
415
|
+
)
|
|
416
|
+
parser.add_argument(
|
|
417
|
+
"--auth-port",
|
|
418
|
+
type=int,
|
|
419
|
+
default=auth.DEFAULT_AUTH_PORT,
|
|
420
|
+
metavar="PORT",
|
|
421
|
+
help=f"loopback redirect port (default: {auth.DEFAULT_AUTH_PORT})",
|
|
422
|
+
)
|
|
423
|
+
parser.add_argument(
|
|
424
|
+
"--auth-timeout",
|
|
425
|
+
type=float,
|
|
426
|
+
default=auth.LOOPBACK_TIMEOUT,
|
|
427
|
+
metavar="SECONDS",
|
|
428
|
+
help=f"seconds to wait for the browser redirect "
|
|
429
|
+
f"(default: {auth.LOOPBACK_TIMEOUT:.0f})",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def main(argv: list[str] | None = None) -> int:
|
|
434
|
+
args = build_parser().parse_args(argv)
|
|
435
|
+
level = logging.WARNING
|
|
436
|
+
if args.verbose == 1:
|
|
437
|
+
level = logging.INFO
|
|
438
|
+
elif args.verbose >= 2:
|
|
439
|
+
level = logging.DEBUG
|
|
440
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
441
|
+
|
|
442
|
+
if args.env_file:
|
|
443
|
+
try:
|
|
444
|
+
for key, value in load_env_file(Path(args.env_file)).items():
|
|
445
|
+
os.environ.setdefault(key, value)
|
|
446
|
+
except EnvFileError as exc:
|
|
447
|
+
print(str(exc), file=sys.stderr)
|
|
448
|
+
return 1
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
return int(args.func(args))
|
|
452
|
+
except LockHeldError as exc:
|
|
453
|
+
print(str(exc), file=sys.stderr)
|
|
454
|
+
return EXIT_LOCKED
|
|
455
|
+
except KeyboardInterrupt:
|
|
456
|
+
print("\nAborted.", file=sys.stderr)
|
|
457
|
+
return 130
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__": # pragma: no cover - script entry point
|
|
461
|
+
sys.exit(main())
|
graphmail/envfile.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Dotenv-style credentials file (``KEY=VALUE``) for unattended jobs.
|
|
2
|
+
|
|
3
|
+
Full-line ``#`` comments, optional ``export `` prefix, quotes stripped.
|
|
4
|
+
No variable expansion. Real environment variables always win.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EnvFileError(Exception):
|
|
13
|
+
"""The env file is missing, unreadable, or malformed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_env_file(path: Path) -> dict[str, str]:
|
|
17
|
+
"""Parse *path* into a ``{KEY: VALUE}`` mapping.
|
|
18
|
+
|
|
19
|
+
Raises :class:`EnvFileError` if the file cannot be read or a line is
|
|
20
|
+
malformed.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
text = path.read_text(encoding="utf-8")
|
|
24
|
+
except OSError as exc:
|
|
25
|
+
raise EnvFileError(f"Cannot read env file {path}: {exc}") from exc
|
|
26
|
+
|
|
27
|
+
result: dict[str, str] = {}
|
|
28
|
+
for lineno, raw_line in enumerate(text.splitlines(), start=1):
|
|
29
|
+
line = raw_line.strip()
|
|
30
|
+
if not line or line.startswith("#"):
|
|
31
|
+
continue
|
|
32
|
+
if line.startswith("export "):
|
|
33
|
+
line = line[len("export ") :].lstrip()
|
|
34
|
+
if "=" not in line:
|
|
35
|
+
raise EnvFileError(
|
|
36
|
+
f"{path}:{lineno}: expected KEY=VALUE, got {raw_line!r}"
|
|
37
|
+
)
|
|
38
|
+
key, _, value = line.partition("=")
|
|
39
|
+
key = key.strip()
|
|
40
|
+
value = value.strip()
|
|
41
|
+
if not key:
|
|
42
|
+
raise EnvFileError(f"{path}:{lineno}: empty key in {raw_line!r}")
|
|
43
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
44
|
+
value = value[1:-1]
|
|
45
|
+
result[key] = value
|
|
46
|
+
return result
|
graphmail/export.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Excel export: a rebuildable view over the archive index.
|
|
2
|
+
|
|
3
|
+
The workbook is generated on demand, never written during fetch, and every
|
|
4
|
+
email row carries a *relative* ``Open`` hyperlink to its ``.eml`` file, so the
|
|
5
|
+
archive folder can be moved or shared and the links keep working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import openpyxl
|
|
17
|
+
from openpyxl.styles import Font
|
|
18
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
19
|
+
|
|
20
|
+
from graphmail.index import ArchiveIndex, MessageRecord
|
|
21
|
+
from graphmail.text import clean_sheetname
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
DEFAULT_EXPORT_FILE = Path("email_index.xlsx")
|
|
26
|
+
COLUMNS = [
|
|
27
|
+
"Subject",
|
|
28
|
+
"From",
|
|
29
|
+
"To",
|
|
30
|
+
"CC",
|
|
31
|
+
"Received Date",
|
|
32
|
+
"Body Content",
|
|
33
|
+
"Open",
|
|
34
|
+
]
|
|
35
|
+
_GROUPING = ("folder", "year", "none")
|
|
36
|
+
|
|
37
|
+
# Excel cells max 32,767 chars; leave a margin.
|
|
38
|
+
_EXCEL_CELL_LIMIT = 32_000
|
|
39
|
+
# Control chars Excel refuses (\n and \t survive; Excel allows them).
|
|
40
|
+
_EXCEL_ILLEGAL = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clean_for_excel(value: str) -> str:
|
|
44
|
+
"""Strip characters Excel rejects and cap the length."""
|
|
45
|
+
cleaned = _EXCEL_ILLEGAL.sub("", value)
|
|
46
|
+
if len(cleaned) > _EXCEL_CELL_LIMIT:
|
|
47
|
+
return cleaned[:_EXCEL_CELL_LIMIT] + "…"
|
|
48
|
+
return cleaned
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def relative_link(eml_path: str, out_dir: Path, base_dir: Path) -> str:
|
|
52
|
+
"""Path of *eml_path* relative to *out_dir*, always with forward slashes.
|
|
53
|
+
|
|
54
|
+
Excel on Windows accepts ``/``; backslash paths break macOS, LibreOffice,
|
|
55
|
+
and mixed network shares. Falls back to the absolute path when the
|
|
56
|
+
archive and workbook are on different drives.
|
|
57
|
+
"""
|
|
58
|
+
absolute_eml = (base_dir / eml_path).resolve()
|
|
59
|
+
try:
|
|
60
|
+
rel = os.path.relpath(absolute_eml, out_dir.resolve())
|
|
61
|
+
except ValueError:
|
|
62
|
+
logger.warning(
|
|
63
|
+
"Archive and workbook are on different drives; "
|
|
64
|
+
"using absolute link for %s",
|
|
65
|
+
eml_path,
|
|
66
|
+
)
|
|
67
|
+
return absolute_eml.as_posix()
|
|
68
|
+
# Forward slashes work everywhere; backslashes only work on Windows.
|
|
69
|
+
return rel.replace(os.sep, "/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sheet_title(name: str) -> str:
|
|
73
|
+
# Excel sheet names: max 31 chars, illegal chars already sanitized.
|
|
74
|
+
return clean_sheetname(name)[:31]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _unique_title(base: str, used: set[str]) -> str:
|
|
78
|
+
"""Deduplicate *base* against *used* (Excel sheet names are unique,
|
|
79
|
+
case-insensitively, and max 31 chars)."""
|
|
80
|
+
candidate = base
|
|
81
|
+
counter = 2
|
|
82
|
+
while candidate.casefold() in used:
|
|
83
|
+
suffix = f" ({counter})"
|
|
84
|
+
candidate = f"{base[: 31 - len(suffix)]}{suffix}"
|
|
85
|
+
counter += 1
|
|
86
|
+
used.add(candidate.casefold())
|
|
87
|
+
return candidate
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _write_row(
|
|
91
|
+
sheet: Worksheet, record: MessageRecord, link_target: str
|
|
92
|
+
) -> None:
|
|
93
|
+
sheet.append(
|
|
94
|
+
[
|
|
95
|
+
clean_for_excel(record.subject),
|
|
96
|
+
clean_for_excel(record.sender),
|
|
97
|
+
clean_for_excel(record.recipients_to),
|
|
98
|
+
clean_for_excel(record.recipients_cc),
|
|
99
|
+
record.received_at[:10],
|
|
100
|
+
clean_for_excel(record.body_text),
|
|
101
|
+
"Open",
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
cell = sheet.cell(row=sheet.max_row, column=len(COLUMNS))
|
|
105
|
+
cell.hyperlink = link_target
|
|
106
|
+
cell.style = "Hyperlink"
|
|
107
|
+
# Some viewers ignore the named style; pin the font explicitly.
|
|
108
|
+
# NOT =HYPERLINK(): a formula cell breaks simple parsers.
|
|
109
|
+
cell.font = Font(color="0563C1", underline="single")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _new_workbook() -> openpyxl.Workbook:
|
|
113
|
+
workbook = openpyxl.Workbook()
|
|
114
|
+
# Drop the empty default sheet.
|
|
115
|
+
default_sheet = workbook.active
|
|
116
|
+
if default_sheet is not None:
|
|
117
|
+
workbook.remove(default_sheet)
|
|
118
|
+
return workbook
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def export_to_excel(
|
|
122
|
+
index: ArchiveIndex,
|
|
123
|
+
out_path: Path = DEFAULT_EXPORT_FILE,
|
|
124
|
+
*,
|
|
125
|
+
group_by: Literal["folder", "year", "none"] = "folder",
|
|
126
|
+
base_dir: Path | None = None,
|
|
127
|
+
) -> Path:
|
|
128
|
+
"""Build an Excel index from *index* and return the written path.
|
|
129
|
+
|
|
130
|
+
``group_by``:
|
|
131
|
+
* ``"folder"``: one sheet per mail folder (default)
|
|
132
|
+
* ``"year"`` : one sheet per year of the received date
|
|
133
|
+
* ``"none"`` : a single "Emails" sheet
|
|
134
|
+
|
|
135
|
+
``base_dir`` is the directory ``eml_path`` values are relative to: the
|
|
136
|
+
working directory (``index.db`` is cwd-relative too, so the two move
|
|
137
|
+
together). Defaults to the current working directory.
|
|
138
|
+
"""
|
|
139
|
+
if group_by not in _GROUPING:
|
|
140
|
+
raise ValueError(f"group_by must be one of {_GROUPING}, got {group_by!r}")
|
|
141
|
+
|
|
142
|
+
root = base_dir or Path.cwd()
|
|
143
|
+
out_path = Path(out_path)
|
|
144
|
+
records = index.messages()
|
|
145
|
+
|
|
146
|
+
groups: dict[str, list[MessageRecord]] = {}
|
|
147
|
+
for record in records:
|
|
148
|
+
if group_by == "folder":
|
|
149
|
+
key = record.folder_name or "Unknown Folder"
|
|
150
|
+
elif group_by == "year":
|
|
151
|
+
key = record.received_at[:4] if record.received_at else "Unknown"
|
|
152
|
+
else:
|
|
153
|
+
key = "Emails"
|
|
154
|
+
groups.setdefault(key, []).append(record)
|
|
155
|
+
|
|
156
|
+
workbook = _new_workbook()
|
|
157
|
+
out_dir = out_path.parent
|
|
158
|
+
|
|
159
|
+
titles = sorted(groups) or ["Emails"]
|
|
160
|
+
used_titles: set[str] = set()
|
|
161
|
+
missing_files = 0
|
|
162
|
+
|
|
163
|
+
for title in titles:
|
|
164
|
+
sheet = workbook.create_sheet(
|
|
165
|
+
title=_unique_title(_sheet_title(title), used_titles)
|
|
166
|
+
)
|
|
167
|
+
sheet.append(COLUMNS)
|
|
168
|
+
for record in groups.get(title, []):
|
|
169
|
+
target = relative_link(record.eml_path, out_dir, root)
|
|
170
|
+
if not (root / record.eml_path).exists():
|
|
171
|
+
missing_files += 1
|
|
172
|
+
if missing_files <= 5:
|
|
173
|
+
logger.warning(
|
|
174
|
+
"Missing .eml file (link will not open): %s",
|
|
175
|
+
record.eml_path,
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
logger.debug(
|
|
179
|
+
"Missing .eml file (link will not open): %s",
|
|
180
|
+
record.eml_path,
|
|
181
|
+
)
|
|
182
|
+
_write_row(sheet, record, target)
|
|
183
|
+
|
|
184
|
+
if missing_files:
|
|
185
|
+
logger.warning(
|
|
186
|
+
"%d .eml file(s) missing from the archive; their hyperlinks "
|
|
187
|
+
"will not open",
|
|
188
|
+
missing_files,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
workbook.save(out_path)
|
|
193
|
+
return out_path
|