"""Suppression list manager: manual add, CSV/TXT import, search, delete, Clear All, bulk check. Suppressed addresses are skipped at import AND at send time.""" import csv import io from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile from pydantic import BaseModel, EmailStr from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db, require_csrf from app.core.security import log_audit from app.models import SuppressionEntry, User from app.services.recipient_service import is_valid_email router = APIRouter(prefix="/suppression", tags=["suppression"]) REASON_CHOICES = ("unsubscribed", "bounced", "complaint", "manual", "other") def _norm_reason(r: str) -> str: r = (r or "").strip().lower() if r == "complained": r = "complaint" return r if r in REASON_CHOICES else "manual" class SuppressionIn(BaseModel): email: EmailStr reason: str = "manual" source: str = "" class BulkEmails(BaseModel): emails: list[EmailStr] reason: str = "manual" class CheckEmails(BaseModel): emails: list[EmailStr] def _serialize(s: SuppressionEntry) -> dict: return { "id": s.id, "email": s.email, "reason": s.reason, "source": s.source, "campaign_id": s.campaign_id, "created_at": s.created_at.isoformat() if s.created_at else None, } @router.get("/list") async def list_suppression(search: str = Query(""), reason: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(100, ge=1, le=500), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): @router.post("/import") async def import_suppression(file: UploadFile = None, reason: str = Query("manual"), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Import suppressed emails from CSV/TXT (one email per line or email,reason).""" if file is None: raise HTTPException(status_code=400, detail="No file uploaded") raw = await file.read() if len(raw) > 2 * 1024 * 1024: raise HTTPException(status_code=413, detail="File exceeds 2MB") try: text = raw.decode("utf-8-sig") except UnicodeDecodeError: raise HTTPException(status_code=400, detail="File must be UTF-8 text") if text.lstrip().startswith(("{", "[")): raise HTTPException(status_code=400, detail="Only CSV or TXT files are accepted") entries, invalid = [], [] lines = text.splitlines() looks_csv = any("," in ln for ln in lines[:50]) if looks_csv: for row in csv.reader(io.StringIO(text)): if not row or not (row[0] or "").strip(): continue email = row[0].strip().lower() r = (row[1].strip() if len(row) > 1 else reason) or reason if email.lower().startswith(("email", "address")): continue if is_valid_email(email): entries.append((email, _norm_reason(r))) else: invalid.append(email) else: for line in lines: email = line.strip().lower() if not email or email.lower().startswith(("email", "address", "#")): continue if is_valid_email(email): entries.append((email, reason)) else: invalid.append(email) if not entries: raise HTTPException(status_code=400, detail=f"No valid emails found. Invalid: {invalid[:10]}") existing = set((await db.execute( select(SuppressionEntry.email).where(SuppressionEntry.user_id == user.id))).scalars()) added = 0 for email, r in entries: if email in existing: continue db.add(SuppressionEntry(user_id=user.id, email=email, reason=r, source="import")) existing.add(email) added += 1 await db.commit() await log_audit(db, user.id, "suppression.import", "suppression", None, {"added": added, "invalid": len(invalid)}) return {"added": added, "duplicates": len(entries) - added, "invalid": invalid[:20], "total_valid": len(entries)} @router.post("/bulk") =====SETTINGS===== if key == "attachment_kind" and v not in ("invoice", "order", "receipt", "document"): return False, "attachment_kind must be invoice|order|receipt|document" if key == "invoice_format" and ("{" not in v and v): return False, "Format must contain tokens like {prefix}{R#}{seq4}" return True, v except (TypeError, ValueError): return False, f"Value must be {t}" return False, "Invalid value" @router.get("/list") async def list_settings(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """All settings with current values (DB overrides) and metadata for the UI.""" rows = (await db.execute( select(ApplicationSettings).where(ApplicationSettings.key.in_(SETTING_DEFS.keys())) )).scalars().all() stored = {r.key: r.value for r in rows} out = [] for key, d in SETTING_DEFS.items(): out.append({ "key": key, "label": d["label"], "category": d["category"], "type": d["type"], "value": stored.get(key, d["default"]), "default": d["default"], "is_default": key not in stored, }) return {"settings": out} @router.post("/save") async def save_setting(payload: SettingIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): ok, value = _validate_value(payload.key, payload.value) if not ok: raise HTTPException(status_code=400, detail=value) row = await db.execute( select(ApplicationSettings).where(ApplicationSettings.key == payload.key)) row = row.scalar() if row: row.value = value row.description = payload.description or row.description else: db.add(ApplicationSettings(key=payload.key, value=value, description=payload.description or SETTING_DEFS[payload.key]["label"], category=SETTING_DEFS[payload.key]["category"])) await db.commit() await log_audit(db, user.id, "settings.save", "application_setting", None, {"key": payload.key}) return {"key": payload.key, "value": value} @router.post("/save-batch") async def save_batch(payload: BatchSettingsIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Save multiple settings at once (Settings page form).""" applied, errors = {}, {} for key, value in payload.values.items(): if key not in SETTING_DEFS: errors[key] = "Unknown setting key" continue ok, v = _validate_value(key, value) if not ok: errors[key] = v continue row = await db.execute( select(ApplicationSettings).where(ApplicationSettings.key == key)) row = row.scalar() if row: row.value = v else: db.add(ApplicationSettings(key=key, value=v, description=SETTING_DEFS[key]["label"], category=SETTING_DEFS[key]["category"])) applied[key] = v await db.commit() if applied: await log_audit(db, user.id, "settings.save_batch", "application_setting", None, {"keys": list(applied.keys())}) return {"applied": applied, "errors": errors} @router.post("/reset") async def reset_setting(payload: SettingIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Reset one setting to its default value (deletes the DB row).""" if payload.key not in SETTING_DEFS: raise HTTPException(status_code=400, detail="Unknown setting key") row = await db.execute( select(ApplicationSettings).where(ApplicationSettings.key == payload.key)) row = row.scalar() if row: await db.delete(row) await db.commit() await log_audit(db, user.id, "settings.reset", "application_setting", None, {"key": payload.key}) return {"key": payload.key, "value": SETTING_DEFS[payload.key]["default"]} @router.post("/reset-all") async def reset_all_settings(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): rows = (await db.execute( select(ApplicationSettings).where(ApplicationSettings.key.in_(SETTING_DEFS.keys())) )).scalars().all() for r in rows: await db.delete(r) await db.commit() await log_audit(db, user.id, "settings.reset_all", "application_setting", None, {"reset": len(rows)}) return {"reset": len(rows)} =====LOGSQ===== "created_at": a.created_at.isoformat() if a.created_at else None, } @router.get("/list") async def list_logs(campaign_id: int = Query(0), outcome: str = Query("", description="submitted|accepted|temp_failed|perm_failed|skipped"), search: str = Query("", description="email or message id fragment"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """Unified send log: SendAttempt rows joined with CampaignRecipient for context.""" q = (select(SendAttempt, CampaignRecipient) .join(CampaignRecipient, SendAttempt.campaign_recipient_id == CampaignRecipient.id) .join(Campaign, Campaign.id == SendAttempt.campaign_id) .where(Campaign.user_id == user.id)) if campaign_id: q = q.where(SendAttempt.campaign_id == campaign_id) if outcome: q = q.where(SendAttempt.outcome == outcome) if search: like = f"%{search}%" q = q.where(or_(CampaignRecipient.email.ilike(like), SendAttempt.message_id.ilike(like))) total = (await db.execute( select(func.count()).select_from(q.subquery()))).scalar() or 0 rows = (await db.execute( q.order_by(SendAttempt.id.desc()).offset((page - 1) * page_size).limit(page_size) )).all() out = [] for a, cr in rows: d = _ser_attempt(a) d["email"] = cr.email d["recipient_status"] = cr.status out.append(d) return {"logs": out, "total": total, "page": page, "page_size": page_size} @router.get("/events") async def list_events(campaign_id: int = Query(0), =====ATT_SER===== return None def _serialize(att: Attachment) -> dict: return { "id": att.id, "name": att.name, "kind": att.kind, "filename": att.filename, "mime_type": att.mime_type, "size_bytes": att.size_bytes, "file_sha256": att.file_sha256, "description": att.description, "is_html_attachment": bool(att.is_html_attachment), "created_at": att.created_at.isoformat() if att.created_at else None, } async def _get_own(db: AsyncSession, user: User, att_id: int) -> Attachment: att = await db.get(Attachment, att_id) if not att or att.user_id != user.id: raise HTTPException(status_code=404, detail="Attachment not found") return att @router.get("/list")