130 lines
4.2 KiB
Python
130 lines
4.2 KiB
Python
"""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"}
|