Files
webmail-client/backend/app/api/settings.py
T
lukasandClaude Opus 5.5 8a1b9979e9 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>
2026-10-18 20:00:31 +02:00

213 lines
9.0 KiB
Python

"""User settings + per-sender image allowlist (SPEC §9.1 Settings, §9.4)."""
from __future__ import annotations
import re
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import Message, User
router = APIRouter(prefix="/settings", tags=["settings"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
_ALLOWED_ORDER = {"asc", "desc"}
_ALLOWED_TIME_FORMAT = {"12", "24"}
_ALLOWED_REMINDER_METHOD = {"alert", "email"}
_ALLOWED_INVITE_POLICY = {"add_update", "update_only", "none"}
_ALLOWED_INVITE_FROM = {"contacts", "anyone"}
# "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de".
_LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*")
_UI_LANGUAGE = re.compile(r"[a-z]{2,3}(-[A-Za-z]{2,4})?")
class ReminderPref(BaseModel):
method: str = "alert" # alert = in-app notification, email = email me
minutes: int = 0 # minutes before the event start (0 = at time of event)
class SettingsOut(BaseModel):
thread_order: str = "asc"
tz: str = "UTC"
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
jitsi_server: str = "" # blank => https://meet.jit.si/
default_calendar: str = "" # calendar id new events default to (blank => first)
default_reminders_timed: list[ReminderPref] = [] # defaults for timed events
default_reminders_allday: list[ReminderPref] = [] # defaults for all-day events
# How emailed events update the calendar: none | update_only | add_update.
invitation_policy: str = "none"
invitation_from: str = "contacts" # whose emailed events auto-add (contacts|anyone)
invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
invitation_file: bool = False # also mark the invite message read + filed
allowed_image_senders: list[str] = []
# Spelling and grammar check in the compose window, and its language
# ("auto" detects it per message).
spellcheck_enabled: bool = True
spellcheck_language: str = "auto"
# Gmail-style keyboard shortcuts in the mail view (Settings → Keyboard shortcuts).
shortcuts_enabled: bool = True
# Remove tracking pixels from mail (Settings → Privacy).
block_trackers: bool = True
# Grey sentence endings in the compose window, taken with Tab.
completion_enabled: bool = True
# Interface language ("en", "pl", …); "" until chosen — the web app then
# picks one from the browser on first sign-in and saves it here.
ui_language: str = ""
# Names and words email translation leaves as they are.
class SettingsPatch(BaseModel):
thread_order: str | None = None
tz: str | None = None
time_format: str | None = None
jitsi_server: str | None = None
default_calendar: str | None = None
default_reminders_timed: list[ReminderPref] | None = None
default_reminders_allday: list[ReminderPref] | None = None
invitation_policy: str | None = None
invitation_from: str | None = None
invitation_calendar: str | None = None
invitation_file: bool | None = None
spellcheck_enabled: bool | None = None
spellcheck_language: str | None = None
shortcuts_enabled: bool | None = None
block_trackers: bool | None = None
completion_enabled: bool | None = None
ui_language: str | None = None
class QuotaOut(BaseModel):
used_bytes: int
quota_bytes: int
def allowed_image_senders(user: User) -> set[str]:
value = user.settings.get("allowed_image_senders", [])
if isinstance(value, list):
return {str(e).lower() for e in value}
return set()
def blocks_trackers(user: User) -> bool:
"""Whether tracking pixels are taken out of this user's mail (on unless switched off)."""
return bool(user.settings.get("block_trackers", True))
def add_allowed_image_sender(user: User, email: str) -> None:
current = sorted(allowed_image_senders(user))
if email.lower() not in current:
current.append(email.lower())
# Reassign the whole dict so SQLAlchemy detects the JSONB change.
user.settings = {**user.settings, "allowed_image_senders": current}
def _reminder_list(value: object) -> list[ReminderPref]:
"""Normalize stored reminders. Accepts the legacy ``list[int]`` (minutes only,
method defaults to alert) as well as the ``{method, minutes}`` object form."""
if not isinstance(value, list):
return []
out: list[ReminderPref] = []
for x in value:
if isinstance(x, dict):
method = str(x.get("method", "alert"))
out.append(
ReminderPref(
method=method if method in _ALLOWED_REMINDER_METHOD else "alert",
minutes=int(x.get("minutes", 0)),
)
)
else:
out.append(ReminderPref(method="alert", minutes=int(x)))
return out
def _settings_out(user: User) -> SettingsOut:
s = user.settings
return SettingsOut(
thread_order=str(s.get("thread_order", "asc")),
tz=str(s.get("tz", "UTC")),
time_format=str(s.get("time_format", "24")),
jitsi_server=str(s.get("jitsi_server", "")),
default_calendar=str(s.get("default_calendar", "")),
default_reminders_timed=_reminder_list(s.get("default_reminders_timed")),
default_reminders_allday=_reminder_list(s.get("default_reminders_allday")),
invitation_policy=str(s.get("invitation_policy", "none")),
invitation_from=str(s.get("invitation_from", "contacts")),
invitation_calendar=str(s.get("invitation_calendar", "")),
invitation_file=bool(s.get("invitation_file", False)),
allowed_image_senders=sorted(allowed_image_senders(user)),
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
spellcheck_language=str(s.get("spellcheck_language", "auto")),
shortcuts_enabled=bool(s.get("shortcuts_enabled", True)),
block_trackers=blocks_trackers(user),
completion_enabled=bool(s.get("completion_enabled", True)),
ui_language=str(s.get("ui_language", "")),
)
@router.get("", response_model=SettingsOut)
async def get_settings_endpoint(user: CurrentUser) -> SettingsOut:
return _settings_out(user)
@router.get("/quota", response_model=QuotaOut)
async def get_quota(user: CurrentUser, db: DbSession) -> QuotaOut:
"""Storage quota and current usage (sum of stored message sizes)."""
used = await db.scalar(
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
)
return QuotaOut(used_bytes=int(used or 0), quota_bytes=user.quota_bytes)
@router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)])
async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut:
updates = dict(user.settings)
if body.thread_order is not None and body.thread_order in _ALLOWED_ORDER:
updates["thread_order"] = body.thread_order
if body.tz is not None:
updates["tz"] = body.tz
if body.time_format is not None and body.time_format in _ALLOWED_TIME_FORMAT:
updates["time_format"] = body.time_format
if body.jitsi_server is not None:
updates["jitsi_server"] = body.jitsi_server.strip()
if body.default_calendar is not None:
updates["default_calendar"] = body.default_calendar
if body.default_reminders_timed is not None:
updates["default_reminders_timed"] = [r.model_dump() for r in body.default_reminders_timed]
if body.default_reminders_allday is not None:
updates["default_reminders_allday"] = [
r.model_dump() for r in body.default_reminders_allday
]
if body.invitation_policy is not None and body.invitation_policy in _ALLOWED_INVITE_POLICY:
updates["invitation_policy"] = body.invitation_policy
if body.invitation_from is not None and body.invitation_from in _ALLOWED_INVITE_FROM:
updates["invitation_from"] = body.invitation_from
if body.invitation_calendar is not None:
updates["invitation_calendar"] = body.invitation_calendar
if body.invitation_file is not None:
updates["invitation_file"] = body.invitation_file
if body.spellcheck_enabled is not None:
updates["spellcheck_enabled"] = body.spellcheck_enabled
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
updates["spellcheck_language"] = body.spellcheck_language
if body.shortcuts_enabled is not None:
updates["shortcuts_enabled"] = body.shortcuts_enabled
if body.block_trackers is not None:
updates["block_trackers"] = body.block_trackers
if body.completion_enabled is not None:
updates["completion_enabled"] = body.completion_enabled
if body.ui_language is not None and _UI_LANGUAGE.fullmatch(body.ui_language):
updates["ui_language"] = body.ui_language
user.settings = updates
await db.commit()
return _settings_out(user)