@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)): q = select(SuppressionEntry).where(SuppressionEntry.user_id == user.id) if search: q = q.where(SuppressionEntry.email.ilike(f"%{search}%")) if reason: q = q.where(SuppressionEntry.reason == reason) total = (await db.execute( select(func.count()).select_from(q.subquery()))).scalar() or 0 rows = (await db.execute( q.order_by(SuppressionEntry.id.desc()).offset((page - 1) * page_size).limit(page_size) )).scalars().all() return {"entries": [_serialize(s) for s in rows], "total": total, "page": page, "page_size": page_size} @router.post("/add") async def add_suppression(payload: SuppressionIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): email = payload.email.strip().lower() payload.reason = _norm_reason(payload.reason) existing = await db.execute( select(SuppressionEntry).where(SuppressionEntry.email == email, SuppressionEntry.user_id == user.id)) if existing.first(): raise HTTPException(status_code=409, detail="Already suppressed") s = SuppressionEntry(user_id=user.id, email=email, reason=payload.reason, source=payload.source.strip()[:200]) db.add(s) await db.commit() await log_audit(db, user.id, "suppression.add", "suppression", s.id, {"reason": s.reason}) return _serialize(s) @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() ===LOGS=== @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 via SendJob to CampaignRecipient.""" q = (select(SendAttempt, SendJob) .join(SendJob, SendAttempt.send_job_id == SendJob.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_(SendAttempt.recipient_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, job in rows: d = _ser_attempt(a, cr_id=job.campaign_recipient_id) d["email"] = a.recipient_email d["recipient_status"] = None # filled cheaply below for joined rows out.append(d) if out: cr_ids = [d["campaign_recipient_id"] for d in out if d["campaign_recipient_id"]] if cr_ids: crs = (await db.execute( select(CampaignRecipient.id, CampaignRecipient.status) .where(CampaignRecipient.id.in_(cr_ids)))).all() smap = {r[0]: r[1] for r in crs} for d in out: if d["campaign_recipient_id"]: d["recipient_status"] = smap.get(d["campaign_recipient_id"]) return {"logs": out, "total": total, "page": page, "page_size": page_size} @router.get("/events") async def list_events(campaign_id: int = Query(0), event_type: str = Query(""), 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)): q = (select(EmailEvent, CampaignRecipient) .join(CampaignRecipient, EmailEvent.campaign_recipient_id == CampaignRecipient.id) .join(Campaign, Campaign.id == EmailEvent.campaign_id) .where(Campaign.user_id == user.id)) if campaign_id: q = q.where(EmailEvent.campaign_id == campaign_id) if event_type: q = q.where(EmailEvent.event_type == event_type) total = (await db.execute( select(func.count()).select_from(q.subquery()))).scalar() or 0 rows = (await db.execute( q.order_by(EmailEvent.id.desc()).offset((page - 1) * page_size).limit(page_size) )).all() out = [] for e, cr in rows: out.append({ "id": e.id, "campaign_id": e.campaign_id, "email": cr.email, "event_type": e.event_type, "detail": (e.detail or "")[:300], "message_id": e.message_id, "created_at": e.created_at.isoformat() if e.created_at else None, }) return {"events": out, "total": total, "page": page, "page_size": page_size} @router.get("/audit") async def list_audit(action: str = Query(""), page: int = Query(1, ge=1),