Feature: Email output mode (#603)

This commit is contained in:
Alex
2026-02-09 19:33:12 +00:00
committed by GitHub
parent 022e50a0ba
commit e09f5f7757
18 changed files with 1208 additions and 36 deletions
+42
View File
@@ -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}"}
+259
View File
@@ -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
+6 -1
View File
@@ -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)
+90 -3
View File
@@ -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,
)
+41
View File
@@ -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
+7 -6
View File
@@ -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,
)
+425
View File
@@ -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)
+1 -3
View File
@@ -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
View File
@@ -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(),
+73 -3
View File
@@ -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>
);
};
+4 -8
View File
@@ -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">
+8 -2
View File
@@ -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',
+9
View File
@@ -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
+11
View File
@@ -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;
}