Coverage for src/notify.py: 100%

442 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +0000

1"""Send notifications when 2FA is required for iCloud authentication.""" 

2 

3import datetime 

4import smtplib 

5 

6import requests 

7 

8from src import config_parser, get_logger 

9from src.email_message import EmailMessage as Message 

10 

11LOGGER = get_logger() 

12 

13# Throttling period for notifications (24 hours) 

14THROTTLE_HOURS = 24 

15 

16 

17def _is_throttled(last_send) -> bool: 

18 """ 

19 Check if notification should be throttled based on last send time. 

20 

21 Args: 

22 last_send: The datetime when notification was last sent, or None 

23 

24 Returns: 

25 True if notification should be throttled, False otherwise 

26 """ 

27 if last_send is None: 

28 return False 

29 if not isinstance(last_send, datetime.datetime): 

30 return False 

31 return last_send > datetime.datetime.now() - datetime.timedelta(hours=THROTTLE_HOURS) 

32 

33 

34def _account_uses_security_key(username: str) -> bool: 

35 """True when Apple has demanded a FIDO2 assertion for this Apple ID. 

36 

37 Recorded by the web UI the first time Apple answers with an 

38 ``fsaChallenge``. Best-effort: an unreadable state file just means we 

39 fall back to the generic wording. 

40 """ 

41 try: 

42 from src import web_signals 

43 

44 return web_signals.get_auth_method(username) == "security_key" 

45 except Exception: # pragma: no cover - wording must never break notify 

46 return False 

47 

48 

49def _create_2fa_message( 

50 username: str, 

51 region: str = "global", 

52 dashboard_url: str | None = None, 

53) -> tuple[str, str]: 

54 """ 

55 Create the 2FA notification message and subject. 

56 

57 Args: 

58 username: The iCloud username requiring 2FA 

59 region: The iCloud region (default: "global") 

60 dashboard_url: Web UI URL (optional). When provided, the message 

61 tells the user to tap the URL instead of running the docker 

62 exec command. Set from ``app.web_ui.public_url`` (or the 

63 host:port fallback) by ``send()`` when ``app.web_ui.enabled`` 

64 is true. 

65 

66 Returns: 

67 Tuple of (message, subject) 

68 """ 

69 if dashboard_url: 

70 if _account_uses_security_key(username): 

71 # A security-key account never receives a 6-digit code, so point 

72 # straight at the ceremony rather than the code form. 

73 message = ( 

74 f"icloud-docker: iCloud re-auth required for {username}. This " 

75 f"account uses a security key -- Apple will not send a code. " 

76 f"Open {dashboard_url}/auth/security-key and sign the challenge " 

77 f"with your key." 

78 ) 

79 else: 

80 message = ( 

81 f"icloud-docker: iCloud login required. Sign in at {dashboard_url}/auth" 

82 ) 

83 else: 

84 region_opt = "" if region == "global" else f"--region={region} " 

85 message = f"""Two-step authentication for iCloud Drive, Photos (Docker) is required. 

86 Please login to your server and authenticate. Please run - 

87 `docker exec -it icloud /bin/sh -c "su-exec abc icloud --session-directory=/config/session_data {region_opt}--username={username}"`.""" # noqa: E501 

88 subject = f"icloud-docker: iCloud login required for {username}" 

89 return message, subject 

90 

91 

92def _create_trust_expiring_message( 

93 username: str, 

94 days_remaining: int, 

95 dashboard_url: str | None = None, 

96) -> tuple[str, str]: 

97 """ 

98 Create the trust-expiring notification message and subject. 

99 

100 Fires once per cookie lifetime when ``days_remaining`` first drops 

101 below ``app.trust_expiry_warn_days`` so the user can refresh trust 

102 BEFORE the sync loop hits a failed-auth state. 

103 

104 Args: 

105 username: The iCloud username whose trust window is expiring 

106 days_remaining: Days until ``X-APPLE-WEBAUTH-HSA-TRUST`` expires 

107 dashboard_url: Web UI URL (optional). When provided, the message 

108 tells the user to tap the URL to refresh trust without 

109 retyping their password. 

110 

111 Returns: 

112 Tuple of (message, subject) 

113 """ 

114 horizon = ( 

115 "today" 

116 if days_remaining <= 0 

117 else f"in {days_remaining} day{'s' if days_remaining != 1 else ''}" 

118 ) 

119 if dashboard_url: 

120 message = ( 

121 f"icloud-docker: iCloud login expires {horizon}, refresh at {dashboard_url}" 

122 ) 

123 else: 

124 message = ( 

125 f"icloud-docker: iCloud login expires {horizon}. " 

126 f"Sign in to the container to refresh before the next sync fails." 

127 ) 

128 subject = f"icloud-docker: iCloud login for {username} expires {horizon}" 

129 return message, subject 

130 

131 

132def _create_telegram_reply_prompt(config) -> str: 

133 """Build the actionable Telegram reply-2FA prompt (used when listen is on). 

134 

135 Replaces the generic "run docker exec" instructions with steps the user can 

136 complete from their phone: reply the auth keyword to request a code, then 

137 reply the 6-digit code. 

138 """ 

139 auth_keyword = config_parser.get_telegram_auth_keyword(config=config) 

140 return ( 

141 "\U0001f510 iCloud needs re-authentication. Reply " 

142 f"'{auth_keyword}' and a 2FA code will be sent to your Apple devices; " 

143 "then reply the 6-digit code here." 

144 ) 

145 

146 

147def _get_current_timestamp() -> datetime.datetime: 

148 """ 

149 Get the current timestamp for notification tracking. 

150 

151 Returns: 

152 Current datetime 

153 """ 

154 return datetime.datetime.now() 

155 

156 

157def _get_telegram_config(config) -> tuple[str | None, str | None, bool]: 

158 """ 

159 Extract Telegram configuration from config. 

160 

161 Args: 

162 config: The configuration dictionary 

163 

164 Returns: 

165 Tuple of (bot_token, chat_id, is_configured) 

166 """ 

167 bot_token = config_parser.get_telegram_bot_token(config=config) 

168 chat_id = config_parser.get_telegram_chat_id(config=config) 

169 is_configured = bool(bot_token and chat_id) 

170 return bot_token, chat_id, is_configured 

171 

172 

173def notify_telegram(config, message, last_send=None, dry_run=False): 

174 """ 

175 Send Telegram notification with throttling and error handling. 

176 

177 Args: 

178 config: Configuration dictionary 

179 message: Message to send 

180 last_send: Timestamp of last send for throttling 

181 dry_run: If True, don't actually send the message 

182 

183 Returns: 

184 Timestamp when message was sent, or last_send if throttled, or None if failed 

185 """ 

186 if _is_throttled(last_send): 

187 LOGGER.info("Throttling telegram to once a day") 

188 return last_send 

189 

190 bot_token, chat_id, is_configured = _get_telegram_config(config) 

191 if not is_configured: 

192 LOGGER.warning("Not sending 2FA notification because Telegram is not configured.") 

193 return None 

194 

195 sent_on = _get_current_timestamp() 

196 if dry_run: 

197 return sent_on 

198 

199 # bot_token and chat_id are guaranteed to be non-None due to is_configured check 

200 if post_message_to_telegram(bot_token, chat_id, message): # type: ignore[arg-type] 

201 return sent_on 

202 return None 

203 

204 

205def post_message_to_telegram(bot_token: str, chat_id: str, message: str) -> bool: 

206 """ 

207 Post message to Telegram bot using API. 

208 

209 Args: 

210 bot_token: Telegram bot token 

211 chat_id: Telegram chat ID 

212 message: Message to send 

213 

214 Returns: 

215 True if message was sent successfully, False otherwise 

216 """ 

217 url = f"https://api.telegram.org/bot{bot_token}/sendMessage" 

218 params = {"chat_id": chat_id, "text": message} 

219 response = requests.post(url, params=params, timeout=10) 

220 if response.status_code == 200: 

221 return True 

222 # Log error message 

223 LOGGER.error(f"Failed to send telegram notification. Response: {response.text}") 

224 return False 

225 

226 

227def poll_telegram_for_text( 

228 bot_token: str, 

229 chat_id: str, 

230 offset: int = 0, 

231 request_timeout: int = 10, 

232) -> tuple[str | None, int]: 

233 """Poll Telegram ``getUpdates`` for the next text message from ``chat_id``. 

234 

235 Returns ``(text, new_offset)`` where ``text`` is the first stripped text 

236 message from the user's chat (the caller classifies it as an auth keyword 

237 vs a 6-digit code) and ``new_offset`` is that message's ``update_id`` so the 

238 next poll continues to the following message -- important when a keyword and 

239 the code arrive close together. Non-text / foreign-chat updates are skipped 

240 and still advance the offset past them. Network / API failures are logged 

241 and swallowed, returning ``(None, offset)`` so the caller keeps trying. 

242 """ 

243 url = f"https://api.telegram.org/bot{bot_token}/getUpdates" 

244 params = { 

245 "offset": str(offset + 1), 

246 "allowed_updates": '["message"]', 

247 "timeout": "0", 

248 } 

249 try: 

250 response = requests.post(url, params=params, timeout=request_timeout) 

251 except (requests.RequestException, OSError) as e: 

252 LOGGER.warning(f"telegram getUpdates failed: {e!s}") 

253 return None, offset 

254 if response.status_code != 200: 

255 LOGGER.warning( 

256 f"telegram getUpdates returned {response.status_code}: {response.text[:200]}", 

257 ) 

258 return None, offset 

259 try: 

260 payload = response.json() 

261 except ValueError as e: 

262 LOGGER.warning(f"telegram getUpdates: malformed JSON: {e!s}") 

263 return None, offset 

264 updates = payload.get("result") or [] 

265 new_offset = offset 

266 for update in updates: 

267 update_id = update.get("update_id") 

268 message = update.get("message") or {} 

269 chat = message.get("chat") or {} 

270 if str(chat.get("id")) != str(chat_id): 

271 if isinstance(update_id, int) and update_id > new_offset: 

272 new_offset = update_id 

273 continue 

274 text = (message.get("text") or "").strip() 

275 if text: 

276 return text, (update_id if isinstance(update_id, int) else new_offset) 

277 if isinstance(update_id, int) and update_id > new_offset: 

278 new_offset = update_id 

279 return None, new_offset 

280 

281 

282def _get_discord_config(config) -> tuple[str | None, str | None, bool]: 

283 """ 

284 Extract Discord configuration from config. 

285 

286 Args: 

287 config: The configuration dictionary 

288 

289 Returns: 

290 Tuple of (webhook_url, username, is_configured) 

291 """ 

292 webhook_url = config_parser.get_discord_webhook_url(config=config) 

293 username = config_parser.get_discord_username(config=config) 

294 is_configured = bool(webhook_url and username) 

295 return webhook_url, username, is_configured 

296 

297 

298def post_message_to_discord(webhook_url: str, username: str, message: str) -> bool: 

299 """ 

300 Post message to Discord webhook. 

301 

302 Args: 

303 webhook_url: Discord webhook URL 

304 username: Username to display in Discord 

305 message: Message to send 

306 

307 Returns: 

308 True if message was sent successfully, False otherwise 

309 """ 

310 data = {"username": username, "content": message} 

311 response = requests.post(webhook_url, data=data, timeout=10) 

312 if response.status_code == 204: 

313 return True 

314 # Log error message 

315 LOGGER.error(f"Failed to send Discord notification. Response: {response.text}") 

316 return False 

317 

318 

319def notify_discord(config, message, last_send=None, dry_run=False): 

320 """ 

321 Send Discord notification with throttling and error handling. 

322 

323 Args: 

324 config: Configuration dictionary 

325 message: Message to send 

326 last_send: Timestamp of last send for throttling 

327 dry_run: If True, don't actually send the message 

328 

329 Returns: 

330 Timestamp when message was sent, or last_send if throttled, or None if failed 

331 """ 

332 if _is_throttled(last_send): 

333 LOGGER.info("Throttling discord to once a day") 

334 return last_send 

335 

336 webhook_url, username, is_configured = _get_discord_config(config) 

337 if not is_configured: 

338 LOGGER.warning("Not sending 2FA notification because Discord is not configured.") 

339 return None 

340 

341 sent_on = _get_current_timestamp() 

342 if dry_run or post_message_to_discord(webhook_url, username, message): # type: ignore[arg-type] 

343 return sent_on 

344 return None 

345 

346 

347def _get_pushover_config(config) -> tuple[str | None, str | None, int | None, bool]: 

348 """ 

349 Extract Pushover configuration from config. 

350 

351 Args: 

352 config: The configuration dictionary 

353 

354 Returns: 

355 Tuple of (user_key, api_token, priority, is_configured) 

356 """ 

357 user_key = config_parser.get_pushover_user_key(config=config) 

358 api_token = config_parser.get_pushover_api_token(config=config) 

359 priority = config_parser.get_pushover_notification_priority(config=config) 

360 is_configured = bool(user_key and api_token) 

361 return user_key, api_token, priority, is_configured 

362 

363 

364def post_message_to_pushover(api_token: str, user_key: str, priority: int | None, message: str) -> bool: 

365 """ 

366 Post message to Pushover API. 

367 

368 Args: 

369 api_token: Pushover API token 

370 user_key: Pushover user key 

371 priority: Pushover notification priority (-2 to 2, optional) 

372 message: Message to send 

373 

374 Returns: 

375 True if message was sent successfully, False otherwise 

376 """ 

377 url = "https://api.pushover.net/1/messages.json" 

378 data = {"token": api_token, "user": user_key, "message": message} 

379 if priority is not None: 

380 data["priority"] = priority 

381 response = requests.post(url, data=data, timeout=10) 

382 if response.status_code == 200: 

383 return True 

384 LOGGER.error(f"Failed to send Pushover notification. Response: {response.text}") 

385 return False 

386 

387 

388def notify_pushover(config, message, last_send=None, dry_run=False): 

389 """ 

390 Send Pushover notification with throttling and error handling. 

391 

392 Args: 

393 config: Configuration dictionary 

394 message: Message to send 

395 last_send: Timestamp of last send for throttling 

396 dry_run: If True, don't actually send the message 

397 

398 Returns: 

399 Timestamp when message was sent, or last_send if throttled, or None if failed 

400 """ 

401 if _is_throttled(last_send): 

402 LOGGER.info("Throttling Pushover to once a day") 

403 return last_send 

404 

405 user_key, api_token, priority, is_configured = _get_pushover_config(config) 

406 if not is_configured: 

407 LOGGER.warning("Not sending 2FA notification because Pushover is not configured.") 

408 return None 

409 

410 sent_on = _get_current_timestamp() 

411 if dry_run: 

412 return sent_on 

413 

414 # user_key and api_token are guaranteed to be non-None due to is_configured check 

415 if post_message_to_pushover(api_token, user_key, priority, message): # type: ignore[arg-type] 

416 return sent_on 

417 return None 

418 

419 

420def notify_email(config, message: str, subject: str, last_send=None, dry_run=False): 

421 """ 

422 Send email notification with throttling and error handling. 

423 

424 Args: 

425 config: Configuration dictionary 

426 message: Message to send 

427 subject: Email subject 

428 last_send: Timestamp of last send for throttling 

429 dry_run: If True, don't actually send the message 

430 

431 Returns: 

432 Timestamp when message was sent, or last_send if throttled, or None if failed 

433 """ 

434 if _is_throttled(last_send): 

435 LOGGER.info("Throttling email to once a day") 

436 return last_send 

437 

438 email, to_email, host, port, no_tls, username, password, is_configured = ( 

439 _get_smtp_config(config) 

440 ) 

441 if not is_configured: 

442 LOGGER.warning("Not sending 2FA notification because SMTP is not configured") 

443 return None 

444 

445 sent_on = _get_current_timestamp() 

446 if dry_run: 

447 return sent_on 

448 

449 try: 

450 # All necessary config values are guaranteed to be non-None due to is_configured check 

451 smtp = _create_smtp_connection(host, port, no_tls) # type: ignore[arg-type] 

452 

453 if password: 

454 _authenticate_smtp(smtp, email, username, password) # type: ignore[arg-type] 

455 

456 # to_email could be None, use email as fallback 

457 recipient = to_email if to_email else email 

458 msg = build_message(email, recipient, message, subject) # type: ignore[arg-type] 

459 _send_email_message(smtp, email, recipient, msg) # type: ignore[arg-type] 

460 smtp.quit() 

461 return sent_on 

462 except Exception as e: 

463 LOGGER.error(f"Failed to send email: {e!s}.") 

464 return None 

465 

466 

467def send( 

468 config, 

469 username, 

470 last_send=None, 

471 dry_run=False, 

472 region="global", 

473 dashboard_url=None, 

474 reply_prompt=False, 

475 event="two_factor_required", 

476): 

477 """ 

478 Send 2FA notification to all configured notification services. 

479 

480 Args: 

481 config: Configuration dictionary 

482 username: iCloud username requiring 2FA 

483 last_send: Timestamp of last send for throttling 

484 dry_run: If True, don't actually send notifications 

485 region: iCloud region (default: "global") 

486 dashboard_url: When set, message body links the user to this URL 

487 (web UI ``/auth``) instead of the docker-exec command. Caller 

488 should resolve from ``app.web_ui.public_url`` (or the 

489 host:port fallback) when ``app.web_ui.enabled`` is true. 

490 reply_prompt: Set only by the 2FA handler. With Telegram listen 

491 enabled, Telegram then gets the actionable "reply 'auth'" prompt 

492 instead of the standard alert. Other callers (a rejected 

493 password, a throttled sign-in) must not set it: sign-in never 

494 reaches 2FA there, so the reply flow cannot work and the prompt 

495 would hide the real error from Telegram. 

496 event: Webhook event name for this alert. The message is the same 

497 for every caller, but a receiver acting on it needs to know 

498 whether a code is wanted, a keyring entry is missing or Apple 

499 refused the sign-in -- see ``WEBHOOK_EVENTS``. 

500 

501 Returns: 

502 Timestamp when notifications were sent, or None if all failed 

503 """ 

504 message, subject = _create_2fa_message( 

505 username, 

506 region, 

507 dashboard_url=dashboard_url, 

508 ) 

509 

510 # When Telegram reply-2FA is enabled, send the actionable reply prompt to 

511 # Telegram instead of the generic "run docker exec" message; the other 

512 # channels still get the standard alert. 

513 telegram_message = message 

514 if reply_prompt and config_parser.get_telegram_listen_enabled(config=config): 

515 telegram_message = _create_telegram_reply_prompt(config) 

516 

517 # Send to all notification services 

518 telegram_sent = notify_telegram( 

519 config=config, 

520 message=telegram_message, 

521 last_send=last_send, 

522 dry_run=dry_run, 

523 ) 

524 discord_sent = notify_discord( 

525 config=config, 

526 message=message, 

527 last_send=last_send, 

528 dry_run=dry_run, 

529 ) 

530 pushover_sent = notify_pushover( 

531 config=config, 

532 message=message, 

533 last_send=last_send, 

534 dry_run=dry_run, 

535 ) 

536 email_sent = notify_email( 

537 config=config, 

538 message=message, 

539 subject=subject, 

540 last_send=last_send, 

541 dry_run=dry_run, 

542 ) 

543 webhook_sent = notify_webhook( 

544 config=config, 

545 event=event, 

546 message=message, 

547 last_send=last_send, 

548 dry_run=dry_run, 

549 data=_event_data(username=username, dashboard_url=dashboard_url), 

550 ) 

551 

552 # Return the timestamp if any notification was sent successfully 

553 sent_timestamps = [ 

554 t 

555 for t in [telegram_sent, discord_sent, pushover_sent, email_sent, webhook_sent] 

556 if t is not None 

557 ] 

558 return sent_timestamps[0] if sent_timestamps else None 

559 

560 

561def send_trust_expiring( 

562 config, 

563 username, 

564 days_remaining, 

565 last_send=None, 

566 dry_run=False, 

567 dashboard_url=None, 

568): 

569 """Send trust-expiring notification to all configured services. 

570 

571 Mirrors ``send()`` but for the pre-emptive "trust window closing" 

572 event. Caller is responsible for the once-per-cookie-lifetime 

573 debounce (see ``web_signals.get_trust_state`` / 

574 ``record_trust_state``); the ``last_send`` arg here is the 

575 daily-throttle from ``_is_throttled`` shared with the 2FA flow, 

576 not the per-cookie one. 

577 

578 Args: 

579 config: Configuration dictionary 

580 username: iCloud username whose trust window is expiring 

581 days_remaining: Days until ``X-APPLE-WEBAUTH-HSA-TRUST`` expires 

582 last_send: Timestamp of last send for daily throttling 

583 dry_run: If True, don't actually send notifications 

584 dashboard_url: When set, message body links the user to this 

585 URL (web UI dashboard) instead of the docker-exec 

586 instruction. Caller resolves from ``app.web_ui.public_url`` 

587 (or the host:port fallback) when web UI is enabled. 

588 

589 Returns: 

590 Timestamp when notifications were sent, or None if all failed. 

591 """ 

592 message, subject = _create_trust_expiring_message( 

593 username, 

594 days_remaining, 

595 dashboard_url=dashboard_url, 

596 ) 

597 telegram_sent = notify_telegram( 

598 config=config, 

599 message=message, 

600 last_send=last_send, 

601 dry_run=dry_run, 

602 ) 

603 discord_sent = notify_discord( 

604 config=config, 

605 message=message, 

606 last_send=last_send, 

607 dry_run=dry_run, 

608 ) 

609 pushover_sent = notify_pushover( 

610 config=config, 

611 message=message, 

612 last_send=last_send, 

613 dry_run=dry_run, 

614 ) 

615 email_sent = notify_email( 

616 config=config, 

617 message=message, 

618 subject=subject, 

619 last_send=last_send, 

620 dry_run=dry_run, 

621 ) 

622 webhook_sent = notify_webhook( 

623 config=config, 

624 event="trust_expiring", 

625 message=message, 

626 last_send=last_send, 

627 dry_run=dry_run, 

628 data=_event_data( 

629 username=username, 

630 dashboard_url=dashboard_url, 

631 days_remaining=days_remaining, 

632 ), 

633 ) 

634 sent_timestamps = [ 

635 t 

636 for t in [telegram_sent, discord_sent, pushover_sent, email_sent, webhook_sent] 

637 if t is not None 

638 ] 

639 return sent_timestamps[0] if sent_timestamps else None 

640 

641 

642def _get_smtp_config( 

643 config, 

644) -> tuple[str | None, str | None, str | None, int | None, bool, str | None, str | None, bool]: 

645 """ 

646 Extract SMTP configuration from config. 

647 

648 Args: 

649 config: The configuration dictionary 

650 

651 Returns: 

652 Tuple of (email, to_email, host, port, no_tls, username, password, is_configured) 

653 """ 

654 email = config_parser.get_smtp_email(config=config) 

655 to_email = config_parser.get_smtp_to_email(config=config) 

656 host = config_parser.get_smtp_host(config=config) 

657 port = config_parser.get_smtp_port(config=config) 

658 no_tls = config_parser.get_smtp_no_tls(config=config) 

659 username = config_parser.get_smtp_username(config=config) 

660 password = config_parser.get_smtp_password(config=config) 

661 is_configured = bool(email and host and port) 

662 return email, to_email, host, port, no_tls, username, password, is_configured 

663 

664 

665def _create_smtp_connection(host: str, port: int, no_tls: bool) -> smtplib.SMTP: 

666 """ 

667 Create and configure SMTP connection. 

668 

669 Args: 

670 host: SMTP host 

671 port: SMTP port 

672 no_tls: Whether to skip TLS 

673 

674 Returns: 

675 Configured SMTP connection 

676 """ 

677 smtp = smtplib.SMTP(host, port) 

678 smtp.set_debuglevel(0) 

679 smtp.connect(host, port) 

680 if not no_tls: 

681 smtp.starttls() 

682 return smtp 

683 

684 

685def _authenticate_smtp(smtp: smtplib.SMTP, email: str, username: str | None, password: str) -> None: 

686 """ 

687 Authenticate SMTP connection. 

688 

689 Args: 

690 smtp: SMTP connection 

691 email: Email address for fallback authentication 

692 username: SMTP username (optional) 

693 password: SMTP password 

694 """ 

695 if username: 

696 smtp.login(username, password) 

697 else: 

698 smtp.login(email, password) 

699 

700 

701def _send_email_message(smtp: smtplib.SMTP, email: str, to_email: str, message_obj: Message) -> None: 

702 """ 

703 Send email message through SMTP connection. 

704 

705 Args: 

706 smtp: SMTP connection 

707 email: From email address 

708 to_email: To email address 

709 message_obj: Email message object 

710 """ 

711 smtp.sendmail(from_addr=email, to_addrs=to_email, msg=message_obj.as_string()) 

712 

713 

714def _contains_non_ascii(text: str | None) -> bool: 

715 """Determine if the provided text contains non-ASCII characters.""" 

716 

717 if text is None: 

718 return False 

719 

720 try: 

721 text.encode("ascii") 

722 except UnicodeEncodeError: 

723 return True 

724 return False 

725 

726 

727def build_message(email: str, to_email: str, message: str, subject: str) -> Message: 

728 """ 

729 Create email message with proper headers. 

730 

731 Args: 

732 email: From email address 

733 to_email: To email address 

734 message: Message body 

735 subject: Message subject 

736 

737 Returns: 

738 Configured email message object 

739 """ 

740 requires_utf8 = _contains_non_ascii(message) or _contains_non_ascii(subject) 

741 charset = "utf-8" if requires_utf8 else "us-ascii" 

742 

743 msg = Message(to=to_email, charset=charset) 

744 msg.sender = "icloud-docker <" + email + ">" 

745 msg.date = datetime.datetime.now().strftime("%d/%m/%Y %H:%M") 

746 msg.subject = subject 

747 msg.body = message 

748 return msg 

749 

750 

751# ============================================================================= 

752# Sync Summary Notification Functions 

753# ============================================================================= 

754 

755 

756def _format_sync_summary_message(summary) -> tuple[str, str]: 

757 """ 

758 Format sync summary as notification message. 

759 

760 Args: 

761 summary: SyncSummary object containing sync statistics 

762 

763 Returns: 

764 Tuple of (message, subject) 

765 """ 

766 from src.sync_stats import format_bytes, format_duration 

767 

768 has_errors = summary.has_errors() 

769 status_emoji = "⚠️" if has_errors else "✅" 

770 status_text = "Completed with Errors" if has_errors else "Complete" 

771 

772 message_lines = [f"{status_emoji} iCloud Sync {status_text}", ""] 

773 

774 # Drive statistics 

775 if summary.drive_stats and summary.drive_stats.has_activity(): 

776 drive = summary.drive_stats 

777 message_lines.append("📁 Drive:") 

778 if drive.files_downloaded > 0: 

779 size_str = format_bytes(drive.bytes_downloaded) 

780 message_lines.append(f" • Downloaded: {drive.files_downloaded} files ({size_str})") 

781 if drive.files_skipped > 0: 

782 message_lines.append(f" • Skipped: {drive.files_skipped} files (up-to-date)") 

783 if drive.files_removed > 0: 

784 message_lines.append(f" • Removed: {drive.files_removed} obsolete files") 

785 if drive.duration_seconds > 0: 

786 duration_str = format_duration(drive.duration_seconds) 

787 message_lines.append(f" • Duration: {duration_str}") 

788 if drive.has_errors(): 

789 message_lines.append(f" • Errors: {len(drive.errors)} failed") 

790 message_lines.append("") 

791 

792 # Photos statistics 

793 if summary.photo_stats and summary.photo_stats.has_activity(): 

794 photos = summary.photo_stats 

795 message_lines.append("📷 Photos:") 

796 if photos.photos_downloaded > 0: 

797 size_str = format_bytes(photos.bytes_downloaded) 

798 message_lines.append(f" • Downloaded: {photos.photos_downloaded} photos ({size_str})") 

799 if photos.photos_hardlinked > 0: 

800 message_lines.append(f" • Hard-linked: {photos.photos_hardlinked} photos") 

801 if photos.bytes_saved_by_hardlinks > 0: 

802 saved_str = format_bytes(photos.bytes_saved_by_hardlinks) 

803 message_lines.append(f" • Storage saved: {saved_str}") 

804 if photos.albums_synced: 

805 albums_str = ", ".join(photos.albums_synced[:5]) 

806 if len(photos.albums_synced) > 5: 

807 albums_str += f" (+{len(photos.albums_synced) - 5} more)" 

808 message_lines.append(f" • Albums: {albums_str}") 

809 if photos.duration_seconds > 0: 

810 duration_str = format_duration(photos.duration_seconds) 

811 message_lines.append(f" • Duration: {duration_str}") 

812 if photos.has_errors(): 

813 message_lines.append(f" • Errors: {len(photos.errors)} failed") 

814 message_lines.append("") 

815 

816 # Error details if present 

817 if has_errors: 

818 message_lines.append("Failed items:") 

819 all_errors = [] 

820 if summary.drive_stats: 

821 all_errors.extend(summary.drive_stats.errors[:5]) # Limit to first 5 

822 if summary.photo_stats: 

823 all_errors.extend(summary.photo_stats.errors[:5]) # Limit to first 5 

824 message_lines.extend([f" • {error}" for error in all_errors[:10]]) 

825 total_errors = 0 

826 if summary.drive_stats: 

827 total_errors += len(summary.drive_stats.errors) 

828 if summary.photo_stats: 

829 total_errors += len(summary.photo_stats.errors) 

830 if total_errors > 10: 

831 message_lines.append(f" ... and {total_errors - 10} more errors") 

832 message_lines.append("") 

833 

834 message = "\n".join(message_lines) 

835 subject = f"icloud-docker: Sync {status_text}" 

836 return message, subject 

837 

838 

839def _should_send_sync_summary(config, summary) -> bool: 

840 """ 

841 Determine if sync summary notification should be sent. 

842 

843 Args: 

844 config: Configuration dictionary 

845 summary: SyncSummary object 

846 

847 Returns: 

848 True if notification should be sent, False otherwise 

849 """ 

850 # Check if sync summary is enabled 

851 if not config_parser.get_sync_summary_enabled(config=config): 

852 return False 

853 

854 # Check if there was any activity 

855 if not summary.has_activity(): 

856 return False 

857 

858 # Check error/success preferences 

859 has_errors = summary.has_errors() 

860 on_error = config_parser.get_sync_summary_on_error(config=config) 

861 on_success = config_parser.get_sync_summary_on_success(config=config) 

862 

863 if has_errors and not on_error: 

864 return False 

865 if not has_errors and not on_success: 

866 return False 

867 

868 # Check minimum downloads threshold 

869 min_downloads = config_parser.get_sync_summary_min_downloads(config=config) 

870 total_downloads = 0 

871 if summary.drive_stats: 

872 total_downloads += summary.drive_stats.files_downloaded 

873 if summary.photo_stats: 

874 total_downloads += summary.photo_stats.photos_downloaded 

875 

876 if total_downloads < min_downloads: 

877 return False 

878 

879 return True 

880 

881 

882def send_sync_summary(config, summary, dry_run=False): 

883 """ 

884 Send sync summary notification to all configured services. 

885 

886 Note: Sync summaries are NOT throttled like 2FA notifications, 

887 as they provide valuable operational information for each sync. 

888 

889 Args: 

890 config: Configuration dictionary 

891 summary: SyncSummary object containing sync statistics 

892 dry_run: If True, don't actually send notifications 

893 

894 Returns: 

895 True if at least one notification was sent successfully, False otherwise 

896 """ 

897 if not _should_send_sync_summary(config, summary): 

898 LOGGER.debug("Sync summary notification skipped (not enabled or no activity)") 

899 return False 

900 

901 message, subject = _format_sync_summary_message(summary) 

902 

903 # Send to all notification services (no throttling for sync summaries) 

904 telegram_sent = _send_telegram_no_throttle(config, message, dry_run) 

905 discord_sent = _send_discord_no_throttle(config, message, dry_run) 

906 pushover_sent = _send_pushover_no_throttle(config, message, dry_run) 

907 email_sent = _send_email_no_throttle(config, message, subject, dry_run) 

908 webhook_sent = _send_webhook_no_throttle( 

909 config, 

910 "sync_summary", 

911 message, 

912 summary_event_data(summary), 

913 dry_run, 

914 ) 

915 

916 # Return True if any notification was sent successfully 

917 any_sent = any([telegram_sent, discord_sent, pushover_sent, email_sent, webhook_sent]) 

918 if any_sent: 

919 LOGGER.info("Sync summary notification sent successfully") 

920 return any_sent 

921 

922 

923def _send_telegram_no_throttle(config, message: str, dry_run: bool) -> bool: 

924 """Send Telegram notification without throttling. 

925 

926 Args: 

927 config: Configuration dictionary 

928 message: Message to send 

929 dry_run: If True, don't actually send 

930 

931 Returns: 

932 True if sent successfully, False otherwise 

933 """ 

934 bot_token, chat_id, is_configured = _get_telegram_config(config) 

935 if not is_configured: 

936 return False 

937 

938 if dry_run: 

939 return True 

940 

941 return post_message_to_telegram(bot_token, chat_id, message) 

942 

943 

944def _send_discord_no_throttle(config, message: str, dry_run: bool) -> bool: 

945 """Send Discord notification without throttling. 

946 

947 Args: 

948 config: Configuration dictionary 

949 message: Message to send 

950 dry_run: If True, don't actually send 

951 

952 Returns: 

953 True if sent successfully, False otherwise 

954 """ 

955 webhook_url, username, is_configured = _get_discord_config(config) 

956 if not is_configured: 

957 return False 

958 

959 if dry_run: 

960 return True 

961 

962 return post_message_to_discord(webhook_url, username, message) 

963 

964 

965def _send_pushover_no_throttle(config, message: str, dry_run: bool) -> bool: 

966 """Send Pushover notification without throttling. 

967 

968 Args: 

969 config: Configuration dictionary 

970 message: Message to send 

971 dry_run: If True, don't actually send 

972 

973 Returns: 

974 True if sent successfully, False otherwise 

975 """ 

976 user_key, api_token, priority, is_configured = _get_pushover_config(config) 

977 if not is_configured: 

978 return False 

979 

980 if dry_run: 

981 return True 

982 

983 return post_message_to_pushover(api_token, user_key, priority, message) 

984 

985 

986def _send_email_no_throttle(config, message: str, subject: str, dry_run: bool) -> bool: 

987 """Send email notification without throttling. 

988 

989 Args: 

990 config: Configuration dictionary 

991 message: Message to send 

992 subject: Email subject 

993 dry_run: If True, don't actually send 

994 

995 Returns: 

996 True if sent successfully, False otherwise 

997 """ 

998 email, to_email, host, port, no_tls, username, password, is_configured = ( 

999 _get_smtp_config(config) 

1000 ) 

1001 if not is_configured: 

1002 return False 

1003 

1004 if dry_run: 

1005 return True 

1006 

1007 try: 

1008 smtp = _create_smtp_connection(host, port, no_tls) # type: ignore[arg-type] 

1009 

1010 if password: 

1011 _authenticate_smtp(smtp, email, username, password) # type: ignore[arg-type] 

1012 

1013 recipient = to_email if to_email else email 

1014 msg = build_message(email, recipient, message, subject) # type: ignore[arg-type] 

1015 _send_email_message(smtp, email, recipient, msg) # type: ignore[arg-type] 

1016 smtp.quit() 

1017 return True 

1018 except Exception as e: 

1019 LOGGER.error(f"Failed to send sync summary email: {e!s}") 

1020 return False 

1021 

1022 

1023# ============================================================================= 

1024# Sync Lifecycle Webhook Functions 

1025# ============================================================================= 

1026 

1027# Short enough that a dead endpoint cannot stall a sync cycle, long enough 

1028# for a cold serverless receiver. 

1029WEBHOOK_TIMEOUT_SECONDS = 10 

1030 

1031 

1032def ping_webhook(config, event: str) -> bool: 

1033 """GET the ping URL configured for ``event``, if there is one. 

1034 

1035 Built for Healthchecks.io-style monitors: the URL itself carries the 

1036 identity of the check, so the request needs no body, no auth header and 

1037 no retry -- the monitor's own grace period covers a dropped ping. 

1038 

1039 A ping URL is a credential: anyone holding it can report the check 

1040 healthy. Nothing here may log it, not even indirectly -- a ``requests`` 

1041 exception stringifies to the full URL, so only the exception type is 

1042 logged. 

1043 

1044 Args: 

1045 config: Configuration dictionary 

1046 event: Event name -- ``start``, ``success`` or ``failure`` 

1047 

1048 Returns: 

1049 True if the ping was delivered, False if not configured or it failed 

1050 """ 

1051 url = config_parser.get_webhook_url(config=config, event=event) 

1052 if not url: 

1053 return False 

1054 try: 

1055 response = requests.get(url, timeout=WEBHOOK_TIMEOUT_SECONDS) 

1056 except Exception as e: 

1057 LOGGER.warning(f"Failed to ping the {event} webhook: {type(e).__name__}") 

1058 return False 

1059 if response.ok: 

1060 return True 

1061 LOGGER.warning(f"The {event} webhook returned HTTP {response.status_code}") 

1062 return False 

1063 

1064 

1065# Every event name a receiver can see, and the only names 

1066# ``app.webhooks.events`` recognises. All but ``trust_refreshed`` mirror a 

1067# notification the other transports already get; that one is webhook-only 

1068# because nothing else notifies on a successful refresh. 

1069WEBHOOK_EVENTS = ( 

1070 "sync_started", 

1071 "sync_succeeded", 

1072 "sync_failed", 

1073 "sync_summary", 

1074 "two_factor_required", 

1075 "security_key_required", 

1076 "password_missing", 

1077 "sign_in_failed", 

1078 "trust_expiring", 

1079 "trust_refreshed", 

1080) 

1081 

1082# Sync-cycle boundary -> (ping config key, POSTed event name). The ping keys 

1083# are what a monitor is configured with; the event names are what a receiver 

1084# switches on. 

1085_CYCLE_EVENTS = { 

1086 "start": "sync_started", 

1087 "success": "sync_succeeded", 

1088 "failure": "sync_failed", 

1089} 

1090 

1091 

1092def _event_data(**fields) -> dict: 

1093 """Drop the unset fields so a payload never carries explicit nulls.""" 

1094 return {name: value for name, value in fields.items() if value is not None} 

1095 

1096 

1097def summary_event_data(summary) -> dict: 

1098 """Structured form of a sync summary, for receivers that act on numbers. 

1099 

1100 The message text other transports get is formatted for humans; a webhook 

1101 receiver should not have to parse it back out. 

1102 """ 

1103 data: dict = { 

1104 "has_errors": summary.has_errors(), 

1105 "duration_seconds": round(summary.total_duration_seconds(), 3), 

1106 } 

1107 if summary.drive_stats: 

1108 drive = summary.drive_stats 

1109 data["drive"] = { 

1110 "files_downloaded": drive.files_downloaded, 

1111 "files_skipped": drive.files_skipped, 

1112 "files_removed": drive.files_removed, 

1113 "bytes_downloaded": drive.bytes_downloaded, 

1114 "duration_seconds": round(drive.duration_seconds, 3), 

1115 "errors": len(drive.errors), 

1116 } 

1117 if summary.photo_stats: 

1118 photos = summary.photo_stats 

1119 data["photos"] = { 

1120 "photos_downloaded": photos.photos_downloaded, 

1121 "photos_skipped": photos.photos_skipped, 

1122 "photos_hardlinked": photos.photos_hardlinked, 

1123 "bytes_downloaded": photos.bytes_downloaded, 

1124 "bytes_saved_by_hardlinks": photos.bytes_saved_by_hardlinks, 

1125 "albums_synced": list(photos.albums_synced), 

1126 "duration_seconds": round(photos.duration_seconds, 3), 

1127 "errors": len(photos.errors), 

1128 } 

1129 return data 

1130 

1131 

1132def _webhook_wants(config, event: str) -> bool: 

1133 """Whether ``app.webhooks.events`` selects this event (absent = all).""" 

1134 selected = config_parser.get_webhook_events(config=config) 

1135 return True if selected is None else event in selected 

1136 

1137 

1138def warn_unknown_webhook_events(config) -> None: 

1139 """Report misspelled ``app.webhooks.events`` entries once, at startup. 

1140 

1141 A name that matches no event is inert, which looks exactly like a 

1142 receiver that stopped working. The filter itself is consulted for every 

1143 event of every cycle -- far too often to warn from there. 

1144 """ 

1145 selected = config_parser.get_webhook_events(config=config) 

1146 if not selected: 

1147 return 

1148 unknown = sorted({event for event in selected if event not in WEBHOOK_EVENTS}) 

1149 if unknown: 

1150 LOGGER.warning( 

1151 f"app.webhooks.events names {', '.join(unknown)}, which match no event and will " 

1152 f"never fire. Known events: {', '.join(WEBHOOK_EVENTS)}.", 

1153 ) 

1154 

1155 

1156def post_event_to_webhook(config, event: str, message: str, data: dict | None = None) -> bool: 

1157 """POST one event to ``app.webhooks.url`` as JSON. 

1158 

1159 Payload shape is part of the config surface and must stay stable:: 

1160 

1161 {"event": ..., "message": ..., "timestamp": ..., "data": {...}} 

1162 

1163 ``message`` is the same human text the other transports send, so a 

1164 receiver can forward it verbatim; ``data`` carries the structured form. 

1165 

1166 The URL and the configured headers are credentials, so no failure path 

1167 may log either -- a ``requests`` exception stringifies to the full URL, 

1168 hence only the exception type. 

1169 

1170 Args: 

1171 config: Configuration dictionary 

1172 event: One of ``WEBHOOK_EVENTS`` 

1173 message: Human-readable text for this event 

1174 data: Event-specific structured fields 

1175 

1176 Returns: 

1177 True if the receiver accepted the event, False otherwise 

1178 """ 

1179 url = config_parser.get_webhook_post_url(config=config) 

1180 if not url or not _webhook_wants(config, event): 

1181 return False 

1182 payload = { 

1183 "event": event, 

1184 "message": message, 

1185 "timestamp": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), 

1186 "data": data or {}, 

1187 } 

1188 try: 

1189 response = requests.post( 

1190 url, 

1191 json=payload, 

1192 headers=config_parser.get_webhook_headers(config=config) or None, 

1193 timeout=WEBHOOK_TIMEOUT_SECONDS, 

1194 ) 

1195 except Exception as e: 

1196 LOGGER.warning(f"Failed to post the {event} webhook event: {type(e).__name__}") 

1197 return False 

1198 if response.ok: 

1199 return True 

1200 LOGGER.warning(f"The webhook returned HTTP {response.status_code} for {event}") 

1201 return False 

1202 

1203 

1204def notify_webhook(config, event, message, last_send=None, dry_run=False, data=None): 

1205 """Send a webhook event with throttling and error handling. 

1206 

1207 Shaped like ``notify_telegram`` / ``notify_discord`` on purpose: it is 

1208 called from the same dispatch, so it inherits the 24h throttle those 

1209 alerts already share rather than carrying one of its own. 

1210 

1211 Args: 

1212 config: Configuration dictionary 

1213 event: One of ``WEBHOOK_EVENTS`` 

1214 message: Message to send 

1215 last_send: Timestamp of last send for throttling 

1216 dry_run: If True, don't actually send the event 

1217 data: Event-specific structured fields 

1218 

1219 Returns: 

1220 Timestamp when the event was sent, or last_send if throttled, or 

1221 None if not configured or failed 

1222 """ 

1223 # Before the throttle check, unlike the other transports: an install 

1224 # with no webhook must not gain a log line it never asked for. 

1225 if not config_parser.get_webhook_post_url(config=config): 

1226 return None 

1227 

1228 if _is_throttled(last_send): 

1229 LOGGER.info("Throttling webhook to once a day") 

1230 return last_send 

1231 

1232 sent_on = _get_current_timestamp() 

1233 if dry_run or post_event_to_webhook(config, event, message, data): 

1234 return sent_on 

1235 return None 

1236 

1237 

1238def _send_webhook_no_throttle(config, event: str, message: str, data: dict, dry_run: bool) -> bool: 

1239 """Post a webhook event without throttling. 

1240 

1241 Mirrors the other ``_send_*_no_throttle`` senders, including the 

1242 "configured but dry-run" answer: without the URL check a dry run would 

1243 report the summary as sent on an install that has no webhook at all. 

1244 

1245 Args: 

1246 config: Configuration dictionary 

1247 event: One of ``WEBHOOK_EVENTS`` 

1248 message: Message to send 

1249 data: Event-specific structured fields 

1250 dry_run: If True, don't actually send 

1251 

1252 Returns: 

1253 True if sent successfully, False otherwise 

1254 """ 

1255 if not config_parser.get_webhook_post_url(config=config): 

1256 return False 

1257 

1258 if dry_run: 

1259 return True 

1260 

1261 return post_event_to_webhook(config, event, message, data) 

1262 

1263 

1264def send_cycle_event(config, boundary: str, message: str, data: dict | None = None) -> None: 

1265 """Report a sync-cycle boundary on both webhook transports. 

1266 

1267 The monitor gets a bare ping (it only needs to know the cycle happened); 

1268 the POST endpoint gets the named event with whatever the cycle knows, so 

1269 one receiver can follow the whole lifecycle. Unthrottled either way: a 

1270 monitor measures the gap between pings, and a failure that repeats every 

1271 retry is the signal, not noise. 

1272 

1273 Args: 

1274 config: Configuration dictionary 

1275 boundary: ``start``, ``success`` or ``failure`` 

1276 message: Human-readable text for this boundary 

1277 data: Event-specific structured fields 

1278 """ 

1279 ping_webhook(config, boundary) 

1280 post_event_to_webhook(config, _CYCLE_EVENTS[boundary], message, data)