Files
lukasandClaude Opus 5.5 addb2232a1 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>
2026-10-14 22:08:06 +02:00

185 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Special-use inference from folder names (SPEC §6.3 / §12 M2 fallback).
Used only when the server does not advertise the SPECIAL-USE extension. Matching
is accent- and punctuation-insensitive (so "Wysłane", "Éléments envoyés" and
"Junk E-mail" all resolve), covers the hierarchy leaf under any of the common
IMAP delimiters (``/ \\ .``), and recognises provider prefixes like
``[Gmail]/`` implicitly via the leaf. Names are curated per language rather than
substring-matched, so a folder like "Sent to John" is *not* misfiled as Sent.
"""
from __future__ import annotations
import unicodedata
# Latin letters NFKD doesn't decompose to ASCII; fold them explicitly.
_LATIN_SPECIAL = str.maketrans(
{"ł": "l", "ø": "o", "đ": "d", "ð": "d", "ß": "ss", "æ": "ae", "œ": "oe", "ı": "i", "þ": "th"}
)
def _compact(name: str) -> str:
"""Fold to a comparable key: lowercase, strip accents/diacritics, drop every
non-alphanumeric char. "Éléments envoyés" -> "elementsenvoyes"."""
folded = unicodedata.normalize("NFKD", name.strip().lower()).translate(_LATIN_SPECIAL)
return "".join(ch for ch in folded if ch.isalnum())
# 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
"lähetetyt", # Finnish
"odeslané",
"odeslaná pošta", # Czech
],
"drafts": [
"drafts",
"draft",
"robocze",
"kopie robocze",
"wersje robocze",
"entwürfe",
"entwurf",
"brouillons",
"borradores",
"bozze",
"concepten",
"rascunhos",
"черновики",
"utkast",
"kladder", # Danish
"luonnokset", # Finnish
"koncepty", # Czech
],
"trash": [
"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",
"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",
"lixo eletrônico",
"спам",
"нежелательная почта",
"skräp",
"skräppost",
"uønsket",
"roskaposti",
"nevyžádaná pošta",
],
"archive": [
"archive",
"archives",
"all mail",
"archiwum",
"archiv",
"archivio",
"archivo",
"archief",
"arquivo",
"arquivo morto",
"архив",
"вся почта",
"arkiv",
"arkisto",
],
}
_NAME_MAP: dict[str, str] = {
_compact(name): special for special, names in _NAMES.items() for name in names
}
def infer_special_use(path: str) -> str | None:
"""Infer a special_use value from a mailbox path/name (accent-insensitive)."""
if path.strip().upper().rstrip("/.") == "INBOX":
return "inbox"
# Take the hierarchy leaf under any common IMAP delimiter (/ \ or . for
# Dovecot/Courier namespaces), then fold for lookup.
normalized = path
for delim in ("/", "\\", "."):
normalized = normalized.replace(delim, "\n")
segments = [s for s in normalized.split("\n") if s.strip()]
tail = segments[-1] if segments else path
return _NAME_MAP.get(_compact(tail)) or _NAME_MAP.get(_compact(path))