mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 08:11:11 +01:00
Feature: Email output mode (#603)
This commit is contained in:
@@ -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}"}
|
||||
|
||||
@@ -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 <mail@example.com>). Leave blank to default to the SMTP username (when it is an email address).",
|
||||
placeholder="Shelfmark <mail@example.com>",
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
+24
-2
@@ -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(),
|
||||
|
||||
@@ -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<string> => {
|
||||
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<void> => {
|
||||
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() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<EmailRecipientModal
|
||||
isOpen={emailRecipientModalOpen}
|
||||
recipients={config?.email_recipients ?? []}
|
||||
onSelect={handleEmailRecipientSelect}
|
||||
onCancel={handleEmailRecipientCancel}
|
||||
/>
|
||||
|
||||
</main>
|
||||
|
||||
<Footer
|
||||
|
||||
@@ -23,8 +23,8 @@ const sizeClasses: Record<ButtonSize, string> = {
|
||||
};
|
||||
|
||||
const iconVariantSizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'p-px m-0.5 sm:p-1 sm:m-0.5 aspect-square',
|
||||
md: 'p-0.5 m-0.5 sm:p-1.5 sm:m-0.5 aspect-square',
|
||||
sm: 'p-2 sm:p-1.5 aspect-square',
|
||||
md: 'p-2.5 sm:p-2 aspect-square',
|
||||
};
|
||||
|
||||
const primaryIconSizes: Record<ButtonSize, string> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
|
||||
import { isUserCancelledError } from '../utils/errors';
|
||||
|
||||
interface DetailsModalProps {
|
||||
book: Book | null;
|
||||
@@ -66,6 +67,9 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
// Don't close here - wait for button state to change
|
||||
} catch (error) {
|
||||
setIsQueuing(false);
|
||||
if (isUserCancelledError(error)) {
|
||||
return;
|
||||
}
|
||||
// Close on error
|
||||
setTimeout(handleClose, 300);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { EmailRecipient } from '../types';
|
||||
|
||||
interface EmailRecipientModalProps {
|
||||
isOpen: boolean;
|
||||
recipients: EmailRecipient[];
|
||||
onSelect: (nickname: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const EmailRecipientModal = ({ isOpen, recipients, onSelect, onCancel }: EmailRecipientModalProps) => {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedNickname, setSelectedNickname] = useState<string | null>(null);
|
||||
|
||||
// Reset selection when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (isOpen) setSelectedNickname(null);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isClosing) return;
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onCancel();
|
||||
setIsClosing(false);
|
||||
}, 150);
|
||||
}, [onCancel, isClosing]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (isClosing || !selectedNickname) return;
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onSelect(selectedNickname);
|
||||
setIsClosing(false);
|
||||
}, 150);
|
||||
}, [onSelect, selectedNickname, isClosing]);
|
||||
|
||||
// ESC to cancel
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, handleCancel]);
|
||||
|
||||
// Prevent body scroll while open
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const titleId = 'email-recipient-modal-title';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay active sm:px-6 sm:py-6"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-full sm:max-w-md h-full sm:h-auto pointer-events-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex h-full sm:h-auto flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)] shadow-none sm:shadow-2xl">
|
||||
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Output</p>
|
||||
<h3 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
Send via Email
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Choose a recipient for this download.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
|
||||
{recipients.length === 0 ? (
|
||||
<div className="rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3 text-sm">
|
||||
No recipients configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recipients.map((r) => {
|
||||
const isSelected = selectedNickname === r.nickname;
|
||||
return (
|
||||
<button
|
||||
key={`${r.nickname}:${r.email}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedNickname(isSelected ? null : r.nickname)}
|
||||
className={`w-full text-left rounded-2xl border px-4 py-3 transition-colors hover-action ${
|
||||
isSelected
|
||||
? 'border-sky-500 bg-sky-500/10'
|
||||
: 'border-[var(--border-muted)] bg-[var(--bg-soft)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
isSelected ? 'border-sky-500 bg-sky-500' : 'border-gray-400 dark:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{r.nickname}</div>
|
||||
<div className="text-xs opacity-70 truncate">{r.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop footer: small Send + Cancel buttons */}
|
||||
<footer className="hidden sm:flex border-t border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4 justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={!selectedNickname}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
bg-sky-600 text-white hover:bg-sky-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
{/* Mobile: full-width Send button, slides up when a recipient is selected */}
|
||||
{selectedNickname && (
|
||||
<div
|
||||
className="sm:hidden flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
|
||||
bg-sky-600 text-white hover:bg-sky-700"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -342,7 +342,7 @@ const ReleaseRow = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pl-5 pr-2 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
|
||||
className="pl-5 pr-4 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animationFillMode: 'both',
|
||||
@@ -1241,13 +1241,9 @@ export const ReleaseModal = ({
|
||||
const handleDownload = useCallback(
|
||||
async (release: Release): Promise<void> => {
|
||||
if (book) {
|
||||
try {
|
||||
await onDownload(book, release, contentType);
|
||||
// Close modal after successful queue
|
||||
handleClose();
|
||||
} catch {
|
||||
// Keep modal open on error so user can try again
|
||||
}
|
||||
await onDownload(book, release, contentType);
|
||||
// Close modal after successful queue
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[book, onDownload, contentType, handleClose]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, CSSProperties } from 'react';
|
||||
import { TableFieldConfig, TableFieldColumn } from '../../../types/settings';
|
||||
import { DropdownList } from '../../DropdownList';
|
||||
|
||||
@@ -37,6 +37,19 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
const columns = useMemo(() => field.columns ?? [], [field.columns]);
|
||||
const rows = useMemo(() => normalizeRows(value ?? [], columns), [value, columns]);
|
||||
|
||||
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
|
||||
// Use fixed width for delete button column to ensure header/data alignment.
|
||||
const gridTemplate = 'sm:[grid-template-columns:var(--table-cols)]';
|
||||
|
||||
const tableCols = useMemo(() => {
|
||||
if (columns.length === 0) {
|
||||
return 'minmax(0,1fr) 2rem';
|
||||
}
|
||||
|
||||
const colDefs = columns.map((_, idx) => (idx === 0 ? 'minmax(0,180px)' : 'minmax(0,1fr)'));
|
||||
return `${colDefs.join(' ')} 2rem`;
|
||||
}, [columns]);
|
||||
|
||||
const updateCell = (rowIndex: number, key: string, cellValue: unknown) => {
|
||||
const next = rows.map((row, idx) => (idx === rowIndex ? { ...row, [key]: cellValue } : row));
|
||||
onChange(next);
|
||||
@@ -74,12 +87,8 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
||||
);
|
||||
}
|
||||
|
||||
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
|
||||
// Use fixed width for delete button column to ensure header/data alignment.
|
||||
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_2rem]';
|
||||
|
||||
return (
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="space-y-3 min-w-0" style={{ '--table-cols': tableCols } as CSSProperties}>
|
||||
<div className={`hidden sm:grid ${gridTemplate} gap-3 items-start min-w-0 text-xs font-medium opacity-70`}>
|
||||
{columns.map((col) => (
|
||||
<div key={col.key} className="min-w-0 truncate">
|
||||
|
||||
@@ -189,8 +189,13 @@ export const getMetadataBookInfo = async (provider: string, bookId: string): Pro
|
||||
return transformMetadataToBook(response);
|
||||
};
|
||||
|
||||
export const downloadBook = async (id: string): Promise<void> => {
|
||||
await fetchJSON(`${API.download}?id=${encodeURIComponent(id)}`);
|
||||
export const downloadBook = async (id: string, emailRecipient?: string): Promise<void> => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('id', id);
|
||||
if (emailRecipient) {
|
||||
params.set('email_recipient', emailRecipient);
|
||||
}
|
||||
await fetchJSON(`${API.download}?${params.toString()}`);
|
||||
};
|
||||
|
||||
// Download a specific release (from ReleaseModal)
|
||||
@@ -214,6 +219,7 @@ export const downloadRelease = async (release: {
|
||||
series_position?: number;
|
||||
subtitle?: string;
|
||||
search_author?: string;
|
||||
email_recipient?: string;
|
||||
}): Promise<void> => {
|
||||
await fetchJSON(`${API_BASE}/releases/download`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -149,6 +149,13 @@ export type MetadataSearchField =
|
||||
// Content type for search (ebook vs audiobook)
|
||||
export type ContentType = 'ebook' | 'audiobook';
|
||||
|
||||
export type BooksOutputMode = 'folder' | 'booklore' | 'email';
|
||||
|
||||
export interface EmailRecipient {
|
||||
nickname: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
calibre_web_url: string;
|
||||
audiobook_library_url: string;
|
||||
@@ -163,6 +170,8 @@ export interface AppConfig {
|
||||
metadata_sort_options: SortOption[];
|
||||
metadata_search_fields: MetadataSearchField[];
|
||||
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
|
||||
books_output_mode: BooksOutputMode;
|
||||
email_recipients: EmailRecipient[];
|
||||
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
||||
download_to_browser: boolean; // Auto-download completed files to browser
|
||||
settings_enabled: boolean; // Whether config directory is mounted and writable
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export class UserCancelledError extends Error {
|
||||
constructor(message: string = 'Cancelled') {
|
||||
super(message);
|
||||
this.name = 'UserCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isUserCancelledError(error: unknown): error is UserCancelledError {
|
||||
return error instanceof UserCancelledError;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user