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
+1 -3
View File
@@ -125,9 +125,7 @@ def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
smtp = _server("outgoingServer", "smtp") smtp = _server("outgoingServer", "smtp")
if imap is None or smtp is None: if imap is None or smtp is None:
return None return None
return CandidateConfig( return CandidateConfig(imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth))
imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth)
)
# --- DNS SRV (RFC 6186) ------------------------------------------------------ # --- DNS SRV (RFC 6186) ------------------------------------------------------
+49 -28
View File
@@ -2,9 +2,10 @@
All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no
off-domain redirects, and the target host must resolve exclusively to public IPs off-domain redirects, and the target host must resolve exclusively to public IPs
(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap (reject private/loopback/link-local/reserved ranges). The request then goes to
(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1; the very address that was checked — the name is not looked up a second time —
a pinned-IP transport can close it later. so a DNS answer that changes in between (DNS rebinding) cannot point it inward.
TLS is still verified against the host name.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,15 +27,12 @@ class SsrfError(Exception):
def _is_public_ip(ip: str) -> bool: def _is_public_ip(ip: str) -> bool:
addr = ipaddress.ip_address(ip) addr = ipaddress.ip_address(ip.split("%", 1)[0]) # drop an IPv6 zone index
return not ( if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr.is_private addr = addr.ipv4_mapped # ::ffff:127.0.0.1 is 127.0.0.1
or addr.is_loopback # is_global: not private, loopback, link-local, reserved, unspecified, nor
or addr.is_link_local # shared/carrier-grade NAT (100.64.0.0/10, which is_private misses).
or addr.is_multicast return addr.is_global and not addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
async def _resolve_all(host: str) -> list[str]: async def _resolve_all(host: str) -> list[str]:
@@ -43,8 +41,10 @@ async def _resolve_all(host: str) -> list[str]:
return [info[4][0] for info in infos] return [info[4][0] for info in infos]
async def assert_host_is_public(host: str) -> None: async def assert_host_is_public(host: str) -> list[str]:
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6).""" """Resolve ``host`` and reject if any address is non-public (SPEC §10.6).
Returns the addresses checked, for the caller to connect to."""
try: try:
addresses = await _resolve_all(host) addresses = await _resolve_all(host)
except socket.gaierror as exc: except socket.gaierror as exc:
@@ -54,6 +54,31 @@ async def assert_host_is_public(host: str) -> None:
for ip in addresses: for ip in addresses:
if not _is_public_ip(ip): if not _is_public_ip(ip):
raise SsrfError(f"{host} resolves to non-public address {ip}") raise SsrfError(f"{host} resolves to non-public address {ip}")
return addresses
async def _pinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
"""``url`` aimed at the address just checked: (url, headers, extensions).
The URL names the IP, the Host header keeps the name (virtual hosting), and
TLS gets the name for SNI and for checking the certificate."""
parts = urlsplit(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
hostname = parts.hostname
if not hostname:
raise SsrfError("missing host")
addresses = await assert_host_is_public(hostname)
# IPv4 first: containers often have no IPv6 route, though DNS returns AAAA.
ip = next((a for a in addresses if ":" not in a), addresses[0])
literal = f"[{ip}]" if ":" in ip else ip
netloc = f"{literal}:{parts.port}" if parts.port else literal
host_header = f"{hostname}:{parts.port}" if parts.port else hostname
return (
parts._replace(netloc=netloc).geturl(),
{"Host": host_header},
{"sni_hostname": hostname},
)
async def guarded_get(url: str) -> str | None: async def guarded_get(url: str) -> str | None:
@@ -63,12 +88,7 @@ async def guarded_get(url: str) -> str | None:
(any non-200 response). Raises :class:`SsrfError` on a policy violation (any non-200 response). Raises :class:`SsrfError` on a policy violation
(bad scheme, private IP, oversize); lets httpx network errors propagate. (bad scheme, private IP, oversize); lets httpx network errors propagate.
""" """
parts = urlsplit(url) target, pin_headers, extensions = await _pinned(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with ( async with (
httpx.AsyncClient( httpx.AsyncClient(
@@ -76,7 +96,7 @@ async def guarded_get(url: str) -> str | None:
timeout=HTTP_TIMEOUT, timeout=HTTP_TIMEOUT,
headers={"User-Agent": "webmail-autodiscovery/1.0"}, headers={"User-Agent": "webmail-autodiscovery/1.0"},
) as client, ) as client,
client.stream("GET", url) as resp, client.stream("GET", target, headers=pin_headers, extensions=extensions) as resp,
): ):
if resp.status_code != 200: if resp.status_code != 200:
return None return None
@@ -114,16 +134,17 @@ async def guarded_request(
fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body
capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns
status + headers + body (all statuses; the caller decides what's an error).""" status + headers + body (all statuses; the caller decides what's an error)."""
parts = urlsplit(url) target, pin_headers, extensions = await _pinned(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with ( async with (
httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client, httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client,
client.stream(method, url, headers=headers, content=content) as resp, client.stream(
method,
target,
headers={**(headers or {}), **pin_headers},
content=content,
extensions=extensions,
) as resp,
): ):
chunks: list[bytes] = [] chunks: list[bytes] = []
total = 0 total = 0
+1
View File
@@ -89,6 +89,7 @@ async def test_account(
smtp_tls=smtp.tls, smtp_tls=smtp.tls,
username=username, username=username,
password=password, password=password,
public_only=True,
) )
) )
try: try:
+2 -6
View File
@@ -125,9 +125,7 @@ async def mark_all_read(
return {"changed": 0} return {"changed": 0}
messages = list( messages = list(
await db.scalars( await db.scalars(
select(Message).where( select(Message).where(Message.thread_id.in_(thread_ids), Message.user_id == user.id)
Message.thread_id.in_(thread_ids), Message.user_id == user.id
)
) )
) )
add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([]) add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([])
@@ -137,9 +135,7 @@ async def mark_all_read(
await _push_and_publish(user.id, changed, add, remove) await _push_and_publish(user.id, changed, add, remove)
bus = get_event_bus() bus = get_event_bus()
for tid in thread_ids: for tid in thread_ids:
await bus.publish( await bus.publish("thread.updated", {"user_id": str(user.id), "thread_id": str(tid)})
"thread.updated", {"user_id": str(user.id), "thread_id": str(tid)}
)
return {"changed": len(changed)} return {"changed": len(changed)}
+17
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import NoReturn from typing import NoReturn
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -29,7 +30,23 @@ async def _handler(request: Request, exc: StarletteHTTPException) -> JSONRespons
return JSONResponse(status_code=exc.status_code, content=body, headers=headers) return JSONResponse(status_code=exc.status_code, content=body, headers=headers)
async def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""A malformed request, in the usual error shape. Names the first field at
fault and what is wrong with it — enough to show the user — but not the
whole request model the framework's own answer would spell out."""
errors = exc.errors()
first = errors[0] if errors else {}
loc = [str(p) for p in first.get("loc", ()) if p not in ("body", "query", "path")]
field = loc[-1] if loc else ""
reason = str(first.get("msg", "invalid value"))
message = f"{field}: {reason}" if field else reason
return JSONResponse(
status_code=422, content={"error": {"code": "validation_error", "message": message}}
)
def register_error_handlers(app: FastAPI) -> None: def register_error_handlers(app: FastAPI) -> None:
"""Install the error-shape handler for all HTTP exceptions.""" """Install the error-shape handler for all HTTP exceptions."""
app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type] app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type]
app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type] app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, _validation_handler) # type: ignore[arg-type]
+21 -4
View File
@@ -88,6 +88,23 @@ def _wrap(fragment: str) -> str:
return _DOC_TEMPLATE.format(body=fragment) return _DOC_TEMPLATE.format(body=fragment)
# The sanitized message as a document of its own. The app shows it in a
# sandboxed frame (frontend MessageBody), but this URL can also be opened
# directly in a tab, on our own origin: there, this policy is what keeps any
# script the sanitizer might ever miss from running — no scripts, no plugins,
# no forms, a sandbox of its own origin, and nothing framing it elsewhere.
_MESSAGE_CSP = (
"default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'self'; img-src 'self' data: https: http:; "
"style-src 'self' 'unsafe-inline'; font-src 'self' data: https:; media-src 'self'; "
"sandbox allow-popups allow-popups-to-escape-sandbox"
)
def _html(document: str) -> HTMLResponse:
return HTMLResponse(content=document, headers={"Content-Security-Policy": _MESSAGE_CSP})
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message: async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
msg = await db.scalar( msg = await db.scalar(
select(Message).where(Message.id == message_id, Message.user_id == user_id) select(Message).where(Message.id == message_id, Message.user_id == user_id)
@@ -123,7 +140,7 @@ async def message_html(
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
if html_part is not None and not allow_remote and not stale: if html_part is not None and not allow_remote and not stale:
return HTMLResponse(content=_wrap(html_part.content)) return _html(_wrap(html_part.content))
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy). # Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
# If the raw blob is unavailable, fall back to whatever is already stored. # If the raw blob is unavailable, fall back to whatever is already stored.
@@ -142,13 +159,13 @@ async def message_html(
html_part.content = result.html # persist the upgraded sanitization html_part.content = result.html # persist the upgraded sanitization
html_part.sanitizer_v = result.sanitizer_v html_part.sanitizer_v = result.sanitizer_v
await db.commit() await db.commit()
return HTMLResponse(content=_wrap(result.html)) return _html(_wrap(result.html))
if html_part is not None: if html_part is not None:
return HTMLResponse(content=_wrap(html_part.content)) return _html(_wrap(html_part.content))
text_part = next((p for p in parts if p.kind == "text"), None) text_part = next((p for p in parts if p.kind == "text"), None)
body = html_lib.escape(text_part.content) if text_part else "" body = html_lib.escape(text_part.content) if text_part else ""
return HTMLResponse(content=_wrap(f'<div class="wm-plaintext">{body}</div>')) return _html(_wrap(f'<div class="wm-plaintext">{body}</div>'))
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)]) @router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
+3
View File
@@ -71,6 +71,9 @@ class Settings(BaseSettings):
# Spelling and grammar checking in the compose window (LanguageTool server, # Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone. # e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL") languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
# New passwords are checked against known data breaches (Have I Been Pwned's
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
# Auto-purge Trash/Spam messages older than this many days (0 disables). # Auto-purge Trash/Spam messages older than this many days (0 disables).
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS") trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
+145
View File
@@ -0,0 +1,145 @@
"""What a new account password must not be (sign-up and reset).
Length is bounded by the request schemas (8–128). On top of that a password is
refused when it is one of the passwords every guessing attack tries first, is
made of the user's own address, is one character repeated, or has turned up in
a known data breach. The breach check asks Have I Been Pwned's range API with
the first five hex digits of the password's SHA-1 only (k-anonymity: neither the
password nor its hash leaves the server) and is skipped, not failed, when that
service cannot be reached — a sign-up never hangs on someone else's outage.
Set ``PASSWORD_BREACH_CHECK=false`` to never ask it.
"""
from __future__ import annotations
import hashlib
import logging
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
MIN_LENGTH = 8
MAX_LENGTH = 128
_RANGE_API = "https://api.pwnedpasswords.com/range/"
_TIMEOUT = 3.0
# The front of every guessing list (and of every breach), lower-cased.
_COMMON = frozenset(
{
"password",
"password1",
"password12",
"password123",
"password1234",
"passw0rd",
"p@ssw0rd",
"p@ssword",
"12345678",
"123456789",
"1234567890",
"0123456789",
"87654321",
"11111111",
"00000000",
"12341234",
"qwertyui",
"qwerty123",
"qwerty1234",
"qwertyuiop",
"1q2w3e4r",
"1q2w3e4r5t",
"1qaz2wsx",
"zaq12wsx",
"asdfghjk",
"asdfasdf",
"iloveyou",
"iloveyou1",
"sunshine",
"princess",
"football",
"baseball",
"welcome1",
"welcome123",
"letmein1",
"trustno1",
"superman",
"batman123",
"dragon123",
"monkey123",
"abc12345",
"abcd1234",
"aa123456",
"qazwsxedc",
"changeme",
"changeme1",
"administrator",
"admin123",
"adminadmin",
"computer",
"internet",
"whatever",
"starwars",
"michelle",
"jennifer",
"charlie1",
"zaq1zaq1",
"haslo123",
"polska123",
"kochamcie",
"zaq1@wsx",
"qwerty12",
"1234qwer",
"q1w2e3r4",
}
)
class PasswordRejected(Exception):
"""The password may not be used; ``code`` and ``message`` say why."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
async def _breached(password: str) -> bool:
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() # noqa: S324 - HIBP's format
prefix, suffix = digest[:5], digest[5:]
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(_RANGE_API + prefix, headers={"Add-Padding": "true"})
resp.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("breached-password check skipped: %s", exc)
return False
for line in resp.text.splitlines():
candidate, _, count = line.partition(":")
if candidate.strip() == suffix and count.strip() not in ("", "0"): # padding rows say 0
return True
return False
async def check_new_password(password: str, handle: str) -> None:
"""Raise :class:`PasswordRejected` if ``password`` may not be used by ``handle``."""
lowered = password.lower()
if len(set(password)) <= 2:
raise PasswordRejected("weak_password", "this password is too easy to guess")
if lowered in _COMMON:
raise PasswordRejected("weak_password", "this is one of the most common passwords")
localpart = handle.split("@", 1)[0].lower()
if len(localpart) >= 3 and localpart in lowered:
# The name with a year or a few characters added ("jsmith2026") is the
# first thing anyone who knows the address will try.
rest = lowered.replace(localpart, "", 1)
if len(rest) < 4 or all(not c.isalpha() for c in rest):
raise PasswordRejected("weak_password", "the password must not be your address")
if get_settings().password_breach_check and await _breached(password):
raise PasswordRejected(
"breached_password",
"this password has appeared in a data breach — please choose another",
)
+10
View File
@@ -27,6 +27,7 @@ from app.identity.cookies import (
) )
from app.identity.deps import CurrentSession, CurrentUser, require_csrf from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail
from app.identity.password_policy import PasswordRejected, check_new_password
from app.identity.passwords import hash_password, verify_password from app.identity.passwords import hash_password, verify_password
from app.identity.reset import ( from app.identity.reset import (
consume_token, consume_token,
@@ -125,6 +126,10 @@ _REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
async def register( async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession body: RegisterRequest, request: Request, response: Response, db: DbSession
) -> UserOut: ) -> UserOut:
try:
await check_new_password(body.password, resolve_handle(body.localpart))
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
try: try:
user = await register_user( user = await register_user(
db, db,
@@ -312,6 +317,11 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user = await db.get(User, user_id) if user_id else None user = await db.get(User, user_id) if user_id else None
if user is None: if user is None:
raise_api_error(400, "invalid_token", "this link is invalid or has expired") raise_api_error(400, "invalid_token", "this link is invalid or has expired")
# Before the 2FA code and the link are spent: a refused password costs neither.
try:
await check_new_password(body.password, user.handle)
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
if user.totp_secret is not None: if user.totp_secret is not None:
if not body.totp: if not body.totp:
raise_api_error(401, "totp_required", "a 2FA code is required") raise_api_error(401, "totp_required", "a 2FA code is required")
+2 -2
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
localpart: str = Field(min_length=3, max_length=30) localpart: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8, max_length=1024) password: str = Field(min_length=8, max_length=128)
recovery_email: str | None = None recovery_email: str | None = None
@@ -49,7 +49,7 @@ class PasswordResetRequestIn(BaseModel):
class PasswordResetIn(BaseModel): class PasswordResetIn(BaseModel):
token: str = Field(min_length=1) token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024) password: str = Field(min_length=8, max_length=128)
totp: str | None = None # required when the account has 2FA enabled totp: str | None = None # required when the account has 2FA enabled
+7 -1
View File
@@ -18,7 +18,8 @@ import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a # Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4). # render re-sanitizes stale content from the raw blob (SPEC §10.4).
# v5: this server's own inline-image URLs quoted in a message load from here. # v5: this server's own inline-image URLs quoted in a message load from here.
SANITIZER_VERSION = 5 # v6: links keep only http(s) and mailto (no data: pages).
SANITIZER_VERSION = 6
# This server's inline-image URL, however it ended up written: "/api/…", # This server's inline-image URL, however it ended up written: "/api/…",
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy. # "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
@@ -202,6 +203,11 @@ def sanitize_html(
return _sanitize_style(value, allow_remote_images) return _sanitize_style(value, allow_remote_images)
if tag == "a" and attr == "target": if tag == "a" and attr == "target":
return "_blank" return "_blank"
if attr == "href":
# A link goes to the web or to a mail address: data: (a page made
# up by the sender) and cid: are kept for inline images only.
scheme = value.strip().lower().split(":", 1)[0] if ":" in value else ""
return value if scheme in ("", "http", "https", "mailto") else None
if tag == "img" and attr == "src": if tag == "img" and attr == "src":
lowered = value.strip().lower() lowered = value.strip().lower()
if lowered.startswith("cid:"): if lowered.startswith("cid:"):
+4
View File
@@ -37,6 +37,10 @@ def create_app() -> FastAPI:
register_error_handlers(app) register_error_handlers(app)
from app.security.cross_site import CrossSiteGuard
app.add_middleware(CrossSiteGuard)
@app.get("/api/healthz") @app.get("/api/healthz")
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
"""Liveness/readiness probe: checks DB and Redis (dev-setup §6).""" """Liveness/readiness probe: checks DB and Redis (dev-setup §6)."""
+1 -3
View File
@@ -168,9 +168,7 @@ class Mailbox(Base):
# IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer. # IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer.
uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0")) uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
highestmodseq: Mapped[int | None] = mapped_column(BigInteger) highestmodseq: Mapped[int | None] = mapped_column(BigInteger)
last_seen_uid: Mapped[int] = mapped_column( last_seen_uid: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
BigInteger, nullable=False, server_default=text("0")
)
class Thread(Base): class Thread(Base):
+1 -3
View File
@@ -21,9 +21,7 @@ class PluginHttp:
content: bytes | str | None = None, content: bytes | str | None = None,
auth: tuple[str, str] | None = None, auth: tuple[str, str] | None = None,
) -> GuardedResponse: ) -> GuardedResponse:
return await guarded_request( return await guarded_request(method, url, headers=headers, content=content, auth=auth)
method, url, headers=headers, content=content, auth=auth
)
async def get(self, url: str, **kw: object) -> GuardedResponse: async def get(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("GET", url, **kw) # type: ignore[arg-type] return await self.request("GET", url, **kw) # type: ignore[arg-type]
@@ -122,6 +122,16 @@ def _date_criteria(since: date | None, before: date | None) -> str:
return " ".join(parts) if parts else "ALL" return " ".join(parts) if parts else "ALL"
async def _require_public(host: str) -> None:
"""Refuse a user-named server that now resolves to a non-public address."""
from app.accounts.ssrf import SsrfError, assert_host_is_public
try:
await assert_host_is_public(host)
except SsrfError as exc:
raise ProviderConnectError(f"refusing to connect to {host}: {exc}") from exc
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderConfig: class ProviderConfig:
imap_host: str imap_host: str
@@ -138,6 +148,10 @@ class ProviderConfig:
# The hosted mail server: every user's mailbox is on that one host, so the # The hosted mail server: every user's mailbox is on that one host, so the
# per-host connection cap (which spares outside providers) does not apply. # per-host connection cap (which spares outside providers) does not apply.
internal: bool = False internal: bool = False
# A server the user named (every external account): its name is checked
# again before each connection, so DNS changed after the account was added
# cannot turn the worker on the internal network (DNS rebinding).
public_only: bool = False
class ImapSmtpProvider(MailProvider): class ImapSmtpProvider(MailProvider):
@@ -196,6 +210,8 @@ class ImapSmtpProvider(MailProvider):
if self._imap is not None: if self._imap is not None:
return self._imap return self._imap
cfg = self._cfg cfg = self._cfg
if cfg.public_only:
await _require_public(cfg.imap_host)
await self._acquire_conn_slots() await self._acquire_conn_slots()
try: try:
try: try:
@@ -540,6 +556,8 @@ class ImapSmtpProvider(MailProvider):
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None: async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
cfg = self._cfg cfg = self._cfg
if cfg.public_only:
await _require_public(cfg.smtp_host)
use_tls = cfg.smtp_tls == "ssl" use_tls = cfg.smtp_tls == "ssl"
start_tls = cfg.smtp_tls == "starttls" start_tls = cfg.smtp_tls == "starttls"
host_slot = ( host_slot = (
+91
View File
@@ -0,0 +1,91 @@
"""Refuse state-changing API requests that another web site makes a browser send.
The double-submit CSRF token (app/identity/deps.py ``require_csrf``) guards
everything a signed-in user can change. The requests made before anyone is
signed in — sign-in itself, sign-up, a password reset — have no token yet, so a
hostile page could make a visitor's browser sign in to an account of the
attacker's choosing (login CSRF): whatever the victim then writes, files or
uploads lands in the attacker's account.
Browsers say where a request comes from: ``Sec-Fetch-Site`` in every current
browser, ``Origin`` on any cross-origin POST in older ones. A request another
site started is refused here, before any route runs. Mail apps, calendar apps
and servers send neither header and are not affected (DAV lives outside /api
anyway).
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Receive, Scope, Send
from app.config import get_settings
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_BODY = json.dumps(
{"error": {"code": "cross_site", "message": "request refused: sent from another site"}}
).encode()
def _hostname(value: str) -> str:
"""``host[:port]`` -> ``host``, lower-cased (IPv6 brackets kept)."""
value = value.strip().lower()
if value.startswith("["):
return value.split("]", 1)[0] + "]"
return value.rsplit(":", 1)[0] if value.count(":") == 1 else value
def is_cross_site(headers: Headers) -> bool:
"""Whether a browser reports this request as started by another site."""
fetch_site = headers.get("sec-fetch-site")
if fetch_site is not None:
# "same-origin", "same-site" (our own subdomains), "none" (typed or
# bookmarked): all fine. Only another site is refused.
return fetch_site.strip().lower() == "cross-site"
origin = headers.get("origin")
if origin is None:
return False # not a browser, or a same-origin request from an old one
if origin.strip().lower() == "null":
return True # sandboxed frame, data: URL, file: page
origin_host = (urlsplit(origin.strip()).hostname or "").lower()
# Our own domain, whatever a reverse proxy did to the Host header.
domain = get_settings().app_domain.lower()
if origin_host == domain or origin_host.endswith("." + domain):
return False
# Otherwise the name the browser used: Host, or the one a reverse proxy saw.
ours = {_hostname(headers.get("host", ""))}
forwarded = headers.get("x-forwarded-host")
if forwarded:
ours.add(_hostname(forwarded.split(",")[0]))
return origin_host not in ours
class CrossSiteGuard:
"""ASGI middleware: 403 on cross-site POST/PUT/PATCH/DELETE under /api/."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] == "http"
and scope["method"] not in _SAFE_METHODS
and scope["path"].startswith("/api/")
and is_cross_site(Headers(scope=scope))
):
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(_BODY)).encode()),
],
}
)
await send({"type": "http.response.body", "body": _BODY})
return
await self.app(scope, receive, send)
+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 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 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) tid = uuid.UUID(thread_id)
async with session_scope() as session: 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))) await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
) )
path_by_id = {mb.id: mb.path for mb in mailboxes} 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"} 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. # (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = [] plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages: for m in messages:
if m.mailbox_id not in removal_ids:
continue # never removed: nothing to restore
target_id = m.restore_mailbox_id target_id = m.restore_mailbox_id
if target_id is None or target_id not in path_by_id: if target_id is None or target_id not in path_by_id:
target_id = inbox_by_account.get(m.account_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) self._mailboxes.add(mailbox_path)
async with session_scope() as session: async with session_scope() as session:
await session.execute( await session.execute(
update(Account) update(Account).where(Account.id == self.account.id).values(backfill_done=self.done)
.where(Account.id == self.account.id)
.values(backfill_done=self.done)
) )
await get_event_bus().publish( await get_event_bus().publish(
"sync.progress", "sync.progress",
@@ -321,16 +319,28 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
dedupe = gmail and mailbox.special_use == "archive" dedupe = gmail and mailbox.special_use == "archive"
if since is not None: # 3months: single windowed pass if since is not None: # 3months: single windowed pass
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=since, provider,
account_obj,
mailbox,
progress,
since=since,
dedupe_account_scope=dedupe, dedupe_account_scope=dedupe,
) )
else: # all: recent window first (usable fast), then the remainder else: # all: recent window first (usable fast), then the remainder
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=recent_cutoff, provider,
account_obj,
mailbox,
progress,
since=recent_cutoff,
dedupe_account_scope=dedupe, dedupe_account_scope=dedupe,
) )
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, before=recent_cutoff, provider,
account_obj,
mailbox,
progress,
before=recent_cutoff,
dedupe_account_scope=dedupe, dedupe_account_scope=dedupe,
) )
await _set_state(account_id, "live") await _set_state(account_id, "live")
@@ -491,9 +501,7 @@ async def apply_delta(
await _reconcile_drafts(provider, account, mailbox) await _reconcile_drafts(provider, account, mailbox)
async def _reconcile_drafts( async def _reconcile_drafts(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> None:
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox
) -> None:
"""Drop DB rows for drafts that no longer exist on the server (were expunged).""" """Drop DB rows for drafts that no longer exist on the server (were expunged)."""
from app.db import session_scope from app.db import session_scope
@@ -548,13 +556,13 @@ async def live_loop(
await provider.verify() # signed in: the account is reachable await provider.verify() # signed in: the account is reachable
if health is not None: if health is not None:
await health.ok() await health.ok()
first = True
while not stop.is_set(): while not stop.is_set():
try: # Catch up before each IDLE, not only after it: IDLE reports what
await provider.idle(mailbox_path) # arrives from now on, so mail that came while this loop was not
except ProviderNotSupported: # running (a new account's first minutes, a reconnect, a worker
await asyncio.sleep(idle_fallback) # restart) would otherwise wait for the next new message or the
if stop.is_set(): # IDLE timeout (10 min) to show.
break
async with session_scope() as session: async with session_scope() as session:
mailbox = await session.scalar( mailbox = await session.scalar(
select(Mailbox).where( select(Mailbox).where(
@@ -562,14 +570,23 @@ async def live_loop(
) )
) )
mailbox_obj = mailbox 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 break
delta = await provider.poll( try:
mailbox_path, await provider.idle(mailbox_path)
last_seen_uid=mailbox_obj.last_seen_uid, except ProviderNotSupported:
highestmodseq=mailbox_obj.highestmodseq, await asyncio.sleep(idle_fallback)
)
await apply_delta(provider, account_obj, mailbox_obj, delta)
finally: finally:
await provider.close() await provider.close()
@@ -630,9 +647,7 @@ async def poll_folders_once(
await provider.close() await provider.close()
async def _poll_secondary_once( async def _poll_secondary_once(account_id: uuid.UUID, health: AccountHealth | None = None) -> None:
account_id: uuid.UUID, health: AccountHealth | None = None
) -> None:
await poll_folders_once(account_id, include_inbox=False, health=health) 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. # Localized folder names by special_use. Written readably; compacted at load.
_NAMES: dict[str, list[str]] = { _NAMES: dict[str, list[str]] = {
"sent": [ "sent": [
"sent", "sent items", "sent mail", "sent messages", # English / Outlook / Gmail "sent",
"wysłane", "elementy wysłane", "wiadomości wysłane", # Polish "sent items",
"gesendet", "gesendete objekte", "gesendete elemente", # German "sent mail",
"envoyés", "éléments envoyés", "messages envoyés", "courrier envoyé", # French "sent messages", # English / Outlook / Gmail
"enviados", "elementos enviados", "correo enviado", # Spanish "wysłane",
"inviata", "posta inviata", "messaggi inviati", # Italian "elementy wysłane",
"verzonden", "verzonden items", "verzonden berichten", # Dutch "wiadomości wysłane", # Polish
"enviadas", "itens enviados", "mensagens enviadas", # Portuguese "gesendet",
"отправленные", "исходящие", # Russian "gesendete objekte",
"skickat", "skickade", "skickade objekt", # Swedish "gesendete elemente", # German
"sendt", "sendte", "sendte elementer", # Norwegian / Danish "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 "lähetetyt", # Finnish
"odeslané", "odeslaná pošta", # Czech "odeslané",
"odeslaná pošta", # Czech
], ],
"drafts": [ "drafts": [
"drafts", "draft", "drafts",
"robocze", "kopie robocze", "wersje robocze", "draft",
"entwürfe", "entwurf", "robocze",
"kopie robocze",
"wersje robocze",
"entwürfe",
"entwurf",
"brouillons", "brouillons",
"borradores", "borradores",
"bozze", "bozze",
@@ -58,44 +86,80 @@ _NAMES: dict[str, list[str]] = {
"koncepty", # Czech "koncepty", # Czech
], ],
"trash": [ "trash": [
"trash", "deleted", "deleted items", "deleted messages", "bin", "recycle bin", "trash",
"deleted",
"deleted items",
"deleted messages",
"bin",
"recycle bin",
"kosz", "kosz",
"papierkorb", "gelöschte objekte", "gelöschte elemente", "papierkorb",
"corbeille", "éléments supprimés", "gelöschte objekte",
"papelera", "elementos eliminados", "gelöschte elemente",
"cestino", "posta eliminata", "corbeille",
"prullenbak", "verwijderde items", "éléments supprimés",
"lixeira", "lixo", "itens excluídos", "papelera",
"корзина", "удаленные", "elementos eliminados",
"papperskorg", "borttaget", "cestino",
"papirkurv", "slettet", "posta eliminata",
"roskakori", "poistetut", "prullenbak",
"verwijderde items",
"lixeira",
"lixo",
"itens excluídos",
"корзина",
"удаленные",
"papperskorg",
"borttaget",
"papirkurv",
"slettet",
"roskakori",
"poistetut",
"koš", # Czech "koš", # Czech
], ],
"junk": [ "junk": [
"junk", "junk email", "junk e-mail", "spam", "bulk mail", "bulk", "junk",
"wiadomości-śmieci", "niechciane", "junk email",
"unerwünscht", "werbung", "junk e-mail",
"courrier indésirable", "indésirables", "pourriel", "spam",
"correo no deseado", "no deseado", "correo basura", "bulk mail",
"posta indesiderata", "indesiderata", "bulk",
"ongewenst", "ongewenste e-mail", "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", "lixo eletrônico",
"спам", "нежелательная почта", "спам",
"skräp", "skräppost", "нежелательная почта",
"skräp",
"skräppost",
"uønsket", "uønsket",
"roskaposti", "roskaposti",
"nevyžádaná pošta", "nevyžádaná pošta",
], ],
"archive": [ "archive": [
"archive", "archives", "all mail", "archive",
"archives",
"all mail",
"archiwum", "archiwum",
"archiv", "archiv",
"archivio", "archivio",
"archivo", "archivo",
"archief", "archief",
"arquivo", "arquivo morto", "arquivo",
"архив", "вся почта", "arquivo morto",
"архив",
"вся почта",
"arkiv", "arkiv",
"arkisto", "arkisto",
], ],
+1
View File
@@ -33,5 +33,6 @@ def build_provider(account: Account) -> ImapSmtpProvider:
smtp_tls=str(smtp.get("tls", "ssl")), smtp_tls=str(smtp.get("tls", "ssl")),
username=account.email, username=account.email,
password=password, password=password,
public_only=True, # a server the user named: never one inside our network
) )
return ImapSmtpProvider(config) return ImapSmtpProvider(config)
+7
View File
@@ -18,6 +18,7 @@ from app.events import get_event_bus
from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE
from app.identity.sessions import resolve_session from app.identity.sessions import resolve_session
from app.redis_client import get_redis from app.redis_client import get_redis
from app.security.cross_site import is_cross_site
from app.sync import leases from app.sync import leases
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,6 +28,12 @@ router = APIRouter()
@router.websocket("/ws") @router.websocket("/ws")
async def ws_endpoint(websocket: WebSocket) -> None: async def ws_endpoint(websocket: WebSocket) -> None:
# Only our own pages may listen in: a socket another site opens is refused
# even if the browser sent the session cookie with it (cross-site WebSocket
# hijacking; SameSite=Lax already stops current browsers from sending it).
if is_cross_site(websocket.headers):
await websocket.close(code=1008)
return
token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get( token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get(
SESSION_COOKIE_INSECURE, "" SESSION_COOKIE_INSECURE, ""
) )
+1 -3
View File
@@ -16,9 +16,7 @@ from app.models import Message
async def main() -> None: async def main() -> None:
async with session_scope() as session: async with session_scope() as session:
rows = list( rows = list(await session.scalars(select(Message).where(Message.delivery_id.is_(None))))
await session.scalars(select(Message).where(Message.delivery_id.is_(None)))
)
fixed = 0 fixed = 0
for m in rows: for m in rows:
try: try:
+1 -3
View File
@@ -42,9 +42,7 @@ async def main() -> None:
update(Message) update(Message)
.where(Message.id == msg.id) .where(Message.id == msg.id)
.values( .values(
body_indexed=Message.body_indexed.op("||")( body_indexed=Message.body_indexed.op("||")(func.to_tsvector(_TS_CONFIG, blob))
func.to_tsvector(_TS_CONFIG, blob)
)
) )
) )
updated += 1 updated += 1
+6 -2
View File
@@ -37,10 +37,14 @@ AUTOCONFIG_XML = """<?xml version="1.0"?>
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def] def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Bypass the real DNS/public-IP check so respx can intercept the fetch.""" """Bypass the real DNS/public-IP check so respx can intercept the fetch."""
async def ok(_host: str) -> None: async def ok(_host: str) -> list[str]:
return None return ["93.184.216.34"]
async def unpinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
return url, {}, {} # respx matches on the host name, not the pinned IP
monkeypatch.setattr(ssrf, "assert_host_is_public", ok) monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
monkeypatch.setattr(ssrf, "_pinned", unpinned)
# --- XML parsing ------------------------------------------------------------- # --- XML parsing -------------------------------------------------------------
+27 -11
View File
@@ -310,7 +310,8 @@ async def test_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
await api.session.commit() await api.session.commit()
aid = next( aid = next(
a["id"] for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json() a["id"]
for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
if a["filename"] == name if a["filename"] == name
) )
resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}") resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}")
@@ -404,8 +405,11 @@ async def test_folder_counts(api: ApiCtx) -> None:
async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None: async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
assert api.session is not None assert api.session is not None
# The kickoff thread's first message; give it a real delivery_id. # The kickoff thread's first message; give it a real delivery_id.
kickoff = next(t for t in (await api.client.get("/api/threads")).json()["threads"] kickoff = next(
if t["message_count"] == 2) t
for t in (await api.client.get("/api/threads")).json()["threads"]
if t["message_count"] == 2
)
detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json() detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
first_id = uuid.UUID(detail["messages"][0]["id"]) first_id = uuid.UUID(detail["messages"][0]["id"])
first = await api.session.get(Message, first_id) first = await api.session.get(Message, first_id)
@@ -414,20 +418,32 @@ async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
# A second account receives the SAME email (same delivery_id) into the same thread. # A second account receives the SAME email (same delivery_id) into the same thread.
acct2 = Account( acct2 = Account(
user_id=api.user_id, email="me2@example.com", imap_config={}, smtp_config={}, user_id=api.user_id,
secret_ct=b"", discovery_src="manual", email="me2@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
) )
api.session.add(acct2) api.session.add(acct2)
await api.session.flush() await api.session.flush()
inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox") inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox")
api.session.add(inbox2) api.session.add(inbox2)
await api.session.flush() await api.session.flush()
api.session.add(Message( api.session.add(
user_id=api.user_id, account_id=acct2.id, mailbox_id=inbox2.id, uid=1, Message(
thread_id=first.thread_id, message_id_hdr="<synthetic@synthetic.local>", user_id=api.user_id,
delivery_id="<shared@x.com>", subject=first.subject, account_id=acct2.id,
internal_date=datetime.now(UTC), blob_sha256="z" * 64, # later -> first stays the rep mailbox_id=inbox2.id,
)) uid=1,
thread_id=first.thread_id,
message_id_hdr="<synthetic@synthetic.local>",
delivery_id="<shared@x.com>",
subject=first.subject,
internal_date=datetime.now(UTC),
blob_sha256="z" * 64, # later -> first stays the rep
)
)
await api.session.commit() await api.session.commit()
body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json() body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
+1 -3
View File
@@ -104,9 +104,7 @@ async def test_saved_search_crud_and_upsert(ctx: Ctx) -> None:
async def test_saved_search_requires_csrf(ctx: Ctx) -> None: async def test_saved_search_requires_csrf(ctx: Ctx) -> None:
# No CSRF header -> rejected (state-changing route). # No CSRF header -> rejected (state-changing route).
resp = await ctx.client.post( resp = await ctx.client.post("/api/saved-searches", json={"name": "x", "query": "y"})
"/api/saved-searches", json={"name": "x", "query": "y"}
)
assert resp.status_code == 403 assert resp.status_code == 403
+2
View File
@@ -21,6 +21,8 @@ os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
# Hosted mailboxes off: tests that need the mail server turn them on themselves # Hosted mailboxes off: tests that need the mail server turn them on themselves
# (tests/hosted/), against the dev stack's Stalwart container. # (tests/hosted/), against the dev stack's Stalwart container.
os.environ["HOSTED_MAILBOXES"] = "false" os.environ["HOSTED_MAILBOXES"] = "false"
# No calls to the breached-password service from tests (app/identity/password_policy.py).
os.environ["PASSWORD_BREACH_CHECK"] = "false"
for _key, _value in { for _key, _value in {
"STALWART_URL": "http://127.0.0.1:8180", "STALWART_URL": "http://127.0.0.1:8180",
"STALWART_ADMIN_SECRET": "stalwart_admin_dev", "STALWART_ADMIN_SECRET": "stalwart_admin_dev",
+33
View File
@@ -421,3 +421,36 @@ async def test_delete_keeps_rows_while_unreachable(ctx: Ctx, monkeypatch) -> Non
select(func.count()).select_from(Message).where(Message.thread_id == ctx.thread_id) select(func.count()).select_from(Message).where(Message.thread_id == ctx.thread_id)
) )
assert left == len(ctx.message_ids) # kept: the server still has them assert left == len(ctx.message_ids) # kept: the server still has them
async def test_restore_moves_only_what_was_removed(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""A thread restored from Spam brings its spam back; the user's own reply in
Sent (never removed, so no recorded origin) stays in Sent."""
sent = Mailbox(account_id=None, user_id=ctx.user_id, path="Sent", special_use="sent")
first = await ctx.session.get(Message, ctx.message_ids[0])
reply = await ctx.session.get(Message, ctx.message_ids[1])
assert first is not None and reply is not None
sent.account_id = first.account_id
ctx.session.add(sent)
await ctx.session.flush()
first.mailbox_id = ctx.archive_id # removed, origin unknown (as when synced in)
reply.mailbox_id = sent.id
await ctx.session.commit()
moves: list[tuple[int, str, str]] = []
class Recorder:
async def move(self, uid: int, src: str, dst: str) -> int:
moves.append((uid, src, dst))
return 100 + uid
async def close(self) -> None:
return None
monkeypatch.setattr(sync_actions, "build_provider", lambda _a: Recorder())
await sync_actions.restore_thread_job({"job_try": 1}, str(ctx.thread_id))
assert moves == [(first.uid, "Archive", "INBOX")]
ctx.session.expunge_all()
async with session_scope() as verify:
kept = await verify.get(Message, ctx.message_ids[1])
assert kept is not None and kept.mailbox_id == sent.id
@@ -104,7 +104,8 @@ async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-unty
assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder
assert sent[-1]["title"] == "Dentist" assert sent[-1]["title"] == "Dentist"
assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4" assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4"
assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=") and sent[-1]["sticky"] is True assert sent[-1]["url"].startswith("/p/calendar?date=2031-06-02&event=")
assert sent[-1]["sticky"] is True
assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again
# The worker was down when the 10-minute reminder came due: sent late, once. # The worker was down when the 10-minute reminder came due: sent late, once.
assert await run(MON_9 - timedelta(minutes=4)) == 1 assert await run(MON_9 - timedelta(minutes=4)) == 1
+34 -17
View File
@@ -49,8 +49,12 @@ async def ctx() -> AsyncIterator[_Ctx]:
s.add(user) s.add(user)
await s.flush() await s.flush()
account = Account( account = Account(
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={}, user_id=user.id,
secret_ct=b"", discovery_src="manual", email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
) )
s.add(account) s.add(account)
await s.flush() await s.flush()
@@ -64,12 +68,17 @@ async def ctx() -> AsyncIterator[_Ctx]:
s.add(thread) s.add(thread)
await s.flush() await s.flush()
msg = Message( msg = Message(
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1, user_id=user.id,
account_id=account.id,
mailbox_id=mbox.id,
uid=1,
thread_id=thread.id, thread_id=thread.id,
subject="Your Invoice 123", subject="Your Invoice 123",
from_addr=[{"name": "Billing", "email": "billing@acme.com"}], from_addr=[{"name": "Billing", "email": "billing@acme.com"}],
to_addr=[{"name": "Me", "email": "me@example.com"}], to_addr=[{"name": "Me", "email": "me@example.com"}],
flags=[], internal_date=datetime.now(UTC), blob_sha256="", flags=[],
internal_date=datetime.now(UTC),
blob_sha256="",
) )
s.add(msg) s.add(msg)
await s.commit() await s.commit()
@@ -99,24 +108,30 @@ async def _flags(ctx: _Ctx) -> list[str]:
async def test_hook_applies_flag_on_match(ctx: _Ctx) -> None: async def test_hook_applies_flag_on_match(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set( await (
"_rules", [{"field": "subject", "contains": "invoice", "action": "read"}] sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "subject", "contains": "invoice", "action": "read"}])
) )
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Seen" in await _flags(ctx) assert "\\Seen" in await _flags(ctx)
async def test_hook_no_match_leaves_flags(ctx: _Ctx) -> None: async def test_hook_no_match_leaves_flags(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set( await (
"_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}] sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}])
) )
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert await _flags(ctx) == [] assert await _flags(ctx) == []
async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None: async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
await sdk("filters").kv(ctx.user_id).set( await (
"_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}] sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}])
) )
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Flagged" in await _flags(ctx) # "pin" == the \Flagged flag in this app assert "\\Flagged" in await _flags(ctx) # "pin" == the \Flagged flag in this app
@@ -124,16 +139,16 @@ async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
async def test_hook_matches_on_to(ctx: _Ctx) -> None: async def test_hook_matches_on_to(ctx: _Ctx) -> None:
# Multi-account setups match on the recipient address. # Multi-account setups match on the recipient address.
await sdk("filters").kv(ctx.user_id).set( await (
"_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}] sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}])
) )
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert "\\Seen" in await _flags(ctx) assert "\\Seen" in await _flags(ctx)
async def test_hook_trash_moves_thread_to_trash( async def test_hook_trash_moves_thread_to_trash(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
ctx: _Ctx, monkeypatch: pytest.MonkeyPatch
) -> None:
calls: list[tuple[object, ...]] = [] calls: list[tuple[object, ...]] = []
async def fake_enqueue(name: str, *args: object, **kwargs: object) -> None: async def fake_enqueue(name: str, *args: object, **kwargs: object) -> None:
@@ -141,8 +156,10 @@ async def test_hook_trash_moves_thread_to_trash(
# trash() enqueues the same job the UI Delete uses (imported from app.queue). # trash() enqueues the same job the UI Delete uses (imported from app.queue).
monkeypatch.setattr("app.queue.enqueue_job", fake_enqueue) monkeypatch.setattr("app.queue.enqueue_job", fake_enqueue)
await sdk("filters").kv(ctx.user_id).set( await (
"_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}] sdk("filters")
.kv(ctx.user_id)
.set("_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}])
) )
await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)})
assert ("move_thread_to_folder_job", str(ctx.thread_id), "trash") in calls assert ("move_thread_to_folder_job", str(ctx.thread_id), "trash") in calls
+1 -3
View File
@@ -68,9 +68,7 @@ async def ctx() -> AsyncIterator[_Ctx]:
await c.client_a.aclose() await c.client_a.aclose()
await c.client_b.aclose() await c.client_b.aclose()
async with sm() as s: async with sm() as s:
await s.execute( await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b])))
delete(User).where(User.id.in_([c.user_a, c.user_b]))
)
await s.execute( await s.execute(
text("DELETE FROM plg_hello_note WHERE user_id IN (:a, :b)"), text("DELETE FROM plg_hello_note WHERE user_id IN (:a, :b)"),
{"a": str(c.user_a), "b": str(c.user_b)}, {"a": str(c.user_a), "b": str(c.user_b)},
+4 -3
View File
@@ -51,9 +51,10 @@ async def test_install_run_uninstall_dropdata_roundtrip() -> None:
# Table exists, RLS is enabled on it, and the per-plugin version table is present. # Table exists, RLS is enabled on it, and the per-plugin version table is present.
assert await _regclass("plg_demo_note") == "plg_demo_note" assert await _regclass("plg_demo_note") == "plg_demo_note"
assert await _scalar( assert (
"SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'" await _scalar("SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'")
) is True is True
)
assert await _regclass("alembic_version_plg_demo") == "alembic_version_plg_demo" assert await _regclass("alembic_version_plg_demo") == "alembic_version_plg_demo"
# --drop-data removes the table AND the version table: zero residue. # --drop-data removes the table AND the version table: zero residue.
+14 -4
View File
@@ -27,8 +27,12 @@ async def test_messages_header_reads_raw_header() -> None:
s.add(user) s.add(user)
await s.flush() await s.flush()
account = Account( account = Account(
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={}, user_id=user.id,
secret_ct=b"", discovery_src="manual", email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
) )
s.add(account) s.add(account)
await s.flush() await s.flush()
@@ -44,8 +48,14 @@ async def test_messages_header_reads_raw_header() -> None:
sha = blobstore.store(user.id, raw.as_bytes()) sha = blobstore.store(user.id, raw.as_bytes())
msg = Message( msg = Message(
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1, user_id=user.id,
subject="Weekly digest", flags=[], internal_date=datetime.now(UTC), blob_sha256=sha, account_id=account.id,
mailbox_id=mbox.id,
uid=1,
subject="Weekly digest",
flags=[],
internal_date=datetime.now(UTC),
blob_sha256=sha,
) )
s.add(msg) s.add(msg)
await s.commit() await s.commit()
@@ -44,9 +44,7 @@ async def test_ui_manifest_endpoint() -> None:
) )
try: try:
# Unauthenticated request is rejected. # Unauthenticated request is rejected.
async with httpx.AsyncClient( async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon:
transport=transport, base_url="http://testserver"
) as anon:
assert (await anon.get("/api/plugins/ui-manifest")).status_code == 401 assert (await anon.get("/api/plugins/ui-manifest")).status_code == 401
resp = await client.get("/api/plugins/ui-manifest") resp = await client.get("/api/plugins/ui-manifest")
+9 -1
View File
@@ -100,7 +100,15 @@ def test_parse_search_uids() -> None:
def test_parse_vanished() -> None: def test_parse_vanished() -> None:
# aioimaplib strips the leading "* " before buffering the line. # aioimaplib strips the leading "* " before buffering the line.
assert parse_vanished([b"VANISHED (EARLIER) 300:305,405"]) == [300, 301, 302, 303, 304, 305, 405] # noqa: E501 assert parse_vanished([b"VANISHED (EARLIER) 300:305,405"]) == [
300,
301,
302,
303,
304,
305,
405,
] # noqa: E501
# Live-expunge form (no EARLIER) and a descending range are both handled. # Live-expunge form (no EARLIER) and a descending range are both handled.
assert parse_vanished([b"VANISHED 41", b"VANISHED (EARLIER) 5:3"]) == [41, 3, 4, 5] assert parse_vanished([b"VANISHED 41", b"VANISHED (EARLIER) 5:3"]) == [41, 3, 4, 5]
# Non-VANISHED lines are ignored; non-bytes entries are skipped. # Non-VANISHED lines are ignored; non-bytes entries are skipped.
+1 -3
View File
@@ -52,9 +52,7 @@ async def test_meili_typo_tolerance_and_filters() -> None:
await backend.index(_doc(user_id)) await backend.index(_doc(user_id))
# Acceptance (§12 M2): a typo still finds the message. # Acceptance (§12 M2): a typo still finds the message.
hits, _c = await backend.query( hits, _c = await backend.query(to_search_query("invocie", user_id), limit=10, cursor=None)
to_search_query("invocie", user_id), limit=10, cursor=None
)
if not hits: # wait for the async index task if not hits: # wait for the async index task
assert await _poll(backend, "invocie", user_id) == 1 assert await _poll(backend, "invocie", user_id) == 1
hits, _c = await backend.query( hits, _c = await backend.query(
+6 -2
View File
@@ -159,8 +159,12 @@ async def test_search_excludes_trash_by_default(ctx: Ctx) -> None:
ctx.session.add(trash) ctx.session.add(trash)
await ctx.session.flush() await ctx.session.flush()
await _ingest( await _ingest(
ctx, frm="X <x@corp.com>", subject="zephyr in trash", body="bye", ctx,
mailbox_id=trash.id, special="trash", frm="X <x@corp.com>",
subject="zephyr in trash",
body="bye",
mailbox_id=trash.id,
special="trash",
) )
assert await _search(ctx, "zephyr") == 1 # inbox only; the trashed one is hidden assert await _search(ctx, "zephyr") == 1 # inbox only; the trashed one is hidden
assert await _search(ctx, "in:trash zephyr") == 1 # explicit -> searches Trash assert await _search(ctx, "in:trash zephyr") == 1 # explicit -> searches Trash
+285
View File
@@ -0,0 +1,285 @@
"""Fixes from the 2026-10-14 security assessment (ultimo-security-report.md):
cross-site requests refused (login CSRF), terse validation errors, the new-password
policy, link schemes in message HTML, and DNS-rebinding-proof outbound fetches."""
from __future__ import annotations
import asyncio
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from starlette.datastructures import Headers
from app.accounts import ssrf
from app.config import get_settings
from app.identity import password_policy
from app.identity.password_policy import PasswordRejected, check_new_password
from app.indexer.sanitize import sanitize_html
from app.main import create_app
from app.security.cross_site import is_cross_site
@pytest_asyncio.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
async with httpx.AsyncClient(
transport=ASGITransport(app=create_app()), base_url="http://mail.example.org"
) as c:
yield c
def _login(client: httpx.AsyncClient, headers: dict[str, str]): # type: ignore[no-untyped-def]
return client.post(
"/api/auth/login", json={"handle": "nobody", "password": "x"}, headers=headers
)
# --- cross-site requests (finding 3) --------------------------------------------------
@pytest.mark.parametrize(
"headers",
[
{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"},
{"Origin": "https://evil.example"}, # an older browser: Origin only
{"Origin": "null"}, # a sandboxed frame
],
)
async def test_sign_in_from_another_site_is_refused(
client: httpx.AsyncClient, headers: dict[str, str]
) -> None:
resp = await _login(client, headers)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "cross_site"
@pytest.mark.parametrize(
"headers",
[
{"Sec-Fetch-Site": "same-origin", "Origin": "http://mail.example.org"},
{"Origin": "http://mail.example.org"},
{"Origin": "https://mail.example.org:8443"}, # the port does not matter
{"Sec-Fetch-Site": "same-site"}, # our own subdomain
{"Sec-Fetch-Site": "none"}, # typed into the address bar
{}, # not a browser (scripts, mail apps)
],
)
async def test_sign_in_from_our_own_pages_goes_through(
client: httpx.AsyncClient, headers: dict[str, str]
) -> None:
resp = await _login(client, headers)
assert resp.status_code == 401 # evaluated: wrong credentials, not refused
assert resp.json()["error"]["code"] == "invalid_credentials"
async def test_reads_from_another_site_are_not_refused(client: httpx.AsyncClient) -> None:
resp = await client.get("/api/auth/registration", headers={"Sec-Fetch-Site": "cross-site"})
assert resp.status_code == 200
def test_origin_on_our_own_domain_passes_whatever_the_host_header() -> None:
domain = get_settings().app_domain
headers = Headers({"origin": f"https://{domain}", "host": "localhost:8088"})
assert is_cross_site(headers) is False
sub = Headers({"origin": f"https://mail.{domain}", "host": "localhost:8088"})
assert is_cross_site(sub) is False
lookalike = Headers({"origin": f"https://evil{domain}", "host": "localhost:8088"})
assert is_cross_site(lookalike) is True
def test_origin_matches_the_name_a_reverse_proxy_saw() -> None:
headers = Headers(
{"origin": "https://ultimo.email", "host": "web:80", "x-forwarded-host": "ultimo.email"}
)
assert is_cross_site(headers) is False
# --- validation errors (finding 5) ----------------------------------------------------
async def test_validation_errors_do_not_spell_out_the_request_model(
client: httpx.AsyncClient,
) -> None:
resp = await client.post("/api/auth/login", json={})
assert resp.status_code == 422
body = resp.json()
assert "detail" not in body
assert body["error"]["code"] == "validation_error"
assert body["error"]["message"].startswith("handle:")
async def test_a_too_short_password_says_so(client: httpx.AsyncClient) -> None:
resp = await client.post("/api/auth/register", json={"localpart": "abcde", "password": "123"})
assert resp.status_code == 422
assert "password" in resp.json()["error"]["message"]
async def test_new_passwords_are_capped_at_128(client: httpx.AsyncClient) -> None:
resp = await client.post(
"/api/auth/register", json={"localpart": "abcde", "password": "x" * 129}
)
assert resp.status_code == 422
# --- new-password policy (finding 6) --------------------------------------------------
@pytest.mark.parametrize(
"password",
["password123", "Password123", "12345678", "aaaaaaaaaa", "abababab", "jsmith2026"],
)
async def test_guessable_passwords_are_refused(password: str) -> None:
with pytest.raises(PasswordRejected):
await check_new_password(password, "jsmith@example.org")
async def test_a_decent_password_passes() -> None:
await check_new_password("correct horse battery", "jsmith@example.org")
async def test_breached_passwords_are_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(get_settings(), "password_breach_check", True)
sha = "A" * 40
async def breached(_password: str) -> bool:
return True
monkeypatch.setattr(password_policy, "_breached", breached)
with pytest.raises(PasswordRejected) as err:
await check_new_password("long unusual phrase " + sha, "jsmith@example.org")
assert err.value.code == "breached_password"
async def test_the_breach_service_being_down_does_not_block_sign_up(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def unreachable(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
real_client = httpx.AsyncClient
def client_factory(*args, **kwargs): # type: ignore[no-untyped-def]
return real_client(transport=httpx.MockTransport(unreachable))
monkeypatch.setattr(password_policy.httpx, "AsyncClient", client_factory)
assert await password_policy._breached("anything at all") is False
async def test_sign_up_with_a_common_password_is_refused(client: httpx.AsyncClient) -> None:
resp = await client.post(
"/api/auth/register",
json={"localpart": f"pw{uuid.uuid4().hex[:8]}", "password": "password123"},
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "weak_password"
# --- message HTML (V2) ----------------------------------------------------------------
@pytest.mark.parametrize(
"html",
[
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
'<a href="javascript:alert(1)">x</a>',
'<a href="jav&#x09;ascript:alert(1)">x</a>',
'<a href="cid:part1">x</a>',
"<img src=x onerror=alert(1)>",
"<svg onload=alert(1)><circle/></svg>",
"<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>",
'<noscript><p title="</noscript><img src=x onerror=alert(1)>">',
'<iframe srcdoc="<script>alert(1)</script>"></iframe>',
'<div style="width:expression(alert(1))">x</div>',
'<meta http-equiv="refresh" content="0;url=https://evil.example">',
'<base href="https://evil.example/">',
'<form action="https://evil.example"><input type=password></form>',
],
)
def test_no_active_content_survives(html: str) -> None:
out = sanitize_html(html, uuid.uuid4()).html.lower()
for marker in (
"<script", "onerror", "onload", "javascript:", "data:text", "cid:", "expression(",
"<iframe", "<meta", "<base", "<form", "<svg", "<math",
): # fmt: skip
assert marker not in out, (html, out)
def test_ordinary_links_stay() -> None:
out = sanitize_html(
'<a href="https://example.com/x">w</a><a href="mailto:a@b.c">m</a>', uuid.uuid4()
).html
assert 'href="https://example.com/x"' in out and 'href="mailto:a@b.c"' in out
async def test_the_message_document_carries_its_own_strict_policy() -> None:
from app.api import messages
resp = messages._html("<p>x</p>")
csp = resp.headers["content-security-policy"]
assert "script-src 'none'" in csp and "sandbox" in csp
# --- outbound fetches (V3) ------------------------------------------------------------
@pytest.mark.parametrize(
"ip", ["100.64.0.1", "::ffff:127.0.0.1", "fd00::1", "169.254.169.254", "fe80::1%eth0"]
)
def test_more_non_public_addresses_are_recognised(ip: str) -> None:
assert ssrf._is_public_ip(ip) is False
async def test_requests_go_to_the_address_that_was_checked(
monkeypatch: pytest.MonkeyPatch,
) -> None:
answers = iter([["93.184.216.34"], ["127.0.0.1"]]) # a rebinding DNS server
async def resolve(_host: str) -> list[str]:
return next(answers)
monkeypatch.setattr(ssrf, "_resolve_all", resolve)
url, headers, extensions = await ssrf._pinned("https://cal.example.com:8443/dav/?a=1")
assert url == "https://93.184.216.34:8443/dav/?a=1"
assert headers == {"Host": "cal.example.com:8443"}
assert extensions == {"sni_hostname": "cal.example.com"}
async def test_pinning_prefers_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
async def resolve(_host: str) -> list[str]:
return ["2606:4700::6810:84e5", "104.16.132.229"]
monkeypatch.setattr(ssrf, "_resolve_all", resolve)
url, _, _ = await ssrf._pinned("https://example.com/")
assert url == "https://104.16.132.229/"
async def test_mail_servers_are_checked_again_at_every_connection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.providers.base import ProviderConnectError
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
async def resolve(_host: str) -> list[str]:
return ["10.0.0.5"] # the name now points inside the network
monkeypatch.setattr(ssrf, "_resolve_all", resolve)
provider = ImapSmtpProvider(
ProviderConfig(
imap_host="imap.example.com",
imap_port=993,
imap_tls="ssl",
smtp_host="smtp.example.com",
smtp_port=465,
smtp_tls="ssl",
username="u",
password="p",
public_only=True,
)
)
with pytest.raises(ProviderConnectError):
await asyncio.wait_for(provider.verify(), 5)
with pytest.raises(ProviderConnectError):
await asyncio.wait_for(provider.send(b"x", "u@example.com", ["v@example.com"]), 5)
+65
View File
@@ -371,3 +371,68 @@ async def test_new_inbox_mail_while_live_asks_for_a_push(monkeypatch) -> None:
await cleanup.execute(delete(User).where(User.id == user.id)) await cleanup.execute(delete(User).where(User.id == user.id))
await session.close() await session.close()
await engine.dispose() await engine.dispose()
async def test_live_loop_catches_up_before_it_idles(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Mail that reached INBOX while the loop was not running (a new account's
first minutes, a reconnect) is fetched at once, not after the next new
message or the 10-minute IDLE timeout."""
import asyncio
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"idl{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
session.add(account)
await session.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
session.add(inbox)
await session.commit()
calls: list[str] = []
stop = asyncio.Event()
class FakeProvider:
async def verify(self) -> None:
return None
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
calls.append("poll")
waiting = RemoteMessage(
uid=1,
raw=_raw("Arrived while away", "<away@x.com>"),
flags=frozenset(),
internal_date=datetime.now(UTC),
)
return MailboxDelta([waiting] if len(calls) == 1 else [], [], [], None, None)
async def idle(self, mailbox: str) -> None:
calls.append("idle")
stop.set()
async def close(self) -> None:
return None
monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider())
try:
await asyncio.wait_for(sync_engine.live_loop(account.id, "INBOX", stop), 10)
assert calls == ["poll", "idle"]
n = await session.scalar(
select(func.count()).select_from(Message).where(Message.mailbox_id == inbox.id)
)
assert n == 1
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
+2
View File
@@ -77,6 +77,8 @@ x-backend-env: &backend-env
MAX_ATTACHMENT_MB: ${MAX_ATTACHMENT_MB:-25} MAX_ATTACHMENT_MB: ${MAX_ATTACHMENT_MB:-25}
TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30} TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
SENDER_AVATARS_ENABLED: ${SENDER_AVATARS_ENABLED:-true} SENDER_AVATARS_ENABLED: ${SENDER_AVATARS_ENABLED:-true}
# New passwords checked against Have I Been Pwned (k-anonymity; docs §5.2).
PASSWORD_BREACH_CHECK: ${PASSWORD_BREACH_CHECK:-true}
GOOGLE_OAUTH_ENABLED: ${GOOGLE_OAUTH_ENABLED:-false} GOOGLE_OAUTH_ENABLED: ${GOOGLE_OAUTH_ENABLED:-false}
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-} OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-} OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
+1
View File
@@ -30,6 +30,7 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# Shared server body, included by both listeners. Outside conf.d/ so nginx # Shared server body, included by both listeners. Outside conf.d/ so nginx
# does not try to load it as a config of its own. # does not try to load it as a config of its own.
COPY docker/nginx-app.conf /etc/nginx/webmail-app.conf COPY docker/nginx-app.conf /etc/nginx/webmail-app.conf
COPY docker/nginx-security.conf /etc/nginx/webmail-security.conf
COPY --from=builder /src/dist /usr/share/nginx/html COPY --from=builder /src/dist /usr/share/nginx/html
# Surface the running version to `deploy/webmail.sh status` without needing the API. # Surface the running version to `deploy/webmail.sh status` without needing the API.
+10 -5
View File
@@ -20,11 +20,10 @@ gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json gzip_types text/plain text/css application/javascript application/json
image/svg+xml application/manifest+json; image/svg+xml application/manifest+json;
# Clickjacking / sniffing basics. The app's own CSP for mail HTML is applied # Security headers (CSP, framing, HSTS, …) for everything this listener serves.
# server-side in app/indexer/sanitize.py; this is only the shell document. # Every location below that adds a header of its own includes the file again —
add_header X-Content-Type-Options nosniff always; # see the note at the top of it.
add_header X-Frame-Options SAMEORIGIN always; include /etc/nginx/webmail-security.conf;
add_header Referrer-Policy no-referrer always;
# ---- API ---------------------------------------------------------------- # ---- API ----------------------------------------------------------------
location /api/ { location /api/ {
@@ -72,12 +71,14 @@ location ~ ^/\.well-known/(caldav|carddav)$ {
location /assets/ { location /assets/ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
include /etc/nginx/webmail-security.conf;
try_files $uri =404; try_files $uri =404;
} }
location /fonts/ { location /fonts/ {
expires 30d; expires 30d;
add_header Cache-Control "public"; add_header Cache-Control "public";
include /etc/nginx/webmail-security.conf;
try_files $uri =404; try_files $uri =404;
} }
@@ -85,6 +86,7 @@ location = /healthz {
access_log off; access_log off;
return 200 "ok\n"; return 200 "ok\n";
add_header Content-Type text/plain; add_header Content-Type text/plain;
include /etc/nginx/webmail-security.conf;
} }
# The SPA fallback lives in each server block, not here: the two listeners serve # The SPA fallback lives in each server block, not here: the two listeners serve
@@ -95,12 +97,14 @@ location = /healthz {
# because the public listener 404s /admin.html before it can be served. # because the public listener 404s /admin.html before it can be served.
location = /index.html { location = /index.html {
add_header Cache-Control "no-store, must-revalidate"; add_header Cache-Control "no-store, must-revalidate";
include /etc/nginx/webmail-security.conf;
} }
# The offline-access service worker (frontend/public/sw.js): a fixed URL the # The offline-access service worker (frontend/public/sw.js): a fixed URL the
# browser re-checks for a new version, so it must not be cached either. # browser re-checks for a new version, so it must not be cached either.
location = /sw.js { location = /sw.js {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
include /etc/nginx/webmail-security.conf;
try_files $uri =404; try_files $uri =404;
} }
@@ -110,5 +114,6 @@ location = /manifest.webmanifest {
default_type application/manifest+json; default_type application/manifest+json;
types { } types { }
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";
include /etc/nginx/webmail-security.conf;
try_files $uri =404; try_files $uri =404;
} }
+31
View File
@@ -0,0 +1,31 @@
# Security headers for every response of the front door — the app shell, the
# static files and the API alike.
#
# Included at server level by nginx-app.conf AND inside every location that
# adds a header of its own: nginx drops ALL inherited add_header lines in a
# location that has any add_header, which is how index.html once went out with
# only its Cache-Control (security report, finding 1). A new location with an
# add_header must include this file too.
#
# Content-Security-Policy, by directive:
# script-src 'self' the Vite bundles, plugin bundles (/api/plugins/…) and
# workers are all same-origin; no inline script, no eval
# (pdf.js is told not to try).
# style-src 'unsafe-inline': React and the plugins set style
# attributes, and message HTML carries its own <style>.
# img-src / font-src https:/http: because an email whose images the user
# chose to load fetches them from the sender's servers
# (the message frame is srcdoc, so it shares this policy).
# connect-src the API and the live-update WebSocket on this host
# ($http_host: with the port, for LAN access on WEB_PORT).
# frame-ancestors 'self', not 'none': printing an attachment loads it in
# a hidden frame of our own. No other site may frame us.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data: https:; media-src 'self' blob:; connect-src 'self' wss://$http_host ws://$http_host; worker-src 'self' blob:; frame-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
# Only on requests that reached the reverse proxy over HTTPS ($hsts is empty
# otherwise, and nginx sends no header with an empty value): plain-HTTP LAN
# access on WEB_PORT keeps working.
add_header Strict-Transport-Security $hsts always;
+17
View File
@@ -28,6 +28,15 @@ map $http_x_forwarded_proto $fwd_proto {
http http; http http;
} }
# HSTS only for requests that came in over HTTPS (nginx-security.conf).
map $fwd_proto $hsts {
https "max-age=31536000";
default "";
}
# No nginx version in the Server header or on error pages.
server_tokens off;
# Connection header for the WebSocket upgrade dance on /api/ws. # Connection header for the WebSocket upgrade dance on /api/ws.
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
@@ -44,6 +53,13 @@ server {
listen 80; listen 80;
server_name _; server_name _;
# The reverse proxy in front (DSM) terminates TLS and says how the browser
# came in. Plain HTTP from the internet goes to HTTPS; a request with no
# such header (LAN access straight to WEB_PORT) is left alone.
if ($http_x_forwarded_proto = "http") {
return 301 https://$host$request_uri;
}
# Regex locations beat prefix ones, so this wins over `location /api/` # Regex locations beat prefix ones, so this wins over `location /api/`
# regardless of order. `(/|$)` keeps it to /api/admin and /api/admin/..., # regardless of order. `(/|$)` keeps it to /api/admin and /api/admin/...,
# never a future /api/administrators. 404 rather than 403: a port open to # never a future /api/administrators. 404 rather than 403: a port open to
@@ -76,6 +92,7 @@ server {
location = /admin.html { location = /admin.html {
add_header Cache-Control "no-store, must-revalidate"; add_header Cache-Control "no-store, must-revalidate";
include /etc/nginx/webmail-security.conf;
} }
# SPA fallback -> the admin panel. `/` on this port IS the panel; the mail # SPA fallback -> the admin panel. `/` on this port IS the panel; the mail
+18
View File
@@ -569,6 +569,24 @@ Session cookies get the `Secure` and `__Host-` treatment automatically as soon
as the app sees an HTTPS request: nginx forwards `X-Forwarded-Proto`, and the as the app sees an HTTPS request: nginx forwards `X-Forwarded-Proto`, and the
API is started with `--proxy-headers`. Nothing to configure. API is started with `--proxy-headers`. Nothing to configure.
**Plain HTTP and HSTS.** If DSM also has a rule for **HTTP** / port 80 to the
same hostname, keep it: the web container answers any request DSM forwarded
over plain HTTP with a `301` to `https://` (it recognises one by DSM's
`X-Forwarded-Proto: http`), and sends `Strict-Transport-Security` on HTTPS
responses. Leave DSM's own *Enable HSTS* box unticked — the container already
sends the header. Opening `http://NAS:8088` on the LAN is not redirected
(no reverse proxy, no such header).
**Security headers** (CSP, framing, `nosniff`, referrer, HSTS) are set by the
web container on every response — `docker/nginx-security.conf`. Don't add
Custom Headers for them in DSM: two differing CSPs are both enforced.
**Breached-password check.** New passwords (sign-up, reset) are checked
against Have I Been Pwned's range API — only the first 5 hex digits of the
password's SHA-1 leave the server. The API container needs outbound HTTPS to
`api.pwnedpasswords.com`; when it cannot reach it the check is skipped, never
fatal. `PASSWORD_BREACH_CHECK=false` turns it off.
### 5.3 Installing as an app, and notifications about new mail ### 5.3 Installing as an app, and notifications about new mail
Over HTTPS the web app is installable (Chrome/Edge: the install icon in the Over HTTPS the web app is installable (Chrome/Edge: the install icon in the
+66 -2
View File
@@ -31,6 +31,8 @@ function initials(p: Address | undefined): string {
} }
// Past this many characters the sender column switches to first names only. // Past this many characters the sender column switches to first names only.
// List width (px) below which rows stack instead of using one line.
const STACK_BELOW = 560;
const SENDERS_FULL_MAX = 25; const SENDERS_FULL_MAX = 25;
// A lone sender's name longer than this is cut to one character less plus "...". // A lone sender's name longer than this is cut to one character less plus "...".
const SINGLE_SENDER_MAX = 22; const SINGLE_SENDER_MAX = 22;
@@ -119,6 +121,11 @@ export function MessageList({
}) { }) {
const t = useT(); const t = useT();
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
// A list narrower than this has no room for the one-line row (a 200px sender
// column and the action rail would leave the subject no width at all): it
// stacks sender, subject and snippet as on a phone.
const [narrow, setNarrow] = useState(false);
const stacked = compact || narrow;
const swipe = useSwipeToDelete(onSwipeDelete); const swipe = useSwipeToDelete(onSwipeDelete);
// In Trash/Spam/Archive the middle action restores; elsewhere it archives. // In Trash/Spam/Archive the middle action restores; elsewhere it archives.
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive"; const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
@@ -162,6 +169,15 @@ export function MessageList({
parentRef.current?.scrollTo({ top: 0 }); parentRef.current?.scrollTo({ top: 0 });
}, [page]); }, [page]);
const empty = threads.length === 0;
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setNarrow(el.clientWidth < STACK_BELOW));
ro.observe(el);
return () => ro.disconnect();
}, [empty]);
if (threads.length === 0) { if (threads.length === 0) {
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>; return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
} }
@@ -309,7 +325,7 @@ export function MessageList({
</button> </button>
</div> </div>
{compact ? ( {stacked ? (
<div className="flex min-w-0 flex-1 flex-col justify-center"> <div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
<span <span
@@ -324,7 +340,11 @@ export function MessageList({
{thread.message_count} {thread.message_count}
</span> </span>
)} )}
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-app-faint"> <span
className={`ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-app-faint ${
compact ? "" : "group-hover:hidden"
}`}
>
{thread.has_attachments && <Paperclip />} {thread.has_attachments && <Paperclip />}
{thread.pinned && ( {thread.pinned && (
<Icon name="pin" size={15} className="text-[rgb(220_129_143)]" /> <Icon name="pin" size={15} className="text-[rgb(220_129_143)]" />
@@ -337,6 +357,50 @@ export function MessageList({
{timeLabel(thread.last_message_at)} {timeLabel(thread.last_message_at)}
</span> </span>
</span> </span>
{/* A narrow desktop list: the actions take the date's place on hover. */}
{!compact && (
<span className="ml-auto hidden shrink-0 items-center gap-3 pl-2 group-hover:flex">
<button
type="button"
title={thread.pinned ? t("mail.unpin") : t("mail.pin")}
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
className={`wm-online-only ${
thread.pinned ? "text-[rgb(220_129_143)]" : "text-app-muted hover:text-app-fg"
}`}
>
<Icon name="pin" size={16} />
</button>
{isRemovalFolder ? (
<button
type="button"
title={t("mail.restore")}
onClick={stop(() => onRestore(thread.id))}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="restore" size={16} />
</button>
) : (
<button
type="button"
title={t("mail.archive")}
onClick={stop(() => onArchive(thread.id))}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="archive" size={14} />
</button>
)}
<button
type="button"
title={t("mail.delete")}
onClick={stop(() =>
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
)}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="trash" size={16} />
</button>
</span>
)}
</div> </div>
<div <div
className={`flex min-w-0 items-center gap-1.5 text-[14px] ${ className={`flex min-w-0 items-center gap-1.5 text-[14px] ${
@@ -532,7 +532,8 @@ function PdfView({
try { try {
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer(); const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
if (cancelled) return; if (cancelled) return;
const doc = await pdfjs.getDocument({ data: buf, password }).promise; // No eval: the Content-Security-Policy forbids it (docker/nginx-security.conf).
const doc = await pdfjs.getDocument({ data: buf, password, isEvalSupported: false }).promise;
if (cancelled) return; if (cancelled) return;
docRef.current = doc; docRef.current = doc;
setNeedPassword(false); setNeedPassword(false);
@@ -351,6 +351,8 @@ export function Attachments({ messageId }: { messageId: string }) {
/> />
))} ))}
</div> </div>
{/* One file has its own download button: a zip of it would add nothing. */}
{attachments.length > 1 && (
<div className="mt-2"> <div className="mt-2">
<a <a
href={api.attachmentsZipUrl(messageId)} href={api.attachmentsZipUrl(messageId)}
@@ -360,6 +362,7 @@ export function Attachments({ messageId }: { messageId: string }) {
{t("mail.downloadAll", { count: attachments.length })} {t("mail.downloadAll", { count: attachments.length })}
</a> </a>
</div> </div>
)}
{previewIdx !== null && ( {previewIdx !== null && (
<Suspense fallback={null}> <Suspense fallback={null}>
<AttachmentPreview <AttachmentPreview
+41 -2
View File
@@ -6,6 +6,7 @@ import { api } from "../../lib/api";
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage"; import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
import { fixInvisibleText } from "../../lib/readability"; import { fixInvisibleText } from "../../lib/readability";
import { useTheme } from "../../lib/theme"; import { useTheme } from "../../lib/theme";
import { useIsMobile } from "../../lib/useIsMobile";
import type { MessageEnvelope } from "../../lib/types"; import type { MessageEnvelope } from "../../lib/types";
import { Attachments } from "./Attachments"; import { Attachments } from "./Attachments";
@@ -38,6 +39,8 @@ export function MessageBody({
const dark = useTheme().resolved === "dark" && darkMessages; const dark = useTheme().resolved === "dark" && darkMessages;
const darkRef = useRef(dark); const darkRef = useRef(dark);
darkRef.current = dark; darkRef.current = dark;
const mobileRef = useRef(false);
mobileRef.current = useIsMobile();
const { data, isLoading, isError, error } = useQuery({ const { data, isLoading, isError, error } = useQuery({
queryKey: ["message-html", message.id, allowed], queryKey: ["message-html", message.id, allowed],
@@ -64,13 +67,15 @@ export function MessageBody({
// Float-accurate content bottom (getBoundingClientRect keeps sub-pixels that // Float-accurate content bottom (getBoundingClientRect keeps sub-pixels that
// scrollHeight would floor), combined with the integer scroll/offset heights. // scrollHeight would floor), combined with the integer scroll/offset heights.
const rectBottom = body ? body.getBoundingClientRect().bottom : 0; const rectBottom = body ? body.getBoundingClientRect().bottom : 0;
// The body's own heights are before a phone's fit-to-width scaling (below).
const zoom = Number(body?.style.zoom) || 1;
let h = Math.ceil( let h = Math.ceil(
Math.max( Math.max(
rectBottom, rectBottom,
de.scrollHeight, de.scrollHeight,
de.offsetHeight, de.offsetHeight,
body?.scrollHeight ?? 0, (body?.scrollHeight ?? 0) * zoom,
body?.offsetHeight ?? 0, (body?.offsetHeight ?? 0) * zoom,
), ),
); );
// If the email overflows horizontally, its bottom scrollbar steals vertical // If the email overflows horizontally, its bottom scrollbar steals vertical
@@ -81,6 +86,26 @@ export function MessageBody({
setHeight(h); setHeight(h);
}, []); }, []);
// Phone: an email laid out wider than the screen (a fixed-width table, one
// long file name that must not wrap) is scaled down to fit, as phone mail
// apps do, instead of being cut off at the right edge. Its no-wrap cells may
// then wrap, so one long name no longer squeezes the other columns to a
// letter per line. On a desktop a wide email scrolls sideways, as before.
const fitWidth = useCallback((doc: Document) => {
const de = doc.documentElement;
const body = doc.body;
if (!body) return;
body.style.zoom = "";
doc.getElementById("wm-fit")?.remove();
if (!mobileRef.current || de.scrollWidth <= de.clientWidth + 1) return;
const scale = Math.max(0.5, de.clientWidth / de.scrollWidth);
const relax = doc.createElement("style");
relax.id = "wm-fit";
relax.textContent = "td,th{white-space:normal!important}";
doc.head.appendChild(relax);
body.style.zoom = String(scale);
}, []);
// Hide the quoted original by default and drop a "•••" toggle right after the // Hide the quoted original by default and drop a "•••" toggle right after the
// "On <date>, <sender> wrote:" attribution (inside the white body), so the reader // "On <date>, <sender> wrote:" attribution (inside the white body), so the reader
// sees only the new content until they choose to reveal the quote. // sees only the new content until they choose to reveal the quote.
@@ -148,6 +173,7 @@ export function MessageBody({
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts) fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
} }
injectQuoteToggle(doc); injectQuoteToggle(doc);
fitWidth(doc);
resize(); resize();
// Re-measure after the first paint settles (fonts/layout can shift the height // Re-measure after the first paint settles (fonts/layout can shift the height
// by a hair, which is enough to leave a spurious scrollbar). // by a hair, which is enough to leave a spurious scrollbar).
@@ -171,9 +197,22 @@ export function MessageBody({
const doc = frameRef.current?.contentDocument; const doc = frameRef.current?.contentDocument;
if (!doc) return; if (!doc) return;
prepare(doc); prepare(doc);
fitWidth(doc); // images may have widened it
resize(); // everything has loaded: one last measure resize(); // everything has loaded: one last measure
}; };
// A phone turned sideways: fit to the new width.
useEffect(() => {
const refit = () => {
const doc = frameRef.current?.contentDocument;
if (preparedRef.current !== doc || !doc) return;
fitWidth(doc);
resize();
};
window.addEventListener("resize", refit);
return () => window.removeEventListener("resize", refit);
}, [fitWidth, resize]);
// Watch for the message's document to be parsed (the frame starts on an // Watch for the message's document to be parsed (the frame starts on an
// empty about:blank document, which does not count). // empty about:blank document, which does not count).
useEffect(() => { useEffect(() => {
+10
View File
@@ -130,6 +130,7 @@ export const en = {
"mail.selectAll": "Select all", "mail.selectAll": "Select all",
"mail.conversationSelected": "1 conversation selected", "mail.conversationSelected": "1 conversation selected",
"mail.conversationsSelected": "{count} conversations selected", "mail.conversationsSelected": "{count} conversations selected",
"mail.conversationsSelected_one": "1 conversation selected",
"mail.alsoIn": "Also delivered to", "mail.alsoIn": "Also delivered to",
"mail.emptyTrash": "Empty trash", "mail.emptyTrash": "Empty trash",
"mail.emptySpam": "Empty spam", "mail.emptySpam": "Empty spam",
@@ -201,6 +202,7 @@ export const en = {
"mail.hideQuoted": "Hide quoted text", "mail.hideQuoted": "Hide quoted text",
"mail.markReadUnread": "Mark read / unread", "mail.markReadUnread": "Mark read / unread",
"mail.messageCount": "{count} messages", "mail.messageCount": "{count} messages",
"mail.messageCount_one": "1 message",
"mail.downloadAll": "Download all {count} attachments", "mail.downloadAll": "Download all {count} attachments",
"mail.downloadZip": "Download zip", "mail.downloadZip": "Download zip",
"mail.download": "Download", "mail.download": "Download",
@@ -218,6 +220,7 @@ export const en = {
"compose.closePrompt": "Save this message as a draft?", "compose.closePrompt": "Save this message as a draft?",
"compose.sent": "Message sent.", "compose.sent": "Message sent.",
"compose.undoIn": "{count} seconds to", "compose.undoIn": "{count} seconds to",
"compose.undoIn_one": "1 second to",
"compose.undo": "Undo", "compose.undo": "Undo",
"compose.sendFailed": "Send failed.", "compose.sendFailed": "Send failed.",
"compose.savingDraft": "Saving draft…", "compose.savingDraft": "Saving draft…",
@@ -419,6 +422,7 @@ export const en = {
"appPasswords.created": "Created {date}", "appPasswords.created": "Created {date}",
"appPasswords.lastUsed": "last used {date}", "appPasswords.lastUsed": "last used {date}",
"appPasswords.neverUsed": "never used", "appPasswords.neverUsed": "never used",
"appPasswords.lastUsedDav": "calendars or contacts last synced {date}",
"appPasswords.confirmRevoke": "Revoke the app password “{name}”? The app using it will stop signing in.", "appPasswords.confirmRevoke": "Revoke the app password “{name}”? The app using it will stop signing in.",
"settings.thisDevice": "this device", "settings.thisDevice": "this device",
"settings.revoke": "Revoke", "settings.revoke": "Revoke",
@@ -474,6 +478,7 @@ export const en = {
"settings.icloudHint": "settings.icloudHint":
"Use an app-specific password from account.apple.com → Sign-In and Security.", "Use an app-specific password from account.apple.com → Sign-In and Security.",
"settings.calendarCount": "{count} calendars", "settings.calendarCount": "{count} calendars",
"settings.calendarCount_one": "1 calendar",
"settings.removeSubscription": "Remove subscription", "settings.removeSubscription": "Remove subscription",
"settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?", "settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?",
"settings.deleteCalendar": "Delete calendar", "settings.deleteCalendar": "Delete calendar",
@@ -597,6 +602,7 @@ export const en = {
"offline.folderNotSaved": "This folder isn't saved on this device.", "offline.folderNotSaved": "This folder isn't saved on this device.",
"offline.noticeScheduled": "Saved to Scheduled — it will be sent when you're back online.", "offline.noticeScheduled": "Saved to Scheduled — it will be sent when you're back online.",
"offline.noticeSendingScheduled": "Sending {count} scheduled messages…", "offline.noticeSendingScheduled": "Sending {count} scheduled messages…",
"offline.noticeSendingScheduled_one": "Sending 1 scheduled message…",
"offline.draftsNotSaved": "Only drafts written on this device are available offline.", "offline.draftsNotSaved": "Only drafts written on this device are available offline.",
"compose.sendLater": "Schedule", "compose.sendLater": "Schedule",
"compose.savedOnDevice": "Saved on this device", "compose.savedOnDevice": "Saved on this device",
@@ -615,6 +621,8 @@ export const en = {
"scheduled.delete": "Delete", "scheduled.delete": "Delete",
"scheduled.statusRetrying": "scheduled.statusRetrying":
"The mail server couldn't take this message yet ({count} tries). Next try {when}; it keeps trying until {until}.", "The mail server couldn't take this message yet ({count} tries). Next try {when}; it keeps trying until {until}.",
"scheduled.statusRetrying_one":
"The mail server couldn't take this message yet (1 try). Next try {when}; it keeps trying until {until}.",
"scheduled.statusFailed": "Not sent: {error}", "scheduled.statusFailed": "Not sent: {error}",
"scheduled.lastError": "Last error: {error}", "scheduled.lastError": "Last error: {error}",
"scheduled.to": "To", "scheduled.to": "To",
@@ -631,7 +639,9 @@ export const en = {
"offline.settingHelp": "offline.settingHelp":
"Keep your newest 100 Inbox conversations, and the ones you open, on this device, so you can read them without a connection. They are stored unencrypted in this browser and removed when you sign out — leave this off on shared computers.", "Keep your newest 100 Inbox conversations, and the ones you open, on this device, so you can read them without a connection. They are stored unencrypted in this browser and removed when you sign out — leave this off on shared computers.",
"offline.settingStatus": "{count} conversations saved · {size} · updated {when}", "offline.settingStatus": "{count} conversations saved · {size} · updated {when}",
"offline.settingStatus_one": "1 conversation saved · {size} · updated {when}",
"offline.settingStatusNoTime": "{count} conversations saved · {size}", "offline.settingStatusNoTime": "{count} conversations saved · {size}",
"offline.settingStatusNoTime_one": "1 conversation saved · {size}",
"offline.settingSaving": "Saving recent mail…", "offline.settingSaving": "Saving recent mail…",
"offline.settingUnsupported": "offline.settingUnsupported":
"Not available here: browsers allow offline access only when the app is opened over HTTPS.", "Not available here: browsers allow offline access only when the app is opened over HTTPS.",
+9 -1
View File
@@ -35,7 +35,15 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const value = useMemo<I18nContextValue>(() => { const value = useMemo<I18nContextValue>(() => {
const catalog = catalogs[locale] ?? en; const catalog = catalogs[locale] ?? en;
const t: TFunc = (key, vars) => interpolate(catalog[key] ?? en[key] ?? key, vars); // A count of exactly one picks the "<key>_one" form where there is one
// ("1 message", not "1 messages").
const lookup = (key: string): string | undefined =>
(catalog as Record<string, string>)[key] ?? (en as Record<string, string>)[key];
const t: TFunc = (key, vars) =>
interpolate(
(vars?.count === 1 ? lookup(`${key}_one`) : undefined) ?? lookup(key) ?? key,
vars,
);
return { return {
locale, locale,
setLocale: (l) => { setLocale: (l) => {
+7 -3
View File
@@ -630,10 +630,14 @@ function AppPasswordsSetting() {
<span className="min-w-0"> <span className="min-w-0">
<span className="break-words">{a.name}</span> <span className="break-words">{a.name}</span>
<span className="block text-xs text-app-faint"> <span className="block text-xs text-app-faint">
{t("appPasswords.created", { date: date(a.created_at) })} ·{" "} {t("appPasswords.created", { date: date(a.created_at) })}
{/* Only calendar and contacts sign-ins are seen here: the mail
server checks IMAP/SMTP ones itself and keeps no record. */}
{a.last_used_at {a.last_used_at
? t("appPasswords.lastUsed", { date: date(a.last_used_at) }) ? ` · ${t(mailHost ? "appPasswords.lastUsedDav" : "appPasswords.lastUsed", { date: date(a.last_used_at) })}`
: t("appPasswords.neverUsed")} : mailHost
? ""
: ` · ${t("appPasswords.neverUsed")}`}
</span> </span>
</span> </span>
<button <button
+24
View File
@@ -0,0 +1,24 @@
// A count of one reads in the singular ("1 message"), any other in the plural.
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { I18nProvider, useT } from "../i18n";
const wrapper = ({ children }: { children: ReactNode }) => <I18nProvider>{children}</I18nProvider>;
describe("t()", () => {
it("uses the singular form for a count of one", () => {
const { result } = renderHook(() => useT(), { wrapper });
const t = result.current;
expect(t("mail.messageCount", { count: 1 })).toBe("1 message");
expect(t("mail.messageCount", { count: 2 })).toBe("2 messages");
expect(t("mail.messageCount", { count: 0 })).toBe("0 messages");
});
it("falls back to the plain string where there is no singular", () => {
const { result } = renderHook(() => useT(), { wrapper });
expect(result.current("mail.downloadAll", { count: 1 })).toBe("Download all 1 attachments");
});
});
+3
View File
@@ -25,6 +25,9 @@ export default defineConfig({
target: "http://127.0.0.1:8000", target: "http://127.0.0.1:8000",
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
// X-Forwarded-Host: the API's cross-site check (app/security/cross_site.py)
// compares the page's Origin with the name the browser used.
xfwd: true,
}, },
// CalDAV / CardDAV (SPEC Appendix E), for trying clients against dev. // CalDAV / CardDAV (SPEC Appendix E), for trying clients against dev.
"/dav": { target: "http://127.0.0.1:8000", changeOrigin: true }, "/dav": { target: "http://127.0.0.1:8000", changeOrigin: true },