feat: email unsubscribe tokens with one-click opt-out

- Migration 0019: email_unsubscribe_token column on users (unique, indexed)
- Token auto-generated on first email address save (same pattern as RSS token)
- GET /api/notifications/unsubscribe/{token} — no auth required, sets
  email_enabled=False and returns a branded HTML confirmation page
- List-Unsubscribe + List-Unsubscribe-Post headers on every email
  (improves deliverability; enables one-click unsubscribe in Gmail/Outlook)
- Unsubscribe link appended to email body plain text

Authored by: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jack Levy
2026-03-14 18:56:59 -04:00
parent 49bda16ad5
commit 380ff4addb
4 changed files with 91 additions and 4 deletions

View File

@@ -192,7 +192,7 @@ def dispatch_notifications(self):
email_address = prefs.get("email_address", "").strip()
if email_enabled and email_address:
try:
_send_email(event, email_address)
_send_email(event, email_address, unsubscribe_token=user.email_unsubscribe_token)
sent += 1
except Exception as e:
logger.warning(f"email dispatch failed for event {event.id}: {e}")
@@ -285,9 +285,14 @@ def _build_reason(payload: dict) -> str | None:
return None
def _send_email(event: NotificationEvent, email_address: str) -> None:
def _send_email(
event: NotificationEvent,
email_address: str,
unsubscribe_token: str | None = None,
) -> None:
"""Send a plain-text email notification via SMTP."""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import settings as app_settings
@@ -299,6 +304,7 @@ def _send_email(event: NotificationEvent, email_address: str) -> None:
bill_label = payload.get("bill_label", event.bill_id.upper())
bill_title = payload.get("bill_title", "")
event_label = _EVENT_TITLES.get(event.event_type, "Bill Update")
base_url = (app_settings.PUBLIC_URL or app_settings.LOCAL_URL).rstrip("/")
subject = f"PocketVeto: {event_label}{bill_label}"
@@ -315,13 +321,23 @@ def _send_email(event: NotificationEvent, email_address: str) -> None:
if payload.get("bill_url"):
lines.append("")
lines.append(f"View bill: {payload['bill_url']}")
unsubscribe_url = f"{base_url}/api/notifications/unsubscribe/{unsubscribe_token}" if unsubscribe_token else None
if unsubscribe_url:
lines.append("")
lines.append(f"Unsubscribe from email alerts: {unsubscribe_url}")
body = "\n".join(lines)
from_addr = app_settings.SMTP_FROM or app_settings.SMTP_USER
msg = MIMEText(body, "plain", "utf-8")
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = email_address
if unsubscribe_url:
msg["List-Unsubscribe"] = f"<{unsubscribe_url}>"
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
msg.attach(MIMEText(body, "plain", "utf-8"))
with smtplib.SMTP(app_settings.SMTP_HOST, app_settings.SMTP_PORT, timeout=10) as s:
if app_settings.SMTP_STARTTLS: