"""Templates: saved replies, inserted from the compose window's More menu.""" from __future__ import annotations import datetime as dt import uuid from typing import Annotated import nh3 from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import raise_api_error from app.db import get_session from app.identity.deps import CurrentUser, require_csrf from app.models import MailTemplate router = APIRouter(prefix="/templates", tags=["compose"], dependencies=[Depends(require_csrf)]) DbSession = Annotated[AsyncSession, Depends(get_session)] MAX_TEMPLATES = 200 MAX_BODY_BYTES = 256 * 1024 # What the compose editor writes. A template goes back into the editor as HTML, # so it is cleaned like any HTML the app renders: no scripts, no handlers. _TAGS = { "a", "b", "blockquote", "br", "code", "div", "em", "font", "h1", "h2", "h3", "hr", "i", "img", "li", "ol", "p", "pre", "s", "span", "strike", "strong", "sub", "sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul", } # fmt: skip _GENERIC = {"style", "class", "dir", "align"} _SPECIFIC = { "a": {"href", "title"}, "img": {"src", "alt", "width", "height"}, "font": {"color", "face", "size"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan"}, } # nh3 takes no wildcard: the generic attributes go onto every tag. _ATTRS = {tag: _GENERIC | _SPECIFIC.get(tag, set()) for tag in _TAGS} class TemplateOut(BaseModel): id: uuid.UUID name: str subject: str body_html: str updated_at: dt.datetime class TemplateIn(BaseModel): name: str = Field(min_length=1, max_length=80) subject: str = Field(default="", max_length=500) body_html: str = "" def _out(t: MailTemplate) -> TemplateOut: return TemplateOut( id=t.id, name=t.name, subject=t.subject, body_html=t.body_html, updated_at=t.updated_at ) def clean_body(html: str) -> str: return nh3.clean( html, tags=_TAGS, attributes=_ATTRS, url_schemes={"http", "https", "mailto", "data"}, link_rel="noopener noreferrer", ) @router.get("", response_model=list[TemplateOut]) async def list_templates(user: CurrentUser, db: DbSession) -> list[TemplateOut]: rows = await db.scalars( select(MailTemplate) .where(MailTemplate.user_id == user.id) .order_by(func.lower(MailTemplate.name)) ) return [_out(t) for t in rows] @router.post("", response_model=TemplateOut) async def save_template(body: TemplateIn, user: CurrentUser, db: DbSession) -> TemplateOut: """Save a template. Saving under a name that exists replaces that template.""" name = " ".join(body.name.split()) if not name: raise_api_error(400, "invalid", "a template needs a name") if len(body.body_html.encode()) > MAX_BODY_BYTES: raise_api_error(400, "too_large", "the template is too large") html = clean_body(body.body_html) subject = " ".join(body.subject.split()) existing = await db.scalar( select(MailTemplate).where( MailTemplate.user_id == user.id, func.lower(MailTemplate.name) == name.lower() ) ) if existing is not None: existing.name, existing.subject, existing.body_html = name, subject, html existing.updated_at = dt.datetime.now(dt.UTC) saved = existing else: count = await db.scalar( select(func.count()).select_from(MailTemplate).where(MailTemplate.user_id == user.id) ) if (count or 0) >= MAX_TEMPLATES: raise_api_error(409, "too_many", f"at most {MAX_TEMPLATES} templates") saved = MailTemplate(user_id=user.id, name=name, subject=subject, body_html=html) db.add(saved) await db.commit() await db.refresh(saved) return _out(saved) @router.delete("/{template_id}") async def delete_template( template_id: uuid.UUID, user: CurrentUser, db: DbSession ) -> dict[str, str]: row = await db.scalar( select(MailTemplate).where(MailTemplate.id == template_id, MailTemplate.user_id == user.id) ) if row is None: raise_api_error(404, "not_found", "template not found") await db.delete(row) await db.commit() return {"status": "deleted"}