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>
185 lines
5.2 KiB
Python
185 lines
5.2 KiB
Python
"""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))
|