- 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>
213 lines
9.0 KiB
Python
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)
|