From e09f5f775765f382e970a22672833f2584080e43 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:33:12 +0000 Subject: [PATCH] Feature: Email output mode (#603) --- shelfmark/config/email_settings.py | 42 ++ shelfmark/config/settings.py | 259 +++++++++++ shelfmark/core/models.py | 7 +- shelfmark/download/orchestrator.py | 93 +++- shelfmark/download/outputs/__init__.py | 41 ++ shelfmark/download/outputs/booklore.py | 13 +- shelfmark/download/outputs/email.py | 425 ++++++++++++++++++ shelfmark/download/outputs/folder.py | 4 +- shelfmark/main.py | 26 +- src/frontend/src/App.tsx | 76 +++- .../src/components/BookDownloadButton.tsx | 4 +- src/frontend/src/components/DetailsModal.tsx | 4 + .../src/components/EmailRecipientModal.tsx | 187 ++++++++ src/frontend/src/components/ReleaseModal.tsx | 12 +- .../components/settings/fields/TableField.tsx | 21 +- src/frontend/src/services/api.ts | 10 +- src/frontend/src/types/index.ts | 9 + src/frontend/src/utils/errors.ts | 11 + 18 files changed, 1208 insertions(+), 36 deletions(-) create mode 100644 shelfmark/config/email_settings.py create mode 100644 shelfmark/download/outputs/email.py create mode 100644 src/frontend/src/components/EmailRecipientModal.tsx create mode 100644 src/frontend/src/utils/errors.ts diff --git a/shelfmark/config/email_settings.py b/shelfmark/config/email_settings.py new file mode 100644 index 00000000..82a13e62 --- /dev/null +++ b/shelfmark/config/email_settings.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Any + +from shelfmark.core.config import config +from shelfmark.download.outputs.email import EmailOutputError, build_email_smtp_config, test_smtp_connection + + +def test_email_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Test SMTP connectivity using current form values (including unsaved changes).""" + + current_values = current_values or {} + + def _get_value(key: str, default: Any = None) -> Any: + value = current_values.get(key) + if value not in (None, ""): + return value + if default is None: + return config.get(key) + return config.get(key, default) + + settings = { + "EMAIL_SMTP_HOST": _get_value("EMAIL_SMTP_HOST", ""), + "EMAIL_SMTP_PORT": _get_value("EMAIL_SMTP_PORT", 587), + "EMAIL_SMTP_SECURITY": _get_value("EMAIL_SMTP_SECURITY", "starttls"), + "EMAIL_SMTP_USERNAME": _get_value("EMAIL_SMTP_USERNAME", ""), + "EMAIL_SMTP_PASSWORD": _get_value("EMAIL_SMTP_PASSWORD", ""), + "EMAIL_FROM": _get_value("EMAIL_FROM", ""), + "EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"), + "EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60), + "EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", False), + } + + try: + smtp_config = build_email_smtp_config(settings) + test_smtp_connection(smtp_config) + return {"success": True, "message": "Connected to SMTP server"} + except EmailOutputError as exc: + return {"success": False, "message": str(exc)} + except Exception as exc: + return {"success": False, "message": f"SMTP test failed: {exc}"} + diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 1e53b6c5..d50e9376 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -69,6 +69,7 @@ from shelfmark.config.booklore_settings import ( get_booklore_path_options, test_booklore_connection, ) +from shelfmark.config.email_settings import test_email_connection from shelfmark.core.logger import setup_logger logger = setup_logger(__name__) @@ -624,6 +625,140 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: "values": values, } + # Email output (SMTP) validation. + if books_output_mode == "email": + from email.utils import parseaddr + + def _is_plain_email_address(addr: str) -> bool: + parsed = parseaddr(addr or "")[1] + return bool(parsed) and "@" in parsed and parsed == addr + + raw_recipients = effective.get("EMAIL_RECIPIENTS", []) + if not isinstance(raw_recipients, list): + return {"error": True, "message": "Email recipients must be a list", "values": values} + + cleaned_recipients: list[dict[str, str]] = [] + seen_nicknames: set[str] = set() + + for entry in raw_recipients: + if not isinstance(entry, dict): + continue + + nickname = str(entry.get("nickname", "") or "").strip() + email = str(entry.get("email", "") or "").strip() + + # Allow incomplete rows in the UI by skipping them. + if not nickname or not email: + continue + + nickname_key = nickname.lower() + if nickname_key in seen_nicknames: + return { + "error": True, + "message": f"Duplicate email recipient nickname: {nickname}", + "values": values, + } + seen_nicknames.add(nickname_key) + + if not _is_plain_email_address(email): + return { + "error": True, + "message": f"Invalid email address for '{nickname}': {email}", + "values": values, + } + + cleaned_recipients.append({"nickname": nickname, "email": email}) + + if not cleaned_recipients: + return { + "error": True, + "message": "At least one email recipient is required (Downloads -> Books -> Email).", + "values": values, + } + + smtp_host = str(effective.get("EMAIL_SMTP_HOST", "") or "").strip() + if not smtp_host: + return {"error": True, "message": "SMTP host is required", "values": values} + + security = str(effective.get("EMAIL_SMTP_SECURITY", "starttls") or "").strip().lower() + if security not in {"none", "starttls", "ssl"}: + return { + "error": True, + "message": "SMTP security must be one of: none, starttls, ssl", + "values": values, + } + + try: + port = int(effective.get("EMAIL_SMTP_PORT", 587)) + except (TypeError, ValueError): + return {"error": True, "message": "SMTP port must be a number", "values": values} + + if port < 1 or port > 65535: + return {"error": True, "message": "SMTP port must be between 1 and 65535", "values": values} + + try: + timeout_seconds = int(effective.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60)) + except (TypeError, ValueError): + return {"error": True, "message": "SMTP timeout (seconds) must be a number", "values": values} + + if timeout_seconds < 1: + return {"error": True, "message": "SMTP timeout (seconds) must be >= 1", "values": values} + + username = str(effective.get("EMAIL_SMTP_USERNAME", "") or "").strip() + password = effective.get("EMAIL_SMTP_PASSWORD", "") or "" + if username and not password: + return {"error": True, "message": "SMTP password is required when username is set", "values": values} + + try: + attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)) + except (TypeError, ValueError): + return { + "error": True, + "message": "Attachment size limit (MB) must be a number", + "values": values, + } + + if attachment_limit_mb < 1 or attachment_limit_mb > 600: + return { + "error": True, + "message": "Attachment size limit (MB) must be between 1 and 600", + "values": values, + } + + from_addr = str(effective.get("EMAIL_FROM", "") or "").strip() + if not from_addr: + # If From is empty, default to the SMTP username when it looks like an email address. + username_email = parseaddr(username)[1] + if username_email and "@" in username_email: + from_addr = f"Shelfmark <{username_email}>" + values["EMAIL_FROM"] = from_addr + else: + return { + "error": True, + "message": "From address is required (or set SMTP username to an email address).", + "values": values, + } + else: + from_email = parseaddr(from_addr)[1] + if not from_email or "@" not in from_email: + return { + "error": True, + "message": "From address must be a valid email address", + "values": values, + } + + # Persist any normalization/coercion for fields that may have been edited this save. + if "EMAIL_RECIPIENTS" in values: + values["EMAIL_RECIPIENTS"] = cleaned_recipients + if "EMAIL_SMTP_SECURITY" in values: + values["EMAIL_SMTP_SECURITY"] = security + if "EMAIL_SMTP_PORT" in values: + values["EMAIL_SMTP_PORT"] = port + if "EMAIL_SMTP_TIMEOUT_SECONDS" in values: + values["EMAIL_SMTP_TIMEOUT_SECONDS"] = timeout_seconds + if "EMAIL_ATTACHMENT_SIZE_LIMIT_MB" in values: + values["EMAIL_ATTACHMENT_SIZE_LIMIT_MB"] = attachment_limit_mb + return {"error": False, "values": values} @@ -648,6 +783,11 @@ def download_settings(): "label": "Folder", "description": "Save files to the destination folder", }, + { + "value": "email", + "label": "Email (SMTP)", + "description": "Send files as an email attachment", + }, { "value": "booklore", "label": "Booklore (API)", @@ -783,6 +923,125 @@ def download_settings(): callback=test_booklore_connection, show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, ), + HeadingField( + key="email_heading", + title="Email", + description="Send books as email attachments via SMTP. Audiobooks always use folder mode.", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + TableField( + key="EMAIL_RECIPIENTS", + label="Recipients", + columns=[ + { + "key": "nickname", + "label": "Nickname", + "type": "text", + "placeholder": "eReader", + }, + { + "key": "email", + "label": "Email", + "type": "text", + "placeholder": "device@example.com", + }, + ], + default=[], + add_label="Add Recipient", + empty_message="No recipients configured.", + env_supported=False, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + NumberField( + key="EMAIL_ATTACHMENT_SIZE_LIMIT_MB", + label="Attachment Size Limit (MB)", + description="Maximum total attachment size per email. Email encoding adds overhead; keep this below your provider's limit.", + default=25, + min_value=1, + max_value=600, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + TextField( + key="EMAIL_SMTP_HOST", + label="SMTP Host", + description="SMTP server hostname or IP (e.g., smtp.gmail.com).", + placeholder="smtp.example.com", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + NumberField( + key="EMAIL_SMTP_PORT", + label="SMTP Port", + description="SMTP server port (587 is typical for STARTTLS, 465 for SSL).", + default=587, + min_value=1, + max_value=65535, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + SelectField( + key="EMAIL_SMTP_SECURITY", + label="SMTP Security", + description="Transport security mode for SMTP.", + options=[ + {"value": "none", "label": "None", "description": "No TLS (not recommended)."}, + {"value": "starttls", "label": "STARTTLS", "description": "Upgrade to TLS after connecting (recommended)."}, + {"value": "ssl", "label": "SSL/TLS", "description": "Connect using TLS (SMTPS)."}, + ], + default="starttls", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + TextField( + key="EMAIL_SMTP_USERNAME", + label="Username", + description="SMTP username (leave empty for no authentication).", + placeholder="user@example.com", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + PasswordField( + key="EMAIL_SMTP_PASSWORD", + label="Password", + description="SMTP password (required if Username is set).", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + TextField( + key="EMAIL_FROM", + label="From Address", + description="From address used for the email. You can include a display name (e.g., Shelfmark ). Leave blank to default to the SMTP username (when it is an email address).", + placeholder="Shelfmark ", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + TextField( + key="EMAIL_SUBJECT_TEMPLATE", + label="Subject Template", + description="Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.", + default="{Title}", + placeholder="{Title}", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + NumberField( + key="EMAIL_SMTP_TIMEOUT_SECONDS", + label="SMTP Timeout (seconds)", + description="How long to wait for SMTP operations before failing.", + default=60, + min_value=1, + max_value=600, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + CheckboxField( + key="EMAIL_ALLOW_UNVERIFIED_TLS", + label="Allow Unverified TLS", + description="Disable TLS certificate verification (not recommended).", + default=False, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), + ActionButton( + key="test_email", + label="Test SMTP Connection", + description="Verify your SMTP configuration (connect + optional login).", + style="primary", + callback=test_email_connection, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, + ), # === AUDIOBOOKS SECTION === # Universal mode only diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index 0dca6047..eea928f3 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from enum import Enum import re import time @@ -89,6 +89,11 @@ class DownloadTask: # See SearchMode enum for behavioral differences search_mode: Optional[SearchMode] = None + # Output selection for post-processing. + # This is captured at queue time so in-flight tasks are not affected if the user changes settings later. + output_mode: Optional[str] = None # e.g. "folder", "booklore", "email" + output_args: Dict[str, Any] = field(default_factory=dict) # Per-output parameters (e.g. email recipient) + # Runtime state priority: int = 0 added_time: float = field(default_factory=time.time) diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index b6fa0c82..fe1d99ea 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -17,7 +17,7 @@ from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode from shelfmark.core.queue import book_queue -from shelfmark.core.utils import transform_cover_url +from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook from shelfmark.download.fs import run_blocking_io from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path from shelfmark.download.postprocess.router import post_process_download @@ -75,7 +75,49 @@ def get_book_info(book_id: str) -> Optional[Dict[str, Any]]: logger.error_trace(f"Error getting book info: {e}") raise -def queue_book(book_id: str, priority: int = 0, source: str = "direct_download") -> Tuple[bool, Optional[str]]: +def _normalize_email_recipients(value: Any) -> List[Dict[str, str]]: + """Normalize EMAIL_RECIPIENTS config into a list of {nickname,email} dicts.""" + + if not isinstance(value, list): + return [] + + recipients: List[Dict[str, str]] = [] + for entry in value: + if not isinstance(entry, dict): + continue + nickname = str(entry.get("nickname", "") or "").strip() + email = str(entry.get("email", "") or "").strip() + if not nickname or not email: + continue + recipients.append({"nickname": nickname, "email": email}) + return recipients + + +def _resolve_email_recipient(nickname: Optional[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Resolve a configured email recipient nickname to an email address. + + Returns: + (email_to, label, error_message) + """ + + label = (nickname or "").strip() + if not label: + return None, None, None + + recipients = _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", [])) + for entry in recipients: + if entry["nickname"].strip().lower() == label.lower(): + return entry["email"], entry["nickname"], None + + return None, label, f"Unknown email recipient: {label}" + + +def queue_book( + book_id: str, + priority: int = 0, + source: str = "direct_download", + email_recipient: Optional[str] = None, +) -> Tuple[bool, Optional[str]]: """Add a book to the download queue. Returns (success, error_message).""" try: book_info = direct_download.get_book_info(book_id, fetch_download_count=False) @@ -84,6 +126,25 @@ def queue_book(book_id: str, priority: int = 0, source: str = "direct_download") logger.warning(error_msg) return False, error_msg + books_output_mode = str(config.get("BOOKS_OUTPUT_MODE", "folder") or "folder").strip().lower() + is_audiobook = check_audiobook(book_info.content) + + # Capture output mode at queue time so tasks aren't affected if settings change later. + output_mode = "folder" if is_audiobook else books_output_mode + output_args: Dict[str, Any] = {} + + if output_mode == "email" and not is_audiobook: + if not _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", [])): + return False, "No email recipients configured" + + email_to, email_label, email_error = _resolve_email_recipient(email_recipient) + if email_error: + return False, email_error + if not email_to: + return False, "Email recipient is required" + + output_args = {"to": email_to, "label": email_label} + # Create a source-agnostic download task task = DownloadTask( task_id=book_id, @@ -95,6 +156,8 @@ def queue_book(book_id: str, priority: int = 0, source: str = "direct_download") preview=book_info.preview, content_type=book_info.content, search_mode=SearchMode.DIRECT, + output_mode=output_mode, + output_args=output_args, priority=priority, ) @@ -119,7 +182,11 @@ def queue_book(book_id: str, priority: int = 0, source: str = "direct_download") return False, error_msg -def queue_release(release_data: dict, priority: int = 0) -> Tuple[bool, Optional[str]]: +def queue_release( + release_data: dict, + priority: int = 0, + email_recipient: Optional[str] = None, +) -> Tuple[bool, Optional[str]]: """Add a release to the download queue. Returns (success, error_message).""" try: source = release_data.get('source', 'direct_download') @@ -136,6 +203,24 @@ def queue_release(release_data: dict, priority: int = 0) -> Tuple[bool, Optional series_position = release_data.get('series_position') or extra.get('series_position') subtitle = release_data.get('subtitle') or extra.get('subtitle') + books_output_mode = str(config.get("BOOKS_OUTPUT_MODE", "folder") or "folder").strip().lower() + is_audiobook = check_audiobook(content_type) + + output_mode = "folder" if is_audiobook else books_output_mode + output_args: Dict[str, Any] = {} + + if output_mode == "email" and not is_audiobook: + if not _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", [])): + return False, "No email recipients configured" + + email_to, email_label, email_error = _resolve_email_recipient(email_recipient) + if email_error: + return False, email_error + if not email_to: + return False, "Email recipient is required" + + output_args = {"to": email_to, "label": email_label} + # Create a source-agnostic download task from release data task = DownloadTask( task_id=release_data['source_id'], @@ -151,6 +236,8 @@ def queue_release(release_data: dict, priority: int = 0) -> Tuple[bool, Optional series_position=series_position, subtitle=subtitle, search_mode=SearchMode.UNIVERSAL, + output_mode=output_mode, + output_args=output_args, priority=priority, ) diff --git a/shelfmark/download/outputs/__init__.py b/shelfmark/download/outputs/__init__.py index 55188ec6..a9b5ccdc 100644 --- a/shelfmark/download/outputs/__init__.py +++ b/shelfmark/download/outputs/__init__.py @@ -49,14 +49,55 @@ def load_output_handlers() -> None: return from . import booklore # noqa: F401 + from . import email # noqa: F401 from . import folder # noqa: F401 _OUTPUTS_LOADED = True +def _normalize_output_mode(value: object) -> str: + return str(value or "").strip().lower() + + +def _derive_output_mode(task: DownloadTask) -> str: + """Return the desired output mode for a task. + + Prefer the mode captured at queue time. Fall back to current config for + legacy tasks that do not have `output_mode` populated. + """ + + mode = _normalize_output_mode(getattr(task, "output_mode", None)) + if mode: + return mode + + # Legacy / defensive fallback: derive from current config. + from shelfmark.core.config import config + from shelfmark.core.utils import is_audiobook as check_audiobook + + if check_audiobook(getattr(task, "content_type", None)): + return "folder" + + return _normalize_output_mode(config.get("BOOKS_OUTPUT_MODE", "folder")) or "folder" + + def resolve_output_handler(task: DownloadTask) -> Optional[OutputRegistration]: load_output_handlers() + desired_mode = _derive_output_mode(task) + + # Prefer a direct mode match. `supports_task` becomes a capability check + # (e.g., prevent email/booklore for audiobooks). + for entry in _OUTPUT_REGISTRY: + if entry.mode == desired_mode and entry.supports_task(task): + return entry + + # If the requested output isn't supported for this task, fall back to folder. + for entry in _OUTPUT_REGISTRY: + if entry.mode == "folder" and entry.supports_task(task): + return entry + + # Last-resort fallback: keep the legacy "first supporting handler" behavior. for entry in _OUTPUT_REGISTRY: if entry.supports_task(task): return entry + return None diff --git a/shelfmark/download/outputs/booklore.py b/shelfmark/download/outputs/booklore.py index c68a89cb..4a639722 100644 --- a/shelfmark/download/outputs/booklore.py +++ b/shelfmark/download/outputs/booklore.py @@ -13,7 +13,7 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.outputs import register_output -from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir +from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir logger = setup_logger(__name__) @@ -167,9 +167,7 @@ def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> Non def _supports_booklore(task: DownloadTask) -> bool: - if check_audiobook(task.content_type): - return False - return core_config.config.get("BOOKS_OUTPUT_MODE", "folder") == BOOKLORE_OUTPUT_MODE + return not check_audiobook(task.content_type) def _get_booklore_settings() -> Dict[str, Any]: @@ -219,10 +217,13 @@ def _post_process_booklore( status_callback("resolving", "Preparing Booklore upload") + stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE + staging_dir = build_staging_dir("booklore", task.task_id) if stage_action != STAGE_NONE else get_staging_dir() + output_plan = OutputPlan( mode=BOOKLORE_OUTPUT_MODE, - stage_action=STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE, - staging_dir=build_staging_dir("booklore", task.task_id), + stage_action=stage_action, + staging_dir=staging_dir, allow_archive_extraction=True, ) diff --git a/shelfmark/download/outputs/email.py b/shelfmark/download/outputs/email.py new file mode 100644 index 00000000..cfeae274 --- /dev/null +++ b/shelfmark/download/outputs/email.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +import mimetypes +import smtplib +import ssl +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formatdate, make_msgid, parseaddr +from pathlib import Path +from threading import Event +from typing import Any, Dict, Mapping, Optional + +import shelfmark.core.config as core_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.models import DownloadTask +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.outputs import register_output +from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir + +logger = setup_logger(__name__) + +EMAIL_OUTPUT_MODE = "email" + +SECURITY_NONE = "none" +SECURITY_STARTTLS = "starttls" +SECURITY_SSL = "ssl" +ALLOWED_SECURITY = {SECURITY_NONE, SECURITY_STARTTLS, SECURITY_SSL} + + +class EmailOutputError(Exception): + """Raised when the email output integration fails.""" + + +@dataclass(frozen=True) +class EmailSmtpConfig: + host: str + port: int + security: str + username: str = "" + password: str = "" + from_addr: str = "" + timeout_seconds: int = 60 + allow_unverified_tls: bool = False + subject_template: str = "{Title}" + + +def _parse_int(value: Any, label: str, *, minimum: int = 1) -> int: + if value is None or value == "": + raise EmailOutputError(f"{label} is required") + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise EmailOutputError(f"{label} must be a number") from exc + if parsed < minimum: + raise EmailOutputError(f"{label} must be >= {minimum}") + return parsed + + +def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig: + host = str(values.get("EMAIL_SMTP_HOST", "") or "").strip() + port = _parse_int(values.get("EMAIL_SMTP_PORT", 587), "SMTP port", minimum=1) + + security = str(values.get("EMAIL_SMTP_SECURITY", SECURITY_STARTTLS) or "").strip().lower() + if security not in ALLOWED_SECURITY: + raise EmailOutputError(f"SMTP security must be one of: {', '.join(sorted(ALLOWED_SECURITY))}") + + username = str(values.get("EMAIL_SMTP_USERNAME", "") or "").strip() + password = values.get("EMAIL_SMTP_PASSWORD", "") or "" + + from_addr = str(values.get("EMAIL_FROM", "") or "").strip() + subject_template = str(values.get("EMAIL_SUBJECT_TEMPLATE", "{Title}") or "").strip() + timeout_seconds = _parse_int(values.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60), "SMTP timeout (seconds)", minimum=1) + allow_unverified_tls = bool(values.get("EMAIL_ALLOW_UNVERIFIED_TLS", False)) + + if not host: + raise EmailOutputError("SMTP host is required") + if username and not password: + raise EmailOutputError("SMTP password is required when username is set") + + if not from_addr: + # If From is not configured, fall back to the SMTP username if it is an email address. + username_email = parseaddr(username)[1] + if username_email and "@" in username_email: + from_addr = f"Shelfmark <{username_email}>" + else: + raise EmailOutputError("From address is required (or set SMTP username to an email address).") + + return EmailSmtpConfig( + host=host, + port=port, + security=security, + username=username, + password=password, + from_addr=from_addr, + timeout_seconds=timeout_seconds, + allow_unverified_tls=allow_unverified_tls, + subject_template=subject_template or "{Title}", + ) + + +def _get_email_settings() -> Dict[str, Any]: + return { + "EMAIL_SMTP_HOST": core_config.config.get("EMAIL_SMTP_HOST", ""), + "EMAIL_SMTP_PORT": core_config.config.get("EMAIL_SMTP_PORT", 587), + "EMAIL_SMTP_SECURITY": core_config.config.get("EMAIL_SMTP_SECURITY", SECURITY_STARTTLS), + "EMAIL_SMTP_USERNAME": core_config.config.get("EMAIL_SMTP_USERNAME", ""), + "EMAIL_SMTP_PASSWORD": core_config.config.get("EMAIL_SMTP_PASSWORD", ""), + "EMAIL_FROM": core_config.config.get("EMAIL_FROM", ""), + "EMAIL_SUBJECT_TEMPLATE": core_config.config.get("EMAIL_SUBJECT_TEMPLATE", "{Title}"), + "EMAIL_SMTP_TIMEOUT_SECONDS": core_config.config.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60), + "EMAIL_ALLOW_UNVERIFIED_TLS": core_config.config.get("EMAIL_ALLOW_UNVERIFIED_TLS", False), + } + + +def _render_subject(template: str, task: DownloadTask) -> str: + mapping = { + "Author": task.author or "", + "Title": task.title or "", + "Year": task.year or "", + "Series": task.series_name or "", + "SeriesPosition": task.series_position or "", + "Subtitle": task.subtitle or "", + "Format": task.format or "", + } + try: + rendered = template.format(**mapping) + except Exception: + rendered = template + + rendered = " ".join(str(rendered).split()).strip() + return rendered or "Shelfmark" + + +def _msgid_domain(from_addr: str) -> str: + try: + from_email = parseaddr(from_addr)[1] + domain = (from_email.partition("@")[2] or "").strip().rstrip(">") + except Exception: + domain = "" + return domain or "shelfmark.local" + + +def compose_email_message( + smtp_config: EmailSmtpConfig, + *, + task: DownloadTask, + recipient: str, + files: list[Path], +) -> EmailMessage: + message = EmailMessage() + message["From"] = smtp_config.from_addr + message["To"] = recipient + message["Subject"] = _render_subject(smtp_config.subject_template, task) + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = make_msgid(domain=_msgid_domain(smtp_config.from_addr)) + + # Keep email body empty; attachments carry the content. + message.set_content("") + + for file_path in files: + filename = file_path.name + data = file_path.read_bytes() + + content_type, encoding = mimetypes.guess_type(filename) + if content_type is None or encoding is not None: + content_type = "application/octet-stream" + + main_type, sub_type = content_type.split("/", 1) + message.add_attachment(data, maintype=main_type, subtype=sub_type, filename=filename) + + return message + + +def _create_tls_context(allow_unverified: bool) -> ssl.SSLContext: + context = ssl.create_default_context() + if allow_unverified: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None: + """Connect and (optionally) authenticate to the SMTP server. Does not send mail.""" + + smtp: Optional[smtplib.SMTP] = None + try: + if smtp_config.security == SECURITY_SSL: + context = _create_tls_context(smtp_config.allow_unverified_tls) + smtp = smtplib.SMTP_SSL( + smtp_config.host, + smtp_config.port, + timeout=smtp_config.timeout_seconds, + context=context, + ) + else: + smtp = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds) + + smtp.ehlo() + + if smtp_config.security == SECURITY_STARTTLS: + context = _create_tls_context(smtp_config.allow_unverified_tls) + smtp.starttls(context=context) + smtp.ehlo() + + if smtp_config.username: + smtp.login(smtp_config.username, smtp_config.password) + except smtplib.SMTPAuthenticationError as exc: + raise EmailOutputError("SMTP authentication failed") from exc + except (smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected, TimeoutError, OSError) as exc: + raise EmailOutputError(f"Could not connect to SMTP server: {exc}") from exc + finally: + if smtp is not None: + try: + smtp.quit() + except Exception: + try: + smtp.close() + except Exception: + pass + + +def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> None: + smtp: Optional[smtplib.SMTP] = None + try: + if smtp_config.security == SECURITY_SSL: + context = _create_tls_context(smtp_config.allow_unverified_tls) + smtp = smtplib.SMTP_SSL( + smtp_config.host, + smtp_config.port, + timeout=smtp_config.timeout_seconds, + context=context, + ) + else: + smtp = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds) + + smtp.ehlo() + + if smtp_config.security == SECURITY_STARTTLS: + context = _create_tls_context(smtp_config.allow_unverified_tls) + smtp.starttls(context=context) + smtp.ehlo() + + if smtp_config.username: + smtp.login(smtp_config.username, smtp_config.password) + + smtp.send_message(message) + except smtplib.SMTPAuthenticationError as exc: + raise EmailOutputError("SMTP authentication failed") from exc + except (smtplib.SMTPException, TimeoutError, OSError) as exc: + raise EmailOutputError(f"Failed to send email: {exc}") from exc + finally: + if smtp is not None: + try: + smtp.quit() + except Exception: + try: + smtp.close() + except Exception: + pass + + +def _supports_email(task: DownloadTask) -> bool: + return not check_audiobook(task.content_type) + + +def _post_process_email( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback, +) -> Optional[str]: + from shelfmark.download.postprocess.pipeline import ( + CustomScriptContext, + OutputPlan, + cleanup_output_staging, + is_managed_workspace_path, + maybe_run_custom_script, + prepare_output_files, + ) + + if cancel_flag.is_set(): + logger.info("Task %s: cancelled before email send", task.task_id) + return None + + try: + smtp_config = build_email_smtp_config(_get_email_settings()) + except EmailOutputError as exc: + logger.warning("Task %s: email configuration error: %s", task.task_id, exc) + status_callback("error", str(exc)) + return None + + output_args = task.output_args or {} + if not isinstance(output_args, dict): + output_args = {} + + recipient = str(output_args.get("to", "") or "").strip() + label = str(output_args.get("label", "") or "").strip() or recipient + if not recipient: + status_callback("error", "No email recipient selected") + return None + + status_callback("resolving", "Preparing email") + + stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE + staging_dir = build_staging_dir("email", task.task_id) if stage_action != STAGE_NONE else get_staging_dir() + + output_plan = OutputPlan( + mode=EMAIL_OUTPUT_MODE, + stage_action=stage_action, + staging_dir=staging_dir, + allow_archive_extraction=True, + ) + + prepared = prepare_output_files( + temp_file, + task, + EMAIL_OUTPUT_MODE, + status_callback, + output_plan=output_plan, + ) + if not prepared: + return None + + try: + limit_mb_raw = core_config.config.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25) + try: + attachment_limit_mb = int(limit_mb_raw) + except (TypeError, ValueError): + attachment_limit_mb = 25 + + if attachment_limit_mb > 0: + limit_bytes = attachment_limit_mb * 1024 * 1024 + file_sizes: list[tuple[Path, int]] = [] + total_bytes = 0 + + for file_path in prepared.files: + try: + size_bytes = file_path.stat().st_size + except OSError: + continue + file_sizes.append((file_path, size_bytes)) + total_bytes += size_bytes + + too_large = [(path, size) for path, size in file_sizes if size > limit_bytes] + if too_large: + path, size = max(too_large, key=lambda item: item[1]) + status_callback( + "error", + f"Attachment '{path.name}' is {size / (1024 * 1024):.1f} MB (limit {attachment_limit_mb} MB)", + ) + return None + + # Most providers enforce a message size limit and attachments are base64-encoded (~33% overhead). + estimated_encoded_bytes = int(total_bytes * 4 / 3) + if estimated_encoded_bytes > limit_bytes: + status_callback( + "error", + ( + f"Attachments total {total_bytes / (1024 * 1024):.1f} MB " + f"(estimated encoded {estimated_encoded_bytes / (1024 * 1024):.1f} MB) " + f"exceeds limit {attachment_limit_mb} MB" + ), + ) + return None + + if cancel_flag.is_set(): + logger.info("Task %s: cancelled before email send", task.task_id) + return None + + status_callback("resolving", f"Sending email to {label}") + message = compose_email_message( + smtp_config, + task=task, + recipient=recipient, + files=prepared.files, + ) + send_email_message(smtp_config, message) + + script_context = CustomScriptContext( + task=task, + phase="post_email", + output_mode=EMAIL_OUTPUT_MODE, + destination=prepared.files[0].parent if prepared.files else None, + final_paths=prepared.files, + output_details={ + "email": { + "to": recipient, + "label": label, + "host": smtp_config.host, + "port": smtp_config.port, + "security": smtp_config.security, + } + }, + ) + if not maybe_run_custom_script(script_context, status_callback=status_callback): + return None + + status_callback("complete", f"Sent to {label}") + return f"email://{task.task_id}" + + except EmailOutputError as exc: + logger.warning("Task %s: email send failed: %s", task.task_id, exc) + status_callback("error", str(exc)) + return None + except Exception as exc: + logger.error_trace("Task %s: unexpected error sending email: %s", task.task_id, exc) + status_callback("error", f"Email send failed: {exc}") + return None + finally: + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) + + +@register_output(EMAIL_OUTPUT_MODE, supports_task=_supports_email, priority=10) +def process_email_output( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback, +) -> Optional[str]: + return _post_process_email(temp_file, task, cancel_flag, status_callback) diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index a89ea55a..712ec4e0 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -36,9 +36,7 @@ class _ProcessingPlan: def _supports_folder_output(task: DownloadTask) -> bool: - if check_audiobook(task.content_type): - return True - return core_config.config.get("BOOKS_OUTPUT_MODE", FOLDER_OUTPUT_MODE) == FOLDER_OUTPUT_MODE + return True def _build_processing_plan( diff --git a/shelfmark/main.py b/shelfmark/main.py index 550dfc74..e1c07929 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -562,7 +562,8 @@ def api_download() -> Union[Response, Tuple[Response, int]]: try: priority = int(request.args.get('priority', 0)) - success, error_msg = backend.queue_book(book_id, priority) + email_recipient = request.args.get('email_recipient') + success, error_msg = backend.queue_book(book_id, priority, email_recipient=email_recipient) if success: return jsonify({"status": "queued", "priority": priority}) return jsonify({"error": error_msg or "Failed to queue book"}), 500 @@ -600,7 +601,8 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: return jsonify({"error": "source_id is required"}), 400 priority = data.get('priority', 0) - success, error_msg = backend.queue_release(data, priority) + email_recipient = data.get('email_recipient') + success, error_msg = backend.queue_release(data, priority, email_recipient=email_recipient) if success: return jsonify({"status": "queued", "priority": priority}) @@ -628,6 +630,22 @@ def api_config() -> Union[Response, Tuple[Response, int]]: from shelfmark.config.env import _is_config_dir_writable from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete + def _normalize_email_recipients(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + + recipients: list[dict[str, str]] = [] + for entry in value: + if not isinstance(entry, dict): + continue + nickname = str(entry.get("nickname", "") or "").strip() + email = str(entry.get("email", "") or "").strip() + if not nickname or not email: + continue + recipients.append({"nickname": nickname, "email": email}) + + return recipients + config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), "audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""), @@ -642,6 +660,10 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "metadata_sort_options": get_provider_sort_options(), "metadata_search_fields": get_provider_search_fields(), "default_release_source": app_config.get("DEFAULT_RELEASE_SOURCE", "direct_download"), + "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), + # Safe-to-expose subset of email output settings (recipients only). + # SMTP credentials are configured via the settings UI but are never returned to the frontend. + "email_recipients": _normalize_email_recipients(app_config.get("EMAIL_RECIPIENTS", []) or []), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), "download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False), "settings_enabled": _is_config_dir_writable(), diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index aba3f0ea..90d3cff1 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { SearchSection } from './components/SearchSection'; import { AdvancedFilters } from './components/AdvancedFilters'; import { ResultsSection } from './components/ResultsSection'; import { DetailsModal } from './components/DetailsModal'; +import { EmailRecipientModal } from './components/EmailRecipientModal'; import { ReleaseModal } from './components/ReleaseModal'; import { DownloadsSidebar } from './components/DownloadsSidebar'; import { ToastContainer } from './components/ToastContainer'; @@ -29,6 +30,7 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner'; import { OnboardingModal } from './components/OnboardingModal'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; import { buildSearchQuery } from './utils/buildSearchQuery'; +import { UserCancelledError, isUserCancelledError } from './utils/errors'; import { withBasePath } from './utils/basePath'; import { SearchModeProvider } from './contexts/SearchModeContext'; import './styles.css'; @@ -142,6 +144,49 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); + const [emailRecipientModalOpen, setEmailRecipientModalOpen] = useState(false); + const emailRecipientPromiseRef = useRef<{ + resolve: (nickname: string) => void; + reject: (error: Error) => void; + } | null>(null); + + const openEmailRecipientPicker = useCallback(async (): Promise => { + const recipients = config?.email_recipients ?? []; + if (!config || config.books_output_mode !== 'email') { + return ''; + } + + if (recipients.length === 0) { + throw new Error('No email recipients configured'); + } + + if (recipients.length === 1) { + return recipients[0]?.nickname ?? ''; + } + + if (emailRecipientPromiseRef.current) { + throw new Error('Email recipient picker already open'); + } + + setEmailRecipientModalOpen(true); + return new Promise((resolve, reject) => { + emailRecipientPromiseRef.current = { resolve, reject }; + }); + }, [config]); + + const handleEmailRecipientSelect = useCallback((nickname: string) => { + const pending = emailRecipientPromiseRef.current; + emailRecipientPromiseRef.current = null; + setEmailRecipientModalOpen(false); + pending?.resolve(nickname); + }, []); + + const handleEmailRecipientCancel = useCallback(() => { + const pending = emailRecipientPromiseRef.current; + emailRecipientPromiseRef.current = null; + setEmailRecipientModalOpen(false); + pending?.reject(new UserCancelledError()); + }, []); // Expose debug function to trigger onboarding from browser console useEffect(() => { @@ -436,11 +481,20 @@ function App() { // Download book const handleDownload = async (book: Book): Promise => { try { - await downloadBook(book.id); + let emailRecipient: string | undefined; + if (config?.books_output_mode === 'email' && contentType === 'ebook') { + emailRecipient = await openEmailRecipientPicker(); + } + + await downloadBook(book.id, emailRecipient); await fetchStatus(); } catch (error) { + if (isUserCancelledError(error)) { + // Important: rethrow so buttons can reset their "Queuing..." state. + throw error; + } console.error('Download failed:', error); - showToast('Failed to queue download', 'error'); + showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error'); throw error; } }; @@ -491,6 +545,11 @@ function App() { // Handle download from ReleaseModal const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => { try { + let emailRecipient: string | undefined; + if (config?.books_output_mode === 'email' && releaseContentType === 'ebook') { + emailRecipient = await openEmailRecipientPicker(); + } + trackRelease(book.id, release.source_id); await downloadRelease({ @@ -512,11 +571,15 @@ function App() { series_name: book.series_name, series_position: book.series_position, subtitle: book.subtitle, + email_recipient: emailRecipient, }); await fetchStatus(); } catch (error) { + if (isUserCancelledError(error)) { + throw error; + } console.error('Release download failed:', error); - showToast('Failed to queue download', 'error'); + showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error'); throw error; } }; @@ -686,6 +749,13 @@ function App() { /> )} + +