mailvault 0.7.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.
mailvault/__init__.py ADDED
File without changes
mailvault/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m mailvault` and the PyInstaller build."""
2
+
3
+ from mailvault.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Mailbox backends (IMAP, MS Graph) and the interface they share."""
2
+
3
+ from mailvault.backend.base import BackupResult, MailboxClient, MessageRef
@@ -0,0 +1,159 @@
1
+ """Backend-agnostic mailbox interface.
2
+
3
+ The shared types every mailbox backend (IMAP, MS Graph, ...) speaks in. Kept in
4
+ a module of its own so that both the backends and the job runner can depend on
5
+ them without the backends having to import each other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import collections.abc
11
+ import dataclasses
12
+ import logging
13
+ from datetime import datetime
14
+ from typing import Any, Protocol
15
+
16
+ from mailvault import mailutils
17
+ from mailvault.store import cas
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclasses.dataclass
23
+ class BackupResult:
24
+ """Outcome of a folder backup.
25
+
26
+ `failed` counts messages that were seen on the server but could not be
27
+ stored locally. A run with failures is incomplete, so the caller must not
28
+ advance the incremental snapshot — otherwise those messages would fall
29
+ outside the date filter of every future run and stay lost for good.
30
+ """
31
+
32
+ total: int = 0
33
+ stored: int = 0
34
+ failed: int = 0
35
+
36
+ @property
37
+ def complete(self) -> bool:
38
+ """True if every message seen on the server was accounted for."""
39
+ return self.failed == 0
40
+
41
+
42
+ @dataclasses.dataclass(frozen=True)
43
+ class MessageRef:
44
+ """Reference to a message on the server, without its content.
45
+
46
+ `msg_id` is backend-specific (IMAP UID, Graph message id) and only valid
47
+ together with the folder it was listed from.
48
+ """
49
+
50
+ msg_id: Any
51
+ message_id: str
52
+ date: datetime | None = None
53
+
54
+
55
+ class MailboxClient(Protocol):
56
+ """Protocol defining the interface for mailbox backends.
57
+
58
+ Each backend (IMAP, MS Graph, ...) must implement these methods so that
59
+ the job runner in ``jobs.py`` can treat them interchangeably.
60
+ """
61
+
62
+ job_name: str
63
+
64
+ def folders(self) -> collections.abc.Generator[str, None, None]: ...
65
+
66
+ def folder_backup(
67
+ self,
68
+ folder_name: str,
69
+ store: cas.ContentAddressedStorage,
70
+ since: datetime | None = ...,
71
+ callback: collections.abc.Callable[[mailutils.MessageMetadata], None] | None = ...,
72
+ ) -> BackupResult: ...
73
+
74
+ def message_index(
75
+ self,
76
+ folder_name: str,
77
+ since: datetime | None = ...,
78
+ ) -> collections.abc.Generator[MessageRef, None, None]: ...
79
+
80
+ def fetch_message(self, msg_id: Any, folder_name: str) -> bytes: ...
81
+
82
+ def full_backup(
83
+ self,
84
+ store: cas.ContentAddressedStorage,
85
+ since: datetime | None = ...,
86
+ callback: collections.abc.Callable[[mailutils.MessageMetadata], None] | None = ...,
87
+ ) -> None: ...
88
+
89
+ def get_messages(
90
+ self,
91
+ folder_name: str,
92
+ since: datetime | None = ...,
93
+ ) -> collections.abc.Generator[tuple[Any, datetime | None, bytes], None, None]: ...
94
+
95
+ def save_message(
96
+ self,
97
+ msg: bytes,
98
+ folder_name: str,
99
+ date: datetime | None = ...,
100
+ ) -> None: ...
101
+
102
+ def move_message(self, msg_id: Any, folder_name: str) -> None: ...
103
+
104
+ def delete_message(self, msg_id: Any, expunge: bool = ...) -> None: ...
105
+
106
+ def close(self) -> None: ...
107
+
108
+
109
+ def store_message(
110
+ store: cas.ContentAddressedStorage,
111
+ msg: bytes,
112
+ *,
113
+ result: BackupResult,
114
+ log_ctx: str,
115
+ callback: collections.abc.Callable[[mailutils.MessageMetadata], None] | None = None,
116
+ metadata_fn: collections.abc.Callable[[str], mailutils.MessageMetadata] | None = None,
117
+ ) -> str | None:
118
+ """Add one message to the store and record its metadata.
119
+
120
+ The shared tail of every backend's ``folder_backup``: store the bytes, log
121
+ the outcome, and -- if a callback is given -- build and hand over the
122
+ metadata. ``result.stored`` is incremented on success, ``result.failed`` on a
123
+ callback error. ``log_ctx`` is the ``mailbox::folder[id]`` prefix the caller
124
+ would otherwise repeat in every log line.
125
+
126
+ Returns the store id on success, or ``None`` when the message could not be
127
+ recorded. A ``None`` return means the caller must not treat the message as
128
+ archived -- in particular it must not be deleted from the server.
129
+ """
130
+ status, store_id, _path = store.add(msg)
131
+ log.info("%s: %s: id=%s", log_ctx, status, store_id)
132
+ if callback is not None and metadata_fn is not None:
133
+ try:
134
+ callback(metadata_fn(store_id))
135
+ except Exception as exc:
136
+ log.exception("%s: error in callback: %s", log_ctx, exc)
137
+ result.failed += 1
138
+ return None
139
+ result.stored += 1
140
+ return store_id
141
+
142
+
143
+ def run_full_backup(
144
+ client: MailboxClient,
145
+ store: cas.ContentAddressedStorage,
146
+ since: datetime | None = None,
147
+ callback: collections.abc.Callable[[mailutils.MessageMetadata], None] | None = None,
148
+ ) -> None:
149
+ """Back up every folder of a mailbox, logging and skipping folders that fail.
150
+
151
+ The default whole-mailbox backup shared by all backends: iterate
152
+ ``client.folders()`` and delegate each to ``client.folder_backup``. A folder
153
+ that raises is logged and skipped so the remaining folders still run.
154
+ """
155
+ for folder in client.folders():
156
+ try:
157
+ client.folder_backup(folder, store, since=since, callback=callback)
158
+ except Exception as exc:
159
+ log.error("%s::%s: backup failed: %s", client.job_name, folder, exc)
@@ -0,0 +1,401 @@
1
+ """MS Graph backend for accessing Microsoft 365 mailboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections.abc
6
+ import logging
7
+ import re
8
+ import time
9
+ import urllib.parse
10
+ from datetime import datetime
11
+ from typing import Any
12
+
13
+ import httpx
14
+ import msal
15
+
16
+ from mailvault import conf, mailutils
17
+ from mailvault.backend import base
18
+ from mailvault.store import cas
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
23
+ GRAPH_SCOPE = ["https://graph.microsoft.com/.default"]
24
+
25
+ # Transient failures: throttling and gateway/backend hiccups. Graph produces
26
+ # these regularly during long-running bulk exports, so they must be retried
27
+ # rather than silently skipped.
28
+ RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
29
+ RETRY_BASE_DELAY = 2.0
30
+ RETRY_MAX_DELAY = 60.0
31
+
32
+ # Page size for the lightweight message index (no message bodies involved).
33
+ INDEX_PAGE_SIZE = 500
34
+
35
+
36
+ def _parse_graph_datetime(value: str | None) -> datetime | None:
37
+ """Parse a Graph `receivedDateTime` like `2024-01-01T12:00:00Z`.
38
+
39
+ `datetime.fromisoformat` accepts the trailing `Z` on Python 3.11+, which is
40
+ the project's baseline.
41
+ """
42
+ if not value:
43
+ return None
44
+ return datetime.fromisoformat(value)
45
+
46
+
47
+ def _backoff_delay(attempt: int) -> float:
48
+ """Exponentially growing delay (in seconds) for retry number `attempt`."""
49
+ return min(RETRY_BASE_DELAY * 2**attempt, RETRY_MAX_DELAY)
50
+
51
+
52
+ def _retry_delay(resp: httpx.Response, attempt: int) -> float:
53
+ """Delay before retrying `resp`, honouring a numeric Retry-After header."""
54
+ retry_after = resp.headers.get("Retry-After")
55
+ if retry_after:
56
+ try:
57
+ return min(max(float(retry_after), 0.0), RETRY_MAX_DELAY)
58
+ except ValueError:
59
+ # Retry-After may also be an HTTP date; fall back to plain backoff.
60
+ pass
61
+ return _backoff_delay(attempt)
62
+
63
+
64
+ class MSGraphClient:
65
+ """MailboxClient implementation using Microsoft Graph API.
66
+
67
+ Authenticates via MSAL client credentials flow (service principal),
68
+ suitable for unattended backup of Microsoft 365 mailboxes.
69
+ """
70
+
71
+ def __init__(self, job: conf.JobConfig):
72
+ self.job = job
73
+ self.job_name = job.name
74
+ self.delete_after_export = job.delete_after_export
75
+ self.exchange_journal = job.exchange_journal
76
+ self.max_retries = max(job.max_retries, 0)
77
+
78
+ authority = f"https://login.microsoftonline.com/{job.tenant_id}"
79
+ self._msal_app: msal.ConfidentialClientApplication = msal.ConfidentialClientApplication(
80
+ client_id=job.client_id,
81
+ authority=authority,
82
+ client_credential=job.client_secret,
83
+ )
84
+ token = self._acquire_token()
85
+
86
+ self._http: httpx.Client = httpx.Client(
87
+ headers={
88
+ "Authorization": f"Bearer {token}",
89
+ "Accept": "application/json",
90
+ },
91
+ timeout=60.0,
92
+ )
93
+ self._user = job.username
94
+
95
+ self._folder_map: dict[str, str] = {}
96
+ self._build_folder_map()
97
+
98
+ def _acquire_token(self) -> str:
99
+ result = self._msal_app.acquire_token_for_client(scopes=GRAPH_SCOPE)
100
+ if not result or "access_token" not in result:
101
+ error = result.get("error_description", "unknown error") if result else "no result"
102
+ raise RuntimeError(f"MSAL authentication failed: {error}")
103
+ return result["access_token"]
104
+
105
+ def _refresh_auth(self) -> None:
106
+ """Refresh the access token (MSAL handles caching internally)."""
107
+ token = self._acquire_token()
108
+ self._http.headers["Authorization"] = f"Bearer {token}"
109
+
110
+ def close(self) -> None:
111
+ """Close the HTTP client."""
112
+ self._http.close()
113
+
114
+ def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
115
+ """Send an HTTP request, refreshing the token on 401 and retrying transient errors.
116
+
117
+ Connection/timeout errors and the status codes in RETRY_STATUS are retried
118
+ with exponential backoff, up to `max_retries` times. A 401 triggers a single
119
+ token refresh which does not count against the retry budget.
120
+ """
121
+ attempt = 0
122
+ refreshed = False
123
+ while True:
124
+ try:
125
+ resp = self._http.request(method, url, **kwargs)
126
+ except httpx.TransportError as exc:
127
+ if attempt >= self.max_retries:
128
+ log.error("%s: giving up after %s attempt(s): %s", url, attempt + 1, exc)
129
+ raise
130
+ delay = _backoff_delay(attempt)
131
+ attempt += 1
132
+ log.warning(
133
+ "%s: %s, retrying in %.0fs (attempt %s/%s)",
134
+ url,
135
+ exc,
136
+ delay,
137
+ attempt,
138
+ self.max_retries,
139
+ )
140
+ time.sleep(delay)
141
+ continue
142
+
143
+ if resp.status_code == 401 and not refreshed:
144
+ log.debug("Token expired, refreshing")
145
+ refreshed = True
146
+ self._refresh_auth()
147
+ continue
148
+
149
+ if resp.status_code in RETRY_STATUS and attempt < self.max_retries:
150
+ delay = _retry_delay(resp, attempt)
151
+ attempt += 1
152
+ log.warning(
153
+ "%s: HTTP %s, retrying in %.0fs (attempt %s/%s)",
154
+ url,
155
+ resp.status_code,
156
+ delay,
157
+ attempt,
158
+ self.max_retries,
159
+ )
160
+ time.sleep(delay)
161
+ continue
162
+
163
+ return resp
164
+
165
+ def _paginate(
166
+ self,
167
+ url: str,
168
+ params: dict[str, str] | None = None,
169
+ ) -> collections.abc.Generator[dict[str, Any], None, None]:
170
+ """Yield items from a paginated Graph API response."""
171
+ current_params = params.copy() if params else {}
172
+ base_url = url
173
+
174
+ while True:
175
+ resp = self._request("GET", base_url, params=current_params)
176
+ resp.raise_for_status()
177
+ data = resp.json()
178
+
179
+ yield from data.get("value", [])
180
+
181
+ next_link = data.get("@odata.nextLink")
182
+ if not next_link:
183
+ break
184
+
185
+ parsed = urllib.parse.urlparse(next_link)
186
+ query = urllib.parse.parse_qs(parsed.query)
187
+ skip_token = query.get("$skiptoken") or query.get("$skip")
188
+ if skip_token:
189
+ if "$skiptoken" in query:
190
+ current_params["$skiptoken"] = skip_token[0]
191
+ elif "$skip" in query:
192
+ current_params["$skip"] = skip_token[0]
193
+ else:
194
+ base_url = next_link
195
+ current_params = {}
196
+
197
+ def _build_folder_map(self) -> None:
198
+ """Recursively build a mapping of folder display paths to Graph IDs."""
199
+ self._folder_map.clear()
200
+ self._enumerate_folders(
201
+ f"{GRAPH_BASE_URL}/users/{self._user}/mailFolders",
202
+ prefix="",
203
+ )
204
+
205
+ def _enumerate_folders(self, url: str, prefix: str) -> None:
206
+ params = {"$select": "id,displayName,childFolderCount", "$top": "100"}
207
+ for folder in self._paginate(url, params):
208
+ name = folder.get("displayName", "")
209
+ path = f"{prefix}/{name}" if prefix else name
210
+ folder_id = folder["id"]
211
+ self._folder_map[path] = folder_id
212
+
213
+ if folder.get("childFolderCount", 0) > 0:
214
+ child_url = (
215
+ f"{GRAPH_BASE_URL}/users/{self._user}/mailFolders/{folder_id}/childFolders"
216
+ )
217
+ self._enumerate_folders(child_url, prefix=path)
218
+
219
+ def _resolve_folder(self, folder_name: str) -> str:
220
+ """Resolve a folder display name or path to its Graph ID."""
221
+ if folder_name in self._folder_map:
222
+ return self._folder_map[folder_name]
223
+ lower = folder_name.casefold()
224
+ for path, fid in self._folder_map.items():
225
+ if path.casefold() == lower:
226
+ return fid
227
+ raise RuntimeError(f"Folder not found: {folder_name}")
228
+
229
+ def _download_mime(self, msg_id: str) -> bytes:
230
+ """Download a message as RFC 822 MIME content."""
231
+ url = f"{GRAPH_BASE_URL}/users/{self._user}/messages/{msg_id}/$value"
232
+ resp = self._request("GET", url)
233
+ resp.raise_for_status()
234
+ return resp.content
235
+
236
+ def fetch_message(self, msg_id: str, folder_name: str = "") -> bytes:
237
+ """Fetch a single message by its Graph id (folder is irrelevant here)."""
238
+ return self._download_mime(msg_id)
239
+
240
+ def _graph_delete(self, msg_id: str) -> None:
241
+ url = f"{GRAPH_BASE_URL}/users/{self._user}/messages/{msg_id}"
242
+ resp = self._request("DELETE", url)
243
+ resp.raise_for_status()
244
+
245
+ def _iter_messages(
246
+ self,
247
+ folder_name: str,
248
+ folder_id: str,
249
+ since: datetime | None = None,
250
+ select: str = "id,receivedDateTime",
251
+ page_size: int = 50,
252
+ ) -> collections.abc.Generator[dict[str, Any], None, None]:
253
+ """List messages in a folder, optionally filtered by date."""
254
+ url = f"{GRAPH_BASE_URL}/users/{self._user}/mailFolders/{folder_id}/messages"
255
+ params: dict[str, str] = {
256
+ "$top": str(page_size),
257
+ "$select": select,
258
+ "$orderby": "receivedDateTime asc",
259
+ }
260
+ if since:
261
+ since_str = since.strftime("%Y-%m-%dT%H:%M:%SZ")
262
+ params["$filter"] = f"receivedDateTime ge {since_str}"
263
+
264
+ yield from self._paginate(url, params)
265
+
266
+ def message_index(
267
+ self,
268
+ folder_name: str,
269
+ since: datetime | None = None,
270
+ ) -> collections.abc.Generator[base.MessageRef, None, None]:
271
+ """List the folder's messages by Message-ID only, without downloading bodies."""
272
+ folder_id = self._resolve_folder(folder_name)
273
+ for item in self._iter_messages(
274
+ folder_name,
275
+ folder_id,
276
+ since=since,
277
+ select="id,internetMessageId,receivedDateTime",
278
+ page_size=INDEX_PAGE_SIZE,
279
+ ):
280
+ yield base.MessageRef(
281
+ msg_id=item["id"],
282
+ message_id=item.get("internetMessageId") or "",
283
+ date=_parse_graph_datetime(item.get("receivedDateTime")),
284
+ )
285
+
286
+ def folders(self) -> collections.abc.Generator[str, None, None]:
287
+ ignore_names = self.job.ignore_folder_names
288
+ for path in self._folder_map:
289
+ if any(re.match(pattern, path) for pattern in ignore_names):
290
+ continue
291
+ yield path
292
+
293
+ def folder_backup(
294
+ self,
295
+ folder_name: str,
296
+ store: cas.ContentAddressedStorage,
297
+ since: datetime | None = None,
298
+ callback: collections.abc.Callable[[mailutils.MessageMetadata], None] | None = None,
299
+ ) -> base.BackupResult:
300
+ folder_id = self._resolve_folder(folder_name)
301
+ messages = list(self._iter_messages(folder_name, folder_id, since))
302
+ log.info("%s::%s: found %s messages", self.job_name, folder_name, len(messages))
303
+
304
+ result = base.BackupResult(total=len(messages))
305
+ for idx, msg_info in enumerate(messages, 1):
306
+ msg_id = msg_info["id"]
307
+ log_ctx = f"{self.job_name}::{folder_name}[{idx}]"
308
+ try:
309
+ msg = self._download_mime(msg_id)
310
+ except Exception as exc:
311
+ log.error("%s: download failed: %s", log_ctx, exc)
312
+ result.failed += 1
313
+ continue
314
+
315
+ if self.exchange_journal:
316
+ unwrapped = mailutils.unwrap_exchange_journal_item(msg)
317
+ if unwrapped is None:
318
+ log.warning("%s: not a journal item, skipping", log_ctx)
319
+ continue
320
+ msg = unwrapped
321
+
322
+ store_id = base.store_message(
323
+ store,
324
+ msg,
325
+ result=result,
326
+ log_ctx=log_ctx,
327
+ callback=callback,
328
+ metadata_fn=lambda sid, m=msg: mailutils.metadata(
329
+ m, mailbox=self.job_name, folder=folder_name, store_id=sid
330
+ ),
331
+ )
332
+ if store_id is None:
333
+ continue
334
+
335
+ if result.stored % 100 == 0:
336
+ log.info(
337
+ "%s::%s: %s/%s messages processed",
338
+ self.job_name,
339
+ folder_name,
340
+ result.stored,
341
+ len(messages),
342
+ )
343
+
344
+ if self.delete_after_export:
345
+ try:
346
+ self._graph_delete(msg_id)
347
+ except Exception as exc:
348
+ log.error("%s: delete failed: %s", log_ctx, exc)
349
+
350
+ return result
351
+
352
+ def full_backup(
353
+ self,
354
+ store: cas.ContentAddressedStorage,
355
+ since: datetime | None = None,
356
+ callback: collections.abc.Callable[[mailutils.MessageMetadata], None] | None = None,
357
+ ) -> None:
358
+ base.run_full_backup(self, store, since=since, callback=callback)
359
+
360
+ def get_messages(
361
+ self,
362
+ folder_name: str,
363
+ since: datetime | None = None,
364
+ ) -> collections.abc.Generator[tuple[Any, datetime | None, bytes], None, None]:
365
+ folder_id = self._resolve_folder(folder_name)
366
+ for msg_info in self._iter_messages(folder_name, folder_id, since):
367
+ msg_id = msg_info["id"]
368
+ msg_date = _parse_graph_datetime(msg_info.get("receivedDateTime"))
369
+ try:
370
+ msg = self._download_mime(msg_id)
371
+ except Exception as exc:
372
+ log.error(
373
+ "%s::%s: download failed for %s: %s",
374
+ self.job_name,
375
+ folder_name,
376
+ msg_id[:20],
377
+ exc,
378
+ )
379
+ continue
380
+ log.info("%s::%s: fetched %s", self.job_name, folder_name, msg_id[:20])
381
+ yield msg_id, msg_date, msg
382
+
383
+ def save_message(
384
+ self,
385
+ msg: bytes,
386
+ folder_name: str,
387
+ date: datetime | None = None,
388
+ ) -> None:
389
+ folder_id = self._resolve_folder(folder_name)
390
+ url = f"{GRAPH_BASE_URL}/users/{self._user}/mailFolders/{folder_id}/messages"
391
+ resp = self._request("POST", url, content=msg, headers={"Content-Type": "text/plain"})
392
+ resp.raise_for_status()
393
+
394
+ def move_message(self, msg_id: Any, folder_name: str) -> None:
395
+ folder_id = self._resolve_folder(folder_name)
396
+ url = f"{GRAPH_BASE_URL}/users/{self._user}/messages/{msg_id}/move"
397
+ resp = self._request("POST", url, json={"destinationId": folder_id})
398
+ resp.raise_for_status()
399
+
400
+ def delete_message(self, msg_id: Any, expunge: bool = False) -> None:
401
+ self._graph_delete(msg_id)