extendvcc-cli 0.1.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.
extendvcc/client.py ADDED
@@ -0,0 +1,491 @@
1
+ """HTTP client for Extend's private web API.
2
+
3
+ This module intentionally treats anti-bot, WAF, and verification responses as
4
+ account-risk events. A detected risk writes the disabled-state file and all
5
+ subsequent network paths fail closed before requesting or refreshing tokens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+ from urllib.parse import urlencode, urlparse
18
+
19
+ import impit
20
+
21
+ BASE_URL = "https://api.paywithextend.com"
22
+ VAULT_BASE_URL = "https://v.paywithextend.com"
23
+
24
+ RATE_LIMIT_REMAINING_HEADER = "x-rate-limit-remaining"
25
+ RATE_LIMIT_BACKOFF_SECONDS = 1.0
26
+ RATE_LIMIT_LOW_WATERMARK = 10
27
+ RATE_LIMIT_MAX_BACKOFF_SECONDS = 30.0
28
+
29
+ USER_AGENT = (
30
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
31
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
32
+ "Chrome/125.0.0.0 Safari/537.36"
33
+ )
34
+
35
+ EXTEND_BRAND = os.environ.get("EXTENDVCC_BRAND_ID", "br_2F0trP1UmE59x1ZkNIAqsg")
36
+
37
+ EXTEND_HEADERS = {
38
+ "Accept": "application/vnd.paywithextend.v2021-03-12+json",
39
+ "x-extend-app-id": "app.paywithextend.com",
40
+ "x-extend-brand": EXTEND_BRAND,
41
+ "x-extend-platform": "web",
42
+ "x-extend-platform-version": USER_AGENT,
43
+ "User-Agent": USER_AGENT,
44
+ }
45
+
46
+ _JSON_CONTENT_TYPE = "application/json"
47
+ # PAN-shaped digit runs (13-19) and obvious secret key names — scrubbed from any
48
+ # error payload so a card number / CVC echoed in a 4xx body can never reach a log.
49
+ _PAN_RUN_RE = re.compile(r"\d(?:[ -]?\d){12,18}")
50
+ _SENSITIVE_PAYLOAD_KEYS = ("cardnumber", "cvc", "cvv", "cvn", "securitycode", "pan", "vcn")
51
+
52
+
53
+ def _scrub_payload(value: Any) -> Any:
54
+ """Recursively mask PAN-shaped runs and sensitive-keyed values in an error payload."""
55
+ if isinstance(value, dict):
56
+ scrubbed: dict[Any, Any] = {}
57
+ for key, item in value.items():
58
+ compact = str(key).replace("_", "").replace("-", "").lower()
59
+ if any(marker in compact for marker in _SENSITIVE_PAYLOAD_KEYS):
60
+ scrubbed[key] = "[redacted]"
61
+ else:
62
+ scrubbed[key] = _scrub_payload(item)
63
+ return scrubbed
64
+ if isinstance(value, list):
65
+ return [_scrub_payload(item) for item in value]
66
+ if isinstance(value, str):
67
+ return _PAN_RUN_RE.sub("[redacted]", value)
68
+ return value
69
+
70
+
71
+ _HTML_MARKERS = (
72
+ "<!doctype html",
73
+ "<html",
74
+ "cloudflare",
75
+ "cf-chl",
76
+ "attention required",
77
+ "just a moment",
78
+ "checking your browser",
79
+ )
80
+ _VERIFICATION_MARKERS = (
81
+ "email_otp",
82
+ "otp required",
83
+ "verification required",
84
+ "verify your",
85
+ "verify identity",
86
+ "confirm your identity",
87
+ "multi-factor",
88
+ "mfa",
89
+ "captcha",
90
+ "recaptcha",
91
+ "challenge required",
92
+ )
93
+
94
+
95
+ def _disabled_state_path() -> Path:
96
+ from extendvcc._paths import state_dir
97
+
98
+ return state_dir() / "paywithextend_disabled.json"
99
+
100
+
101
+ class PayWithExtendError(RuntimeError):
102
+ """Base PayWithExtend client error."""
103
+
104
+
105
+ class PayWithExtendDisabled(PayWithExtendError):
106
+ """Raised when automation is disabled by the account-risk kill switch."""
107
+
108
+
109
+ class AccountRiskDetected(PayWithExtendDisabled):
110
+ """Raised when a response trips the account-risk kill switch."""
111
+
112
+
113
+ class PayWithExtendAPIError(PayWithExtendError):
114
+ """Raised when Extend returns a JSON error response."""
115
+
116
+ def __init__(
117
+ self,
118
+ message: str,
119
+ *,
120
+ status_code: int,
121
+ path: str,
122
+ payload: Any = None,
123
+ ) -> None:
124
+ super().__init__(message)
125
+ self.status_code = status_code
126
+ self.path = path
127
+ self.payload = payload
128
+
129
+
130
+ class PayWithExtendNonJSONError(PayWithExtendAPIError):
131
+ """Raised when Extend returns a response that cannot be decoded as JSON."""
132
+
133
+
134
+ def disabled_status(disabled_path: Path | None = None) -> dict[str, Any] | None:
135
+ path = disabled_path or _disabled_state_path()
136
+ if not path.exists():
137
+ return None
138
+ try:
139
+ loaded = json.loads(path.read_text())
140
+ except (OSError, json.JSONDecodeError):
141
+ return {
142
+ "disabled": True,
143
+ "reason": "disabled-state file exists but could not be decoded",
144
+ "path": str(path),
145
+ }
146
+ if isinstance(loaded, dict):
147
+ return loaded
148
+ return {
149
+ "disabled": True,
150
+ "reason": "disabled-state file did not contain an object",
151
+ "path": str(path),
152
+ }
153
+
154
+
155
+ def assert_not_disabled(disabled_path: Path | None = None) -> None:
156
+ status = disabled_status(disabled_path)
157
+ if status is None:
158
+ return
159
+ reason = status.get("reason", "PayWithExtend automation is disabled")
160
+ raise PayWithExtendDisabled(str(reason))
161
+
162
+
163
+ def disable_paywithextend(
164
+ reason: str,
165
+ *,
166
+ disabled_path: Path | None = None,
167
+ now: Callable[[], datetime] | None = None,
168
+ ) -> dict[str, Any]:
169
+ path = disabled_path or _disabled_state_path()
170
+ timestamp = (now or _utc_now)().isoformat().replace("+00:00", "Z")
171
+ payload = {
172
+ "disabled": True,
173
+ "reason": reason,
174
+ "timestamp": timestamp,
175
+ }
176
+ path.parent.mkdir(parents=True, exist_ok=True)
177
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
178
+ tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
179
+ tmp_path.replace(path)
180
+ return payload
181
+
182
+
183
+ def clear_disabled(*, manual: bool = False, disabled_path: Path | None = None) -> bool:
184
+ if not manual:
185
+ raise ValueError("clear_disabled requires manual=True")
186
+ path = disabled_path or _disabled_state_path()
187
+ if not path.exists():
188
+ return False
189
+ path.unlink()
190
+ return True
191
+
192
+
193
+ def _utc_now() -> datetime:
194
+ return datetime.now(timezone.utc)
195
+
196
+
197
+ def _ensure_valid_token() -> str:
198
+ assert_not_disabled()
199
+ from .auth import ensure_valid_token
200
+
201
+ return ensure_valid_token()
202
+
203
+
204
+ def _refresh_tokens() -> Any:
205
+ assert_not_disabled()
206
+ from .auth import refresh_tokens
207
+
208
+ return refresh_tokens()
209
+
210
+
211
+ def _headers_get(headers: Any, name: str, default: str | None = None) -> str | None:
212
+ if hasattr(headers, "get"):
213
+ return headers.get(name, default)
214
+ return default
215
+
216
+
217
+ def _response_text(response: Any) -> str:
218
+ text = getattr(response, "text", None)
219
+ if isinstance(text, str):
220
+ return text
221
+ content = getattr(response, "content", b"")
222
+ if isinstance(content, bytes):
223
+ return content.decode("utf-8", errors="replace")
224
+ return str(content)
225
+
226
+
227
+ def _json_or_none(response: Any) -> Any:
228
+ try:
229
+ return response.json()
230
+ except (TypeError, ValueError):
231
+ return None
232
+
233
+
234
+ def _contains_marker(value: Any, markers: tuple[str, ...]) -> bool:
235
+ if isinstance(value, dict):
236
+ return any(_contains_marker(item, markers) for item in value.values())
237
+ if isinstance(value, list):
238
+ return any(_contains_marker(item, markers) for item in value)
239
+ if isinstance(value, str):
240
+ lowered = value.lower()
241
+ return any(marker in lowered for marker in markers)
242
+ return False
243
+
244
+
245
+ def _is_html_response(response: Any) -> bool:
246
+ content_type = (_headers_get(response.headers, "content-type", "") or "").lower()
247
+ if "text/html" in content_type:
248
+ return True
249
+ lowered = _response_text(response).lstrip().lower()
250
+ return any(marker in lowered for marker in _HTML_MARKERS)
251
+
252
+
253
+ def _risk_reason(response: Any, path: str) -> str | None:
254
+ status_code = int(getattr(response, "status_code", 0))
255
+ if status_code == 403:
256
+ return f"403 response from Extend for {path}"
257
+ if _is_html_response(response):
258
+ return f"HTML/WAF challenge response from Extend for {path}"
259
+ payload = _json_or_none(response)
260
+ if payload is not None and _contains_marker(payload, _VERIFICATION_MARKERS):
261
+ return f"Unexpected verification prompt from Extend for {path}"
262
+ return None
263
+
264
+
265
+ def inspect_account_risk(
266
+ response: Any,
267
+ path: str,
268
+ *,
269
+ disable_writer: Callable[[str], Any] = disable_paywithextend,
270
+ ) -> None:
271
+ reason = _risk_reason(response, path)
272
+ if reason is None:
273
+ return
274
+ disable_writer(reason)
275
+ raise AccountRiskDetected(reason)
276
+
277
+
278
+ class PayWithExtendClient:
279
+ def __init__(
280
+ self,
281
+ *,
282
+ http_client: Any | None = None,
283
+ base_url: str = BASE_URL,
284
+ token_getter: Callable[[], str] = _ensure_valid_token,
285
+ token_refresher: Callable[[], Any] = _refresh_tokens,
286
+ disabled_checker: Callable[[], None] = assert_not_disabled,
287
+ disable_writer: Callable[[str], Any] = disable_paywithextend,
288
+ sleeper: Callable[[float], None] = time.sleep,
289
+ rate_limit_low_watermark: int = RATE_LIMIT_LOW_WATERMARK,
290
+ rate_limit_backoff_seconds: float = RATE_LIMIT_BACKOFF_SECONDS,
291
+ rate_limit_max_backoff_seconds: float = RATE_LIMIT_MAX_BACKOFF_SECONDS,
292
+ ) -> None:
293
+ self._client = http_client or impit.Client(browser="chrome")
294
+ self._base_url = base_url.rstrip("/")
295
+ self._token_getter = token_getter
296
+ self._token_refresher = token_refresher
297
+ self._disabled_checker = disabled_checker
298
+ self._disable_writer = disable_writer
299
+ self._sleeper = sleeper
300
+ self._rate_limit_low_watermark = rate_limit_low_watermark
301
+ self._rate_limit_backoff_seconds = rate_limit_backoff_seconds
302
+ self._rate_limit_max_backoff_seconds = rate_limit_max_backoff_seconds
303
+ self._rate_limit_hits = 0
304
+
305
+ def get(
306
+ self,
307
+ path: str,
308
+ *,
309
+ params: dict[str, Any] | None = None,
310
+ headers: dict[str, str] | None = None,
311
+ timeout: float = 30,
312
+ ) -> Any:
313
+ return self.request("GET", path, params=params, headers=headers, timeout=timeout)
314
+
315
+ def post(
316
+ self,
317
+ path: str,
318
+ *,
319
+ json_body: Any | None = None,
320
+ headers: dict[str, str] | None = None,
321
+ timeout: float = 30,
322
+ ) -> Any:
323
+ return self.request("POST", path, json_body=json_body, headers=headers, timeout=timeout)
324
+
325
+ def put(
326
+ self,
327
+ path: str,
328
+ *,
329
+ json_body: Any | None = None,
330
+ headers: dict[str, str] | None = None,
331
+ timeout: float = 30,
332
+ ) -> Any:
333
+ return self.request("PUT", path, json_body=json_body, headers=headers, timeout=timeout)
334
+
335
+ def patch(
336
+ self,
337
+ path: str,
338
+ *,
339
+ json_body: Any | None = None,
340
+ headers: dict[str, str] | None = None,
341
+ timeout: float = 30,
342
+ ) -> Any:
343
+ return self.request("PATCH", path, json_body=json_body, headers=headers, timeout=timeout)
344
+
345
+ def delete(
346
+ self,
347
+ path: str,
348
+ *,
349
+ headers: dict[str, str] | None = None,
350
+ timeout: float = 30,
351
+ ) -> Any:
352
+ return self.request("DELETE", path, headers=headers, timeout=timeout)
353
+
354
+ def request(
355
+ self,
356
+ method: str,
357
+ path: str,
358
+ *,
359
+ params: dict[str, Any] | None = None,
360
+ json_body: Any | None = None,
361
+ content: bytes | str | None = None,
362
+ headers: dict[str, str] | None = None,
363
+ timeout: float = 30,
364
+ ) -> Any:
365
+ response = self._request_once(
366
+ method,
367
+ path,
368
+ params=params,
369
+ json_body=json_body,
370
+ content=content,
371
+ headers=headers,
372
+ timeout=timeout,
373
+ )
374
+ # A routine 401 (expired token) must get one refresh-and-retry BEFORE the
375
+ # account-risk inspection runs — otherwise a 401 body that happens to
376
+ # contain a verification phrase would permanently trip the kill switch
377
+ # instead of simply refreshing. Only the final response is inspected.
378
+ if int(getattr(response, "status_code", 0)) == 401:
379
+ self._disabled_checker()
380
+ self._token_refresher()
381
+ response = self._request_once(
382
+ method,
383
+ path,
384
+ params=params,
385
+ json_body=json_body,
386
+ content=content,
387
+ headers=headers,
388
+ timeout=timeout,
389
+ )
390
+ self._inspect_response(response, path)
391
+ return self._decode_response(response, path)
392
+
393
+ def _request_once(
394
+ self,
395
+ method: str,
396
+ path: str,
397
+ *,
398
+ params: dict[str, Any] | None,
399
+ json_body: Any | None,
400
+ content: bytes | str | None,
401
+ headers: dict[str, str] | None,
402
+ timeout: float,
403
+ ) -> Any:
404
+ self._disabled_checker()
405
+ request_headers = self._auth_headers(headers)
406
+ request_content = content
407
+ if json_body is not None:
408
+ request_headers.setdefault("Content-Type", _JSON_CONTENT_TYPE)
409
+ request_content = json.dumps(json_body, separators=(",", ":")).encode()
410
+ url = self._url(path, params)
411
+ return self._client.request(
412
+ method,
413
+ url,
414
+ headers=request_headers,
415
+ content=request_content,
416
+ timeout=timeout,
417
+ )
418
+
419
+ def _auth_headers(self, extra_headers: dict[str, str] | None) -> dict[str, str]:
420
+ self._disabled_checker()
421
+ token = self._token_getter()
422
+ headers = {**EXTEND_HEADERS, "Authorization": f"Bearer {token}"}
423
+ if extra_headers:
424
+ headers.update(extra_headers)
425
+ # Re-assert the freshly-minted token so caller headers cannot override it.
426
+ headers["Authorization"] = f"Bearer {token}"
427
+ return headers
428
+
429
+ def _url(self, path: str, params: dict[str, Any] | None) -> str:
430
+ if path.startswith("http://") or path.startswith("https://"):
431
+ parsed_url = urlparse(path)
432
+ parsed_base = urlparse(self._base_url)
433
+ if parsed_url.scheme != parsed_base.scheme or parsed_url.netloc != parsed_base.netloc:
434
+ raise ValueError("PayWithExtendClient refuses absolute URLs outside the Extend API host")
435
+ url = path
436
+ else:
437
+ url = f"{self._base_url}/{path.lstrip('/')}"
438
+ if not params:
439
+ return url
440
+ separator = "&" if "?" in url else "?"
441
+ return f"{url}{separator}{urlencode(params, doseq=True)}"
442
+
443
+ def _inspect_response(self, response: Any, path: str) -> None:
444
+ inspect_account_risk(response, path, disable_writer=self._disable_writer)
445
+ self._backoff_for_rate_limit(response)
446
+
447
+ def _backoff_for_rate_limit(self, response: Any) -> None:
448
+ remaining_header = _headers_get(response.headers, RATE_LIMIT_REMAINING_HEADER)
449
+ remaining: int | None = None
450
+ if remaining_header is not None:
451
+ try:
452
+ remaining = int(remaining_header)
453
+ except ValueError:
454
+ remaining = None
455
+
456
+ status_code = int(getattr(response, "status_code", 0))
457
+ should_backoff = status_code == 429 or (remaining is not None and remaining <= self._rate_limit_low_watermark)
458
+ if not should_backoff:
459
+ self._rate_limit_hits = 0
460
+ return
461
+
462
+ self._rate_limit_hits += 1
463
+ delay = min(
464
+ self._rate_limit_backoff_seconds * (2 ** (self._rate_limit_hits - 1)),
465
+ self._rate_limit_max_backoff_seconds,
466
+ )
467
+ self._sleeper(delay)
468
+
469
+ def _decode_response(self, response: Any, path: str) -> Any:
470
+ status_code = int(getattr(response, "status_code", 0))
471
+ try:
472
+ payload = response.json()
473
+ except ValueError as exc:
474
+ raise PayWithExtendNonJSONError(
475
+ f"Extend API returned a non-JSON response: {status_code} {path}",
476
+ status_code=status_code,
477
+ path=path,
478
+ ) from exc
479
+ if status_code >= 400:
480
+ raise PayWithExtendAPIError(
481
+ f"Extend API request failed: {status_code} {path}",
482
+ status_code=status_code,
483
+ path=path,
484
+ payload=_scrub_payload(payload),
485
+ )
486
+ return payload
487
+
488
+
489
+ def vault_client(**kwargs: Any) -> PayWithExtendClient:
490
+ """Return a PayWithExtendClient configured for the vault host (v.paywithextend.com)."""
491
+ return PayWithExtendClient(base_url=VAULT_BASE_URL, **kwargs)
extendvcc/imap_otp.py ADDED
@@ -0,0 +1,170 @@
1
+ """IMAP-based OTP retrieval for PayWithExtend Cognito EMAIL_OTP challenges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import email
7
+ import imaplib
8
+ import os
9
+ import re
10
+ import sys
11
+ import time
12
+ from collections.abc import Callable
13
+ from email.header import decode_header
14
+
15
+ DEFAULT_IMAP_SERVER = "imap.gmail.com"
16
+ DEFAULT_IMAP_PORT = 993
17
+
18
+ EXTEND_SENDER = "paywithextend.com"
19
+
20
+ MAX_WAIT_SECONDS = 60
21
+ POLL_INTERVAL_SECONDS = 2
22
+
23
+
24
+ def read_imap_credentials() -> tuple[str, str, str] | None:
25
+ """Read IMAP credentials from env vars. Returns (email, app_password, server) or None."""
26
+ imap_email = os.environ.get("EXTENDVCC_IMAP_USER", "")
27
+ imap_password = os.environ.get("EXTENDVCC_IMAP_PASSWORD", "")
28
+ if not imap_email or not imap_password:
29
+ return None
30
+ imap_server = os.environ.get("EXTENDVCC_IMAP_HOST", DEFAULT_IMAP_SERVER)
31
+ return imap_email, imap_password, imap_server
32
+
33
+
34
+ def extract_code(text: str) -> str | None:
35
+ """Extract a 6-digit verification code from email body text."""
36
+ clean = re.sub(r"<[^>]+>", " ", text)
37
+ clean = re.sub(r"&\w+;", " ", clean)
38
+ clean = re.sub(r"\s+", " ", clean)
39
+
40
+ patterns = [
41
+ r"verification\s+code[:\s]*(\d{6})",
42
+ r"Enter\s+this\s+verification\s+code[^:]*:\s*(\d{6})",
43
+ r"sign[- ]?in\s+code[:\s]*(\d{6})",
44
+ r"security\s+code[:\s]*(\d{6})",
45
+ r"one[- ]?time\s+(?:code|password)[:\s]*(\d{6})",
46
+ r"your\s+code\s+is[:\s]*(\d{6})",
47
+ r"code[:\s]+(\d{6})",
48
+ ]
49
+ for pattern in patterns:
50
+ match = re.search(pattern, clean, re.IGNORECASE)
51
+ if match:
52
+ return match.group(1)
53
+
54
+ # Fallback: bold standalone 6-digit number (the Extend email format)
55
+ bold_match = re.search(r"<b>\s*(\d{6})\s*</b>", text, re.IGNORECASE)
56
+ if bold_match:
57
+ return bold_match.group(1)
58
+
59
+ # Last resort: any standalone 6-digit number
60
+ fallback = re.search(r"\b(\d{6})\b", clean)
61
+ return fallback.group(1) if fallback else None
62
+
63
+
64
+ def _get_body(msg: email.message.Message) -> str:
65
+ """Extract text body from an email message."""
66
+ if msg.is_multipart():
67
+ html_body = ""
68
+ for part in msg.walk():
69
+ ct = part.get_content_type()
70
+ payload = part.get_payload(decode=True)
71
+ if payload is None:
72
+ continue
73
+ decoded = payload.decode(errors="replace")
74
+ if ct == "text/plain":
75
+ return decoded
76
+ if ct == "text/html" and not html_body:
77
+ html_body = decoded
78
+ return html_body
79
+ payload = msg.get_payload(decode=True)
80
+ return payload.decode(errors="replace") if payload else ""
81
+
82
+
83
+ def fetch_otp(
84
+ since_timestamp: float,
85
+ *,
86
+ max_wait: int = MAX_WAIT_SECONDS,
87
+ poll_interval: int = POLL_INTERVAL_SECONDS,
88
+ _credentials: tuple[str, str, str] | None = None,
89
+ ) -> str | None:
90
+ """Poll IMAP inbox for an Extend OTP email and return the 6-digit code."""
91
+ creds = _credentials or read_imap_credentials()
92
+ if creds is None:
93
+ return None
94
+
95
+ imap_email, imap_password, imap_server = creds
96
+ deadline = time.monotonic() + max_wait
97
+
98
+ conn = imaplib.IMAP4_SSL(imap_server, DEFAULT_IMAP_PORT)
99
+ try:
100
+ conn.login(imap_email, imap_password)
101
+ conn.select("INBOX")
102
+
103
+ since_date = time.strftime("%d-%b-%Y", time.localtime(since_timestamp - 86400))
104
+
105
+ while time.monotonic() < deadline:
106
+ conn.noop()
107
+ conn.select("INBOX")
108
+ _, data = conn.search(None, f'(SINCE {since_date} FROM "{EXTEND_SENDER}")')
109
+ msg_ids = data[0].split() if data[0] else []
110
+
111
+ for msg_id in reversed(msg_ids):
112
+ _, date_data = conn.fetch(msg_id, "(INTERNALDATE)")
113
+ internal_date = imaplib.Internaldate2tuple(date_data[0])
114
+ if internal_date is not None:
115
+ msg_epoch = calendar.timegm(internal_date)
116
+ if msg_epoch < since_timestamp - 5:
117
+ continue
118
+
119
+ _, msg_data = conn.fetch(msg_id, "(RFC822)")
120
+ raw = msg_data[0][1]
121
+ msg = email.message_from_bytes(raw)
122
+
123
+ subject_raw = decode_header(msg.get("Subject", ""))[0][0]
124
+ if isinstance(subject_raw, bytes):
125
+ subject_raw = subject_raw.decode(errors="replace")
126
+ subject = str(subject_raw).lower()
127
+
128
+ if "code" not in subject and "verification" not in subject:
129
+ continue
130
+
131
+ body = _get_body(msg)
132
+ code = extract_code(body)
133
+ if code:
134
+ conn.store(msg_id, "+FLAGS", "\\Seen")
135
+ return code
136
+
137
+ time.sleep(poll_interval)
138
+
139
+ return None
140
+ finally:
141
+ try:
142
+ conn.logout()
143
+ except Exception:
144
+ pass
145
+
146
+
147
+ def _prompt_stdin(prompt: str) -> str:
148
+ """Read a line from stdin after writing the prompt to stderr.
149
+
150
+ Keeps stdout clean: under ``--json`` only structured data may reach stdout,
151
+ so interactive prompt text must go to stderr.
152
+ """
153
+ print(prompt, end="", file=sys.stderr, flush=True)
154
+ return input()
155
+
156
+
157
+ def make_otp_callback() -> Callable[[str], str]:
158
+ """Return an OTP callback: IMAP auto-retrieval if configured, stdin prompt if not."""
159
+ creds = read_imap_credentials()
160
+ if creds is None:
161
+ return _prompt_stdin
162
+
163
+ def _imap_callback(_prompt: str) -> str:
164
+ since = time.time() - 300
165
+ code = fetch_otp(since, _credentials=creds)
166
+ if code is None:
167
+ return _prompt_stdin("IMAP retrieval timed out. Enter OTP manually: ")
168
+ return code
169
+
170
+ return _imap_callback