From 4f2b5e092279c4ebb89600cbe8ea1cd0ff2db31d Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Tue, 3 Mar 2026 16:12:13 +1300 Subject: [PATCH] Add SMTP receipt logging for Exchange relay tracing --- .build_number | 2 +- backend/app/build_info.py | 4 +- backend/app/services/invite_email.py | 111 ++++++++++++++++++++++++--- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 5 files changed, 108 insertions(+), 15 deletions(-) diff --git a/.build_number b/.build_number index 75f4027..c630262 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0303261601 +0303261611 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 7d5c261..0118f36 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0303261601" -CHANGELOG = '2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0303261611" +CHANGELOG = '2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index 5ffe15c..f02e8dd 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -6,8 +6,10 @@ import json import logging import re import smtplib +from email.generator import BytesGenerator from email.message import EmailMessage from email.utils import formataddr +from io import BytesIO from typing import Any, Dict, Optional from ..build_info import BUILD_NUMBER @@ -21,6 +23,8 @@ TEMPLATE_SETTING_PREFIX = "invite_email_template_" TEMPLATE_KEYS = ("invited", "welcome", "warning", "banned") EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") +EXCHANGE_MESSAGE_ID_PATTERN = re.compile(r"<([^>]+)>") +EXCHANGE_INTERNAL_ID_PATTERN = re.compile(r"\[InternalId=([^\],]+)") TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = { "invited": { @@ -396,7 +400,56 @@ def smtp_email_delivery_warning() -> Optional[str]: return None -def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None: +def _flatten_message(message: EmailMessage) -> bytes: + buffer = BytesIO() + BytesGenerator(buffer).flatten(message) + return buffer.getvalue() + + +def _decode_smtp_message(value: bytes | str | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +def _parse_exchange_receipt(value: bytes | str | None) -> Dict[str, str]: + message = _decode_smtp_message(value) + receipt: Dict[str, str] = {"raw": message} + message_id_match = EXCHANGE_MESSAGE_ID_PATTERN.search(message) + internal_id_match = EXCHANGE_INTERNAL_ID_PATTERN.search(message) + if message_id_match: + receipt["provider_message_id"] = message_id_match.group(1) + if internal_id_match: + receipt["provider_internal_id"] = internal_id_match.group(1) + return receipt + + +def _send_via_smtp_session( + smtp: smtplib.SMTP, + *, + from_address: str, + recipient_email: str, + message: EmailMessage, +) -> Dict[str, str]: + mail_code, mail_message = smtp.mail(from_address) + if mail_code >= 400: + raise smtplib.SMTPResponseException(mail_code, mail_message) + rcpt_code, rcpt_message = smtp.rcpt(recipient_email) + if rcpt_code >= 400: + raise smtplib.SMTPRecipientsRefused({recipient_email: (rcpt_code, rcpt_message)}) + data_code, data_message = smtp.data(_flatten_message(message)) + if data_code >= 400: + raise smtplib.SMTPDataError(data_code, data_message) + receipt = _parse_exchange_receipt(data_message) + receipt["mail_response"] = _decode_smtp_message(mail_message) + receipt["rcpt_response"] = _decode_smtp_message(rcpt_message) + receipt["data_response"] = _decode_smtp_message(data_message) + return receipt + + +def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> Dict[str, str]: runtime = get_runtime_settings() host = _normalize_display_text(runtime.magent_notify_email_smtp_host) port = int(runtime.magent_notify_email_smtp_port or 587) @@ -437,9 +490,20 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body if username and password: smtp.login(username, password) logger.debug("smtp login succeeded host=%s username=%s", host, username) - smtp.send_message(message) - logger.info("smtp send accepted recipient=%s host=%s mode=ssl", recipient_email, host) - return + receipt = _send_via_smtp_session( + smtp, + from_address=from_address, + recipient_email=recipient_email, + message=message, + ) + logger.info( + "smtp send accepted recipient=%s host=%s mode=ssl provider_message_id=%s provider_internal_id=%s", + recipient_email, + host, + receipt.get("provider_message_id"), + receipt.get("provider_internal_id"), + ) + return receipt with smtplib.SMTP(host, port, timeout=20) as smtp: logger.debug("smtp connection opened host=%s port=%s", host, port) @@ -451,8 +515,20 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body if username and password: smtp.login(username, password) logger.debug("smtp login succeeded host=%s username=%s", host, username) - smtp.send_message(message) - logger.info("smtp send accepted recipient=%s host=%s mode=plain", recipient_email, host) + receipt = _send_via_smtp_session( + smtp, + from_address=from_address, + recipient_email=recipient_email, + message=message, + ) + logger.info( + "smtp send accepted recipient=%s host=%s mode=plain provider_message_id=%s provider_internal_id=%s", + recipient_email, + host, + receipt.get("provider_message_id"), + receipt.get("provider_internal_id"), + ) + return receipt async def send_templated_email( @@ -484,7 +560,7 @@ async def send_templated_email( reason=reason, overrides=overrides, ) - await asyncio.to_thread( + receipt = await asyncio.to_thread( _send_email_sync, recipient_email=resolved_email, subject=rendered["subject"], @@ -495,6 +571,11 @@ async def send_templated_email( return { "recipient_email": resolved_email, "subject": rendered["subject"], + **{ + key: value + for key, value in receipt.items() + if key in {"provider_message_id", "provider_internal_id", "data_response"} + }, } @@ -524,7 +605,7 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st f"Application URL: {html.escape(application_url)}

" ) - await asyncio.to_thread( + receipt = await asyncio.to_thread( _send_email_sync, recipient_email=resolved_email, subject=subject, @@ -533,6 +614,13 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st ) logger.info("SMTP test email sent: recipient=%s", resolved_email) result = {"recipient_email": resolved_email, "subject": subject} + result.update( + { + key: value + for key, value in receipt.items() + if key in {"provider_message_id", "provider_internal_id", "data_response"} + } + ) warning = smtp_email_delivery_warning() if warning: result["warning"] = warning @@ -575,7 +663,7 @@ async def send_password_reset_email( "

If you did not request this reset, you can ignore this email.

" ) - await asyncio.to_thread( + receipt = await asyncio.to_thread( _send_email_sync, recipient_email=resolved_email, subject=subject, @@ -592,6 +680,11 @@ async def send_password_reset_email( "recipient_email": resolved_email, "subject": subject, "reset_url": reset_url, + **{ + key: value + for key, value in receipt.items() + if key in {"provider_message_id", "provider_internal_id", "data_response"} + }, } warning = smtp_email_delivery_warning() if warning: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b1800f6..0478a7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "magent-frontend", - "version": "0303261601", + "version": "0303261611", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magent-frontend", - "version": "0303261601", + "version": "0303261611", "dependencies": { "next": "16.1.6", "react": "19.2.4", diff --git a/frontend/package.json b/frontend/package.json index 67cf067..1433a17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "magent-frontend", "private": true, - "version": "0303261601", + "version": "0303261611", "scripts": { "dev": "next dev", "build": "next build",