Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""In-process background queue for outbound user emails.
|
||||
|
||||
The queue keeps SMTP delivery off the request path so message composition
|
||||
returns quickly and the rest of the API remains responsive while the worker
|
||||
thread performs the blocking SMTP call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.services.mailer import EmailAttachment, describe_smtp_error, send_email_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueuedEmailMessage:
|
||||
"""A queued outbound email request."""
|
||||
|
||||
request_id: str
|
||||
settings: Any
|
||||
recipients: list[str]
|
||||
subject: str
|
||||
html_body: str
|
||||
text_body: str
|
||||
attachments: list[EmailAttachment] = field(default_factory=list)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class MailQueue:
|
||||
"""Single-worker in-process queue for SMTP delivery."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: queue.Queue[QueuedEmailMessage | None] = queue.Queue()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._pending_count = 0
|
||||
self._active_request_id: str | None = None
|
||||
self._last_request_id: str | None = None
|
||||
self._last_result: str | None = None
|
||||
self._last_error = ""
|
||||
self._last_error_at: float | None = None
|
||||
self._last_success_at: float | None = None
|
||||
self._last_activity_at: float | None = None
|
||||
self._sent_count = 0
|
||||
self._failed_count = 0
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the worker thread if it is not already running."""
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="mail-queue-worker", daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Mail queue worker started")
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Stop the worker thread and wait briefly for shutdown."""
|
||||
with self._lock:
|
||||
thread = self._thread
|
||||
if not thread:
|
||||
return
|
||||
self._stop_event.set()
|
||||
self._queue.put(None)
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("Mail queue worker did not stop within %.1fs", timeout)
|
||||
else:
|
||||
logger.info("Mail queue worker stopped")
|
||||
with self._lock:
|
||||
if self._thread is thread:
|
||||
self._thread = None
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
settings: Any,
|
||||
recipients: list[str],
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str = "",
|
||||
attachments: list[EmailAttachment] | None = None,
|
||||
) -> str:
|
||||
"""Queue an outbound email and return a request identifier."""
|
||||
request_id = uuid.uuid4().hex
|
||||
message = QueuedEmailMessage(
|
||||
request_id=request_id,
|
||||
settings=settings,
|
||||
recipients=list(recipients),
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
text_body=text_body,
|
||||
attachments=list(attachments or []),
|
||||
)
|
||||
with self._lock:
|
||||
self._pending_count += 1
|
||||
self._last_request_id = request_id
|
||||
self._last_result = "queued"
|
||||
self._last_activity_at = time.time()
|
||||
self._queue.put(message)
|
||||
logger.info(
|
||||
"Queued email request_id=%s recipients=%s attachments=%s subject=%s",
|
||||
request_id,
|
||||
len(message.recipients),
|
||||
len(message.attachments),
|
||||
subject,
|
||||
)
|
||||
return request_id
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
"""Return a snapshot of the queue state for health/status endpoints."""
|
||||
with self._lock:
|
||||
worker_running = bool(self._thread and self._thread.is_alive())
|
||||
stop_requested = self._stop_event.is_set()
|
||||
pending_count = self._pending_count
|
||||
active_request_id = self._active_request_id
|
||||
last_request_id = self._last_request_id
|
||||
last_result = self._last_result
|
||||
last_error = self._last_error
|
||||
last_error_at = self._last_error_at
|
||||
last_success_at = self._last_success_at
|
||||
last_activity_at = self._last_activity_at
|
||||
sent_count = self._sent_count
|
||||
failed_count = self._failed_count
|
||||
|
||||
if not worker_running:
|
||||
state = "stopped" if stop_requested else "error"
|
||||
elif active_request_id or pending_count > 0:
|
||||
state = "busy"
|
||||
elif last_result == "failed" and last_error:
|
||||
state = "error"
|
||||
else:
|
||||
state = "idle"
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"worker_running": worker_running,
|
||||
"stop_requested": stop_requested,
|
||||
"pending_count": pending_count,
|
||||
"active_request_id": active_request_id,
|
||||
"last_request_id": last_request_id,
|
||||
"last_result": last_result,
|
||||
"last_error": last_error,
|
||||
"last_error_at": last_error_at,
|
||||
"last_success_at": last_success_at,
|
||||
"last_activity_at": last_activity_at,
|
||||
"sent_count": sent_count,
|
||||
"failed_count": failed_count,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
message = self._queue.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
try:
|
||||
if message is None:
|
||||
continue
|
||||
|
||||
with self._lock:
|
||||
self._pending_count = max(0, self._pending_count - 1)
|
||||
self._active_request_id = message.request_id
|
||||
self._last_request_id = message.request_id
|
||||
self._last_result = "sending"
|
||||
self._last_activity_at = time.time()
|
||||
|
||||
logger.info(
|
||||
"Mail queue sending request_id=%s recipients=%s attachments=%s subject=%s",
|
||||
message.request_id,
|
||||
len(message.recipients),
|
||||
len(message.attachments),
|
||||
message.subject,
|
||||
)
|
||||
result: dict[str, Any] = send_email_message(
|
||||
message.settings,
|
||||
recipients=message.recipients,
|
||||
subject=message.subject,
|
||||
html_body=message.html_body,
|
||||
text_body=message.text_body,
|
||||
attachments=message.attachments,
|
||||
)
|
||||
with self._lock:
|
||||
self._active_request_id = None
|
||||
self._last_result = "sent"
|
||||
self._last_success_at = time.time()
|
||||
self._last_activity_at = self._last_success_at
|
||||
self._last_error = ""
|
||||
self._last_error_at = None
|
||||
self._sent_count += 1
|
||||
logger.info(
|
||||
"Mail queue sent request_id=%s mode=%s auth_user=%s recipient_count=%s attachment_count=%s",
|
||||
message.request_id,
|
||||
(result.get("selected_mode") or {}).get("label", "<unknown>"),
|
||||
result.get("authenticated_as") or "<none>",
|
||||
result.get("recipient_count", 0),
|
||||
result.get("attachment_count", 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
friendly_error = describe_smtp_error(exc)
|
||||
with self._lock:
|
||||
self._active_request_id = None
|
||||
self._last_result = "failed"
|
||||
self._last_error = friendly_error
|
||||
self._last_error_at = time.time()
|
||||
self._last_activity_at = self._last_error_at
|
||||
self._failed_count += 1
|
||||
logger.exception(
|
||||
"Mail queue delivery failed request_id=%s error=%s",
|
||||
getattr(message, "request_id", "unknown"),
|
||||
friendly_error,
|
||||
)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
|
||||
_MAIL_QUEUE = MailQueue()
|
||||
|
||||
|
||||
def get_mail_queue() -> MailQueue:
|
||||
"""Return the singleton mail queue."""
|
||||
return _MAIL_QUEUE
|
||||
@@ -0,0 +1,540 @@
|
||||
"""SMTP email sending helpers for user communication workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import socket
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailAttachment:
|
||||
"""Attachment payload passed from the API layer."""
|
||||
|
||||
filename: str
|
||||
content_type: str
|
||||
data: bytes
|
||||
|
||||
|
||||
class _HTMLToTextParser(HTMLParser):
|
||||
"""Small HTML-to-text helper for plain-text fallback bodies."""
|
||||
|
||||
block_tags = {"p", "div", "section", "article", "header", "footer", "li", "tr", "td", "th", "br"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs): # type: ignore[override]
|
||||
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if data:
|
||||
self.parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return "".join(self.parts)
|
||||
|
||||
|
||||
def html_to_text(html: str) -> str:
|
||||
"""Convert a small HTML body to readable plain text."""
|
||||
parser = _HTMLToTextParser()
|
||||
parser.feed(html or "")
|
||||
text = parser.text()
|
||||
lines = [line.rstrip() for line in text.splitlines()]
|
||||
return "\n".join(line for line in lines if line).strip()
|
||||
|
||||
|
||||
def _from_address(settings: object) -> str:
|
||||
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip()
|
||||
if from_address:
|
||||
return from_address
|
||||
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
if smtp_username:
|
||||
return smtp_username
|
||||
raise ValueError("SMTP from address is required (set SMTP_FROM_ADDRESS or SMTP_USERNAME)")
|
||||
|
||||
|
||||
def validate_smtp_settings(settings: object) -> None:
|
||||
"""Validate that the SMTP configuration is sufficient to send mail."""
|
||||
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
|
||||
if not smtp_host:
|
||||
raise ValueError("SMTP host is required")
|
||||
_from_address(settings)
|
||||
|
||||
|
||||
def _smtp_settings(settings: object) -> dict[str, object]:
|
||||
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
|
||||
if not smtp_host:
|
||||
raise ValueError("SMTP host is required")
|
||||
smtp_port = int(getattr(settings, "smtp_port", 587) or 587)
|
||||
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
smtp_password = str(getattr(settings, "smtp_password", "") or "")
|
||||
use_tls = bool(getattr(settings, "smtp_use_tls", True))
|
||||
use_ssl = bool(getattr(settings, "smtp_use_ssl", False))
|
||||
smtp_timeout = int(getattr(settings, "smtp_timeout", 30) or 30)
|
||||
return {
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": smtp_port,
|
||||
"smtp_username": smtp_username,
|
||||
"smtp_password": smtp_password,
|
||||
"use_tls": use_tls,
|
||||
"use_ssl": use_ssl,
|
||||
"smtp_timeout": smtp_timeout,
|
||||
}
|
||||
|
||||
|
||||
def _smtp_mode_label(mode: dict[str, object]) -> str:
|
||||
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
|
||||
return f"{mode['smtp_host']}:{mode['smtp_port']} via {transport}"
|
||||
|
||||
|
||||
def _smtp_mode_candidates(settings: object) -> list[dict[str, Any]]:
|
||||
base = _smtp_settings(settings)
|
||||
candidates = [dict(base, mode_label="configured")]
|
||||
smtp_host = str(base["smtp_host"]).lower()
|
||||
if "fastmail.com" in smtp_host:
|
||||
fastmail_ssl = {
|
||||
**base,
|
||||
"smtp_port": 465,
|
||||
"use_tls": False,
|
||||
"use_ssl": True,
|
||||
"mode_label": "Fastmail SSL 465",
|
||||
}
|
||||
fastmail_tls = {
|
||||
**base,
|
||||
"smtp_port": 587,
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
"mode_label": "Fastmail STARTTLS 587",
|
||||
}
|
||||
for mode in (fastmail_ssl, fastmail_tls):
|
||||
if not any(
|
||||
candidate["smtp_port"] == mode["smtp_port"]
|
||||
and candidate["use_tls"] == mode["use_tls"]
|
||||
and candidate["use_ssl"] == mode["use_ssl"]
|
||||
for candidate in candidates
|
||||
):
|
||||
candidates.append(mode)
|
||||
return candidates
|
||||
|
||||
|
||||
def _probe_smtp_connection(mode: dict[str, Any]) -> None:
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = str(mode["smtp_host"])
|
||||
smtp_port = int(mode["smtp_port"])
|
||||
smtp_username = str(mode["smtp_username"])
|
||||
smtp_password = str(mode["smtp_password"])
|
||||
use_tls = bool(mode["use_tls"])
|
||||
use_ssl = bool(mode["use_ssl"])
|
||||
smtp_timeout = int(mode["smtp_timeout"])
|
||||
|
||||
if use_ssl:
|
||||
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
|
||||
else:
|
||||
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
|
||||
|
||||
with smtp_connection as smtp:
|
||||
if use_tls and not use_ssl:
|
||||
smtp.ehlo()
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
else:
|
||||
smtp.ehlo()
|
||||
if smtp_username:
|
||||
smtp.login(smtp_username, smtp_password)
|
||||
smtp.noop()
|
||||
|
||||
|
||||
def _smtp_sender_not_authorized(error: Exception) -> bool:
|
||||
code = getattr(error, "smtp_code", None)
|
||||
raw_error = getattr(error, "smtp_error", b"")
|
||||
if isinstance(raw_error, bytes):
|
||||
raw_error_text = raw_error.decode(errors="ignore")
|
||||
else:
|
||||
raw_error_text = str(raw_error)
|
||||
text = f"{code} {raw_error_text} {error}".lower()
|
||||
return code in {551, 553} or "not authorised to send from this header address" in text or "not authorized to send from this header address" in text
|
||||
|
||||
|
||||
def _smtp_attempt_metadata(mode: dict[str, Any]) -> dict[str, Any]:
|
||||
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
|
||||
return {
|
||||
"label": str(mode.get("mode_label") or _smtp_mode_label(mode)),
|
||||
"smtp_host": str(mode["smtp_host"]),
|
||||
"smtp_port": int(mode["smtp_port"]),
|
||||
"use_tls": bool(mode["use_tls"]),
|
||||
"use_ssl": bool(mode["use_ssl"]),
|
||||
"transport": transport,
|
||||
"auth_user": str(mode.get("smtp_username") or "") or "<none>",
|
||||
}
|
||||
|
||||
|
||||
def describe_smtp_error(error: Exception) -> str:
|
||||
"""Convert SMTP failures into operator-friendly messages."""
|
||||
chain: list[Exception] = []
|
||||
current: Exception | None = error
|
||||
while current is not None and current not in chain:
|
||||
chain.append(current)
|
||||
current = current.__cause__ if isinstance(current.__cause__, Exception) else None
|
||||
|
||||
for item in chain:
|
||||
text = str(item).strip()
|
||||
lowered = text.lower()
|
||||
if isinstance(item, (TimeoutError, socket.timeout)) or "timed out" in lowered:
|
||||
return (
|
||||
"SMTP connection timed out while waiting for the server greeting. "
|
||||
"Check host, port, network access, and SMTP_TIMEOUT."
|
||||
)
|
||||
if isinstance(item, smtplib.SMTPAuthenticationError):
|
||||
return (
|
||||
"SMTP authentication failed. Check SMTP_USERNAME and SMTP_PASSWORD "
|
||||
"(Fastmail and similar providers usually require an app password)."
|
||||
)
|
||||
if isinstance(item, (smtplib.SMTPDataError, smtplib.SMTPResponseException)):
|
||||
smtp_code = getattr(item, "smtp_code", None)
|
||||
if smtp_code in {551, 553} or "not authorised to send from this header address" in lowered or "not authorized to send from this header address" in lowered:
|
||||
return (
|
||||
"SMTP server rejected the configured From address. Use an authorized alias "
|
||||
"for this account or change SMTP_FROM_ADDRESS to a sender the provider allows."
|
||||
)
|
||||
if isinstance(item, smtplib.SMTPConnectError):
|
||||
return "SMTP connection was rejected by the server. Check the host and port."
|
||||
if isinstance(item, smtplib.SMTPServerDisconnected) and "timed out" in lowered:
|
||||
return (
|
||||
"SMTP connection timed out while waiting for the server greeting. "
|
||||
"Check host, port, network access, and SMTP_TIMEOUT."
|
||||
)
|
||||
|
||||
return f"SMTP delivery failed: {error}"
|
||||
|
||||
|
||||
def test_smtp_connection(settings: object) -> dict[str, object]:
|
||||
"""Validate SMTP connectivity and authentication without sending a message."""
|
||||
try:
|
||||
base = _smtp_settings(settings)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(exc),
|
||||
"attempts": [],
|
||||
"selected_mode": None,
|
||||
"from_address": "",
|
||||
"from_name": str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer",
|
||||
"smtp_host": str(getattr(settings, "smtp_host", "") or "").strip(),
|
||||
"smtp_port": int(getattr(settings, "smtp_port", 587) or 587),
|
||||
"use_tls": bool(getattr(settings, "smtp_use_tls", True)),
|
||||
"use_ssl": bool(getattr(settings, "smtp_use_ssl", False)),
|
||||
"authenticated": False,
|
||||
}
|
||||
|
||||
from_address = _from_address(settings)
|
||||
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
|
||||
attempts: list[dict[str, Any]] = []
|
||||
last_error = ""
|
||||
logger.info(
|
||||
"SMTP test requested from_address=%s auth_user=%s",
|
||||
from_address,
|
||||
base["smtp_username"] or "<none>",
|
||||
)
|
||||
|
||||
for mode in _smtp_mode_candidates(settings):
|
||||
meta = _smtp_attempt_metadata(mode)
|
||||
logger.info(
|
||||
"SMTP test attempting label=%s host=%s port=%s transport=%s auth_user=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
)
|
||||
try:
|
||||
_probe_smtp_connection(mode)
|
||||
attempts.append(
|
||||
{
|
||||
"label": meta["label"],
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
"status": "ok",
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"SMTP test succeeded label=%s host=%s port=%s transport=%s auth_user=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"SMTP connection successful using {meta['label']}",
|
||||
"from_address": from_address,
|
||||
"from_name": from_name,
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
"authenticated": bool(str(mode["smtp_username"])),
|
||||
"selected_mode": {
|
||||
"label": meta["label"],
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
},
|
||||
"attempts": attempts,
|
||||
}
|
||||
except Exception as exc:
|
||||
last_error = describe_smtp_error(exc)
|
||||
attempts.append(
|
||||
{
|
||||
"label": meta["label"],
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
"status": "failed",
|
||||
"error": last_error,
|
||||
}
|
||||
)
|
||||
logger.warning(
|
||||
"SMTP test failed label=%s host=%s port=%s transport=%s auth_user=%s error=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
last_error,
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"SMTP test exhausted attempts=%s error=%s",
|
||||
[attempt["label"] for attempt in attempts],
|
||||
last_error or "SMTP test failed.",
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": last_error or "SMTP test failed.",
|
||||
"from_address": from_address,
|
||||
"from_name": from_name,
|
||||
"smtp_host": base["smtp_host"],
|
||||
"smtp_port": base["smtp_port"],
|
||||
"use_tls": base["use_tls"],
|
||||
"use_ssl": base["use_ssl"],
|
||||
"authenticated": bool(base["smtp_username"]),
|
||||
"selected_mode": None,
|
||||
"attempts": attempts,
|
||||
}
|
||||
|
||||
|
||||
def build_email_message(
|
||||
settings: object,
|
||||
recipients: list[str],
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str,
|
||||
attachments: Iterable[EmailAttachment] = (),
|
||||
*,
|
||||
sender_address: str | None = None,
|
||||
reply_to_address: str | None = None,
|
||||
) -> tuple[EmailMessage, str]:
|
||||
"""Build a MIME email message with HTML and attachments."""
|
||||
from_address = sender_address or _from_address(settings)
|
||||
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((from_name, from_address))
|
||||
msg["To"] = "Undisclosed recipients:;"
|
||||
msg["Reply-To"] = reply_to_address or from_address
|
||||
|
||||
plain_text = text_body.strip() or html_to_text(html_body)
|
||||
if html_body.strip():
|
||||
msg.set_content(plain_text or " ")
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
else:
|
||||
msg.set_content(plain_text or "")
|
||||
|
||||
for attachment in attachments:
|
||||
content_type = attachment.content_type or mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream"
|
||||
maintype, subtype = content_type.split("/", 1) if "/" in content_type else ("application", "octet-stream")
|
||||
msg.add_attachment(
|
||||
attachment.data,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
filename=attachment.filename or "attachment",
|
||||
)
|
||||
|
||||
return msg, from_address
|
||||
|
||||
|
||||
def _send_email_via_mode(
|
||||
mode: dict[str, Any],
|
||||
message: EmailMessage,
|
||||
recipients: list[str],
|
||||
from_address: str,
|
||||
) -> None:
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = str(mode["smtp_host"])
|
||||
smtp_port = int(mode["smtp_port"])
|
||||
smtp_username = str(mode["smtp_username"])
|
||||
smtp_password = str(mode["smtp_password"])
|
||||
use_tls = bool(mode["use_tls"])
|
||||
use_ssl = bool(mode["use_ssl"])
|
||||
smtp_timeout = int(mode["smtp_timeout"])
|
||||
|
||||
if use_ssl:
|
||||
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
|
||||
else:
|
||||
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
|
||||
|
||||
with smtp_connection as smtp:
|
||||
if use_tls and not use_ssl:
|
||||
smtp.ehlo()
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
else:
|
||||
smtp.ehlo()
|
||||
if smtp_username:
|
||||
smtp.login(smtp_username, smtp_password)
|
||||
smtp.send_message(message, from_addr=from_address, to_addrs=recipients)
|
||||
|
||||
|
||||
def send_email_message(
|
||||
settings: object,
|
||||
recipients: list[str],
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str = "",
|
||||
attachments: Iterable[EmailAttachment] = (),
|
||||
) -> dict[str, object]:
|
||||
"""Send a single outbound email to a recipient list via SMTP BCC."""
|
||||
if not recipients:
|
||||
raise ValueError("At least one recipient is required")
|
||||
|
||||
attachment_list = list(attachments)
|
||||
preferred_from_address = _from_address(settings)
|
||||
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
message, from_address = build_email_message(
|
||||
settings,
|
||||
recipients,
|
||||
subject,
|
||||
html_body,
|
||||
text_body,
|
||||
attachment_list,
|
||||
sender_address=preferred_from_address,
|
||||
reply_to_address=preferred_from_address,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"SMTP send requested subject=%s recipients=%s from_address=%s auth_user=%s attachments=%s",
|
||||
subject,
|
||||
len(recipients),
|
||||
from_address,
|
||||
smtp_username or "<none>",
|
||||
len(attachment_list),
|
||||
)
|
||||
|
||||
attempts: list[dict[str, Any]] = []
|
||||
last_error = ""
|
||||
for mode in _smtp_mode_candidates(settings):
|
||||
meta = _smtp_attempt_metadata(mode)
|
||||
logger.info(
|
||||
"SMTP send attempting label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
from_address,
|
||||
)
|
||||
try:
|
||||
_send_email_via_mode(mode, message, recipients, from_address)
|
||||
attempts.append(
|
||||
{
|
||||
"label": meta["label"],
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
"status": "ok",
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
from_address,
|
||||
)
|
||||
return {
|
||||
"from_address": from_address,
|
||||
"recipient_count": len(recipients),
|
||||
"attachment_count": len(attachment_list),
|
||||
"subject": subject,
|
||||
"authenticated_as": smtp_username or None,
|
||||
"selected_mode": {
|
||||
"label": meta["label"],
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
},
|
||||
"attempts": attempts,
|
||||
}
|
||||
except Exception as exc:
|
||||
last_error = describe_smtp_error(exc)
|
||||
attempts.append(
|
||||
{
|
||||
"label": meta["label"],
|
||||
"smtp_host": meta["smtp_host"],
|
||||
"smtp_port": meta["smtp_port"],
|
||||
"use_tls": meta["use_tls"],
|
||||
"use_ssl": meta["use_ssl"],
|
||||
"status": "failed",
|
||||
"error": last_error,
|
||||
}
|
||||
)
|
||||
if _smtp_sender_not_authorized(exc):
|
||||
logger.warning(
|
||||
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
from_address,
|
||||
last_error,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
|
||||
meta["label"],
|
||||
meta["smtp_host"],
|
||||
meta["smtp_port"],
|
||||
meta["transport"],
|
||||
meta["auth_user"],
|
||||
from_address,
|
||||
last_error,
|
||||
)
|
||||
|
||||
raise RuntimeError(last_error or "SMTP delivery failed")
|
||||
@@ -7,14 +7,18 @@ through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
|
||||
from media_library_viewer_api.path_utils import resolve_remote_media_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Local generated database. It is ignored by git and can be rebuilt from
|
||||
# Jellyfin metadata whenever needed.
|
||||
@@ -42,6 +46,19 @@ SORT_COLUMNS = {
|
||||
}
|
||||
|
||||
|
||||
def _estimate_remaining_seconds(elapsed_seconds: float, progress: float | None) -> float | None:
|
||||
if progress is None:
|
||||
return None
|
||||
progress = max(0.0, min(1.0, progress))
|
||||
if progress <= 0.0:
|
||||
return None
|
||||
return max(0.0, elapsed_seconds * (1.0 - progress) / progress)
|
||||
|
||||
|
||||
class MediaIndexBuildCancelled(Exception):
|
||||
"""Raised when a media index build is requested to stop."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaIndexStatus:
|
||||
"""Lightweight status object displayed by the Media tab."""
|
||||
@@ -51,6 +68,25 @@ class MediaIndexStatus:
|
||||
updated_at: int | None = None
|
||||
updated_at_label: str = ""
|
||||
build_duration_seconds: float | None = None
|
||||
build_running: bool = False
|
||||
build_stage: str = ""
|
||||
build_message: str = ""
|
||||
build_progress: float | None = None
|
||||
build_items_processed: int = 0
|
||||
build_items_total: int = 0
|
||||
build_current_library: str = ""
|
||||
build_library_index: int = 0
|
||||
build_libraries_total: int = 0
|
||||
build_library_progress: float | None = None
|
||||
build_library_items_processed: int = 0
|
||||
build_library_items_total: int = 0
|
||||
build_elapsed_seconds: float | None = None
|
||||
build_eta_seconds: float | None = None
|
||||
build_library_elapsed_seconds: float | None = None
|
||||
build_library_eta_seconds: float | None = None
|
||||
build_cancel_requested: bool = False
|
||||
build_pid: int | None = None
|
||||
build_error: str = ""
|
||||
|
||||
|
||||
class MediaIndex:
|
||||
@@ -66,8 +102,10 @@ class MediaIndex:
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
"""Open a sqlite connection configured to return Row objects."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = sqlite3.connect(self.db_path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def init_schema(self) -> None:
|
||||
@@ -170,24 +208,68 @@ class MediaIndex:
|
||||
try:
|
||||
with self.connect() as conn:
|
||||
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
|
||||
updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone()
|
||||
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
|
||||
meta = {
|
||||
row[0]: row[1]
|
||||
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
|
||||
}
|
||||
except sqlite3.Error:
|
||||
return MediaIndexStatus(exists=False)
|
||||
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
|
||||
updated_at_raw = meta.get("updated_at", "")
|
||||
updated_at = int(updated_at_raw) if str(updated_at_raw).isdigit() else None
|
||||
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
|
||||
duration_raw = meta.get("build_duration_seconds")
|
||||
build_duration = None
|
||||
if duration_row:
|
||||
if duration_raw is not None:
|
||||
try:
|
||||
build_duration = float(duration_row[0])
|
||||
build_duration = float(duration_raw)
|
||||
except (TypeError, ValueError):
|
||||
build_duration = None
|
||||
|
||||
def _bool(key: str, default: bool = False) -> bool:
|
||||
value = str(meta.get(key, str(default))).strip().lower()
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
|
||||
def _int(key: str, default: int = 0) -> int:
|
||||
value = meta.get(key, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _float(key: str) -> float | None:
|
||||
value = meta.get(key)
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return MediaIndexStatus(
|
||||
exists=True,
|
||||
item_count=item_count,
|
||||
updated_at=updated_at,
|
||||
updated_at_label=label,
|
||||
build_duration_seconds=build_duration,
|
||||
build_running=_bool("build_running"),
|
||||
build_stage=str(meta.get("build_stage", "")),
|
||||
build_message=str(meta.get("build_message", "")),
|
||||
build_progress=_float("build_progress"),
|
||||
build_items_processed=_int("build_items_processed"),
|
||||
build_items_total=_int("build_items_total"),
|
||||
build_current_library=str(meta.get("build_current_library", "")),
|
||||
build_library_index=_int("build_library_index"),
|
||||
build_libraries_total=_int("build_libraries_total"),
|
||||
build_library_progress=_float("build_library_progress"),
|
||||
build_library_items_processed=_int("build_library_items_processed"),
|
||||
build_library_items_total=_int("build_library_items_total"),
|
||||
build_elapsed_seconds=_float("build_elapsed_seconds"),
|
||||
build_eta_seconds=_float("build_eta_seconds"),
|
||||
build_library_elapsed_seconds=_float("build_library_elapsed_seconds"),
|
||||
build_library_eta_seconds=_float("build_library_eta_seconds"),
|
||||
build_cancel_requested=_bool("build_cancel_requested"),
|
||||
build_pid=_int("build_pid") or None,
|
||||
build_error=str(meta.get("build_error", "")),
|
||||
)
|
||||
|
||||
def query(
|
||||
@@ -245,18 +327,79 @@ def build_media_index(
|
||||
libraries: list[dict[str, Any]],
|
||||
index: MediaIndex | None = None,
|
||||
page_size: int = 500,
|
||||
media_root: str = "",
|
||||
fallback_prefix: str = "",
|
||||
progress_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
) -> int:
|
||||
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
||||
index = index or MediaIndex()
|
||||
started_at = time.perf_counter()
|
||||
normalized_rows: list[dict[str, Any]] = []
|
||||
for library in libraries:
|
||||
processed_total = 0
|
||||
expected_total = 0
|
||||
current_library_name = ""
|
||||
current_library_index = 0
|
||||
current_library_processed = 0
|
||||
current_library_total = 0
|
||||
current_library_started_at = started_at
|
||||
|
||||
def ensure_not_cancelled() -> None:
|
||||
if should_cancel and should_cancel():
|
||||
raise MediaIndexBuildCancelled()
|
||||
|
||||
def emit(stage: str, message: str) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
elapsed_seconds = time.perf_counter() - started_at
|
||||
library_elapsed_seconds = time.perf_counter() - current_library_started_at
|
||||
overall_progress = (processed_total / expected_total) if expected_total else None
|
||||
library_progress = (current_library_processed / current_library_total) if current_library_total else None
|
||||
progress_callback(
|
||||
{
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"processed": processed_total,
|
||||
"total": expected_total,
|
||||
"progress": overall_progress,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"eta_seconds": _estimate_remaining_seconds(elapsed_seconds, overall_progress),
|
||||
"library": current_library_name,
|
||||
"library_index": current_library_index,
|
||||
"libraries_total": len(libraries),
|
||||
"library_processed": current_library_processed,
|
||||
"library_total": current_library_total,
|
||||
"library_progress": library_progress,
|
||||
"library_elapsed_seconds": library_elapsed_seconds if current_library_total else None,
|
||||
"library_eta_seconds": _estimate_remaining_seconds(library_elapsed_seconds, library_progress),
|
||||
}
|
||||
)
|
||||
|
||||
ensure_not_cancelled()
|
||||
logger.info("Media index build starting libraries=%s page_size=%s", len(libraries), page_size)
|
||||
emit("starting", "Starting media index build")
|
||||
for library_index, library in enumerate(libraries, start=1):
|
||||
library_id = library.get("Id")
|
||||
library_name = library.get("Name", "")
|
||||
current_library_name = library.get("Name", "")
|
||||
current_library_index = library_index
|
||||
current_library_processed = 0
|
||||
current_library_total = 0
|
||||
current_library_started_at = time.perf_counter()
|
||||
if not library_id:
|
||||
continue
|
||||
ensure_not_cancelled()
|
||||
logger.info(
|
||||
"Media index scanning library index=%s/%s name=%s id=%s",
|
||||
library_index,
|
||||
len(libraries),
|
||||
current_library_name or "Library",
|
||||
library_id,
|
||||
)
|
||||
emit("library-starting", f"Scanning {current_library_name or 'Library'}")
|
||||
start = 0
|
||||
discovered_library_total = None
|
||||
while True:
|
||||
ensure_not_cancelled()
|
||||
response = client.items(
|
||||
user_id=user_id,
|
||||
parent_id=library_id,
|
||||
@@ -267,12 +410,49 @@ def build_media_index(
|
||||
sort_by="SortName",
|
||||
sort_order="Ascending",
|
||||
)
|
||||
ensure_not_cancelled()
|
||||
items = response.get("Items", [])
|
||||
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
|
||||
if discovered_library_total is None:
|
||||
discovered_library_total = int(response.get("TotalRecordCount", len(items)))
|
||||
current_library_total = max(discovered_library_total, 0)
|
||||
expected_total += current_library_total
|
||||
normalized_rows.extend(
|
||||
{
|
||||
**row,
|
||||
"path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix),
|
||||
}
|
||||
for row in (
|
||||
normalize_media_item(item, library_id, current_library_name)
|
||||
for item in items
|
||||
)
|
||||
)
|
||||
processed_total += len(items)
|
||||
current_library_processed += len(items)
|
||||
start += len(items)
|
||||
ensure_not_cancelled()
|
||||
emit(
|
||||
"building",
|
||||
f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items",
|
||||
)
|
||||
logger.debug(
|
||||
"Media index progress library=%s processed=%s/%s total_processed=%s",
|
||||
current_library_name or "Library",
|
||||
current_library_processed,
|
||||
current_library_total,
|
||||
processed_total,
|
||||
)
|
||||
total = int(response.get("TotalRecordCount", start))
|
||||
if not items or start >= total:
|
||||
break
|
||||
ensure_not_cancelled()
|
||||
logger.info("Media index finalizing rows=%s", len(normalized_rows))
|
||||
emit("finalizing", "Writing index to disk")
|
||||
ensure_not_cancelled()
|
||||
count = index.replace_items(normalized_rows)
|
||||
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
|
||||
duration = time.perf_counter() - started_at
|
||||
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
|
||||
processed_total = count
|
||||
current_library_processed = current_library_total
|
||||
emit("completed", f"Indexed {count} items in {duration:.1f}s")
|
||||
logger.info("Media index build completed count=%s duration=%.2fs", count, duration)
|
||||
return count
|
||||
|
||||
Reference in New Issue
Block a user