220 app/api/routers/accounts.py 759 app/api/routers/campaigns.py 186 app/api/routers/test_send.py 118 app/api/routers/unsubscribe.py 125 app/api/routers/dashboard.py 1408 total ===== """Public unsubscribe endpoints: tokenized link from sent emails (/unsubscribe/{token}). Adds the email to the suppression list, records an EmailEvent, and never exposes any account data. No authentication required (token IS the credential).""" from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db, rate_limit_user from app.db.session import AsyncSessionLocal from app.models import Campaign, CampaignRecipient, EmailEvent, Recipient, SuppressionEntry router = APIRouter(tags=["unsubscribe"]) UNSUB_PAGE = """ Unsubscribe — {title}
{icon}

{heading}

{message}

""" def _page(icon: str, heading: str, message: str) -> HTMLResponse: html = UNSUB_PAGE.format(title=heading, icon=icon, heading=heading, message=message) return HTMLResponse(html, status_code=200) @router.get("/unsubscribe/{token}", response_class=HTMLResponse) async def unsubscribe_page(token: str, db: AsyncSession = Depends(get_db)): """Public unsubscribe landing page. Shows confirmation form (one-click safe).""" if len(token) < 20 or len(token) > 64: return _page("❌", "Invalid link", "This unsubscribe link is invalid or malformed.") rec = (await db.execute( select(Recipient).where(Recipient.unsub_token == token))).scalar() if not rec: return _page("❌", "Invalid link", "We could not find this unsubscribe link. It may have expired " "or the recipient list was removed.") email = rec.email already = (await db.execute( select(SuppressionEntry).where(SuppressionEntry.email == email))).scalar() if already: return _page("✅", "Already unsubscribed", f'{email} is already on our ' 'do-not-contact list. You will not receive further emails.') return _page("📮", "Unsubscribe", f'Do you want to stop receiving emails at ' f'{email}? Press the button to confirm. ' f'
' f'
') @router.post("/unsubscribe/{token}", response_class=HTMLResponse) async def unsubscribe_confirm(token: str, db: AsyncSession = Depends(get_db)): """Confirm unsubscribe: add to suppression list + record event. Idempotent.""" if len(token) < 20 or len(token) > 64: return _page("❌", "Invalid link", "This unsubscribe link is invalid.") rec = (await db.execute( select(Recipient).where(Recipient.unsub_token == token))).scalar() if not rec: return _page("❌", "Invalid link", "We could not find this unsubscribe link.") email = rec.email existing = (await db.execute( select(SuppressionEntry).where(SuppressionEntry.email == email))).scalar() if not existing: db.add(SuppressionEntry(user_id=rec.list.user_id if rec.list else None, email=email, reason="unsubscribed", source="unsubscribe-link")) await db.commit() # record event on the most recent campaign_recipient for this email (if any) cr = (await db.execute( select(CampaignRecipient).where(CampaignRecipient.email == email) .order_by(CampaignRecipient.id.desc()).limit(1))).scalar() if cr: db.add(EmailEvent(campaign_id=cr.campaign_id, campaign_recipient_id=cr.id, event_type="unsubscribed", detail=f"Unsubscribed via token link from {email}")) rec.status = "unsubscribed" await db.commit() return _page("✅", "Unsubscribed", f'{email} has been added to our ' 'do-not-contact list. You will not receive further emails from us. ' 'This took effect immediately.') @router.get("/unsubscribe-status/{token}") async def unsubscribe_status(token: str, db: AsyncSession = Depends(get_db)): """JSON status endpoint (used by List-Unsubscribe-Post one-click handlers).""" rec = (await db.execute( select(Recipient).where(Recipient.unsub_token == token))).scalar() if not rec: return {"subscribed": False, "error": "invalid token"} suppressed = (await db.execute( select(SuppressionEntry).where(SuppressionEntry.email == rec.email))).scalar() return {"subscribed": not bool(suppressed), "email_domain": rec.email.split("@")[-1]}