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 +0 -0
- mailvault/__main__.py +6 -0
- mailvault/backend/__init__.py +3 -0
- mailvault/backend/base.py +159 -0
- mailvault/backend/graph.py +401 -0
- mailvault/backend/imap.py +435 -0
- mailvault/backend/session.py +35 -0
- mailvault/cli/__init__.py +251 -0
- mailvault/cli/commands.py +145 -0
- mailvault/conf.py +187 -0
- mailvault/fixedpolicy.py +49 -0
- mailvault/importer.py +95 -0
- mailvault/jobs.py +308 -0
- mailvault/mailutils.py +245 -0
- mailvault/py.typed +0 -0
- mailvault/store/__init__.py +4 -0
- mailvault/store/cas.py +218 -0
- mailvault/store/metadb.py +449 -0
- mailvault/utils.py +13 -0
- mailvault-0.7.0.dist-info/METADATA +489 -0
- mailvault-0.7.0.dist-info/RECORD +24 -0
- mailvault-0.7.0.dist-info/WHEEL +4 -0
- mailvault-0.7.0.dist-info/entry_points.txt +3 -0
- mailvault-0.7.0.dist-info/licenses/LICENSE.md +675 -0
mailvault/__init__.py
ADDED
|
File without changes
|
mailvault/__main__.py
ADDED
|
@@ -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)
|