Inherit email link addresses from hosting and proxy settings
Magent CI/CD / verify (push) Successful in 2m2s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-11 22:46:46 +12:00
parent 4f7853b17b
commit 956fb3ecb1
13 changed files with 141 additions and 18 deletions
+5 -3
View File
@@ -6,6 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import Field, field_validator from pydantic import Field, field_validator
from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin from ..auth import get_current_user, require_admin
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog 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) weekday: int = Field(ge=0, le=6)
hour: int = Field(ge=0, le=23) hour: int = Field(ge=0, le=23)
limit_titles: int = Field(ge=1, le=24) 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) intro: str = Field(default='', max_length=2000)
revision: int = Field(ge=1) revision: int = Field(ge=1)
_url = field_validator('public_url')(RecapSettings.origin_only.__func__) _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') @router.put('/admin/newsletters')
def settings(payload: Settings, user: dict = Depends(require_admin)): def settings(payload: Settings, user: dict = Depends(require_admin)):
try: 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: if payload.enabled and not ready:
raise service.NewsletterError(detail) 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: except (service.NewsletterError, store.Conflict) as exc:
fail(exc) fail(exc)
+4 -3
View File
@@ -6,6 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator 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 ..auth import get_current_user, require_admin
from ..feature_guards import require_stats from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store from ..services import email_recaps as recaps, recap_store as store
@@ -31,7 +32,7 @@ class RecapSettings(StrictPayload):
enabled: bool enabled: bool
day: int = Field(ge=1, le=28) day: int = Field(ge=1, le=28)
hour: int = Field(ge=0, le=23) 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") @field_validator("public_url")
@classmethod @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. # Validate against the proposed URL without writing any partial settings.
ready, detail = recaps.smtp_email_config_ready() ready, detail = recaps.smtp_email_config_ready()
runtime = recaps.get_runtime_settings() 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) 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") @router.get("/admin/email-recaps/preview")
+2 -2
View File
@@ -32,7 +32,7 @@ def worker_enabled() -> bool:
def delivery_ready() -> tuple[bool, str]: def delivery_ready() -> tuple[bool, str]:
config = store.settings() config = store.settings()
if not config["public_url"]: 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() ready, detail = smtp_email_config_ready()
if not ready: if not ready:
return False, detail return False, detail
@@ -147,7 +147,7 @@ async def preview(user: dict, month: str | None) -> dict:
selected = completed_month(month) selected = completed_month(month)
config = store.settings() config = store.settings()
if not config["public_url"]: 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: try:
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180) report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
except HistoryLimitError as exc: except HistoryLimitError as exc:
+5 -1
View File
@@ -1,3 +1,4 @@
from .public_urls import magent_public_url
"""Independent newsletter consent and immutable edition snapshots using the shared email queue.""" """Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib import hashlib
@@ -63,6 +64,7 @@ def init_schema(conn):
def settings() -> dict: def settings() -> dict:
result = read_one('SELECT * FROM newsletter_settings WHERE id=1') result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
result['public_url'] = magent_public_url(result['public_url'])
result['enabled'] = bool(result['enabled']) result['enabled'] = bool(result['enabled'])
return result return result
@@ -79,6 +81,7 @@ def next_due(now: datetime, weekday: int, hour: int) -> datetime:
def save_settings(values: dict, now: datetime): def save_settings(values: dict, now: datetime):
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn: with transaction() as conn:
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()) old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
if old['revision'] != values['revision']: if old['revision'] != values['revision']:
@@ -246,7 +249,8 @@ def enqueue_test(sub, identity, revision, request_id, public_url, now):
def enqueue_due(now): def enqueue_due(now):
with transaction() as conn: 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() rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
for raw in rows: for raw in rows:
row = unpack(raw) row = unpack(raw)
+2 -2
View File
@@ -32,7 +32,7 @@ def playback_url(runtime) -> str:
def delivery_ready(public_url=None): def delivery_ready(public_url=None):
config = store.settings() config = store.settings()
if not (public_url if public_url is not None else config['public_url']): 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() runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key: if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
return False, 'Connect Jellyfin to collect new arrivals.' return False, 'Connect Jellyfin to collect new arrivals.'
@@ -150,7 +150,7 @@ async def preview(identity, revision):
runtime = get_runtime_settings() runtime = get_runtime_settings()
config = store.settings() config = store.settings()
if not config['public_url'] or not playback_url(runtime): 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): 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.') raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']} content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
+29
View File
@@ -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)
+4
View File
@@ -1,3 +1,4 @@
from .public_urls import magent_public_url
"""Durable consent, schedule and delivery records for personal email recaps.""" """Durable consent, schedule and delivery records for personal email recaps."""
import hashlib import hashlib
@@ -73,6 +74,7 @@ def read_one(sql: str, args=()) -> dict | None:
def settings() -> dict: def settings() -> dict:
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1") 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"} 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: def save_settings(values: dict, now: datetime) -> dict:
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn: with transaction() as conn:
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone()) 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")) 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: def enqueue_due(now: datetime) -> int:
with transaction() as conn: with transaction() as conn:
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone()) 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(): if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
return 0 return 0
# After long downtime, send only the latest due recap; never backfill a pile of old emails. # After long downtime, send only the latest due recap; never backfill a pile of old emails.
+57
View File
@@ -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')
+1 -1
View File
@@ -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. 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. 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.
+1 -1
View File
@@ -9,7 +9,7 @@ The weekly schedule starts paused, defaults to Friday at 09:00 UTC and selects t
## Setup and subscriptions ## 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. - 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. - 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. - 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.
+3 -2
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell' import AdminShell from '../../ui/AdminShell'
import { authFetch, getApiBase } from '../../lib/auth' 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 dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' })} UTC` : 'Not scheduled'
const labels: Record<string, string> = { 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 labels: Record<string, string> = { 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 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 }) { function Poster({ title }: { title: Title }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -138,7 +139,7 @@ export default function NewslettersAdminPage() {
{edition.state !== 'cancelled' && <div className="newsletter-send"><h3>{isDraft ? 'Ready for the inbox?' : 'Delivery controls'}</h3><p>A test goes to your own confirmed newsletter email. <a href="/profile#newsletters">Manage your subscription </a></p><div className="recap-actions"><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty} onClick={sendTest}>{busy === 'test' ? 'Queuing test…' : 'Send newsletter test to me'}</button></div>{isDraft ? <><label className="recap-month-label" htmlFor="newsletter-send-time">Schedule for (UTC)<input id="newsletter-send-time" type="datetime-local" value={sendAt} disabled={!!busy} onChange={(event) => setSendAt(event.target.value)} /></label><div className="recap-actions"><button type="button" className="account-primary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !sendAt} onClick={() => publish(true)}>Schedule edition</button><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !data.subscribers} onClick={() => publish(false)}>Send now to {data.subscribers} {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</button></div><p className="recap-muted">Preview the saved edition before sending. Scheduling fixes the content for this edition. Users must be subscribed by its send time.</p></> : <p>{dateLabel(edition.send_at)} · Check Delivery history for individual results.</p>}{['draft', 'scheduled', 'queued'].includes(edition.state) && <button type="button" className="ghost-button newsletter-cancel" disabled={!!busy || dirty} onClick={() => void action('cancel', `/editions/${edition.id}/cancel`, 'POST', {}, (row: Edition) => { remember(row); setNotice('Edition cancelled. Pending emails have been stopped.') })}>Cancel edition</button>}</div>} {edition.state !== 'cancelled' && <div className="newsletter-send"><h3>{isDraft ? 'Ready for the inbox?' : 'Delivery controls'}</h3><p>A test goes to your own confirmed newsletter email. <a href="/profile#newsletters">Manage your subscription </a></p><div className="recap-actions"><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty} onClick={sendTest}>{busy === 'test' ? 'Queuing test…' : 'Send newsletter test to me'}</button></div>{isDraft ? <><label className="recap-month-label" htmlFor="newsletter-send-time">Schedule for (UTC)<input id="newsletter-send-time" type="datetime-local" value={sendAt} disabled={!!busy} onChange={(event) => setSendAt(event.target.value)} /></label><div className="recap-actions"><button type="button" className="account-primary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !sendAt} onClick={() => publish(true)}>Schedule edition</button><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !data.subscribers} onClick={() => publish(false)}>Send now to {data.subscribers} {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</button></div><p className="recap-muted">Preview the saved edition before sending. Scheduling fixes the content for this edition. Users must be subscribed by its send time.</p></> : <p>{dateLabel(edition.send_at)} · Check Delivery history for individual results.</p>}{['draft', 'scheduled', 'queued'].includes(edition.state) && <button type="button" className="ghost-button newsletter-cancel" disabled={!!busy || dirty} onClick={() => void action('cancel', `/editions/${edition.id}/cancel`, 'POST', {}, (row: Edition) => { remember(row); setNotice('Edition cancelled. Pending emails have been stopped.') })}>Cancel edition</button>}</div>}
</section>} </section>}
</>} </>}
{tab === 'schedule' && <section className="admin-panel recap-panel"><span className="recap-eyebrow">Set the rhythm</span><h2>A weekly discovery</h2><p>Automatically collect the previous seven days of arrivals and send an edition to confirmed subscribers. Weeks without new arrivals are skipped.</p><form className="recap-schedule-form" onSubmit={(event) => { 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.') }) }}><label htmlFor="newsletter-public-url">Public Magent address<input id="newsletter-public-url" type="url" maxLength={500} placeholder="https://magent.example.com" required={settings.enabled} value={settings.public_url} disabled={!!busy} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} /><small>Used for confirmation links and email preferences in this environment.</small></label><div className="recap-schedule-fields"><label htmlFor="newsletter-weekday">Send day<select id="newsletter-weekday" value={settings.weekday} disabled={!!busy} onChange={(event) => setSettings({ ...settings, weekday: Number(event.target.value) })}>{daysOfWeek.map((day, index) => <option value={index} key={day}>{day}</option>)}</select></label><label htmlFor="newsletter-hour">Send time (UTC)<select id="newsletter-hour" value={settings.hour} disabled={!!busy} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div><label htmlFor="newsletter-limit">Titles per weekly edition<select id="newsletter-limit" value={settings.limit_titles} disabled={!!busy} onChange={(event) => setSettings({ ...settings, limit_titles: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select><small>Newest titles first. Multiple episodes count as one show.</small></label><label htmlFor="newsletter-default-intro">Default announcement<textarea id="newsletter-default-intro" rows={4} maxLength={2000} value={settings.intro} disabled={!!busy} onChange={(event) => setSettings({ ...settings, intro: event.target.value })} /><small>Appears in future weekly editions and newly created drafts.</small></label><label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable automatic weekly newsletters</span></label><p className="recap-muted">The schedule starts at the next future send time, in UTC. Pausing stops pending automatic editions. Custom editions keep their individual schedules.</p><div className="recap-actions"><button type="submit" className="account-primary" disabled={!!busy || !settingsDirty}>{busy === 'settings' ? 'Saving…' : 'Save weekly settings'}</button><button type="button" className="account-secondary" disabled={!!busy} onClick={() => void action('settings-reload', '', 'GET', undefined, (result: Overview) => { setData(result); setSettings(result.settings); setPreview(null) })}>Reload settings</button></div></form>{data.settings.last_error && <p className="recap-setup-note">{data.settings.last_error}</p>}</section>} {tab === 'schedule' && <section className="admin-panel recap-panel"><span className="recap-eyebrow">Set the rhythm</span><h2>A weekly discovery</h2><p>Automatically collect the previous seven days of arrivals and send an edition to confirmed subscribers. Weeks without new arrivals are skipped.</p><form className="recap-schedule-form" onSubmit={(event) => { 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.') }) }}><label htmlFor="newsletter-public-url">Public Magent address<input id="newsletter-public-url" type="url" value={settings.public_url} readOnly /><small>Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Watch links use the public playback URL in <Link href="/admin/jellyfin">Jellyfin settings</Link>.</small></label><div className="recap-schedule-fields"><label htmlFor="newsletter-weekday">Send day<select id="newsletter-weekday" value={settings.weekday} disabled={!!busy} onChange={(event) => setSettings({ ...settings, weekday: Number(event.target.value) })}>{daysOfWeek.map((day, index) => <option value={index} key={day}>{day}</option>)}</select></label><label htmlFor="newsletter-hour">Send time (UTC)<select id="newsletter-hour" value={settings.hour} disabled={!!busy} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div><label htmlFor="newsletter-limit">Titles per weekly edition<select id="newsletter-limit" value={settings.limit_titles} disabled={!!busy} onChange={(event) => setSettings({ ...settings, limit_titles: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select><small>Newest titles first. Multiple episodes count as one show.</small></label><label htmlFor="newsletter-default-intro">Default announcement<textarea id="newsletter-default-intro" rows={4} maxLength={2000} value={settings.intro} disabled={!!busy} onChange={(event) => setSettings({ ...settings, intro: event.target.value })} /><small>Appears in future weekly editions and newly created drafts.</small></label><label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable automatic weekly newsletters</span></label><p className="recap-muted">The schedule starts at the next future send time, in UTC. Pausing stops pending automatic editions. Custom editions keep their individual schedules.</p><div className="recap-actions"><button type="submit" className="account-primary" disabled={!!busy || !settingsDirty}>{busy === 'settings' ? 'Saving…' : 'Save weekly settings'}</button><button type="button" className="account-secondary" disabled={!!busy} onClick={() => void action('settings-reload', '', 'GET', undefined, (result: Overview) => { setData(result); setSettings(result.settings); setPreview(null) })}>Reload settings</button></div></form>{data.settings.last_error && <p className="recap-setup-note">{data.settings.last_error}</p>}</section>}
{tab === 'history' && <section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => setRefresh((value) => value + 1)}>Refresh history</button></div>{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Edition</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((row) => <tr key={row.id}><td><strong>{row.username || 'Removed account'}</strong><small>{row.email}</small></td><td>{row.subject}<small>{row.kind === 'test' ? 'Test email' : 'Newsletter'}</small></td><td><span className={`recap-pill ${row.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(row.state) ? 'is-attention' : ''}`}>{labels[row.state] || row.state}</span><small>{row.attempts} {row.attempts === 1 ? 'attempt' : 'attempts'} · {row.detail || 'Waiting for the next worker check.'}</small>{row.state === 'retry' && <small>Next attempt {dateLabel(row.next_attempt_at)}</small>}{row.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(row.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button type="button" className="ghost-button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button type="button" className="ghost-button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first edition starts here</h3><p>Preview a draft and send yourself a test. Delivery results will appear here.</p></div>}</section>} {tab === 'history' && <section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => setRefresh((value) => value + 1)}>Refresh history</button></div>{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Edition</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((row) => <tr key={row.id}><td><strong>{row.username || 'Removed account'}</strong><small>{row.email}</small></td><td>{row.subject}<small>{row.kind === 'test' ? 'Test email' : 'Newsletter'}</small></td><td><span className={`recap-pill ${row.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(row.state) ? 'is-attention' : ''}`}>{labels[row.state] || row.state}</span><small>{row.attempts} {row.attempts === 1 ? 'attempt' : 'attempts'} · {row.detail || 'Waiting for the next worker check.'}</small>{row.state === 'retry' && <small>Next attempt {dateLabel(row.next_attempt_at)}</small>}{row.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(row.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button type="button" className="ghost-button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button type="button" className="ghost-button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first edition starts here</h3><p>Preview a draft and send yourself a test. Delivery results will appear here.</p></div>}</section>}
</>} </>}
</div> </div>
+4 -3
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell' import AdminShell from '../../ui/AdminShell'
import { authFetch, getApiBase } from '../../lib/auth' import { authFetch, getApiBase } from '../../lib/auth'
@@ -61,8 +62,8 @@ export default function EmailRecapsAdminPage() {
if (busy) return if (busy) return
setBusy('save'); setError(''); setNotice('') setBusy('save'); setError(''); setNotice('')
try { try {
const { enabled, day, hour, public_url } = settings const { enabled, day, hour } = settings
const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour, public_url }) })) as Settings const result = await responseData(await authFetch(`${getApiBase()}/admin/email-recaps`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, day, hour }) })) as Settings
setSettings(result); setData((current) => current ? { ...current, settings: result } : current) setSettings(result); setData((current) => current ? { ...current, settings: result } : current)
setPreview(null) setPreview(null)
setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.') setNotice(result.enabled ? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.` : 'Settings saved. Scheduled delivery is paused.')
@@ -106,7 +107,7 @@ export default function EmailRecapsAdminPage() {
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div> <section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">Set the rhythm</span><h2>Monthly schedule</h2></div></div>
<p>Send the previous months report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p> <p>Send the previous months report to users who have opted in and confirmed their email. All report periods and send times use UTC.</p>
<form className="recap-schedule-form" onSubmit={save}> <form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" placeholder="https://magent.example.com" maxLength={500} value={settings.public_url} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} disabled={!!busy} required={settings.enabled} /><small>The address users open from this environments emails.</small></label> <label htmlFor="recap-public-url">Public Magent address<input id="recap-public-url" type="url" value={settings.public_url} readOnly /><small>Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when that address changes.</small></label>
<div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div> <div className="recap-schedule-fields"><label htmlFor="recap-day">Day of the month<select id="recap-day" value={settings.day} onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 28 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select></label><label htmlFor="recap-hour">Send time (UTC)<select id="recap-hour" value={settings.hour} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })} disabled={!!busy}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div>
<label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label> <label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable scheduled monthly recaps</span></label>
<p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p> <p className="recap-muted">Starting or changing the schedule begins at its next future send time. Pausing cancels queued monthly emails.</p>
+24
View File
@@ -0,0 +1,24 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
(async()=>{const browser=await chromium.launch();try{
const context=await browser.newContext();await context.addCookies([{name:'magent_logged_in',value:'1',url:base}]);
const configs={recaps:{enabled:false,day:2,hour:9,public_url:'https://public.example.test'},newsletters:{enabled:false,weekday:4,hour:9,limit_titles:12,intro:'',revision:1,public_url:'https://public.example.test'}};const writes=[];
await context.route('**/api/**',async route=>{const req=route.request(),path=new URL(req.url()).pathname;
if(path==='/api/auth/me')return route.fulfill({json:{username:'Admin',role:'admin'}});
const key=path==='/api/admin/email-recaps'?'recaps':path==='/api/admin/newsletters'?'newsletters':null;
if(key){if(req.method()==='PUT'){const data=req.postDataJSON();assert(!Object.hasOwn(data,'public_url'));writes.push(key);Object.assign(configs[key],data);return route.fulfill({json:configs[key]});}
return route.fulfill({json:{settings:configs[key],ready:true,detail:'Ready',months:['2026-08'],editions:[],deliveries:[],total:0,subscribers:0,worker_enabled:true}});}
return route.fulfill({json:{}});
});
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
for(const width of [1440,390]){await page.setViewportSize({width,height:950});for(const key of ['recaps','newsletters']){
await page.goto(base+'/admin/'+key);
if(key==='newsletters')await page.getByRole('button',{name:'Weekly schedule',exact:true}).click();
const input=page.locator(key==='recaps'?'#recap-public-url':'#newsletter-public-url');await input.waitFor();assert(await input.evaluate(e=>e.readOnly));assert.equal(await input.inputValue(),'https://public.example.test');
assert(await page.getByRole('link',{name:'Hosting & proxy',exact:true}).count());assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
if(key==='recaps')await page.getByLabel('Day of the month').selectOption(configs[key].day===2?'3':'2');else await page.locator('#newsletter-weekday').selectOption(configs[key].weekday===4?'5':'4');
await page.getByRole('button',{name:key==='recaps'?'Save schedule':'Save weekly settings',exact:true}).click();await page.waitForFunction(()=>!Array.from(document.querySelectorAll('button')).some(b=>b.textContent==='Saving…'));
assert.equal(await input.inputValue(),'https://public.example.test');
}}assert.equal(writes.length,4);assert.deepEqual(errors,[]);console.log('Passed: desktop/mobile inherited read-only addresses, Hosting & proxy links, schedule saves without duplicate URLs, and no overflow/errors. All APIs intercepted.');
}finally{await browser.close();}})().catch(e=>{console.error(e);process.exitCode=1});