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