Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings section, mail templates (Compose > More > Templates), a Subscriptions folder with bulk unsubscribe/delete, and tracker blocking with a Privacy toggle (on by default) - Own dialogs instead of browser popups; emoji picker in compose; compose windows survive a visit to Settings - Sentence completion learned from the user's sent mail (no AI) - Interface in English and Polish (one file per language, English as the fallback), picked from the browser on first run, then kept - Email translation on the server via LibreTranslate: Translate button and "Translate to...", messages in several languages translated part by part, names/addresses/emoji/file names/phone numbers kept, an animated line while translating - Antispam "Mark as spam" loads with the message, not when the menu opens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user