Inherit email link addresses from hosting and proxy settings
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']}
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user