Security hardening from the assessment; testing fixes

Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:08:06 +02:00
co-authored by Claude Opus 5.5
parent 30b806e7da
commit addb2232a1
54 changed files with 1265 additions and 187 deletions
+5 -1
View File
@@ -302,7 +302,8 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
Each message returns to ``restore_mailbox_id`` (the folder it was in before it
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
with no recorded origin fall back to the account's Inbox.
with no recorded origin fall back to the account's Inbox. Only messages in
Archive, Trash or Spam move: the thread's others (your reply in Sent) stay.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
@@ -319,10 +320,13 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
removal_ids = {mb.id for mb in mailboxes if mb.special_use in _REMOVAL_FOLDERS}
inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"}
# (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages:
if m.mailbox_id not in removal_ids:
continue # never removed: nothing to restore
target_id = m.restore_mailbox_id
if target_id is None or target_id not in path_by_id:
target_id = inbox_by_account.get(m.account_id)
+40 -25
View File
@@ -172,9 +172,7 @@ class _BackfillProgress:
self._mailboxes.add(mailbox_path)
async with session_scope() as session:
await session.execute(
update(Account)
.where(Account.id == self.account.id)
.values(backfill_done=self.done)
update(Account).where(Account.id == self.account.id).values(backfill_done=self.done)
)
await get_event_bus().publish(
"sync.progress",
@@ -321,16 +319,28 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
dedupe = gmail and mailbox.special_use == "archive"
if since is not None: # 3months: single windowed pass
count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=since,
provider,
account_obj,
mailbox,
progress,
since=since,
dedupe_account_scope=dedupe,
)
else: # all: recent window first (usable fast), then the remainder
count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=recent_cutoff,
provider,
account_obj,
mailbox,
progress,
since=recent_cutoff,
dedupe_account_scope=dedupe,
)
count += await backfill_mailbox(
provider, account_obj, mailbox, progress, before=recent_cutoff,
provider,
account_obj,
mailbox,
progress,
before=recent_cutoff,
dedupe_account_scope=dedupe,
)
await _set_state(account_id, "live")
@@ -491,9 +501,7 @@ async def apply_delta(
await _reconcile_drafts(provider, account, mailbox)
async def _reconcile_drafts(
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox
) -> None:
async def _reconcile_drafts(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> None:
"""Drop DB rows for drafts that no longer exist on the server (were expunged)."""
from app.db import session_scope
@@ -548,13 +556,13 @@ async def live_loop(
await provider.verify() # signed in: the account is reachable
if health is not None:
await health.ok()
first = True
while not stop.is_set():
try:
await provider.idle(mailbox_path)
except ProviderNotSupported:
await asyncio.sleep(idle_fallback)
if stop.is_set():
break
# Catch up before each IDLE, not only after it: IDLE reports what
# arrives from now on, so mail that came while this loop was not
# running (a new account's first minutes, a reconnect, a worker
# restart) would otherwise wait for the next new message or the
# IDLE timeout (10 min) to show.
async with session_scope() as session:
mailbox = await session.scalar(
select(Mailbox).where(
@@ -562,14 +570,23 @@ async def live_loop(
)
)
mailbox_obj = mailbox
if mailbox_obj is None:
if mailbox_obj is not None:
delta = await provider.poll(
mailbox_path,
last_seen_uid=mailbox_obj.last_seen_uid,
highestmodseq=mailbox_obj.highestmodseq,
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
elif not first:
break # the folder is gone
# else: the account's first sync has not recorded the folder yet.
first = False
if stop.is_set():
break
delta = await provider.poll(
mailbox_path,
last_seen_uid=mailbox_obj.last_seen_uid,
highestmodseq=mailbox_obj.highestmodseq,
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
try:
await provider.idle(mailbox_path)
except ProviderNotSupported:
await asyncio.sleep(idle_fallback)
finally:
await provider.close()
@@ -630,9 +647,7 @@ async def poll_folders_once(
await provider.close()
async def _poll_secondary_once(
account_id: uuid.UUID, health: AccountHealth | None = None
) -> None:
async def _poll_secondary_once(account_id: uuid.UUID, health: AccountHealth | None = None) -> None:
await poll_folders_once(account_id, include_inbox=False, health=health)
+102 -38
View File
@@ -28,24 +28,52 @@ def _compact(name: str) -> str:
# Localized folder names by special_use. Written readably; compacted at load.
_NAMES: dict[str, list[str]] = {
"sent": [
"sent", "sent items", "sent mail", "sent messages", # English / Outlook / Gmail
"wysłane", "elementy wysłane", "wiadomości wysłane", # Polish
"gesendet", "gesendete objekte", "gesendete elemente", # German
"envoyés", "éléments envoyés", "messages envoyés", "courrier envoyé", # French
"enviados", "elementos enviados", "correo enviado", # Spanish
"inviata", "posta inviata", "messaggi inviati", # Italian
"verzonden", "verzonden items", "verzonden berichten", # Dutch
"enviadas", "itens enviados", "mensagens enviadas", # Portuguese
"отправленные", "исходящие", # Russian
"skickat", "skickade", "skickade objekt", # Swedish
"sendt", "sendte", "sendte elementer", # Norwegian / Danish
"sent",
"sent items",
"sent mail",
"sent messages", # English / Outlook / Gmail
"wysłane",
"elementy wysłane",
"wiadomości wysłane", # Polish
"gesendet",
"gesendete objekte",
"gesendete elemente", # German
"envoyés",
"éléments envoyés",
"messages envoyés",
"courrier envoyé", # French
"enviados",
"elementos enviados",
"correo enviado", # Spanish
"inviata",
"posta inviata",
"messaggi inviati", # Italian
"verzonden",
"verzonden items",
"verzonden berichten", # Dutch
"enviadas",
"itens enviados",
"mensagens enviadas", # Portuguese
"отправленные",
"исходящие", # Russian
"skickat",
"skickade",
"skickade objekt", # Swedish
"sendt",
"sendte",
"sendte elementer", # Norwegian / Danish
"lähetetyt", # Finnish
"odeslané", "odeslaná pošta", # Czech
"odeslané",
"odeslaná pošta", # Czech
],
"drafts": [
"drafts", "draft",
"robocze", "kopie robocze", "wersje robocze",
"entwürfe", "entwurf",
"drafts",
"draft",
"robocze",
"kopie robocze",
"wersje robocze",
"entwürfe",
"entwurf",
"brouillons",
"borradores",
"bozze",
@@ -58,44 +86,80 @@ _NAMES: dict[str, list[str]] = {
"koncepty", # Czech
],
"trash": [
"trash", "deleted", "deleted items", "deleted messages", "bin", "recycle bin",
"trash",
"deleted",
"deleted items",
"deleted messages",
"bin",
"recycle bin",
"kosz",
"papierkorb", "gelöschte objekte", "gelöschte elemente",
"corbeille", "éléments supprimés",
"papelera", "elementos eliminados",
"cestino", "posta eliminata",
"prullenbak", "verwijderde items",
"lixeira", "lixo", "itens excluídos",
"корзина", "удаленные",
"papperskorg", "borttaget",
"papirkurv", "slettet",
"roskakori", "poistetut",
"papierkorb",
"gelöschte objekte",
"gelöschte elemente",
"corbeille",
"éléments supprimés",
"papelera",
"elementos eliminados",
"cestino",
"posta eliminata",
"prullenbak",
"verwijderde items",
"lixeira",
"lixo",
"itens excluídos",
"корзина",
"удаленные",
"papperskorg",
"borttaget",
"papirkurv",
"slettet",
"roskakori",
"poistetut",
"koš", # Czech
],
"junk": [
"junk", "junk email", "junk e-mail", "spam", "bulk mail", "bulk",
"wiadomości-śmieci", "niechciane",
"unerwünscht", "werbung",
"courrier indésirable", "indésirables", "pourriel",
"correo no deseado", "no deseado", "correo basura",
"posta indesiderata", "indesiderata",
"ongewenst", "ongewenste e-mail",
"junk",
"junk email",
"junk e-mail",
"spam",
"bulk mail",
"bulk",
"wiadomości-śmieci",
"niechciane",
"unerwünscht",
"werbung",
"courrier indésirable",
"indésirables",
"pourriel",
"correo no deseado",
"no deseado",
"correo basura",
"posta indesiderata",
"indesiderata",
"ongewenst",
"ongewenste e-mail",
"lixo eletrônico",
"спам", "нежелательная почта",
"skräp", "skräppost",
"спам",
"нежелательная почта",
"skräp",
"skräppost",
"uønsket",
"roskaposti",
"nevyžádaná pošta",
],
"archive": [
"archive", "archives", "all mail",
"archive",
"archives",
"all mail",
"archiwum",
"archiv",
"archivio",
"archivo",
"archief",
"arquivo", "arquivo morto",
"архив", "вся почта",
"arquivo",
"arquivo morto",
"архив",
"вся почта",
"arkiv",
"arkisto",
],
+1
View File
@@ -33,5 +33,6 @@ def build_provider(account: Account) -> ImapSmtpProvider:
smtp_tls=str(smtp.get("tls", "ssl")),
username=account.email,
password=password,
public_only=True, # a server the user named: never one inside our network
)
return ImapSmtpProvider(config)