diff --git a/backend/alembic/versions/0019_add_email_unsubscribe_token.py b/backend/alembic/versions/0019_add_email_unsubscribe_token.py new file mode 100644 index 0000000..83c6d37 --- /dev/null +++ b/backend/alembic/versions/0019_add_email_unsubscribe_token.py @@ -0,0 +1,22 @@ +"""Add email_unsubscribe_token to users + +Revision ID: 0019 +Revises: 0018 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0019" +down_revision = "0018" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("users", sa.Column("email_unsubscribe_token", sa.String(64), nullable=True)) + op.create_index("ix_users_email_unsubscribe_token", "users", ["email_unsubscribe_token"], unique=True) + + +def downgrade(): + op.drop_index("ix_users_email_unsubscribe_token", table_name="users") + op.drop_column("users", "email_unsubscribe_token") diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index 1cd90f0..36ef944 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -7,7 +7,7 @@ from xml.etree.ElementTree import Element, SubElement, tostring import httpx from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import Response +from fastapi.responses import HTMLResponse, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -119,6 +119,9 @@ async def update_notification_settings( if not user.rss_token: user.rss_token = secrets.token_urlsafe(32) + # Generate unsubscribe token the first time an email address is saved + if prefs.get("email_address") and not user.email_unsubscribe_token: + user.email_unsubscribe_token = secrets.token_urlsafe(32) await db.commit() await db.refresh(user) @@ -371,6 +374,51 @@ async def test_follow_mode( return NotificationTestResult(status="error", detail=f"Connection error: {e}") +@router.get("/unsubscribe/{token}", response_class=HTMLResponse, include_in_schema=False) +async def email_unsubscribe(token: str, db: AsyncSession = Depends(get_db)): + """One-click email unsubscribe — no login required.""" + result = await db.execute( + select(User).where(User.email_unsubscribe_token == token) + ) + user = result.scalar_one_or_none() + + if not user: + return HTMLResponse( + _unsubscribe_page("Invalid or expired link", success=False), + status_code=404, + ) + + prefs = dict(user.notification_prefs or {}) + prefs["email_enabled"] = False + user.notification_prefs = prefs + await db.commit() + + return HTMLResponse(_unsubscribe_page("You've been unsubscribed from PocketVeto email notifications.", success=True)) + + +def _unsubscribe_page(message: str, success: bool) -> str: + color = "#16a34a" if success else "#dc2626" + icon = "✓" if success else "✗" + return f""" + + +PocketVeto — Unsubscribe + +
+
{icon}
+

Email Notifications

+

{message}

+ Return to PocketVeto +
""" + + @router.get("/feed/{rss_token}.xml", include_in_schema=False) async def rss_feed(rss_token: str, db: AsyncSession = Depends(get_db)): """Public tokenized RSS feed — no auth required.""" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 9e57d00..da17419 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -15,6 +15,7 @@ class User(Base): is_admin = Column(Boolean, nullable=False, default=False) notification_prefs = Column(JSONB, nullable=False, default=dict) rss_token = Column(String, unique=True, nullable=True, index=True) + email_unsubscribe_token = Column(String(64), unique=True, nullable=True, index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/workers/notification_dispatcher.py b/backend/app/workers/notification_dispatcher.py index 111cac4..5209dcd 100644 --- a/backend/app/workers/notification_dispatcher.py +++ b/backend/app/workers/notification_dispatcher.py @@ -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: