Backend - Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX. - Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr. - Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge); inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID. - Per-user storage quota (5 GiB default) and manual "sync all folders" action. - Account editing (display/color/server/password) and per-account HTML signature. - Account view scoped to that account's inbox; new migrations 0006–0009. Frontend - Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh. - Aligned + widened mail-list margins; reading-pane bars fixed height. - Rich-text composer: color palette, highlight, image; attachments; expand/pop-out. - Signatures: auto-insert for new mail, replace on From change, reply insert. - Restore/Delete actions per folder; drafts view merges local + remote, date-sorted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
2.6 KiB
Python
87 lines
2.6 KiB
Python
"""User settings + per-sender image allowlist (SPEC §9.1 Settings, §9.4)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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"}
|
|
|
|
|
|
class SettingsOut(BaseModel):
|
|
thread_order: str = "asc"
|
|
tz: str = "UTC"
|
|
allowed_image_senders: list[str] = []
|
|
|
|
|
|
class SettingsPatch(BaseModel):
|
|
thread_order: str | None = None
|
|
tz: 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 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 _settings_out(user: User) -> SettingsOut:
|
|
s = user.settings
|
|
return SettingsOut(
|
|
thread_order=str(s.get("thread_order", "asc")),
|
|
tz=str(s.get("tz", "UTC")),
|
|
allowed_image_senders=sorted(allowed_image_senders(user)),
|
|
)
|
|
|
|
|
|
@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
|
|
user.settings = updates
|
|
await db.commit()
|
|
return _settings_out(user)
|