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:
@@ -125,9 +125,7 @@ def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
|
||||
smtp = _server("outgoingServer", "smtp")
|
||||
if imap is None or smtp is None:
|
||||
return None
|
||||
return CandidateConfig(
|
||||
imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth)
|
||||
)
|
||||
return CandidateConfig(imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth))
|
||||
|
||||
|
||||
# --- DNS SRV (RFC 6186) ------------------------------------------------------
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
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
|
||||
(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap
|
||||
(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1;
|
||||
a pinned-IP transport can close it later.
|
||||
(reject private/loopback/link-local/reserved ranges). The request then goes to
|
||||
the very address that was checked — the name is not looked up a second time —
|
||||
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
|
||||
@@ -26,15 +27,12 @@ class SsrfError(Exception):
|
||||
|
||||
|
||||
def _is_public_ip(ip: str) -> bool:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return not (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_multicast
|
||||
or addr.is_reserved
|
||||
or addr.is_unspecified
|
||||
)
|
||||
addr = ipaddress.ip_address(ip.split("%", 1)[0]) # drop an IPv6 zone index
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped # ::ffff:127.0.0.1 is 127.0.0.1
|
||||
# is_global: not private, loopback, link-local, reserved, unspecified, nor
|
||||
# shared/carrier-grade NAT (100.64.0.0/10, which is_private misses).
|
||||
return addr.is_global and not addr.is_multicast
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
async def assert_host_is_public(host: str) -> None:
|
||||
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6)."""
|
||||
async def assert_host_is_public(host: str) -> list[str]:
|
||||
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6).
|
||||
|
||||
Returns the addresses checked, for the caller to connect to."""
|
||||
try:
|
||||
addresses = await _resolve_all(host)
|
||||
except socket.gaierror as exc:
|
||||
@@ -54,6 +54,31 @@ async def assert_host_is_public(host: str) -> None:
|
||||
for ip in addresses:
|
||||
if not _is_public_ip(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:
|
||||
@@ -63,12 +88,7 @@ async def guarded_get(url: str) -> str | None:
|
||||
(any non-200 response). Raises :class:`SsrfError` on a policy violation
|
||||
(bad scheme, private IP, oversize); lets httpx network errors propagate.
|
||||
"""
|
||||
parts = urlsplit(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)
|
||||
target, pin_headers, extensions = await _pinned(url)
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(
|
||||
@@ -76,7 +96,7 @@ async def guarded_get(url: str) -> str | None:
|
||||
timeout=HTTP_TIMEOUT,
|
||||
headers={"User-Agent": "webmail-autodiscovery/1.0"},
|
||||
) as client,
|
||||
client.stream("GET", url) as resp,
|
||||
client.stream("GET", target, headers=pin_headers, extensions=extensions) as resp,
|
||||
):
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
@@ -114,16 +134,17 @@ async def guarded_request(
|
||||
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
|
||||
status + headers + body (all statuses; the caller decides what's an error)."""
|
||||
parts = urlsplit(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)
|
||||
target, pin_headers, extensions = await _pinned(url)
|
||||
|
||||
async with (
|
||||
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] = []
|
||||
total = 0
|
||||
|
||||
@@ -89,6 +89,7 @@ async def test_account(
|
||||
smtp_tls=smtp.tls,
|
||||
username=username,
|
||||
password=password,
|
||||
public_only=True,
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -125,9 +125,7 @@ async def mark_all_read(
|
||||
return {"changed": 0}
|
||||
messages = list(
|
||||
await db.scalars(
|
||||
select(Message).where(
|
||||
Message.thread_id.in_(thread_ids), Message.user_id == user.id
|
||||
)
|
||||
select(Message).where(Message.thread_id.in_(thread_ids), Message.user_id == user.id)
|
||||
)
|
||||
)
|
||||
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)
|
||||
bus = get_event_bus()
|
||||
for tid in thread_ids:
|
||||
await bus.publish(
|
||||
"thread.updated", {"user_id": str(user.id), "thread_id": str(tid)}
|
||||
)
|
||||
await bus.publish("thread.updated", {"user_id": str(user.id), "thread_id": str(tid)})
|
||||
return {"changed": len(changed)}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Install the error-shape handler for all HTTP exceptions."""
|
||||
app.add_exception_handler(StarletteHTTPException, _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]
|
||||
|
||||
@@ -88,6 +88,23 @@ def _wrap(fragment: str) -> str:
|
||||
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:
|
||||
msg = await db.scalar(
|
||||
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
|
||||
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).
|
||||
# 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.sanitizer_v = result.sanitizer_v
|
||||
await db.commit()
|
||||
return HTMLResponse(content=_wrap(result.html))
|
||||
return _html(_wrap(result.html))
|
||||
|
||||
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)
|
||||
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)])
|
||||
|
||||
@@ -71,6 +71,9 @@ class Settings(BaseSettings):
|
||||
# Spelling and grammar checking in the compose window (LanguageTool server,
|
||||
# e.g. http://languagetool:8010). Empty: the check is off for everyone.
|
||||
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).
|
||||
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -27,6 +27,7 @@ from app.identity.cookies import (
|
||||
)
|
||||
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.password_policy import PasswordRejected, check_new_password
|
||||
from app.identity.passwords import hash_password, verify_password
|
||||
from app.identity.reset import (
|
||||
consume_token,
|
||||
@@ -125,6 +126,10 @@ _REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
||||
) -> 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:
|
||||
user = await register_user(
|
||||
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
|
||||
if user is None:
|
||||
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 not body.totp:
|
||||
raise_api_error(401, "totp_required", "a 2FA code is required")
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class PasswordResetRequestIn(BaseModel):
|
||||
|
||||
class PasswordResetIn(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import nh3
|
||||
# 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).
|
||||
# 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/…",
|
||||
# "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)
|
||||
if tag == "a" and attr == "target":
|
||||
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":
|
||||
lowered = value.strip().lower()
|
||||
if lowered.startswith("cid:"):
|
||||
|
||||
@@ -37,6 +37,10 @@ def create_app() -> FastAPI:
|
||||
|
||||
register_error_handlers(app)
|
||||
|
||||
from app.security.cross_site import CrossSiteGuard
|
||||
|
||||
app.add_middleware(CrossSiteGuard)
|
||||
|
||||
@app.get("/api/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Liveness/readiness probe: checks DB and Redis (dev-setup §6)."""
|
||||
|
||||
@@ -168,9 +168,7 @@ class Mailbox(Base):
|
||||
# IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer.
|
||||
uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
|
||||
highestmodseq: Mapped[int | None] = mapped_column(BigInteger)
|
||||
last_seen_uid: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, server_default=text("0")
|
||||
)
|
||||
last_seen_uid: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
|
||||
|
||||
|
||||
class Thread(Base):
|
||||
|
||||
@@ -21,9 +21,7 @@ class PluginHttp:
|
||||
content: bytes | str | None = None,
|
||||
auth: tuple[str, str] | None = None,
|
||||
) -> GuardedResponse:
|
||||
return await guarded_request(
|
||||
method, url, headers=headers, content=content, auth=auth
|
||||
)
|
||||
return await guarded_request(method, url, headers=headers, content=content, auth=auth)
|
||||
|
||||
async def get(self, url: str, **kw: object) -> GuardedResponse:
|
||||
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"
|
||||
|
||||
|
||||
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)
|
||||
class ProviderConfig:
|
||||
imap_host: str
|
||||
@@ -138,6 +148,10 @@ class ProviderConfig:
|
||||
# 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.
|
||||
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):
|
||||
@@ -196,6 +210,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
if self._imap is not None:
|
||||
return self._imap
|
||||
cfg = self._cfg
|
||||
if cfg.public_only:
|
||||
await _require_public(cfg.imap_host)
|
||||
await self._acquire_conn_slots()
|
||||
try:
|
||||
try:
|
||||
@@ -540,6 +556,8 @@ class ImapSmtpProvider(MailProvider):
|
||||
|
||||
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
|
||||
cfg = self._cfg
|
||||
if cfg.public_only:
|
||||
await _require_public(cfg.smtp_host)
|
||||
use_tls = cfg.smtp_tls == "ssl"
|
||||
start_tls = cfg.smtp_tls == "starttls"
|
||||
host_slot = (
|
||||
|
||||
@@ -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)
|
||||
@@ -302,7 +302,8 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
|
||||
|
||||
Each message returns to ``restore_mailbox_id`` (the folder it was in before it
|
||||
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
|
||||
with no recorded origin fall back to the account's Inbox.
|
||||
with no recorded origin fall back to the account's Inbox. Only messages in
|
||||
Archive, Trash or Spam move: the thread's others (your reply in Sent) stay.
|
||||
"""
|
||||
tid = uuid.UUID(thread_id)
|
||||
async with session_scope() as session:
|
||||
@@ -319,10 +320,13 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
|
||||
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
|
||||
)
|
||||
path_by_id = {mb.id: mb.path for mb in mailboxes}
|
||||
removal_ids = {mb.id for mb in mailboxes if mb.special_use in _REMOVAL_FOLDERS}
|
||||
inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"}
|
||||
# (message_id, account_id, source_path, target_id) — resolve the target now.
|
||||
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
|
||||
for m in messages:
|
||||
if m.mailbox_id not in removal_ids:
|
||||
continue # never removed: nothing to restore
|
||||
target_id = m.restore_mailbox_id
|
||||
if target_id is None or target_id not in path_by_id:
|
||||
target_id = inbox_by_account.get(m.account_id)
|
||||
|
||||
+40
-25
@@ -172,9 +172,7 @@ class _BackfillProgress:
|
||||
self._mailboxes.add(mailbox_path)
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Account)
|
||||
.where(Account.id == self.account.id)
|
||||
.values(backfill_done=self.done)
|
||||
update(Account).where(Account.id == self.account.id).values(backfill_done=self.done)
|
||||
)
|
||||
await get_event_bus().publish(
|
||||
"sync.progress",
|
||||
@@ -321,16 +319,28 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
|
||||
dedupe = gmail and mailbox.special_use == "archive"
|
||||
if since is not None: # 3months: single windowed pass
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, since=since,
|
||||
provider,
|
||||
account_obj,
|
||||
mailbox,
|
||||
progress,
|
||||
since=since,
|
||||
dedupe_account_scope=dedupe,
|
||||
)
|
||||
else: # all: recent window first (usable fast), then the remainder
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, since=recent_cutoff,
|
||||
provider,
|
||||
account_obj,
|
||||
mailbox,
|
||||
progress,
|
||||
since=recent_cutoff,
|
||||
dedupe_account_scope=dedupe,
|
||||
)
|
||||
count += await backfill_mailbox(
|
||||
provider, account_obj, mailbox, progress, before=recent_cutoff,
|
||||
provider,
|
||||
account_obj,
|
||||
mailbox,
|
||||
progress,
|
||||
before=recent_cutoff,
|
||||
dedupe_account_scope=dedupe,
|
||||
)
|
||||
await _set_state(account_id, "live")
|
||||
@@ -491,9 +501,7 @@ async def apply_delta(
|
||||
await _reconcile_drafts(provider, account, mailbox)
|
||||
|
||||
|
||||
async def _reconcile_drafts(
|
||||
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox
|
||||
) -> None:
|
||||
async def _reconcile_drafts(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> None:
|
||||
"""Drop DB rows for drafts that no longer exist on the server (were expunged)."""
|
||||
from app.db import session_scope
|
||||
|
||||
@@ -548,13 +556,13 @@ async def live_loop(
|
||||
await provider.verify() # signed in: the account is reachable
|
||||
if health is not None:
|
||||
await health.ok()
|
||||
first = True
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await provider.idle(mailbox_path)
|
||||
except ProviderNotSupported:
|
||||
await asyncio.sleep(idle_fallback)
|
||||
if stop.is_set():
|
||||
break
|
||||
# Catch up before each IDLE, not only after it: IDLE reports what
|
||||
# arrives from now on, so mail that came while this loop was not
|
||||
# running (a new account's first minutes, a reconnect, a worker
|
||||
# restart) would otherwise wait for the next new message or the
|
||||
# IDLE timeout (10 min) to show.
|
||||
async with session_scope() as session:
|
||||
mailbox = await session.scalar(
|
||||
select(Mailbox).where(
|
||||
@@ -562,14 +570,23 @@ async def live_loop(
|
||||
)
|
||||
)
|
||||
mailbox_obj = mailbox
|
||||
if mailbox_obj is None:
|
||||
if mailbox_obj is not None:
|
||||
delta = await provider.poll(
|
||||
mailbox_path,
|
||||
last_seen_uid=mailbox_obj.last_seen_uid,
|
||||
highestmodseq=mailbox_obj.highestmodseq,
|
||||
)
|
||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||
elif not first:
|
||||
break # the folder is gone
|
||||
# else: the account's first sync has not recorded the folder yet.
|
||||
first = False
|
||||
if stop.is_set():
|
||||
break
|
||||
delta = await provider.poll(
|
||||
mailbox_path,
|
||||
last_seen_uid=mailbox_obj.last_seen_uid,
|
||||
highestmodseq=mailbox_obj.highestmodseq,
|
||||
)
|
||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||
try:
|
||||
await provider.idle(mailbox_path)
|
||||
except ProviderNotSupported:
|
||||
await asyncio.sleep(idle_fallback)
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
@@ -630,9 +647,7 @@ async def poll_folders_once(
|
||||
await provider.close()
|
||||
|
||||
|
||||
async def _poll_secondary_once(
|
||||
account_id: uuid.UUID, health: AccountHealth | None = None
|
||||
) -> None:
|
||||
async def _poll_secondary_once(account_id: uuid.UUID, health: AccountHealth | None = None) -> None:
|
||||
await poll_folders_once(account_id, include_inbox=False, health=health)
|
||||
|
||||
|
||||
|
||||
@@ -28,24 +28,52 @@ def _compact(name: str) -> str:
|
||||
# Localized folder names by special_use. Written readably; compacted at load.
|
||||
_NAMES: dict[str, list[str]] = {
|
||||
"sent": [
|
||||
"sent", "sent items", "sent mail", "sent messages", # English / Outlook / Gmail
|
||||
"wysłane", "elementy wysłane", "wiadomości wysłane", # Polish
|
||||
"gesendet", "gesendete objekte", "gesendete elemente", # German
|
||||
"envoyés", "éléments envoyés", "messages envoyés", "courrier envoyé", # French
|
||||
"enviados", "elementos enviados", "correo enviado", # Spanish
|
||||
"inviata", "posta inviata", "messaggi inviati", # Italian
|
||||
"verzonden", "verzonden items", "verzonden berichten", # Dutch
|
||||
"enviadas", "itens enviados", "mensagens enviadas", # Portuguese
|
||||
"отправленные", "исходящие", # Russian
|
||||
"skickat", "skickade", "skickade objekt", # Swedish
|
||||
"sendt", "sendte", "sendte elementer", # Norwegian / Danish
|
||||
"sent",
|
||||
"sent items",
|
||||
"sent mail",
|
||||
"sent messages", # English / Outlook / Gmail
|
||||
"wysłane",
|
||||
"elementy wysłane",
|
||||
"wiadomości wysłane", # Polish
|
||||
"gesendet",
|
||||
"gesendete objekte",
|
||||
"gesendete elemente", # German
|
||||
"envoyés",
|
||||
"éléments envoyés",
|
||||
"messages envoyés",
|
||||
"courrier envoyé", # French
|
||||
"enviados",
|
||||
"elementos enviados",
|
||||
"correo enviado", # Spanish
|
||||
"inviata",
|
||||
"posta inviata",
|
||||
"messaggi inviati", # Italian
|
||||
"verzonden",
|
||||
"verzonden items",
|
||||
"verzonden berichten", # Dutch
|
||||
"enviadas",
|
||||
"itens enviados",
|
||||
"mensagens enviadas", # Portuguese
|
||||
"отправленные",
|
||||
"исходящие", # Russian
|
||||
"skickat",
|
||||
"skickade",
|
||||
"skickade objekt", # Swedish
|
||||
"sendt",
|
||||
"sendte",
|
||||
"sendte elementer", # Norwegian / Danish
|
||||
"lähetetyt", # Finnish
|
||||
"odeslané", "odeslaná pošta", # Czech
|
||||
"odeslané",
|
||||
"odeslaná pošta", # Czech
|
||||
],
|
||||
"drafts": [
|
||||
"drafts", "draft",
|
||||
"robocze", "kopie robocze", "wersje robocze",
|
||||
"entwürfe", "entwurf",
|
||||
"drafts",
|
||||
"draft",
|
||||
"robocze",
|
||||
"kopie robocze",
|
||||
"wersje robocze",
|
||||
"entwürfe",
|
||||
"entwurf",
|
||||
"brouillons",
|
||||
"borradores",
|
||||
"bozze",
|
||||
@@ -58,44 +86,80 @@ _NAMES: dict[str, list[str]] = {
|
||||
"koncepty", # Czech
|
||||
],
|
||||
"trash": [
|
||||
"trash", "deleted", "deleted items", "deleted messages", "bin", "recycle bin",
|
||||
"trash",
|
||||
"deleted",
|
||||
"deleted items",
|
||||
"deleted messages",
|
||||
"bin",
|
||||
"recycle bin",
|
||||
"kosz",
|
||||
"papierkorb", "gelöschte objekte", "gelöschte elemente",
|
||||
"corbeille", "éléments supprimés",
|
||||
"papelera", "elementos eliminados",
|
||||
"cestino", "posta eliminata",
|
||||
"prullenbak", "verwijderde items",
|
||||
"lixeira", "lixo", "itens excluídos",
|
||||
"корзина", "удаленные",
|
||||
"papperskorg", "borttaget",
|
||||
"papirkurv", "slettet",
|
||||
"roskakori", "poistetut",
|
||||
"papierkorb",
|
||||
"gelöschte objekte",
|
||||
"gelöschte elemente",
|
||||
"corbeille",
|
||||
"éléments supprimés",
|
||||
"papelera",
|
||||
"elementos eliminados",
|
||||
"cestino",
|
||||
"posta eliminata",
|
||||
"prullenbak",
|
||||
"verwijderde items",
|
||||
"lixeira",
|
||||
"lixo",
|
||||
"itens excluídos",
|
||||
"корзина",
|
||||
"удаленные",
|
||||
"papperskorg",
|
||||
"borttaget",
|
||||
"papirkurv",
|
||||
"slettet",
|
||||
"roskakori",
|
||||
"poistetut",
|
||||
"koš", # Czech
|
||||
],
|
||||
"junk": [
|
||||
"junk", "junk email", "junk e-mail", "spam", "bulk mail", "bulk",
|
||||
"wiadomości-śmieci", "niechciane",
|
||||
"unerwünscht", "werbung",
|
||||
"courrier indésirable", "indésirables", "pourriel",
|
||||
"correo no deseado", "no deseado", "correo basura",
|
||||
"posta indesiderata", "indesiderata",
|
||||
"ongewenst", "ongewenste e-mail",
|
||||
"junk",
|
||||
"junk email",
|
||||
"junk e-mail",
|
||||
"spam",
|
||||
"bulk mail",
|
||||
"bulk",
|
||||
"wiadomości-śmieci",
|
||||
"niechciane",
|
||||
"unerwünscht",
|
||||
"werbung",
|
||||
"courrier indésirable",
|
||||
"indésirables",
|
||||
"pourriel",
|
||||
"correo no deseado",
|
||||
"no deseado",
|
||||
"correo basura",
|
||||
"posta indesiderata",
|
||||
"indesiderata",
|
||||
"ongewenst",
|
||||
"ongewenste e-mail",
|
||||
"lixo eletrônico",
|
||||
"спам", "нежелательная почта",
|
||||
"skräp", "skräppost",
|
||||
"спам",
|
||||
"нежелательная почта",
|
||||
"skräp",
|
||||
"skräppost",
|
||||
"uønsket",
|
||||
"roskaposti",
|
||||
"nevyžádaná pošta",
|
||||
],
|
||||
"archive": [
|
||||
"archive", "archives", "all mail",
|
||||
"archive",
|
||||
"archives",
|
||||
"all mail",
|
||||
"archiwum",
|
||||
"archiv",
|
||||
"archivio",
|
||||
"archivo",
|
||||
"archief",
|
||||
"arquivo", "arquivo morto",
|
||||
"архив", "вся почта",
|
||||
"arquivo",
|
||||
"arquivo morto",
|
||||
"архив",
|
||||
"вся почта",
|
||||
"arkiv",
|
||||
"arkisto",
|
||||
],
|
||||
|
||||
@@ -33,5 +33,6 @@ def build_provider(account: Account) -> ImapSmtpProvider:
|
||||
smtp_tls=str(smtp.get("tls", "ssl")),
|
||||
username=account.email,
|
||||
password=password,
|
||||
public_only=True, # a server the user named: never one inside our network
|
||||
)
|
||||
return ImapSmtpProvider(config)
|
||||
|
||||
@@ -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.sessions import resolve_session
|
||||
from app.redis_client import get_redis
|
||||
from app.security.cross_site import is_cross_site
|
||||
from app.sync import leases
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,6 +28,12 @@ router = APIRouter()
|
||||
|
||||
@router.websocket("/ws")
|
||||
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(
|
||||
SESSION_COOKIE_INSECURE, ""
|
||||
)
|
||||
|
||||
@@ -16,9 +16,7 @@ from app.models import Message
|
||||
|
||||
async def main() -> None:
|
||||
async with session_scope() as session:
|
||||
rows = list(
|
||||
await session.scalars(select(Message).where(Message.delivery_id.is_(None)))
|
||||
)
|
||||
rows = list(await session.scalars(select(Message).where(Message.delivery_id.is_(None))))
|
||||
fixed = 0
|
||||
for m in rows:
|
||||
try:
|
||||
|
||||
@@ -42,9 +42,7 @@ async def main() -> None:
|
||||
update(Message)
|
||||
.where(Message.id == msg.id)
|
||||
.values(
|
||||
body_indexed=Message.body_indexed.op("||")(
|
||||
func.to_tsvector(_TS_CONFIG, blob)
|
||||
)
|
||||
body_indexed=Message.body_indexed.op("||")(func.to_tsvector(_TS_CONFIG, blob))
|
||||
)
|
||||
)
|
||||
updated += 1
|
||||
|
||||
@@ -37,10 +37,14 @@ AUTOCONFIG_XML = """<?xml version="1.0"?>
|
||||
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bypass the real DNS/public-IP check so respx can intercept the fetch."""
|
||||
|
||||
async def ok(_host: str) -> None:
|
||||
return None
|
||||
async def ok(_host: str) -> list[str]:
|
||||
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, "_pinned", unpinned)
|
||||
|
||||
|
||||
# --- XML parsing -------------------------------------------------------------
|
||||
|
||||
@@ -310,7 +310,8 @@ async def test_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
|
||||
await api.session.commit()
|
||||
|
||||
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
|
||||
)
|
||||
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:
|
||||
assert api.session is not None
|
||||
# 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"]
|
||||
if t["message_count"] == 2)
|
||||
kickoff = next(
|
||||
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()
|
||||
first_id = uuid.UUID(detail["messages"][0]["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.
|
||||
acct2 = Account(
|
||||
user_id=api.user_id, email="me2@example.com", imap_config={}, smtp_config={},
|
||||
secret_ct=b"", discovery_src="manual",
|
||||
user_id=api.user_id,
|
||||
email="me2@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
api.session.add(acct2)
|
||||
await api.session.flush()
|
||||
inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox")
|
||||
api.session.add(inbox2)
|
||||
await api.session.flush()
|
||||
api.session.add(Message(
|
||||
user_id=api.user_id, account_id=acct2.id, 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
|
||||
))
|
||||
api.session.add(
|
||||
Message(
|
||||
user_id=api.user_id,
|
||||
account_id=acct2.id,
|
||||
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()
|
||||
|
||||
body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
|
||||
|
||||
@@ -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:
|
||||
# No CSRF header -> rejected (state-changing route).
|
||||
resp = await ctx.client.post(
|
||||
"/api/saved-searches", json={"name": "x", "query": "y"}
|
||||
)
|
||||
resp = await ctx.client.post("/api/saved-searches", json={"name": "x", "query": "y"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# (tests/hosted/), against the dev stack's Stalwart container.
|
||||
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 {
|
||||
"STALWART_URL": "http://127.0.0.1:8180",
|
||||
"STALWART_ADMIN_SECRET": "stalwart_admin_dev",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
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 sent[-1]["title"] == "Dentist"
|
||||
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
|
||||
# The worker was down when the 10-minute reminder came due: sent late, once.
|
||||
assert await run(MON_9 - timedelta(minutes=4)) == 1
|
||||
|
||||
@@ -49,8 +49,12 @@ async def ctx() -> AsyncIterator[_Ctx]:
|
||||
s.add(user)
|
||||
await s.flush()
|
||||
account = Account(
|
||||
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={},
|
||||
secret_ct=b"", discovery_src="manual",
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
s.add(account)
|
||||
await s.flush()
|
||||
@@ -64,12 +68,17 @@ async def ctx() -> AsyncIterator[_Ctx]:
|
||||
s.add(thread)
|
||||
await s.flush()
|
||||
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,
|
||||
subject="Your Invoice 123",
|
||||
from_addr=[{"name": "Billing", "email": "billing@acme.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)
|
||||
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:
|
||||
await sdk("filters").kv(ctx.user_id).set(
|
||||
"_rules", [{"field": "subject", "contains": "invoice", "action": "read"}]
|
||||
await (
|
||||
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)})
|
||||
assert "\\Seen" in await _flags(ctx)
|
||||
|
||||
|
||||
async def test_hook_no_match_leaves_flags(ctx: _Ctx) -> None:
|
||||
await sdk("filters").kv(ctx.user_id).set(
|
||||
"_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}]
|
||||
await (
|
||||
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)})
|
||||
assert await _flags(ctx) == []
|
||||
|
||||
|
||||
async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None:
|
||||
await sdk("filters").kv(ctx.user_id).set(
|
||||
"_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}]
|
||||
await (
|
||||
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)})
|
||||
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:
|
||||
# Multi-account setups match on the recipient address.
|
||||
await sdk("filters").kv(ctx.user_id).set(
|
||||
"_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}]
|
||||
await (
|
||||
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)})
|
||||
assert "\\Seen" in await _flags(ctx)
|
||||
|
||||
|
||||
async def test_hook_trash_moves_thread_to_trash(
|
||||
ctx: _Ctx, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_hook_trash_moves_thread_to_trash(ctx: _Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[tuple[object, ...]] = []
|
||||
|
||||
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).
|
||||
monkeypatch.setattr("app.queue.enqueue_job", fake_enqueue)
|
||||
await sdk("filters").kv(ctx.user_id).set(
|
||||
"_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}]
|
||||
await (
|
||||
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)})
|
||||
assert ("move_thread_to_folder_job", str(ctx.thread_id), "trash") in calls
|
||||
|
||||
@@ -68,9 +68,7 @@ async def ctx() -> AsyncIterator[_Ctx]:
|
||||
await c.client_a.aclose()
|
||||
await c.client_b.aclose()
|
||||
async with sm() as s:
|
||||
await s.execute(
|
||||
delete(User).where(User.id.in_([c.user_a, c.user_b]))
|
||||
)
|
||||
await s.execute(delete(User).where(User.id.in_([c.user_a, c.user_b])))
|
||||
await s.execute(
|
||||
text("DELETE FROM plg_hello_note WHERE user_id IN (:a, :b)"),
|
||||
{"a": str(c.user_a), "b": str(c.user_b)},
|
||||
|
||||
@@ -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.
|
||||
assert await _regclass("plg_demo_note") == "plg_demo_note"
|
||||
assert await _scalar(
|
||||
"SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'"
|
||||
) is True
|
||||
assert (
|
||||
await _scalar("SELECT relrowsecurity FROM pg_class WHERE relname = 'plg_demo_note'")
|
||||
is True
|
||||
)
|
||||
assert await _regclass("alembic_version_plg_demo") == "alembic_version_plg_demo"
|
||||
|
||||
# --drop-data removes the table AND the version table: zero residue.
|
||||
|
||||
@@ -27,8 +27,12 @@ async def test_messages_header_reads_raw_header() -> None:
|
||||
s.add(user)
|
||||
await s.flush()
|
||||
account = Account(
|
||||
user_id=user.id, email="me@example.com", imap_config={}, smtp_config={},
|
||||
secret_ct=b"", discovery_src="manual",
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
s.add(account)
|
||||
await s.flush()
|
||||
@@ -44,8 +48,14 @@ async def test_messages_header_reads_raw_header() -> None:
|
||||
sha = blobstore.store(user.id, raw.as_bytes())
|
||||
|
||||
msg = Message(
|
||||
user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1,
|
||||
subject="Weekly digest", flags=[], internal_date=datetime.now(UTC), blob_sha256=sha,
|
||||
user_id=user.id,
|
||||
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)
|
||||
await s.commit()
|
||||
|
||||
@@ -44,9 +44,7 @@ async def test_ui_manifest_endpoint() -> None:
|
||||
)
|
||||
try:
|
||||
# Unauthenticated request is rejected.
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver"
|
||||
) as anon:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon:
|
||||
assert (await anon.get("/api/plugins/ui-manifest")).status_code == 401
|
||||
|
||||
resp = await client.get("/api/plugins/ui-manifest")
|
||||
|
||||
@@ -100,7 +100,15 @@ def test_parse_search_uids() -> None:
|
||||
|
||||
def test_parse_vanished() -> None:
|
||||
# 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.
|
||||
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.
|
||||
|
||||
@@ -52,9 +52,7 @@ async def test_meili_typo_tolerance_and_filters() -> None:
|
||||
await backend.index(_doc(user_id))
|
||||
|
||||
# Acceptance (§12 M2): a typo still finds the message.
|
||||
hits, _c = await backend.query(
|
||||
to_search_query("invocie", user_id), limit=10, cursor=None
|
||||
)
|
||||
hits, _c = await backend.query(to_search_query("invocie", user_id), limit=10, cursor=None)
|
||||
if not hits: # wait for the async index task
|
||||
assert await _poll(backend, "invocie", user_id) == 1
|
||||
hits, _c = await backend.query(
|
||||
|
||||
@@ -159,8 +159,12 @@ async def test_search_excludes_trash_by_default(ctx: Ctx) -> None:
|
||||
ctx.session.add(trash)
|
||||
await ctx.session.flush()
|
||||
await _ingest(
|
||||
ctx, frm="X <x@corp.com>", subject="zephyr in trash", body="bye",
|
||||
mailbox_id=trash.id, special="trash",
|
||||
ctx,
|
||||
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, "in:trash zephyr") == 1 # explicit -> searches Trash
|
||||
|
||||
@@ -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	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)
|
||||
@@ -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 session.close()
|
||||
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()
|
||||
|
||||
@@ -77,6 +77,8 @@ x-backend-env: &backend-env
|
||||
MAX_ATTACHMENT_MB: ${MAX_ATTACHMENT_MB:-25}
|
||||
TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
|
||||
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}
|
||||
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
|
||||
|
||||
@@ -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
|
||||
# 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-security.conf /etc/nginx/webmail-security.conf
|
||||
COPY --from=builder /src/dist /usr/share/nginx/html
|
||||
|
||||
# Surface the running version to `deploy/webmail.sh status` without needing the API.
|
||||
|
||||
+10
-5
@@ -20,11 +20,10 @@ gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml application/manifest+json;
|
||||
|
||||
# Clickjacking / sniffing basics. The app's own CSP for mail HTML is applied
|
||||
# server-side in app/indexer/sanitize.py; this is only the shell document.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
# Security headers (CSP, framing, HSTS, …) for everything this listener serves.
|
||||
# Every location below that adds a header of its own includes the file again —
|
||||
# see the note at the top of it.
|
||||
include /etc/nginx/webmail-security.conf;
|
||||
|
||||
# ---- API ----------------------------------------------------------------
|
||||
location /api/ {
|
||||
@@ -72,12 +71,14 @@ location ~ ^/\.well-known/(caldav|carddav)$ {
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
include /etc/nginx/webmail-security.conf;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
include /etc/nginx/webmail-security.conf;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ location = /healthz {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
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
|
||||
@@ -95,12 +97,14 @@ location = /healthz {
|
||||
# because the public listener 404s /admin.html before it can be served.
|
||||
location = /index.html {
|
||||
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
|
||||
# browser re-checks for a new version, so it must not be cached either.
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
include /etc/nginx/webmail-security.conf;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -110,5 +114,6 @@ location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
types { }
|
||||
add_header Cache-Control "no-cache";
|
||||
include /etc/nginx/webmail-security.conf;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -28,6 +28,15 @@ map $http_x_forwarded_proto $fwd_proto {
|
||||
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.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
@@ -44,6 +53,13 @@ server {
|
||||
listen 80;
|
||||
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/`
|
||||
# regardless of order. `(/|$)` keeps it to /api/admin and /api/admin/...,
|
||||
# never a future /api/administrators. 404 rather than 403: a port open to
|
||||
@@ -76,6 +92,7 @@ server {
|
||||
|
||||
location = /admin.html {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Over HTTPS the web app is installable (Chrome/Edge: the install icon in the
|
||||
|
||||
@@ -31,6 +31,8 @@ function initials(p: Address | undefined): string {
|
||||
}
|
||||
|
||||
// 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;
|
||||
// A lone sender's name longer than this is cut to one character less plus "...".
|
||||
const SINGLE_SENDER_MAX = 22;
|
||||
@@ -119,6 +121,11 @@ export function MessageList({
|
||||
}) {
|
||||
const t = useT();
|
||||
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);
|
||||
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
|
||||
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
|
||||
@@ -162,6 +169,15 @@ export function MessageList({
|
||||
parentRef.current?.scrollTo({ top: 0 });
|
||||
}, [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) {
|
||||
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
|
||||
}
|
||||
@@ -309,7 +325,7 @@ export function MessageList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{compact ? (
|
||||
{stacked ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
@@ -324,7 +340,11 @@ export function MessageList({
|
||||
{thread.message_count}
|
||||
</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.pinned && (
|
||||
<Icon name="pin" size={15} className="text-[rgb(220_129_143)]" />
|
||||
@@ -337,6 +357,50 @@ export function MessageList({
|
||||
{timeLabel(thread.last_message_at)}
|
||||
</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
|
||||
className={`flex min-w-0 items-center gap-1.5 text-[14px] ${
|
||||
|
||||
@@ -532,7 +532,8 @@ function PdfView({
|
||||
try {
|
||||
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
|
||||
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;
|
||||
docRef.current = doc;
|
||||
setNeedPassword(false);
|
||||
|
||||
@@ -351,6 +351,8 @@ export function Attachments({ messageId }: { messageId: string }) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* One file has its own download button: a zip of it would add nothing. */}
|
||||
{attachments.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={api.attachmentsZipUrl(messageId)}
|
||||
@@ -360,6 +362,7 @@ export function Attachments({ messageId }: { messageId: string }) {
|
||||
{t("mail.downloadAll", { count: attachments.length })}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{previewIdx !== null && (
|
||||
<Suspense fallback={null}>
|
||||
<AttachmentPreview
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from "../../lib/api";
|
||||
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
|
||||
import { fixInvisibleText } from "../../lib/readability";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { useIsMobile } from "../../lib/useIsMobile";
|
||||
import type { MessageEnvelope } from "../../lib/types";
|
||||
import { Attachments } from "./Attachments";
|
||||
|
||||
@@ -38,6 +39,8 @@ export function MessageBody({
|
||||
const dark = useTheme().resolved === "dark" && darkMessages;
|
||||
const darkRef = useRef(dark);
|
||||
darkRef.current = dark;
|
||||
const mobileRef = useRef(false);
|
||||
mobileRef.current = useIsMobile();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
@@ -64,13 +67,15 @@ export function MessageBody({
|
||||
// Float-accurate content bottom (getBoundingClientRect keeps sub-pixels that
|
||||
// scrollHeight would floor), combined with the integer scroll/offset heights.
|
||||
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(
|
||||
Math.max(
|
||||
rectBottom,
|
||||
de.scrollHeight,
|
||||
de.offsetHeight,
|
||||
body?.scrollHeight ?? 0,
|
||||
body?.offsetHeight ?? 0,
|
||||
(body?.scrollHeight ?? 0) * zoom,
|
||||
(body?.offsetHeight ?? 0) * zoom,
|
||||
),
|
||||
);
|
||||
// If the email overflows horizontally, its bottom scrollbar steals vertical
|
||||
@@ -81,6 +86,26 @@ export function MessageBody({
|
||||
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
|
||||
// "On <date>, <sender> wrote:" attribution (inside the white body), so the reader
|
||||
// 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)
|
||||
}
|
||||
injectQuoteToggle(doc);
|
||||
fitWidth(doc);
|
||||
resize();
|
||||
// Re-measure after the first paint settles (fonts/layout can shift the height
|
||||
// by a hair, which is enough to leave a spurious scrollbar).
|
||||
@@ -171,9 +197,22 @@ export function MessageBody({
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
prepare(doc);
|
||||
fitWidth(doc); // images may have widened it
|
||||
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
|
||||
// empty about:blank document, which does not count).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -130,6 +130,7 @@ export const en = {
|
||||
"mail.selectAll": "Select all",
|
||||
"mail.conversationSelected": "1 conversation selected",
|
||||
"mail.conversationsSelected": "{count} conversations selected",
|
||||
"mail.conversationsSelected_one": "1 conversation selected",
|
||||
"mail.alsoIn": "Also delivered to",
|
||||
"mail.emptyTrash": "Empty trash",
|
||||
"mail.emptySpam": "Empty spam",
|
||||
@@ -201,6 +202,7 @@ export const en = {
|
||||
"mail.hideQuoted": "Hide quoted text",
|
||||
"mail.markReadUnread": "Mark read / unread",
|
||||
"mail.messageCount": "{count} messages",
|
||||
"mail.messageCount_one": "1 message",
|
||||
"mail.downloadAll": "Download all {count} attachments",
|
||||
"mail.downloadZip": "Download zip",
|
||||
"mail.download": "Download",
|
||||
@@ -218,6 +220,7 @@ export const en = {
|
||||
"compose.closePrompt": "Save this message as a draft?",
|
||||
"compose.sent": "Message sent.",
|
||||
"compose.undoIn": "{count} seconds to",
|
||||
"compose.undoIn_one": "1 second to",
|
||||
"compose.undo": "Undo",
|
||||
"compose.sendFailed": "Send failed.",
|
||||
"compose.savingDraft": "Saving draft…",
|
||||
@@ -419,6 +422,7 @@ export const en = {
|
||||
"appPasswords.created": "Created {date}",
|
||||
"appPasswords.lastUsed": "last used {date}",
|
||||
"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.",
|
||||
"settings.thisDevice": "this device",
|
||||
"settings.revoke": "Revoke",
|
||||
@@ -474,6 +478,7 @@ export const en = {
|
||||
"settings.icloudHint":
|
||||
"Use an app-specific password from account.apple.com → Sign-In and Security.",
|
||||
"settings.calendarCount": "{count} calendars",
|
||||
"settings.calendarCount_one": "1 calendar",
|
||||
"settings.removeSubscription": "Remove subscription",
|
||||
"settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?",
|
||||
"settings.deleteCalendar": "Delete calendar",
|
||||
@@ -597,6 +602,7 @@ export const en = {
|
||||
"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.noticeSendingScheduled": "Sending {count} scheduled messages…",
|
||||
"offline.noticeSendingScheduled_one": "Sending 1 scheduled message…",
|
||||
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
|
||||
"compose.sendLater": "Schedule",
|
||||
"compose.savedOnDevice": "Saved on this device",
|
||||
@@ -615,6 +621,8 @@ export const en = {
|
||||
"scheduled.delete": "Delete",
|
||||
"scheduled.statusRetrying":
|
||||
"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.lastError": "Last error: {error}",
|
||||
"scheduled.to": "To",
|
||||
@@ -631,7 +639,9 @@ export const en = {
|
||||
"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.",
|
||||
"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_one": "1 conversation saved · {size}",
|
||||
"offline.settingSaving": "Saving recent mail…",
|
||||
"offline.settingUnsupported":
|
||||
"Not available here: browsers allow offline access only when the app is opened over HTTPS.",
|
||||
|
||||
@@ -35,7 +35,15 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
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 {
|
||||
locale,
|
||||
setLocale: (l) => {
|
||||
|
||||
@@ -630,10 +630,14 @@ function AppPasswordsSetting() {
|
||||
<span className="min-w-0">
|
||||
<span className="break-words">{a.name}</span>
|
||||
<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
|
||||
? t("appPasswords.lastUsed", { date: date(a.last_used_at) })
|
||||
: t("appPasswords.neverUsed")}
|
||||
? ` · ${t(mailHost ? "appPasswords.lastUsedDav" : "appPasswords.lastUsed", { date: date(a.last_used_at) })}`
|
||||
: mailHost
|
||||
? ""
|
||||
: ` · ${t("appPasswords.neverUsed")}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,9 @@ export default defineConfig({
|
||||
target: "http://127.0.0.1:8000",
|
||||
changeOrigin: 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.
|
||||
"/dav": { target: "http://127.0.0.1:8000", changeOrigin: true },
|
||||
|
||||
Reference in New Issue
Block a user