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 ADDED
@@ -0,0 +1,3 @@
1
+ """graphmail: extract Microsoft Graph / .eml emails into a local archive."""
2
+
3
+ __version__ = "1.0.0"
graphmail/auth.py ADDED
@@ -0,0 +1,411 @@
1
+ """Microsoft Graph OAuth: authorization-code flow with refresh-token renewal.
2
+
3
+ Interactive login defaults to system-browser loopback + PKCE (RFC 8252);
4
+ ``--manual`` is the paste-code fallback. A cached refresh token means cron
5
+ never needs a browser again.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import secrets
16
+ import sys
17
+ import threading
18
+ import time
19
+ import webbrowser
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
+ from pathlib import Path
24
+ from typing import Any
25
+ from urllib.parse import parse_qs, urlencode, urlparse
26
+
27
+ import requests
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ DEFAULT_SCOPE = ("Mail.Read", "User.Read", "offline_access")
32
+ # Overridable for tests against a mock token endpoint.
33
+ AUTHORITY = os.environ.get(
34
+ "GRAPHMAIL_AUTHORITY", "https://login.microsoftonline.com"
35
+ ).rstrip("/")
36
+ # Manual-paste redirect (register in Entra for --manual).
37
+ NATIVE_CLIENT_REDIRECT = f"{AUTHORITY}/common/oauth2/nativeclient"
38
+ # Entra ignores the port when the registered URI is bare `http://localhost`.
39
+ DEFAULT_AUTH_PORT = 8400
40
+ LOOPBACK_TIMEOUT = 120.0
41
+ # (connect, read) seconds: a stalled token endpoint must not hang a cron job.
42
+ REQUEST_TIMEOUT = (10.0, 60.0)
43
+
44
+
45
+ def loopback_redirect(port: int) -> str:
46
+ """Redirect URI for the loopback listener."""
47
+ return f"http://localhost:{port}/"
48
+
49
+
50
+ def generate_pkce() -> tuple[str, str]:
51
+ """Return ``(code_verifier, code_challenge)`` for S256 PKCE (RFC 7636)."""
52
+ verifier = secrets.token_urlsafe(64)
53
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
54
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
55
+ return verifier, challenge
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class OAuthConfig:
60
+ """Azure AD application credentials and token cache location."""
61
+
62
+ client_id: str
63
+ client_secret: str
64
+ tenant_id: str
65
+ scope: tuple[str, ...] = DEFAULT_SCOPE
66
+ token_path: Path = Path("token.json")
67
+ redirect_uri: str = NATIVE_CLIENT_REDIRECT
68
+
69
+ @property
70
+ def token_endpoint(self) -> str:
71
+ return f"{AUTHORITY}/{self.tenant_id}/oauth2/v2.0/token"
72
+
73
+ def authorization_url(
74
+ self,
75
+ *,
76
+ redirect_uri: str | None = None,
77
+ code_challenge: str | None = None,
78
+ state: str | None = None,
79
+ ) -> str:
80
+ params: dict[str, str] = {
81
+ "client_id": self.client_id,
82
+ "response_type": "code",
83
+ "redirect_uri": redirect_uri or self.redirect_uri,
84
+ "response_mode": "query",
85
+ "scope": " ".join(self.scope),
86
+ }
87
+ if code_challenge is not None:
88
+ params["code_challenge"] = code_challenge
89
+ params["code_challenge_method"] = "S256"
90
+ if state is not None:
91
+ params["state"] = state
92
+ return f"{AUTHORITY}/{self.tenant_id}/oauth2/v2.0/authorize?{urlencode(params)}"
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class AuthSession:
97
+ """An access token with its expiry and the refresh token that renewed it."""
98
+
99
+ access_token: str
100
+ expires_at: float
101
+ refresh_token: str
102
+
103
+
104
+ def load_token(config: OAuthConfig) -> dict[str, Any] | None:
105
+ """Read the cached token payload, or ``None`` if there is no cache yet."""
106
+ try:
107
+ payload: dict[str, Any] = json.loads(
108
+ config.token_path.read_text(encoding="utf-8")
109
+ )
110
+ return payload
111
+ except FileNotFoundError:
112
+ return None
113
+
114
+
115
+ def save_token(config: OAuthConfig, token_data: dict[str, Any]) -> None:
116
+ """Persist the token payload to disk."""
117
+ config.token_path.write_text(json.dumps(token_data), encoding="utf-8")
118
+
119
+
120
+ def _session_from_payload(
121
+ payload: dict[str, Any], fallback_refresh: str | None = None
122
+ ) -> AuthSession:
123
+ return AuthSession(
124
+ access_token=payload["access_token"],
125
+ expires_at=time.time() + int(payload["expires_in"]),
126
+ refresh_token=payload.get("refresh_token") or fallback_refresh or "",
127
+ )
128
+
129
+
130
+ def refresh_access_token(
131
+ config: OAuthConfig, refresh_token: str | None
132
+ ) -> AuthSession | None:
133
+ """Exchange a refresh token for a new access token; persist with expires_at."""
134
+ data: dict[str, str] = {
135
+ "client_id": config.client_id,
136
+ "grant_type": "refresh_token",
137
+ "refresh_token": refresh_token or "",
138
+ "scope": " ".join(config.scope),
139
+ }
140
+ if config.client_secret:
141
+ data["client_secret"] = config.client_secret
142
+ try:
143
+ response = requests.post(
144
+ config.token_endpoint, data=data, timeout=REQUEST_TIMEOUT
145
+ )
146
+ except requests.RequestException as exc:
147
+ logger.error("Token refresh failed (network): %s", exc)
148
+ return None
149
+ if response.status_code != 200:
150
+ logger.error("Token refresh failed. You may need to log in again.")
151
+ return None
152
+
153
+ session = _session_from_payload(response.json(), fallback_refresh=refresh_token)
154
+ save_token(
155
+ config,
156
+ {
157
+ "access_token": session.access_token,
158
+ "refresh_token": session.refresh_token,
159
+ "expires_at": session.expires_at,
160
+ },
161
+ )
162
+ return session
163
+
164
+
165
+ def _exchange_code(
166
+ config: OAuthConfig,
167
+ *,
168
+ auth_code: str,
169
+ redirect_uri: str,
170
+ code_verifier: str,
171
+ ) -> AuthSession | None:
172
+ data: dict[str, str] = {
173
+ "client_id": config.client_id,
174
+ "grant_type": "authorization_code",
175
+ "code": auth_code,
176
+ "redirect_uri": redirect_uri,
177
+ "scope": " ".join(config.scope),
178
+ "code_verifier": code_verifier,
179
+ }
180
+ if config.client_secret:
181
+ data["client_secret"] = config.client_secret
182
+ try:
183
+ response = requests.post(
184
+ config.token_endpoint, data=data, timeout=REQUEST_TIMEOUT
185
+ )
186
+ except requests.RequestException as exc:
187
+ logger.error("Authentication failed (network): %s", exc)
188
+ return None
189
+ if response.status_code != 200:
190
+ logger.error("Authentication failed. Check credentials.")
191
+ return None
192
+
193
+ session = _session_from_payload(response.json())
194
+ save_token(
195
+ config,
196
+ {
197
+ "access_token": session.access_token,
198
+ "refresh_token": session.refresh_token,
199
+ "expires_at": session.expires_at,
200
+ },
201
+ )
202
+ return session
203
+
204
+
205
+ def _default_open_url(url: str) -> None:
206
+ """Open a URL in the browser (looked up at call time, so tests can patch)."""
207
+ webbrowser.open(url)
208
+
209
+
210
+ _LOOPBACK_PAGE = b"""<!doctype html>
211
+ <html><head><title>graphmail</title></head>
212
+ <body style="font-family: sans-serif; margin: 3rem;">
213
+ <h1>Sign-in complete</h1>
214
+ <p>You can close this tab and return to the terminal.</p>
215
+ </body></html>
216
+ """
217
+
218
+
219
+ class _LoopbackResult:
220
+ """Shared state between the HTTP handler and the waiting authenticate()."""
221
+
222
+ def __init__(self, expected_state: str) -> None:
223
+ self.expected_state = expected_state
224
+ self.code: str | None = None
225
+ self.error: str | None = None
226
+ self.error_description: str | None = None
227
+ self.event = threading.Event()
228
+
229
+
230
+ def _make_handler(result: _LoopbackResult) -> type[BaseHTTPRequestHandler]:
231
+ class Handler(BaseHTTPRequestHandler):
232
+ def log_message(self, *_args: object) -> None:
233
+ # Keep the default stderr access log out of the CLI.
234
+ return
235
+
236
+ def do_GET(self) -> None: # noqa: N802 (http.server API)
237
+ query = parse_qs(urlparse(self.path).query)
238
+ error = query.get("error", [None])[0]
239
+ if error is not None:
240
+ result.error = error
241
+ result.error_description = query.get("error_description", [None])[0]
242
+ else:
243
+ result.code = query.get("code", [None])[0]
244
+ state = query.get("state", [None])[0]
245
+ if state != result.expected_state:
246
+ result.error = "state_mismatch"
247
+ result.code = None
248
+ self.send_response(200)
249
+ self.send_header("Content-Type", "text/html; charset=utf-8")
250
+ self.send_header("Content-Length", str(len(_LOOPBACK_PAGE)))
251
+ self.end_headers()
252
+ self.wfile.write(_LOOPBACK_PAGE)
253
+ result.event.set()
254
+
255
+ return Handler
256
+
257
+
258
+ def _interactive_code(
259
+ config: OAuthConfig,
260
+ *,
261
+ flow: str,
262
+ auth_port: int,
263
+ timeout: float,
264
+ open_url: Callable[[str], None],
265
+ prompt: Callable[[str], str],
266
+ ) -> tuple[str | None, str, str, str]:
267
+ """Run the interactive leg; return ``(code, redirect_uri, verifier, error)``."""
268
+ verifier, challenge = generate_pkce()
269
+ state = secrets.token_urlsafe(16)
270
+
271
+ if flow == "loopback":
272
+ redirect = loopback_redirect(auth_port)
273
+ auth_url = config.authorization_url(
274
+ redirect_uri=redirect, code_challenge=challenge, state=state
275
+ )
276
+ result = _LoopbackResult(state)
277
+ try:
278
+ server = ThreadingHTTPServer(
279
+ ("127.0.0.1", auth_port), _make_handler(result)
280
+ )
281
+ except OSError as exc:
282
+ logger.warning(
283
+ "Loopback port %d unavailable (%s); falling back to manual "
284
+ "code paste.",
285
+ auth_port,
286
+ exc,
287
+ )
288
+ flow = "manual"
289
+ else:
290
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
291
+ thread.start()
292
+ browser_failed = False
293
+ try:
294
+ logger.info("Login required: %s", auth_url)
295
+ open_url(auth_url)
296
+ if not result.event.wait(timeout):
297
+ logger.error(
298
+ "Login failed (no redirect received within %.0fs).",
299
+ timeout,
300
+ )
301
+ return None, redirect, verifier, "timeout"
302
+ except OSError as exc:
303
+ logger.warning(
304
+ "Could not open a browser (%s); falling back to manual "
305
+ "code paste.",
306
+ exc,
307
+ )
308
+ browser_failed = True
309
+ flow = "manual"
310
+ finally:
311
+ server.shutdown()
312
+ server.server_close()
313
+ thread.join(timeout=5)
314
+ if not browser_failed:
315
+ if result.error is not None:
316
+ detail = (
317
+ result.error
318
+ if result.error_description is None
319
+ else f"{result.error}: {result.error_description}"
320
+ )
321
+ logger.error("Login failed (%s).", detail)
322
+ return None, redirect, verifier, result.error
323
+ if result.code is None:
324
+ logger.error("Login failed (no authorization code).")
325
+ return None, redirect, verifier, "no_code"
326
+ return result.code, redirect, verifier, ""
327
+
328
+ # Manual / embedded-browser paste flow (also the bind-failure fallback).
329
+ redirect = NATIVE_CLIENT_REDIRECT
330
+ auth_url = config.authorization_url(
331
+ redirect_uri=redirect, code_challenge=challenge, state=state
332
+ )
333
+ logger.info("Login required: %s", auth_url)
334
+ try:
335
+ open_url(auth_url)
336
+ except OSError as exc:
337
+ logger.warning(
338
+ "Could not open a browser (%s); open the URL above manually "
339
+ "if needed.",
340
+ exc,
341
+ )
342
+ code = prompt("Enter the authorization code from the browser: ").strip()
343
+ if not code:
344
+ return None, redirect, verifier, "empty_code"
345
+ return code, redirect, verifier, ""
346
+
347
+
348
+ def authenticate(
349
+ config: OAuthConfig,
350
+ *,
351
+ flow: str = "loopback",
352
+ auth_port: int = DEFAULT_AUTH_PORT,
353
+ timeout: float = LOOPBACK_TIMEOUT,
354
+ open_url: Callable[[str], None] | None = None,
355
+ prompt: Callable[[str], str] | None = None,
356
+ ) -> AuthSession | None:
357
+ """Return a valid session: cached, refreshed, or via interactive login.
358
+
359
+ ``flow="loopback"`` (default) catches the browser redirect on localhost;
360
+ ``flow="manual"`` asks the user to paste the code. Loopback falls back
361
+ to manual if the port is busy or the browser fails to open.
362
+ """
363
+ open_fn: Callable[[str], None] = open_url if open_url is not None else _default_open_url
364
+ prompt_fn: Callable[[str], str] = prompt if prompt is not None else input
365
+
366
+ stored = load_token(config)
367
+ if stored is not None:
368
+ expires_at = stored.get("expires_at", 0)
369
+ if time.time() < expires_at:
370
+ return AuthSession(
371
+ access_token=stored.get("access_token", ""),
372
+ expires_at=expires_at,
373
+ refresh_token=stored.get("refresh_token", ""),
374
+ )
375
+ logger.info("Access token expired. Refreshing...")
376
+ return refresh_access_token(config, stored.get("refresh_token"))
377
+
378
+ # First-time authentication.
379
+ if flow not in ("loopback", "manual"):
380
+ raise ValueError(f"unknown auth flow: {flow!r}")
381
+
382
+ # Cron without a token cache: fail fast instead of hanging for a browser.
383
+ # Tests inject open_url/prompt and bypass this TTY check.
384
+ if (
385
+ flow == "loopback"
386
+ and open_url is None
387
+ and prompt is None
388
+ and not sys.stdin.isatty()
389
+ ):
390
+ logger.error(
391
+ "No cached token and no interactive terminal. "
392
+ "Run `graphmail login` once to seed token.json."
393
+ )
394
+ return None
395
+
396
+ code, redirect, verifier, error = _interactive_code(
397
+ config,
398
+ flow=flow,
399
+ auth_port=auth_port,
400
+ timeout=timeout,
401
+ open_url=open_fn,
402
+ prompt=prompt_fn,
403
+ )
404
+ if code is None or error:
405
+ return None
406
+ return _exchange_code(
407
+ config,
408
+ auth_code=code,
409
+ redirect_uri=redirect,
410
+ code_verifier=verifier,
411
+ )