"""Subject lines manager: CRUD, search, CSV/TXT import, export, clear-all.""" import csv import io from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy import delete as sa_delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.core.config import settings from app.models import SubjectLine, User router = APIRouter(prefix="/subjects", tags=["subjects"]) class SubjectIn(BaseModel): subject: str @router.get("") async def list_subjects(q: str = "", page: int = 1, limit: int = 100, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): stmt = select(SubjectLine).where(SubjectLine.user_id == user.id) if q: stmt = stmt.where(SubjectLine.subject.ilike(f"%{q}%")) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() rows = (await db.execute(stmt.order_by(SubjectLine.id).offset((page - 1) * limit).limit(limit))).scalars().all() return {"total": total, "subjects": [{"id": s.id, "subject": s.subject, "use_count": s.use_count} for s in rows]} @router.post("") async def add_subject(payload: SubjectIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): count = (await db.execute(select(func.count(SubjectLine.id)).where( SubjectLine.user_id == user.id))).scalar() if count >= settings.MAX_SUBJECTS: raise HTTPException(400, f"Maximum of {settings.MAX_SUBJECTS} subject variations reached") row = SubjectLine(user_id=user.id, subject=payload.subject.strip()[:500]) db.add(row) await db.commit() return {"id": row.id, "subject": row.subject} @router.post("/bulk") async def add_bulk(payload: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): subjects = [s.strip() for s in (payload.get("subjects") or []) if s.strip()] if not subjects: raise HTTPException(400, "No subjects provided") count = (await db.execute(select(func.count(SubjectLine.id)).where( SubjectLine.user_id == user.id))).scalar() if count + len(subjects) > settings.MAX_SUBJECTS: raise HTTPException(400, f"Would exceed maximum of {settings.MAX_SUBJECTS} subjects") for s in subjects: db.add(SubjectLine(user_id=user.id, subject=s[:500])) await db.commit() return {"ok": True, "added": len(subjects)} @router.post("/import") async def import_subjects(file: UploadFile = File(...), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): fname = (file.filename or "").lower() if fname.endswith(".json"): raise HTTPException(400, "Only CSV/TXT subject imports are supported") content = (await file.read()).decode("utf-8", errors="replace") subjects = [] =====TPL===== """HTML templates manager: paste/upload/editor/preview/duplicate/import/clear-all.""" import re import secrets from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlalchemy import delete as sa_delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.core.config import settings from app.models import EmailTemplate, User from app.services import message_builder from app.services.variable_engine import analyze_template_variables router = APIRouter(prefix="/templates", tags=["templates"]) class TemplateIn(BaseModel): name: str body_html: str body_text: str = "" subject_hint: str = "" @router.get("") async def list_templates(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): rows = (await db.execute(select(EmailTemplate).where(EmailTemplate.user_id == user.id) .order_by(EmailTemplate.id))).scalars().all() out = [] for t in rows: analysis = analyze_template_variables(t.body_html) out.append({"id": t.id, "name": t.name, "subject_hint": t.subject_hint, "source": t.source, "sequence_order": t.sequence_order, "size_html": len(t.body_html), "variables": analysis["variables"], "unknown_variables": analysis["unknown"], "created_at": t.created_at.isoformat()}) return {"templates": out, "count": len(out)} @router.post("") async def create_template(payload: TemplateIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): if not payload.body_html.strip(): raise HTTPException(400, "HTML body is required") if len(payload.body_html) > 1024 * 1024: raise HTTPException(413, "Template too large (max 1 MB)") problems = [] if re.search(r" 1024 * 1024: raise HTTPException(413, "Template too large") if re.search(r" MAX_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="File exceeds 10MB limit") if not content: raise HTTPException(status_code=400, detail="Empty file") orig = file.filename or "attachment.bin" ext = _ext(orig) kind = _kind_for(ext) if kind is None: raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext or 'unknown'}. Allowed: " f"txt, html, pdf, png, jpg, gif, webp, csv, zip") # security: scan buffer for executable magic before persisting verdict, reason = att_svc.scan_for_malware(content, orig) if not verdict: raise HTTPException(status_code=400, detail=f"Rejected by malware scan: {reason}") is_html = kind == "html" html_body = "" if is_html: try: html_body = content.decode("utf-8") except UnicodeDecodeError: raise HTTPException(status_code=400, detail="HTML file is not valid UTF-8") problems = validate_html(html_body) if problems.get("errors"): raise HTTPException(status_code=400, detail=f"HTML rejected: {'; '.join(problems['errors'])}") # HTML attachments are stored inline (used with variable substitution at send time) att = Attachment( user_id=user.id, name=name or re.sub(r"\.[^.]+$", "", orig) or "html-attachment", filename=orig, stored_path="", mime_type="text/html", size_bytes=len(content), kind="html", description=description or "", is_html_attachment=True, html_attachment_body=html_body, file_sha256=hashlib.sha256(content).hexdigest(), ) else: stored = att_svc.save_upload(orig, content) att = Attachment( user_id=user.id, name=name or re.sub(r"\.[^.]+$", "", orig) or "attachment", filename=orig, stored_path=stored["stored_path"], mime_type=MIME_BY_EXT.get(ext, "application/octet-stream"), size_bytes=len(content), kind=kind, description=description or "", file_sha256=hashlib.sha256(content).hexdigest(), ) db.add(att) await db.commit() await log_audit(db, user.id, "attachment.upload", "attachment", att.id, {"name": att.name, "kind": kind, "sha256": att.file_sha256, "size": len(content)}) return {"id": att.id, **_serialize(att)} @router.post("/convert-pdf") async def convert_html_to_pdf(template_id: int = Query(..., description="HTML template to render"), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Convert one of the user's saved HTML templates into a real PDF attachment using WeasyPrint. The generated PDF is stored and can be attached to campaigns.""" tpl = await db.get(EmailTemplate, template_id) if not tpl or tpl.user_id != user.id: raise HTTPException(status_code=404, detail="Template not found")