From 956fb3ecb10153d77724409800aa6ebe17e1eeb4 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Fri, 11 Sep 2026 22:46:46 +1200 Subject: [PATCH] Inherit email link addresses from hosting and proxy settings --- backend/app/routers/newsletters.py | 8 ++-- backend/app/routers/recaps.py | 7 +-- backend/app/services/email_recaps.py | 4 +- backend/app/services/newsletter_store.py | 6 ++- backend/app/services/newsletters.py | 4 +- backend/app/services/public_urls.py | 29 ++++++++++++ backend/app/services/recap_store.py | 4 ++ backend/tests/test_public_urls.py | 57 ++++++++++++++++++++++++ docs/jellystat-integration.md | 2 +- docs/newsletters.md | 2 +- frontend/app/admin/newsletters/page.tsx | 5 ++- frontend/app/admin/recaps/page.tsx | 7 +-- scripts/review_public_urls_ui.cjs | 24 ++++++++++ 13 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 backend/app/services/public_urls.py create mode 100644 backend/tests/test_public_urls.py create mode 100644 scripts/review_public_urls_ui.cjs diff --git a/backend/app/routers/newsletters.py b/backend/app/routers/newsletters.py index 54b0c7a..5e7d025 100644 --- a/backend/app/routers/newsletters.py +++ b/backend/app/routers/newsletters.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import Field, field_validator +from ..services.public_urls import magent_public_url from ..auth import get_current_user, require_admin from ..runtime import get_runtime_settings from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog @@ -19,7 +20,7 @@ class Settings(StrictPayload): weekday: int = Field(ge=0, le=6) hour: int = Field(ge=0, le=23) limit_titles: int = Field(ge=1, le=24) - public_url: str = Field(max_length=500) + public_url: str = Field(default="", max_length=500) intro: str = Field(default='', max_length=2000) revision: int = Field(ge=1) _url = field_validator('public_url')(RecapSettings.origin_only.__func__) @@ -114,10 +115,11 @@ def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = De @router.put('/admin/newsletters') def settings(payload: Settings, user: dict = Depends(require_admin)): try: - ready, detail = service.delivery_ready(payload.public_url) + public_url = magent_public_url(payload.public_url or store.settings()['public_url']) + ready, detail = service.delivery_ready(public_url) if payload.enabled and not ready: raise service.NewsletterError(detail) - return store.save_settings(payload.model_dump(), datetime.now(timezone.utc)) + return store.save_settings({**payload.model_dump(), "public_url": public_url}, datetime.now(timezone.utc)) except (service.NewsletterError, store.Conflict) as exc: fail(exc) diff --git a/backend/app/routers/recaps.py b/backend/app/routers/recaps.py index 4e712d4..8401abd 100644 --- a/backend/app/routers/recaps.py +++ b/backend/app/routers/recaps.py @@ -6,6 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, ConfigDict, Field, field_validator +from ..services.public_urls import magent_public_url from ..auth import get_current_user, require_admin from ..feature_guards import require_stats from ..services import email_recaps as recaps, recap_store as store @@ -31,7 +32,7 @@ class RecapSettings(StrictPayload): enabled: bool day: int = Field(ge=1, le=28) hour: int = Field(ge=0, le=23) - public_url: str = Field(max_length=500) + public_url: str = Field(default="", max_length=500) @field_validator("public_url") @classmethod @@ -114,9 +115,9 @@ def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dic # Validate against the proposed URL without writing any partial settings. ready, detail = recaps.smtp_email_config_ready() runtime = recaps.get_runtime_settings() - if not payload.public_url or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key: + if not magent_public_url(payload.public_url or store.settings()["public_url"]) or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key: raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail) - return store.save_settings(payload.model_dump(), datetime.now(timezone.utc)) + return store.save_settings({**payload.model_dump(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, datetime.now(timezone.utc)) @router.get("/admin/email-recaps/preview") diff --git a/backend/app/services/email_recaps.py b/backend/app/services/email_recaps.py index 902c207..55d814b 100644 --- a/backend/app/services/email_recaps.py +++ b/backend/app/services/email_recaps.py @@ -32,7 +32,7 @@ def worker_enabled() -> bool: def delivery_ready() -> tuple[bool, str]: config = store.settings() if not config["public_url"]: - return False, "Set the public Magent address for email links." + return False, "Set the application URL in Hosting & proxy for email links." ready, detail = smtp_email_config_ready() if not ready: return False, detail @@ -147,7 +147,7 @@ async def preview(user: dict, month: str | None) -> dict: selected = completed_month(month) config = store.settings() if not config["public_url"]: - raise RecapError("Save the public Magent address before previewing an email.") + raise RecapError("Set the application URL in Hosting & proxy before previewing an email.") try: report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180) except HistoryLimitError as exc: diff --git a/backend/app/services/newsletter_store.py b/backend/app/services/newsletter_store.py index b5f812a..d4cdfee 100644 --- a/backend/app/services/newsletter_store.py +++ b/backend/app/services/newsletter_store.py @@ -1,3 +1,4 @@ +from .public_urls import magent_public_url """Independent newsletter consent and immutable edition snapshots using the shared email queue.""" import hashlib @@ -63,6 +64,7 @@ def init_schema(conn): def settings() -> dict: result = read_one('SELECT * FROM newsletter_settings WHERE id=1') + result['public_url'] = magent_public_url(result['public_url']) result['enabled'] = bool(result['enabled']) return result @@ -79,6 +81,7 @@ def next_due(now: datetime, weekday: int, hour: int) -> datetime: def save_settings(values: dict, now: datetime): + values = {**values, "public_url": magent_public_url(values.get("public_url", ""))} with transaction() as conn: old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()) if old['revision'] != values['revision']: @@ -246,7 +249,8 @@ def enqueue_test(sub, identity, revision, request_id, public_url, now): def enqueue_due(now): with transaction() as conn: - config = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone() + config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()) + config['public_url'] = magent_public_url(config['public_url']) rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall() for raw in rows: row = unpack(raw) diff --git a/backend/app/services/newsletters.py b/backend/app/services/newsletters.py index 6c3a494..f343efa 100644 --- a/backend/app/services/newsletters.py +++ b/backend/app/services/newsletters.py @@ -32,7 +32,7 @@ def playback_url(runtime) -> str: def delivery_ready(public_url=None): config = store.settings() if not (public_url if public_url is not None else config['public_url']): - return False, 'Set the public Magent address for newsletter email links.' + return False, 'Set the application URL in Hosting & proxy for newsletter email links.' runtime = get_runtime_settings() if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key: return False, 'Connect Jellyfin to collect new arrivals.' @@ -150,7 +150,7 @@ async def preview(identity, revision): runtime = get_runtime_settings() config = store.settings() if not config['public_url'] or not playback_url(runtime): - raise NewsletterError('Set the public Magent and Jellyfin addresses before previewing.') + raise NewsletterError('Check the application URL in Hosting & proxy and the public playback URL in Jellyfin settings before previewing.') if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime): raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.') content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']} diff --git a/backend/app/services/public_urls.py b/backend/app/services/public_urls.py new file mode 100644 index 0000000..a0e7da3 --- /dev/null +++ b/backend/app/services/public_urls.py @@ -0,0 +1,29 @@ +"""Configured public email links, independent of request Host/forwarded headers.""" +from urllib.parse import urlsplit +from ..runtime import get_runtime_settings + + +def valid_public_url(value): + value = str(value or '').strip().rstrip('/') + try: + parsed = urlsplit(value) + if (parsed.scheme in {'http', 'https'} and parsed.hostname + and not (parsed.username or parsed.password or parsed.query or parsed.fragment) + and (parsed.port is None or parsed.port > 0) + and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)): + return value + except ValueError: + pass + return '' + + +def magent_public_url(legacy_url=''): + runtime = get_runtime_settings() + proxy = getattr(runtime, 'magent_proxy_base_url', None) + application = getattr(runtime, 'magent_application_url', None) + if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip(): + return valid_public_url(proxy) + if str(application or '').strip(): + return valid_public_url(application) + # Preserve pre-existing installations until Hosting & proxy has been configured. + return valid_public_url(legacy_url) diff --git a/backend/app/services/recap_store.py b/backend/app/services/recap_store.py index 04577d2..6f0b673 100644 --- a/backend/app/services/recap_store.py +++ b/backend/app/services/recap_store.py @@ -1,3 +1,4 @@ +from .public_urls import magent_public_url """Durable consent, schedule and delivery records for personal email recaps.""" import hashlib @@ -73,6 +74,7 @@ def read_one(sql: str, args=()) -> dict | None: def settings() -> dict: row = read_one("SELECT * FROM email_recap_settings WHERE id = 1") + row["public_url"] = magent_public_url(row["public_url"]) return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"} @@ -82,6 +84,7 @@ def next_due(now: datetime, day: int, hour: int) -> datetime: def save_settings(values: dict, now: datetime) -> dict: + values = {**values, "public_url": magent_public_url(values.get("public_url", ""))} with transaction() as conn: old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone()) changed = any(old[key] != values[key] for key in ("day", "hour", "public_url")) @@ -174,6 +177,7 @@ def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: f def enqueue_due(now: datetime) -> int: with transaction() as conn: config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone()) + config["public_url"] = magent_public_url(config["public_url"]) if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp(): return 0 # After long downtime, send only the latest due recap; never backfill a pile of old emails. diff --git a/backend/tests/test_public_urls.py b/backend/tests/test_public_urls.py new file mode 100644 index 0000000..2e5ea75 --- /dev/null +++ b/backend/tests/test_public_urls.py @@ -0,0 +1,57 @@ +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from backend.app.services import public_urls, newsletter_store, recap_store, newsletters, newsletter_catalog +from backend.tests.test_newsletters import NewsletterFixture + + +class PublicUrlTests(unittest.TestCase): + def resolve(self, application=None, proxy=None, enabled=False, legacy='https://legacy.test'): + with patch.object(public_urls,'get_runtime_settings',return_value=SimpleNamespace( + magent_application_url=application,magent_proxy_base_url=proxy,magent_proxy_enabled=enabled)): + return public_urls.magent_public_url(legacy) + + def test_hosting_is_authoritative_with_proxy_and_path_support(self): + self.assertEqual(self.resolve('https://prod.test/'),'https://prod.test') + self.assertEqual(self.resolve('http://internal:3000','https://public.test/magent/',True),'https://public.test/magent') + self.assertEqual(self.resolve('https://prod.test','https://old-proxy.test',False),'https://prod.test') + self.assertEqual(self.resolve(),'https://legacy.test') + + def test_invalid_configured_address_does_not_use_stale_legacy(self): + for value in ['javascript:alert(1)','https://user:password@host.test','https://host.test?key=secret','https://host.test/#fragment','https://host.test:99999','https://host.test/ bad']: + self.assertEqual(self.resolve(value),'') + + +class NewsletterHostingTests(NewsletterFixture, unittest.IsolatedAsyncioTestCase): + async def test_existing_draft_previews_using_hosting_without_duplicate_url(self): + draft=self.draft() + with newsletter_store.transaction() as c: + c.execute("UPDATE newsletter_settings SET public_url=''") + self.runtime.magent_application_url='https://prod.example.test' + with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime),patch.object(newsletter_catalog,'posters',new=AsyncMock(return_value={})): + rendered=await newsletters.preview(draft['id'],draft['revision']) + self.assertIn('https://prod.example.test/profile#newsletters',rendered['body_html']) + self.assertIn('https://watch.example.test',rendered['body_html']) + self.assertNotIn('https://beta.example.test',rendered['body_html']) + self.assertEqual(recap_store.settings()['public_url'],'https://prod.example.test') + + def test_scheduled_delivery_uses_current_hosting_address(self): + self.subscribe(when=100) + draft=self.draft() + newsletter_store.publish(draft['id'],draft['revision'],200,150) + self.runtime.magent_application_url='https://prod.example.test' + with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime): + newsletter_store.enqueue_due(201) + delivery=newsletter_store.read_one('SELECT * FROM newsletter_deliveries WHERE edition_id=?',(draft['id'],)) + self.assertEqual(delivery['public_url'],'https://prod.example.test') + self.runtime.magent_application_url='https://new.example.test' + self.assertEqual(newsletter_store.settings()['public_url'],'https://new.example.test') + + def test_saving_schedule_uses_hosting_instead_of_client_address(self): + self.runtime.magent_application_url='https://prod.example.test' + with patch.object(public_urls,'get_runtime_settings',return_value=self.runtime): + result=newsletter_store.save_settings({**self.config,'public_url':'https://stale.example.test'},datetime.now(timezone.utc)) + self.assertEqual(result['public_url'],'https://prod.example.test') + result=recap_store.save_settings({'enabled':False,'day':2,'hour':9,'public_url':''},datetime.now(timezone.utc)) + self.assertEqual(result['public_url'],'https://prod.example.test') diff --git a/docs/jellystat-integration.md b/docs/jellystat-integration.md index 2f2ea2f..6a70731 100644 --- a/docs/jellystat-integration.md +++ b/docs/jellystat-integration.md @@ -63,7 +63,7 @@ Open **My Stats → Monthly reports** (`/insights/reports`). The default is the New-arrival emails are managed separately in [Grizzlyflix newsletters](newsletters.md). They use Jellyfin library additions and have their own Profile subscription. -**Settings → Monthly email recaps** (`/admin/recaps`) controls the public Magent address, monthly schedule, personal preview, test emails and delivery history. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email. +**Settings → Monthly email recaps** (`/admin/recaps`) controls the monthly schedule, personal preview, test emails and delivery history. Email links inherit the application URL from Hosting & proxy (or the proxy base URL when enabled); this address is shown read-only on the recap page. The dark email design matches My Stats and includes viewing/request totals, changes against the previous month, the longest run and top three titles. The full-report link preserves its month through sign-in. A plain-text alternative is included; private artwork tokens and service credentials are never embedded in an email. New installations start with scheduled delivery paused and no subscriptions. Set this environment's public Magent origin (for Beta, `https://beta.grizzlyflix.co.nz`), check **Email & notifications**, preview your own report and confirm your email in **Profile → Monthly recaps** before sending yourself a test. Test emails use the same queue and are allowed while the monthly schedule is paused. They can only go to the signed-in administrator's confirmed profile email. Previewing never sends email, and the preview's preference links do not contain a live unsubscribe token. diff --git a/docs/newsletters.md b/docs/newsletters.md index 89b6a11..c4c116d 100644 --- a/docs/newsletters.md +++ b/docs/newsletters.md @@ -9,7 +9,7 @@ The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects t ## Setup and subscriptions - Configure Jellyfin and its **public** address for Watch links, plus the existing SMTP email settings. Background automation must be enabled. Jellystat is not required for newsletters. -- Save this environment's public Magent address in Weekly schedule. On first migration it inherits the monthly recap address, if configured. No schedule or subscriptions are enabled by migration. +- Email links inherit the application URL from Hosting & proxy, or the proxy base URL when reverse proxy mode is enabled. Weekly schedule displays the effective address read-only. Existing email-specific addresses remain a fallback only when hosting has not been configured. Watch links use Jellyfin's public playback URL. No schedule or subscriptions are enabled by migration. - Users opt in at **Profile → New on Grizzlyflix**. This consent is separate from monthly viewing recaps. A current email already confirmed for monthly recaps can be reused after the user explicitly subscribes to newsletters. Otherwise a confirmation email is sent, with a 24-hour expiry and a five-minute resend limit. - Each subscriber needs a stored Jellyfin account link. Email or identity changes, blocking and account removal invalidate consent. Confirmation and unsubscribe tokens are specific to newsletters. Opening a public link checks it; changing the preference requires pressing its confirmation button. diff --git a/frontend/app/admin/newsletters/page.tsx b/frontend/app/admin/newsletters/page.tsx index 8c329df..b77bb6a 100644 --- a/frontend/app/admin/newsletters/page.tsx +++ b/frontend/app/admin/newsletters/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' +import Link from 'next/link' import { useRouter } from 'next/navigation' import AdminShell from '../../ui/AdminShell' import { authFetch, getApiBase } from '../../lib/auth' @@ -18,7 +19,7 @@ const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Sat const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled' const labels: Record = { draft: 'Draft', scheduled: 'Scheduled', queued: 'Queued', complete: 'Finished', skipped: 'Skipped', preparing: 'Preparing email', sending: 'Sending', sent: 'Accepted by mail server', retry: 'Retry scheduled', failed: 'Failed', unknown: 'Needs review', cancelled: 'Cancelled' } const editableFields = (edition: Edition) => ({ subject: edition.subject, intro: edition.intro, titles: edition.content.titles.map(({ id, selected, featured }) => ({ id, selected, featured })) }) -const scheduleFields = (settings: Settings) => ({ enabled: settings.enabled, weekday: settings.weekday, hour: settings.hour, limit_titles: settings.limit_titles, public_url: settings.public_url, intro: settings.intro, revision: settings.revision }) +const scheduleFields = (settings: Settings) => ({ enabled: settings.enabled, weekday: settings.weekday, hour: settings.hour, limit_titles: settings.limit_titles, intro: settings.intro, revision: settings.revision }) function Poster({ title }: { title: Title }) { const [failed, setFailed] = useState(false) @@ -138,7 +139,7 @@ export default function NewslettersAdminPage() { {edition.state !== 'cancelled' &&

{isDraft ? 'Ready for the inbox?' : 'Delivery controls'}

A test goes to your own confirmed newsletter email. Manage your subscription ↗

{isDraft ? <>

Preview the saved edition before sending. Scheduling fixes the content for this edition. Users must be subscribed by its send time.

:

{dateLabel(edition.send_at)} · Check Delivery history for individual results.

}{['draft', 'scheduled', 'queued'].includes(edition.state) && }
} } } - {tab === 'schedule' &&
Set the rhythm

A weekly discovery

Automatically collect the previous seven days of arrivals and send an edition to confirmed subscribers. Weeks without new arrivals are skipped.

{ event.preventDefault(); void action('settings', '', 'PUT', scheduleFields(settings), (result: Settings) => { setSettings(result); setData({ ...data, settings: result }); setPreview(null); setNotice(result.enabled ? `Weekly settings saved. Next edition ${dateLabel(result.next_send_at)}.` : 'Settings saved. Automatic weekly editions are paused.') }) }}>