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
+
+"""
+
+
@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: