Coverage for src/notify.py: 100%
442 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-08 21:05 +0000
« 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."""
3import datetime
4import smtplib
6import requests
8from src import config_parser, get_logger
9from src.email_message import EmailMessage as Message
11LOGGER = get_logger()
13# Throttling period for notifications (24 hours)
14THROTTLE_HOURS = 24
17def _is_throttled(last_send) -> bool:
18 """
19 Check if notification should be throttled based on last send time.
21 Args:
22 last_send: The datetime when notification was last sent, or None
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)
34def _account_uses_security_key(username: str) -> bool:
35 """True when Apple has demanded a FIDO2 assertion for this Apple ID.
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
44 return web_signals.get_auth_method(username) == "security_key"
45 except Exception: # pragma: no cover - wording must never break notify
46 return False
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.
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.
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
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.
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.
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.
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
132def _create_telegram_reply_prompt(config) -> str:
133 """Build the actionable Telegram reply-2FA prompt (used when listen is on).
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 )
147def _get_current_timestamp() -> datetime.datetime:
148 """
149 Get the current timestamp for notification tracking.
151 Returns:
152 Current datetime
153 """
154 return datetime.datetime.now()
157def _get_telegram_config(config) -> tuple[str | None, str | None, bool]:
158 """
159 Extract Telegram configuration from config.
161 Args:
162 config: The configuration dictionary
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
173def notify_telegram(config, message, last_send=None, dry_run=False):
174 """
175 Send Telegram notification with throttling and error handling.
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
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
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
195 sent_on = _get_current_timestamp()
196 if dry_run:
197 return sent_on
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
205def post_message_to_telegram(bot_token: str, chat_id: str, message: str) -> bool:
206 """
207 Post message to Telegram bot using API.
209 Args:
210 bot_token: Telegram bot token
211 chat_id: Telegram chat ID
212 message: Message to send
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
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``.
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
282def _get_discord_config(config) -> tuple[str | None, str | None, bool]:
283 """
284 Extract Discord configuration from config.
286 Args:
287 config: The configuration dictionary
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
298def post_message_to_discord(webhook_url: str, username: str, message: str) -> bool:
299 """
300 Post message to Discord webhook.
302 Args:
303 webhook_url: Discord webhook URL
304 username: Username to display in Discord
305 message: Message to send
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
319def notify_discord(config, message, last_send=None, dry_run=False):
320 """
321 Send Discord notification with throttling and error handling.
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
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
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
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
347def _get_pushover_config(config) -> tuple[str | None, str | None, int | None, bool]:
348 """
349 Extract Pushover configuration from config.
351 Args:
352 config: The configuration dictionary
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
364def post_message_to_pushover(api_token: str, user_key: str, priority: int | None, message: str) -> bool:
365 """
366 Post message to Pushover API.
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
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
388def notify_pushover(config, message, last_send=None, dry_run=False):
389 """
390 Send Pushover notification with throttling and error handling.
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
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
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
410 sent_on = _get_current_timestamp()
411 if dry_run:
412 return sent_on
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
420def notify_email(config, message: str, subject: str, last_send=None, dry_run=False):
421 """
422 Send email notification with throttling and error handling.
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
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
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
445 sent_on = _get_current_timestamp()
446 if dry_run:
447 return sent_on
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]
453 if password:
454 _authenticate_smtp(smtp, email, username, password) # type: ignore[arg-type]
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
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.
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``.
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 )
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)
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 )
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
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.
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.
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.
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
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.
648 Args:
649 config: The configuration dictionary
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
665def _create_smtp_connection(host: str, port: int, no_tls: bool) -> smtplib.SMTP:
666 """
667 Create and configure SMTP connection.
669 Args:
670 host: SMTP host
671 port: SMTP port
672 no_tls: Whether to skip TLS
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
685def _authenticate_smtp(smtp: smtplib.SMTP, email: str, username: str | None, password: str) -> None:
686 """
687 Authenticate SMTP connection.
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)
701def _send_email_message(smtp: smtplib.SMTP, email: str, to_email: str, message_obj: Message) -> None:
702 """
703 Send email message through SMTP connection.
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())
714def _contains_non_ascii(text: str | None) -> bool:
715 """Determine if the provided text contains non-ASCII characters."""
717 if text is None:
718 return False
720 try:
721 text.encode("ascii")
722 except UnicodeEncodeError:
723 return True
724 return False
727def build_message(email: str, to_email: str, message: str, subject: str) -> Message:
728 """
729 Create email message with proper headers.
731 Args:
732 email: From email address
733 to_email: To email address
734 message: Message body
735 subject: Message subject
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"
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
751# =============================================================================
752# Sync Summary Notification Functions
753# =============================================================================
756def _format_sync_summary_message(summary) -> tuple[str, str]:
757 """
758 Format sync summary as notification message.
760 Args:
761 summary: SyncSummary object containing sync statistics
763 Returns:
764 Tuple of (message, subject)
765 """
766 from src.sync_stats import format_bytes, format_duration
768 has_errors = summary.has_errors()
769 status_emoji = "⚠️" if has_errors else "✅"
770 status_text = "Completed with Errors" if has_errors else "Complete"
772 message_lines = [f"{status_emoji} iCloud Sync {status_text}", ""]
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("")
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("")
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("")
834 message = "\n".join(message_lines)
835 subject = f"icloud-docker: Sync {status_text}"
836 return message, subject
839def _should_send_sync_summary(config, summary) -> bool:
840 """
841 Determine if sync summary notification should be sent.
843 Args:
844 config: Configuration dictionary
845 summary: SyncSummary object
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
854 # Check if there was any activity
855 if not summary.has_activity():
856 return False
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)
863 if has_errors and not on_error:
864 return False
865 if not has_errors and not on_success:
866 return False
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
876 if total_downloads < min_downloads:
877 return False
879 return True
882def send_sync_summary(config, summary, dry_run=False):
883 """
884 Send sync summary notification to all configured services.
886 Note: Sync summaries are NOT throttled like 2FA notifications,
887 as they provide valuable operational information for each sync.
889 Args:
890 config: Configuration dictionary
891 summary: SyncSummary object containing sync statistics
892 dry_run: If True, don't actually send notifications
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
901 message, subject = _format_sync_summary_message(summary)
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 )
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
923def _send_telegram_no_throttle(config, message: str, dry_run: bool) -> bool:
924 """Send Telegram notification without throttling.
926 Args:
927 config: Configuration dictionary
928 message: Message to send
929 dry_run: If True, don't actually send
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
938 if dry_run:
939 return True
941 return post_message_to_telegram(bot_token, chat_id, message)
944def _send_discord_no_throttle(config, message: str, dry_run: bool) -> bool:
945 """Send Discord notification without throttling.
947 Args:
948 config: Configuration dictionary
949 message: Message to send
950 dry_run: If True, don't actually send
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
959 if dry_run:
960 return True
962 return post_message_to_discord(webhook_url, username, message)
965def _send_pushover_no_throttle(config, message: str, dry_run: bool) -> bool:
966 """Send Pushover notification without throttling.
968 Args:
969 config: Configuration dictionary
970 message: Message to send
971 dry_run: If True, don't actually send
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
980 if dry_run:
981 return True
983 return post_message_to_pushover(api_token, user_key, priority, message)
986def _send_email_no_throttle(config, message: str, subject: str, dry_run: bool) -> bool:
987 """Send email notification without throttling.
989 Args:
990 config: Configuration dictionary
991 message: Message to send
992 subject: Email subject
993 dry_run: If True, don't actually send
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
1004 if dry_run:
1005 return True
1007 try:
1008 smtp = _create_smtp_connection(host, port, no_tls) # type: ignore[arg-type]
1010 if password:
1011 _authenticate_smtp(smtp, email, username, password) # type: ignore[arg-type]
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
1023# =============================================================================
1024# Sync Lifecycle Webhook Functions
1025# =============================================================================
1027# Short enough that a dead endpoint cannot stall a sync cycle, long enough
1028# for a cold serverless receiver.
1029WEBHOOK_TIMEOUT_SECONDS = 10
1032def ping_webhook(config, event: str) -> bool:
1033 """GET the ping URL configured for ``event``, if there is one.
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.
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.
1044 Args:
1045 config: Configuration dictionary
1046 event: Event name -- ``start``, ``success`` or ``failure``
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
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)
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}
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}
1097def summary_event_data(summary) -> dict:
1098 """Structured form of a sync summary, for receivers that act on numbers.
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
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
1138def warn_unknown_webhook_events(config) -> None:
1139 """Report misspelled ``app.webhooks.events`` entries once, at startup.
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 )
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.
1159 Payload shape is part of the config surface and must stay stable::
1161 {"event": ..., "message": ..., "timestamp": ..., "data": {...}}
1163 ``message`` is the same human text the other transports send, so a
1164 receiver can forward it verbatim; ``data`` carries the structured form.
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.
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
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
1204def notify_webhook(config, event, message, last_send=None, dry_run=False, data=None):
1205 """Send a webhook event with throttling and error handling.
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.
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
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
1228 if _is_throttled(last_send):
1229 LOGGER.info("Throttling webhook to once a day")
1230 return last_send
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
1238def _send_webhook_no_throttle(config, event: str, message: str, data: dict, dry_run: bool) -> bool:
1239 """Post a webhook event without throttling.
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.
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
1252 Returns:
1253 True if sent successfully, False otherwise
1254 """
1255 if not config_parser.get_webhook_post_url(config=config):
1256 return False
1258 if dry_run:
1259 return True
1261 return post_event_to_webhook(config, event, message, data)
1264def send_cycle_event(config, boundary: str, message: str, data: dict | None = None) -> None:
1265 """Report a sync-cycle boundary on both webhook transports.
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.
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)