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/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