commit a5ec0d21cb62e6266ef18967bb2df600aadbc4b9 Author: Lukas Remis Date: Fri Jul 17 23:20:34 2026 +0200 Initial commit: webmail client (M0/M1) Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7579898 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +APP_DOMAIN=dev.webmail.local +DATABASE_URL=postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev +REDIS_URL=redis://127.0.0.1:6380/0 +SEARCH_BACKEND=pg # switch to meili in M2 work +MEILI_URL=http://127.0.0.1:7700 +MEILI_KEY=dev_master_key_change_me +MASTER_KEY=CHANGE_ME_base64_32_bytes # generate: openssl rand -base64 32 +BLOB_ROOT=./var/blobs +UNDO_SECONDS=10 +POLL_INTERVAL=120 +SESSION_TTL_DAYS=30 +MAX_ATTACHMENT_MB=25 +LOG_LEVEL=DEBUG diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42cd1f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Secrets / local env +.env.dev +.env.*.local +gitea-server-credentials.md + +# Local tooling +.claude/ + +# Blob store / runtime data (email content — never commit) +/var/ +/backend/var/ +**/var/blobs/ + +# Python +__pycache__/ +*.py[cod] +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.egg-info/ +.eggs/ +build/ +dist/ + +# Node / frontend +node_modules/ +frontend/dist/ +.vite/ + +# Editor / OS +.DS_Store +.idea/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..90652bc --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +up: ## start services + docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch +down: ## stop services (data kept) + docker compose -f docker/docker-compose.yml down +reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate + docker compose -f docker/docker-compose.yml down -v + rm -rf var/blobs && mkdir -p var/blobs + docker compose -f docker/docker-compose.yml up -d --wait postgres redis meilisearch + cd backend && alembic upgrade head +api: + uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend +worker: + cd backend && arq workers.worker.WorkerSettings +web: + cd frontend && npm run dev +test: + cd backend && pytest -q && cd ../frontend && npx vitest run +mail-test: ## start throwaway IMAP/SMTP server + docker compose -f docker/docker-compose.yml up -d greenmail diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..003e661 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..d7de678 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Webmail backend application package.""" diff --git a/backend/app/accounts/__init__.py b/backend/app/accounts/__init__.py new file mode 100644 index 0000000..2aa4468 --- /dev/null +++ b/backend/app/accounts/__init__.py @@ -0,0 +1 @@ +"""Accounts: autodiscovery, credential testing, account lifecycle (SPEC §6.2).""" diff --git a/backend/app/accounts/discovery.py b/backend/app/accounts/discovery.py new file mode 100644 index 0000000..04b7055 --- /dev/null +++ b/backend/app/accounts/discovery.py @@ -0,0 +1,255 @@ +"""Autodiscovery resolver (SPEC §6.2). + +``discover(email)`` runs the chain — presets, Thunderbird autoconfig, ISPDB, DNS +SRV (RFC 6186), then a live probe — returning on the first success but recording +every step's outcome for wizard UX. All HTTP is SSRF-guarded (§10.6). +""" + +from __future__ import annotations + +import asyncio +import ssl +from pathlib import Path + +import dns.asyncresolver +import dns.exception +import httpx +import yaml +from defusedxml import ElementTree as SafeET + +from app.accounts.ssrf import SsrfError, assert_host_is_public, guarded_get +from app.accounts.types import CandidateConfig, DiscoveryResult, DiscoveryStep, ServerConfig + +_PROBE_TIMEOUT = 5.0 + +_presets_cache: dict[str, CandidateConfig] | None = None + + +# --- presets ----------------------------------------------------------------- + + +def _load_presets() -> dict[str, CandidateConfig]: + global _presets_cache + if _presets_cache is None: + raw = yaml.safe_load((Path(__file__).parent / "presets.yaml").read_text()) + mapping: dict[str, CandidateConfig] = {} + for prov in raw["providers"].values(): + auth = prov.get("auth", "password") + imap = ServerConfig( + prov["imap"]["host"], prov["imap"]["port"], prov["imap"]["tls"], auth + ) + smtp = ServerConfig( + prov["smtp"]["host"], prov["smtp"]["port"], prov["smtp"]["tls"], auth + ) + candidate = CandidateConfig(imap, smtp, "preset", prov.get("oauth_provider")) + for domain in prov["domains"]: + mapping[domain.lower()] = candidate + _presets_cache = mapping + return _presets_cache + + +def domain_of(email: str) -> str: + return email.rsplit("@", 1)[-1].strip().lower() + + +# --- Thunderbird autoconfig / ISPDB XML -------------------------------------- + + +def _socket_type(text: str | None) -> str: + return {"ssl": "ssl", "starttls": "starttls", "plain": "plain"}.get( + (text or "").strip().lower(), "ssl" + ) + + +def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None: + """Parse Thunderbird ``clientConfig`` XML into a CandidateConfig (or None).""" + try: + root = SafeET.fromstring(xml_text) + except Exception: + return None + provider = root.find("emailProvider") + if provider is None: + return None + + def _server(kind: str, server_type: str) -> ServerConfig | None: + for node in provider.findall(kind): + if node.get("type") == server_type: + host = node.findtext("hostname") + port = node.findtext("port") + if host and port and port.isdigit(): + auth = node.findtext("authentication") or "" + is_oauth = "oauth" in auth.lower() + return ServerConfig( + host.strip(), + int(port), + _socket_type(node.findtext("socketType")), + "oauth2" if is_oauth else "password", + ) + return None + + imap = _server("incomingServer", "imap") + smtp = _server("outgoingServer", "smtp") + if imap is None or smtp is None: + return None + return CandidateConfig(imap, smtp, "autoconfig") + + +# --- DNS SRV (RFC 6186) ------------------------------------------------------ + + +async def _srv_lookup(name: str, tls: str) -> ServerConfig | None: + resolver = dns.asyncresolver.Resolver() + resolver.lifetime = 5.0 + try: + answer = await resolver.resolve(name, "SRV") + except (dns.exception.DNSException, OSError): + return None + for record in sorted(answer, key=lambda r: (r.priority, r.weight)): + target = str(record.target).rstrip(".") + if target and target != ".": # "." target => service explicitly unavailable + return ServerConfig(target, record.port, tls) + return None + + +async def _discover_srv(domain: str) -> CandidateConfig | None: + imap = await _srv_lookup(f"_imaps._tcp.{domain}", "ssl") or await _srv_lookup( + f"_imap._tcp.{domain}", "starttls" + ) + smtp = await _srv_lookup(f"_submissions._tcp.{domain}", "ssl") or await _srv_lookup( + f"_submission._tcp.{domain}", "starttls" + ) + if imap and smtp: + return CandidateConfig(imap, smtp, "srv") + return None + + +# --- live probe -------------------------------------------------------------- + + +async def _probe_one(host: str, port: int, tls: str, kind: str) -> ServerConfig | None: + try: + await assert_host_is_public(host) # SSRF: never probe internal hosts + except SsrfError: + return None + try: + if tls == "ssl": + ctx = ssl.create_default_context() + conn = asyncio.open_connection(host, port, ssl=ctx) + else: + conn = asyncio.open_connection(host, port) + reader, writer = await asyncio.wait_for(conn, _PROBE_TIMEOUT) + greeting = await asyncio.wait_for(reader.readline(), _PROBE_TIMEOUT) + writer.close() + except (TimeoutError, OSError, ssl.SSLError): + return None + text = greeting.decode("ascii", "ignore") + if kind == "imap" and text.startswith("* OK"): + return ServerConfig(host, port, tls) + if kind == "smtp" and text.startswith("220"): + return ServerConfig(host, port, tls) + return None + + +async def _first_working(candidates: list[tuple[str, int, str]], kind: str) -> ServerConfig | None: + results = await asyncio.gather( + *(_probe_one(host, port, tls, kind) for host, port, tls in candidates) + ) + for result in results: # candidates are in priority order + if result is not None: + return result + return None + + +async def _discover_probe(domain: str) -> CandidateConfig | None: + imap_hosts = [ + (f"imap.{domain}", 993, "ssl"), + (f"mail.{domain}", 993, "ssl"), + (f"imap.{domain}", 143, "starttls"), + (f"mail.{domain}", 143, "starttls"), + ] + smtp_hosts = [ + (f"smtp.{domain}", 465, "ssl"), + (f"mail.{domain}", 465, "ssl"), + (f"smtp.{domain}", 587, "starttls"), + (f"mail.{domain}", 587, "starttls"), + ] + imap, smtp = await asyncio.gather( + _first_working(imap_hosts, "imap"), _first_working(smtp_hosts, "smtp") + ) + if imap and smtp: + return CandidateConfig(imap, smtp, "probe") + return None + + +# --- orchestration ----------------------------------------------------------- + + +async def _try_http_config(url: str, source: str) -> CandidateConfig | None: + xml_text = await guarded_get(url) + if xml_text is None: + return None + candidate = parse_autoconfig_xml(xml_text) + if candidate is None: + return None + return CandidateConfig(candidate.imap, candidate.smtp, source, candidate.oauth_provider) + + +async def discover(email: str) -> DiscoveryResult: + """Run the autodiscovery chain for ``email`` (SPEC §6.2).""" + result = DiscoveryResult() + domain = domain_of(email) + + # 1. Presets. + presets = _load_presets() + if domain in presets: + result.candidates.append(presets[domain]) + result.steps.append(DiscoveryStep("preset", "found", domain)) + return result + result.steps.append(DiscoveryStep("preset", "not_found", domain)) + + # 2. Autoconfig (autoconfig., then /.well-known). + autoconfig_urls = [ + f"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}", + f"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={email}", + ] + for url in autoconfig_urls: + try: + candidate = await _try_http_config(url, "autoconfig") + except (SsrfError, httpx.HTTPError) as exc: + result.steps.append(DiscoveryStep("autoconfig", "error", str(exc)[:200])) + continue + if candidate is not None: + result.candidates.append(candidate) + result.steps.append(DiscoveryStep("autoconfig", "found", url)) + return result + result.steps.append(DiscoveryStep("autoconfig", "not_found", domain)) + + # 3. ISPDB (Thunderbird's database). + ispdb_url = f"https://autoconfig.thunderbird.net/v1.1/{domain}" + try: + candidate = await _try_http_config(ispdb_url, "ispdb") + if candidate is not None: + result.candidates.append(candidate) + result.steps.append(DiscoveryStep("ispdb", "found", ispdb_url)) + return result + result.steps.append(DiscoveryStep("ispdb", "not_found", domain)) + except (SsrfError, httpx.HTTPError) as exc: + result.steps.append(DiscoveryStep("ispdb", "error", str(exc)[:200])) + + # 4. DNS SRV (RFC 6186). + srv = await _discover_srv(domain) + if srv is not None: + result.candidates.append(srv) + result.steps.append(DiscoveryStep("srv", "found", domain)) + return result + result.steps.append(DiscoveryStep("srv", "not_found", domain)) + + # 5. Live probe. + probe = await _discover_probe(domain) + if probe is not None: + result.candidates.append(probe) + result.steps.append(DiscoveryStep("probe", "found", domain)) + return result + result.steps.append(DiscoveryStep("probe", "not_found", domain)) + + return result diff --git a/backend/app/accounts/presets.yaml b/backend/app/accounts/presets.yaml new file mode 100644 index 0000000..bd78330 --- /dev/null +++ b/backend/app/accounts/presets.yaml @@ -0,0 +1,68 @@ +# Provider presets (config, not code) — SPEC §6.2 step 1. +# Matched by the domain of the email address. Each entry gives IMAP + SMTP +# host/port/tls, the auth kind, and an oauth provider id when applicable. +# tls: "ssl" (implicit TLS) | "starttls"; auth: "password" | "oauth2". + +providers: + google: + domains: [gmail.com, googlemail.com] + imap: { host: imap.gmail.com, port: 993, tls: ssl } + smtp: { host: smtp.gmail.com, port: 465, tls: ssl } + auth: oauth2 + oauth_provider: google + + microsoft: + domains: [outlook.com, hotmail.com, live.com, msn.com] + imap: { host: outlook.office365.com, port: 993, tls: ssl } + smtp: { host: smtp.office365.com, port: 587, tls: starttls } + auth: oauth2 + oauth_provider: microsoft + + office365: + domains: [office365.com] + imap: { host: outlook.office365.com, port: 993, tls: ssl } + smtp: { host: smtp.office365.com, port: 587, tls: starttls } + auth: oauth2 + oauth_provider: microsoft + + yahoo: + domains: [yahoo.com, yahoo.co.uk, ymail.com, rocketmail.com] + imap: { host: imap.mail.yahoo.com, port: 993, tls: ssl } + smtp: { host: smtp.mail.yahoo.com, port: 465, tls: ssl } + auth: password + + icloud: + domains: [icloud.com, me.com, mac.com] + imap: { host: imap.mail.me.com, port: 993, tls: ssl } + smtp: { host: smtp.mail.me.com, port: 587, tls: starttls } + auth: password + + gmx: + domains: [gmx.com, gmx.net, gmx.de] + imap: { host: imap.gmx.com, port: 993, tls: ssl } + smtp: { host: mail.gmx.com, port: 587, tls: starttls } + auth: password + + wp: + domains: [wp.pl] + imap: { host: imap.wp.pl, port: 993, tls: ssl } + smtp: { host: smtp.wp.pl, port: 465, tls: ssl } + auth: password + + onet: + domains: [onet.pl, poczta.onet.pl, op.pl] + imap: { host: imap.poczta.onet.pl, port: 993, tls: ssl } + smtp: { host: smtp.poczta.onet.pl, port: 465, tls: ssl } + auth: password + + o2: + domains: [o2.pl, tlen.pl] + imap: { host: imap.poczta.o2.pl, port: 993, tls: ssl } + smtp: { host: smtp.poczta.o2.pl, port: 465, tls: ssl } + auth: password + + interia: + domains: [interia.pl, interia.eu] + imap: { host: imap.poczta.interia.pl, port: 993, tls: ssl } + smtp: { host: smtp.poczta.interia.pl, port: 465, tls: ssl } + auth: password diff --git a/backend/app/accounts/schemas.py b/backend/app/accounts/schemas.py new file mode 100644 index 0000000..c4a9fc6 --- /dev/null +++ b/backend/app/accounts/schemas.py @@ -0,0 +1,74 @@ +"""Request/response models for the accounts API (SPEC §9.1).""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + + +class ServerConfigIn(BaseModel): + host: str + port: int = Field(ge=1, le=65535) + tls: str = "ssl" # ssl | starttls + auth: str = "password" # password | oauth2 + + +class DiscoverRequest(BaseModel): + email: str + + +class CandidateOut(BaseModel): + imap: ServerConfigIn + smtp: ServerConfigIn + source: str + oauth_provider: str | None = None + + +class DiscoveryStepOut(BaseModel): + method: str + status: str + detail: str = "" + + +class DiscoverResponse(BaseModel): + candidates: list[CandidateOut] + steps: list[DiscoveryStepOut] + + +class TestRequest(BaseModel): + imap: ServerConfigIn + smtp: ServerConfigIn + email: str + username: str | None = None # defaults to the email address + password: str = Field(min_length=1) + + +class TestResponse(BaseModel): + ok: bool = True + + +class CreateAccountRequest(BaseModel): + email: str + display_name: str = "" + color: str = "#888888" + imap: ServerConfigIn + smtp: ServerConfigIn + username: str | None = None + password: str = Field(min_length=1) + + +class UpdateAccountRequest(BaseModel): + display_name: str | None = None + color: str | None = None + + +class AccountDetailOut(BaseModel): + id: uuid.UUID + email: str + display_name: str + color: str + state: str + state_detail: str + imap_config: dict[str, object] + smtp_config: dict[str, object] diff --git a/backend/app/accounts/service.py b/backend/app/accounts/service.py new file mode 100644 index 0000000..3ca5b5f --- /dev/null +++ b/backend/app/accounts/service.py @@ -0,0 +1,99 @@ +"""Account lifecycle: create (encrypt + enqueue sync), delete (cascade + blobs) (SPEC §6.2/§9.1).""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.accounts.schemas import CreateAccountRequest +from app.accounts.types import ServerConfig +from app.indexer import blobstore +from app.models import Account, Attachment, Message +from app.security.crypto import encrypt_secret + + +class AccountExistsError(Exception): + """The user already has an account for this email.""" + + +def _server_dict(cfg: ServerConfig) -> dict[str, object]: + return {"host": cfg.host, "port": cfg.port, "tls": cfg.tls, "auth": cfg.auth} + + +async def create_account( + session: AsyncSession, *, user_id: uuid.UUID, req: CreateAccountRequest +) -> Account: + """Create an account row with encrypted credentials (state=backfilling).""" + imap = ServerConfig(req.imap.host, req.imap.port, req.imap.tls, req.imap.auth) + smtp = ServerConfig(req.smtp.host, req.smtp.port, req.smtp.tls, req.smtp.auth) + account = Account( + user_id=user_id, + email=str(req.email), + display_name=req.display_name, + color=req.color, + imap_config=_server_dict(imap), + smtp_config=_server_dict(smtp), + secret_ct=encrypt_secret(req.password.encode("utf-8")), + discovery_src="manual", + state="backfilling", + ) + session.add(account) + try: + await session.flush() + except IntegrityError as exc: + await session.rollback() + raise AccountExistsError(str(req.email)) from exc + return account + + +async def delete_account( + session: AsyncSession, *, user_id: uuid.UUID, account_id: uuid.UUID +) -> bool: + """Delete an account and its data. Removes blobs no longer referenced (dedup-aware). + + Returns False if the account does not belong to the user. DB rows (mailboxes, + messages, body_parts, attachments) and the Postgres search vector cascade via + FK ``ON DELETE CASCADE``. + """ + account = await session.scalar( + select(Account).where(Account.id == account_id, Account.user_id == user_id) + ) + if account is None: + return False + + # Gather blob digests this account references, before the cascade delete. + msg_shas = set( + await session.scalars(select(Message.blob_sha256).where(Message.account_id == account_id)) + ) + att_shas = set( + await session.scalars( + select(Attachment.blob_sha256) + .join(Message, Message.id == Attachment.message_id) + .where(Message.account_id == account_id) + ) + ) + candidate_shas = msg_shas | att_shas + + await session.execute(delete(Account).where(Account.id == account_id)) + await session.flush() + + # Content-addressed blobs are shared across the user's accounts (dedup): only + # remove a blob when no remaining message/attachment references it. + for sha in candidate_shas: + still_used = await session.scalar( + select(Message.id) + .where(Message.user_id == user_id, Message.blob_sha256 == sha) + .limit(1) + ) + if still_used is None: + still_used = await session.scalar( + select(Attachment.id) + .where(Attachment.user_id == user_id, Attachment.blob_sha256 == sha) + .limit(1) + ) + if still_used is None: + blobstore.remove(user_id, sha) + return True diff --git a/backend/app/accounts/ssrf.py b/backend/app/accounts/ssrf.py new file mode 100644 index 0000000..a0198ea --- /dev/null +++ b/backend/app/accounts/ssrf.py @@ -0,0 +1,89 @@ +"""SSRF-guarded outbound HTTP for autodiscovery (SPEC §10.6). + +All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no +off-domain redirects, and the target host must resolve exclusively to public IPs +(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap +(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1; +a pinned-IP transport can close it later. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket +from urllib.parse import urlsplit + +import httpx + +HTTP_TIMEOUT = 5.0 +MAX_RESPONSE_BYTES = 256 * 1024 + + +class SsrfError(Exception): + """Raised when a URL fails the SSRF policy (bad scheme, private IP, oversize).""" + + +def _is_public_ip(ip: str) -> bool: + addr = ipaddress.ip_address(ip) + return not ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_reserved + or addr.is_unspecified + ) + + +async def _resolve_all(host: str) -> list[str]: + loop = asyncio.get_running_loop() + infos = await loop.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) + return [info[4][0] for info in infos] + + +async def assert_host_is_public(host: str) -> None: + """Resolve ``host`` and reject if any address is non-public (SPEC §10.6).""" + try: + addresses = await _resolve_all(host) + except socket.gaierror as exc: + raise SsrfError(f"cannot resolve {host}") from exc + if not addresses: + raise SsrfError(f"no addresses for {host}") + for ip in addresses: + if not _is_public_ip(ip): + raise SsrfError(f"{host} resolves to non-public address {ip}") + + +async def guarded_get(url: str) -> str | None: + """HTTPS GET with the full SSRF policy applied. + + Returns the body text on 200, or ``None`` when the resource is absent + (any non-200 response). Raises :class:`SsrfError` on a policy violation + (bad scheme, private IP, oversize); lets httpx network errors propagate. + """ + parts = urlsplit(url) + if parts.scheme != "https": + raise SsrfError("only https is permitted") + if not parts.hostname: + raise SsrfError("missing host") + await assert_host_is_public(parts.hostname) + + async with ( + httpx.AsyncClient( + follow_redirects=False, # off-domain redirects are forbidden (§10.6) + timeout=HTTP_TIMEOUT, + headers={"User-Agent": "webmail-autodiscovery/1.0"}, + ) as client, + client.stream("GET", url) as resp, + ): + if resp.status_code != 200: + return None + chunks: list[bytes] = [] + total = 0 + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > MAX_RESPONSE_BYTES: + raise SsrfError("response exceeds 256 KB cap") + chunks.append(chunk) + return b"".join(chunks).decode("utf-8", "replace") diff --git a/backend/app/accounts/testing.py b/backend/app/accounts/testing.py new file mode 100644 index 0000000..969cef1 --- /dev/null +++ b/backend/app/accounts/testing.py @@ -0,0 +1,104 @@ +"""Credential testing for the account wizard (SPEC §6.2 ``test(config, credentials)``). + +Runs a full IMAP LOGIN + LIST and an SMTP EHLO + AUTH (no message sent), mapping +failures to precise user-facing codes. This is a §10.2 decryption-boundary sibling: +it handles plaintext credentials the user just typed, before they are encrypted. +""" + +from __future__ import annotations + +import socket +import ssl + +import aiosmtplib + +from app.accounts.ssrf import SsrfError, assert_host_is_public +from app.accounts.types import ServerConfig +from app.providers.base import ProviderAuthError, ProviderConnectError +from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig + +_SMTP_TIMEOUT = 10.0 + + +class AccountTestError(Exception): + """A credential/connectivity test failed, with a precise ``code`` (SPEC §6.2).""" + + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + + +def _classify_cause(cause: BaseException | None) -> tuple[str, str]: + if isinstance(cause, ssl.SSLError): + return "tls_failed", str(cause) + if isinstance(cause, socket.gaierror): + return "unknown_host", str(cause) + if isinstance(cause, TimeoutError): + return "connect_timeout", "connection timed out" + return "connect_failed", str(cause or "connection failed") + + +async def _assert_public(config: ServerConfig, label: str) -> None: + try: + await assert_host_is_public(config.host) + except SsrfError as exc: + raise AccountTestError("host_not_allowed", f"{label} host is not permitted: {exc}") from exc + + +async def _test_smtp(config: ServerConfig, username: str, password: str) -> None: + smtp = aiosmtplib.SMTP( + hostname=config.host, + port=config.port, + use_tls=config.tls == "ssl", + start_tls=config.tls == "starttls", + timeout=_SMTP_TIMEOUT, + ) + try: + await smtp.connect() + await smtp.login(username, password) + await smtp.quit() + except aiosmtplib.SMTPAuthenticationError as exc: + raise AccountTestError("auth_failed", f"SMTP authentication failed: {exc}") from exc + except aiosmtplib.SMTPConnectError as exc: + raise AccountTestError("connect_failed", f"SMTP connect failed: {exc}") from exc + except ssl.SSLError as exc: + raise AccountTestError("tls_failed", f"SMTP TLS failed: {exc}") from exc + except socket.gaierror as exc: + raise AccountTestError("unknown_host", f"SMTP host not found: {exc}") from exc + except (TimeoutError, aiosmtplib.SMTPTimeoutError) as exc: + raise AccountTestError("connect_timeout", "SMTP connection timed out") from exc + except aiosmtplib.SMTPException as exc: + raise AccountTestError("connect_failed", f"SMTP error: {exc}") from exc + + +async def test_account( + *, imap: ServerConfig, smtp: ServerConfig, username: str, password: str +) -> None: + """Verify IMAP + SMTP credentials. Raises :class:`AccountTestError` on failure.""" + await _assert_public(imap, "IMAP") + await _assert_public(smtp, "SMTP") + + provider = ImapSmtpProvider( + ProviderConfig( + imap_host=imap.host, + imap_port=imap.port, + imap_tls=imap.tls, + smtp_host=smtp.host, + smtp_port=smtp.port, + smtp_tls=smtp.tls, + username=username, + password=password, + ) + ) + try: + await provider.verify() # IMAP LOGIN + LIST + except ProviderAuthError as exc: + raise AccountTestError("auth_failed", exc.detail) from exc + except ProviderConnectError as exc: + code, detail = _classify_cause(exc.__cause__) + raise AccountTestError(code, detail) from exc + finally: + await provider.close() + + await _test_smtp(smtp, username, password) diff --git a/backend/app/accounts/types.py b/backend/app/accounts/types.py new file mode 100644 index 0000000..d51bd91 --- /dev/null +++ b/backend/app/accounts/types.py @@ -0,0 +1,34 @@ +"""Autodiscovery result types (SPEC §6.2).""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class ServerConfig: + host: str + port: int + tls: str # "ssl" | "starttls" + auth: str = "password" # "password" | "oauth2" + + +@dataclass(frozen=True) +class CandidateConfig: + imap: ServerConfig + smtp: ServerConfig + source: str # preset | autoconfig | ispdb | srv | probe | manual + oauth_provider: str | None = None + + +@dataclass(frozen=True) +class DiscoveryStep: + method: str # preset | autoconfig | ispdb | srv | probe + status: str # found | not_found | error + detail: str = "" + + +@dataclass +class DiscoveryResult: + candidates: list[CandidateConfig] = field(default_factory=list) + steps: list[DiscoveryStep] = field(default_factory=list) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..492a530 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,39 @@ +"""REST API routers (SPEC §9.1). One module per route group.""" + +from __future__ import annotations + +from fastapi import FastAPI + + +def register_routers(app: FastAPI) -> None: + """Mount all API routers under the ``/api`` prefix. + + Routers are added milestone by milestone; imports are local so the app + factory stays importable while modules are still being built out. + """ + from app.api.accounts import router as accounts_router + from app.api.actions import router as actions_router + from app.api.compose import router as compose_router + from app.api.mailboxes import router as mailboxes_router + from app.api.messages import router as messages_router + from app.api.recipients import router as recipients_router + from app.api.search import router as search_router + from app.api.settings import router as settings_router + from app.api.threads import router as threads_router + from app.identity.routes import router as auth_router + from app.ws import router as ws_router + + for router in ( + auth_router, + accounts_router, + threads_router, + messages_router, + search_router, + actions_router, + compose_router, + recipients_router, + settings_router, + mailboxes_router, + ): + app.include_router(router, prefix="/api") + app.include_router(ws_router, prefix="/api") diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py new file mode 100644 index 0000000..f8304f2 --- /dev/null +++ b/backend/app/api/accounts.py @@ -0,0 +1,176 @@ +"""Accounts API (SPEC §9.1): discover, test, create, list, update, delete.""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.accounts.discovery import discover +from app.accounts.schemas import ( + AccountDetailOut, + CandidateOut, + CreateAccountRequest, + DiscoverRequest, + DiscoverResponse, + DiscoveryStepOut, + ServerConfigIn, + TestRequest, + TestResponse, + UpdateAccountRequest, +) +from app.accounts.service import AccountExistsError, create_account, delete_account +from app.accounts.testing import AccountTestError, test_account +from app.accounts.types import CandidateConfig, ServerConfig +from app.api.errors import raise_api_error +from app.api.schemas import AccountOut +from app.db import get_session +from app.identity.deps import CurrentUser, require_csrf +from app.models import Account +from app.queue import get_arq_pool +from app.redis_client import get_redis + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + +_DISCOVER_LIMIT = 10 # per minute per user (SPEC §10.5) +_DISCOVER_WINDOW = 60 + + +async def _rate_limit_discover(user_id: uuid.UUID) -> None: + redis = get_redis() + key = f"rl:discover:{user_id}" + count = await redis.incr(key) + if count == 1: + await redis.expire(key, _DISCOVER_WINDOW) + if count > _DISCOVER_LIMIT: + raise_api_error(429, "rate_limited", "too many discovery requests; slow down") + + +def _server_in(cfg: ServerConfig) -> ServerConfigIn: + return ServerConfigIn(host=cfg.host, port=cfg.port, tls=cfg.tls, auth=cfg.auth) + + +def _candidate_out(c: CandidateConfig) -> CandidateOut: + return CandidateOut( + imap=_server_in(c.imap), + smtp=_server_in(c.smtp), + source=c.source, + oauth_provider=c.oauth_provider, + ) + + +def _to_server_config(cfg: ServerConfigIn) -> ServerConfig: + return ServerConfig(cfg.host, cfg.port, cfg.tls, cfg.auth) + + +@router.get("", response_model=list[AccountOut]) +async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]: + accounts = await db.scalars( + select(Account).where(Account.user_id == user.id).order_by(Account.created_at) + ) + return [ + AccountOut( + id=a.id, email=a.email, display_name=a.display_name, color=a.color, state=a.state + ) + for a in accounts + ] + + +@router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)]) +async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse: + await _rate_limit_discover(user.id) + result = await discover(str(body.email)) + return DiscoverResponse( + candidates=[_candidate_out(c) for c in result.candidates], + steps=[ + DiscoveryStepOut(method=s.method, status=s.status, detail=s.detail) + for s in result.steps + ], + ) + + +@router.post("/test", response_model=TestResponse, dependencies=[Depends(require_csrf)]) +async def test_account_credentials(body: TestRequest, _user: CurrentUser) -> TestResponse: + try: + await test_account( + imap=_to_server_config(body.imap), + smtp=_to_server_config(body.smtp), + username=body.username or str(body.email), + password=body.password, + ) + except AccountTestError as exc: + raise_api_error(400, exc.code, exc.detail) + return TestResponse(ok=True) + + +@router.post("", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)]) +async def create_new_account( + body: CreateAccountRequest, user: CurrentUser, db: DbSession +) -> AccountDetailOut: + # Validate credentials before persisting (also the §10.2 decryption sibling). + try: + await test_account( + imap=_to_server_config(body.imap), + smtp=_to_server_config(body.smtp), + username=body.username or str(body.email), + password=body.password, + ) + except AccountTestError as exc: + raise_api_error(400, exc.code, exc.detail) + + try: + account = await create_account(db, user_id=user.id, req=body) + except AccountExistsError: + raise_api_error(409, "account_exists", "an account for this email already exists") + await db.commit() + + pool = await get_arq_pool() + await pool.enqueue_job("sync_bootstrap_account", str(account.id)) + + return _detail(account) + + +@router.patch( + "/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)] +) +async def update_account( + account_id: uuid.UUID, body: UpdateAccountRequest, user: CurrentUser, db: DbSession +) -> AccountDetailOut: + account = await db.scalar( + select(Account).where(Account.id == account_id, Account.user_id == user.id) + ) + if account is None: + raise_api_error(404, "not_found", "account not found") + if body.display_name is not None: + account.display_name = body.display_name + if body.color is not None: + account.color = body.color + await db.commit() + return _detail(account) + + +@router.delete("/{account_id}", dependencies=[Depends(require_csrf)]) +async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + ok = await delete_account(db, user_id=user.id, account_id=account_id) + if not ok: + raise_api_error(404, "not_found", "account not found") + await db.commit() + return {"status": "deleted"} + + +def _detail(account: Account) -> AccountDetailOut: + return AccountDetailOut( + id=account.id, + email=account.email, + display_name=account.display_name, + color=account.color, + state=account.state, + state_detail=account.state_detail, + imap_config=account.imap_config, + smtp_config=account.smtp_config, + ) diff --git a/backend/app/api/actions.py b/backend/app/api/actions.py new file mode 100644 index 0000000..277c0fb --- /dev/null +++ b/backend/app/api/actions.py @@ -0,0 +1,159 @@ +"""Flag / move / archive endpoints (SPEC §9.1). DB-first, then push via worker.""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import raise_api_error +from app.api.schemas import FlagChangeRequest, MoveRequest +from app.db import get_session +from app.events import get_event_bus +from app.identity.deps import CurrentUser, require_csrf +from app.models import Mailbox, Message, Thread +from app.queue import get_arq_pool +from app.sync.actions import apply_flags_to_messages, sanitize_flags + +router = APIRouter(tags=["mail"], dependencies=[Depends(require_csrf)]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + + +async def _push_and_publish( + user_id: uuid.UUID, changed: list[Message], add: frozenset[str], remove: frozenset[str] +) -> None: + pool = await get_arq_pool() + bus = get_event_bus() + for message in changed: + await pool.enqueue_job("push_flags_job", str(message.id), sorted(add), sorted(remove)) + await bus.publish( + "flags.changed", + { + "user_id": str(user_id), + "message_id": str(message.id), + "flags": list(message.flags), + }, + ) + + +def _would_change(message: Message, add: frozenset[str], remove: frozenset[str]) -> bool: + current = set(message.flags) + return bool((add - current) or (remove & current)) + + +@router.post("/messages/{message_id}/flags") +async def message_flags( + message_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession +) -> dict[str, object]: + message = await db.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user.id) + ) + if message is None: + raise_api_error(404, "not_found", "message not found") + add, remove = sanitize_flags(body.add), sanitize_flags(body.remove) + changed = [message] if _would_change(message, add, remove) else [] + await apply_flags_to_messages(db, [message], add, remove) + await db.commit() + await _push_and_publish(user.id, changed, add, remove) + return {"id": str(message.id), "flags": list(message.flags)} + + +@router.post("/threads/{thread_id}/flags") +async def thread_flags( + thread_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession +) -> dict[str, object]: + thread = await db.scalar( + select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id) + ) + if thread is None: + raise_api_error(404, "not_found", "thread not found") + messages = list( + await db.scalars( + select(Message).where(Message.thread_id == thread_id, Message.user_id == user.id) + ) + ) + add, remove = sanitize_flags(body.add), sanitize_flags(body.remove) + changed = [m for m in messages if _would_change(m, add, remove)] + await apply_flags_to_messages(db, messages, add, remove) + await db.commit() + await _push_and_publish(user.id, changed, add, remove) + await get_event_bus().publish( + "thread.updated", {"user_id": str(user.id), "thread_id": str(thread_id)} + ) + return {"thread_id": str(thread_id), "changed": len(changed)} + + +async def _move_thread( + user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str +) -> None: + thread = await db.scalar( + select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id) + ) + if thread is None: + raise_api_error(404, "not_found", "thread not found") + pool = await get_arq_pool() + await pool.enqueue_job("move_thread_to_folder_job", str(thread_id), special) + + +@router.post("/threads/{thread_id}/archive") +async def archive_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + await _move_thread(user.id, thread_id, db, "archive") + return {"status": "queued"} + + +@router.post("/threads/{thread_id}/trash") +async def trash_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + await _move_thread(user.id, thread_id, db, "trash") + return {"status": "queued"} + + +@router.post("/threads/{thread_id}/restore") +async def restore_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + """Move a thread back to the Inbox (from Archive or Trash).""" + await _move_thread(user.id, thread_id, db, "inbox") + return {"status": "queued"} + + +@router.post("/threads/{thread_id}/delete") +async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + """Permanently delete a thread (server EXPUNGE + DB rows).""" + thread = await db.scalar( + select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id) + ) + if thread is None: + raise_api_error(404, "not_found", "thread not found") + pool = await get_arq_pool() + await pool.enqueue_job("delete_thread_job", str(thread_id)) + return {"status": "queued"} + + +@router.post("/messages/{message_id}/move") +async def move_message( + message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession +) -> dict[str, object]: + message = await db.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user.id) + ) + if message is None: + raise_api_error(404, "not_found", "message not found") + dst = await db.scalar( + select(Mailbox).where( + Mailbox.id == body.mailbox_id, + Mailbox.user_id == user.id, + Mailbox.account_id == message.account_id, # moves stay within an account + ) + ) + if dst is None: + raise_api_error(400, "bad_mailbox", "target mailbox not found in this account") + + src_mailbox_id = message.mailbox_id + message.mailbox_id = dst.id # optimistic + await db.commit() + + pool = await get_arq_pool() + await pool.enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id)) + return {"id": str(message.id), "mailbox_id": str(dst.id)} diff --git a/backend/app/api/compose.py b/backend/app/api/compose.py new file mode 100644 index 0000000..dbcbdef --- /dev/null +++ b/backend/app/api/compose.py @@ -0,0 +1,163 @@ +"""Compose endpoints: drafts + send + undo (SPEC §9.1).""" + +from __future__ import annotations + +import uuid +from datetime import timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Query, UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import raise_api_error +from app.api.schemas import AttachmentOut +from app.config import get_settings +from app.db import get_session +from app.identity.deps import CurrentUser, require_csrf +from app.indexer import blobstore +from app.models import ComposeAttachment, Draft +from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse +from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft +from app.queue import get_arq_pool + +router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + +MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024 # 25 MB per file (compose Attach limit) + + +def _att_out(att: ComposeAttachment) -> AttachmentOut: + return AttachmentOut( + id=att.id, filename=att.filename, mime_type=att.mime_type, size_bytes=att.size_bytes + ) + + +@router.post("/attachments", response_model=AttachmentOut) +async def upload_attachment( + user: CurrentUser, db: DbSession, file: UploadFile = File(...) +) -> AttachmentOut: + """Store a file for an outgoing message (≤ 25 MB) and return its metadata.""" + data = await file.read() + if not data: + raise_api_error(400, "empty_file", "the file is empty") + if len(data) > MAX_ATTACHMENT_BYTES: + raise_api_error(413, "too_large", "attachment exceeds the 25 MB limit") + sha = blobstore.store(user.id, data) + att = ComposeAttachment( + user_id=user.id, + filename=file.filename or "attachment", + mime_type=file.content_type or "application/octet-stream", + size_bytes=len(data), + blob_sha256=sha, + ) + db.add(att) + await db.commit() + return _att_out(att) + + +@router.get("/attachments", response_model=list[AttachmentOut]) +async def list_compose_attachments( + user: CurrentUser, db: DbSession, ids: str = Query("") +) -> list[AttachmentOut]: + """Resolve metadata for the given compose-attachment ids (reopening a draft).""" + id_list: list[uuid.UUID] = [] + for value in ids.split(","): + value = value.strip() + if not value: + continue + try: + id_list.append(uuid.UUID(value)) + except ValueError: + continue + if not id_list: + return [] + rows = await db.scalars( + select(ComposeAttachment).where( + ComposeAttachment.user_id == user.id, ComposeAttachment.id.in_(id_list) + ) + ) + by_id = {r.id: r for r in rows} + return [_att_out(by_id[i]) for i in id_list if i in by_id] + + +def _draft_out(draft: Draft) -> DraftOut: + return DraftOut( + id=draft.id, + account_id=draft.account_id, + thread_id=draft.thread_id, + reply_to_message_id=draft.reply_to_message_id, + kind=draft.kind, + payload=draft.payload, + updated_at=draft.updated_at.isoformat(), + ) + + +@router.get("/drafts", response_model=list[DraftOut]) +async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]: + drafts = await db.scalars( + select(Draft).where(Draft.user_id == user.id).order_by(Draft.updated_at.desc()) + ) + return [_draft_out(d) for d in drafts] + + +@router.post("/drafts", response_model=DraftOut) +async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut: + try: + draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=None) + except DraftError as exc: + raise_api_error(400, "draft_error", str(exc)) + await db.commit() + return _draft_out(draft) + + +@router.put("/drafts/{draft_id}", response_model=DraftOut) +async def update_draft( + draft_id: uuid.UUID, body: DraftIn, user: CurrentUser, db: DbSession +) -> DraftOut: + try: + draft = await upsert_draft(db, user_id=user.id, body=body, draft_id=draft_id) + except DraftError as exc: + code = 404 if "not found" in str(exc) else 400 + raise_api_error(code, "draft_error", str(exc)) + await db.commit() + return _draft_out(draft) + + +@router.delete("/drafts/{draft_id}") +async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id)) + if draft is None: + raise_api_error(404, "not_found", "draft not found") + await db.delete(draft) + await db.commit() + return {"status": "deleted"} + + +@router.post("/send", response_model=SendResponse) +async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendResponse: + draft = await db.scalar( + select(Draft).where(Draft.id == body.draft_id, Draft.user_id == user.id) + ) + if draft is None: + raise_api_error(404, "not_found", "draft not found") + outbox = await create_outbox(db, user_id=user.id, draft=draft) + await db.commit() + + pool = await get_arq_pool() + await pool.enqueue_job( + "send_job", + str(outbox.id), + _defer_by=timedelta(seconds=get_settings().undo_seconds), + ) + return SendResponse(outbox_id=outbox.id, undo_until=outbox.send_after.isoformat()) + + +@router.post("/send/{outbox_id}/undo") +async def undo_send(outbox_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + ok = await cancel_outbox(db, user_id=user.id, outbox_id=outbox_id) + await db.commit() + if not ok: + raise_api_error(409, "undo_too_late", "the message has already left the undo window") + return {"status": "cancelled"} diff --git a/backend/app/api/cursors.py b/backend/app/api/cursors.py new file mode 100644 index 0000000..4ef0937 --- /dev/null +++ b/backend/app/api/cursors.py @@ -0,0 +1,23 @@ +"""Keyset cursor helpers for thread listings (SPEC §9.1: cursor = last_message_at,id).""" + +from __future__ import annotations + +import base64 +import datetime as dt +import uuid + + +def encode_cursor(last_message_at: dt.datetime, thread_id: uuid.UUID) -> str: + raw = f"{last_message_at.isoformat()}|{thread_id}" + return base64.urlsafe_b64encode(raw.encode()).decode() + + +def decode_cursor(cursor: str | None) -> tuple[dt.datetime, uuid.UUID] | None: + if not cursor: + return None + try: + raw = base64.urlsafe_b64decode(cursor.encode()).decode() + date_str, id_str = raw.split("|", 1) + return dt.datetime.fromisoformat(date_str), uuid.UUID(id_str) + except (ValueError, TypeError): + return None diff --git a/backend/app/api/errors.py b/backend/app/api/errors.py new file mode 100644 index 0000000..13ae5b1 --- /dev/null +++ b/backend/app/api/errors.py @@ -0,0 +1,35 @@ +"""Consistent API error shape ``{error:{code,message}}`` (SPEC §9.1).""" + +from __future__ import annotations + +from typing import NoReturn + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + + +def api_error(status_code: int, code: str, message: str) -> HTTPException: + """Build an :class:`HTTPException` carrying the ``{code,message}`` detail.""" + return HTTPException(status_code=status_code, detail={"code": code, "message": message}) + + +def raise_api_error(status_code: int, code: str, message: str) -> NoReturn: + """Raise :func:`api_error` (typed as ``NoReturn`` for control-flow clarity).""" + raise api_error(status_code, code, message) + + +async def _handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: + detail = exc.detail + if isinstance(detail, dict) and "code" in detail: + body = {"error": detail} + else: + body = {"error": {"code": "http_error", "message": str(detail)}} + headers = getattr(exc, "headers", None) + return JSONResponse(status_code=exc.status_code, content=body, headers=headers) + + +def register_error_handlers(app: FastAPI) -> None: + """Install the error-shape handler for all HTTP exceptions.""" + app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type] + app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type] diff --git a/backend/app/api/mailboxes.py b/backend/app/api/mailboxes.py new file mode 100644 index 0000000..fe75138 --- /dev/null +++ b/backend/app/api/mailboxes.py @@ -0,0 +1,37 @@ +"""Mailbox (folder) listing (SPEC §9.3 folder views, archive action).""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.identity.deps import CurrentUser +from app.models import Mailbox + +router = APIRouter(prefix="/mailboxes", tags=["mail"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + + +class MailboxOut(BaseModel): + id: uuid.UUID + account_id: uuid.UUID + path: str + special_use: str | None + + +@router.get("", response_model=list[MailboxOut]) +async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]: + rows = await db.scalars( + select(Mailbox).where(Mailbox.user_id == user.id).order_by(Mailbox.account_id, Mailbox.path) + ) + return [ + MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use) + for m in rows + ] diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py new file mode 100644 index 0000000..05b9ef5 --- /dev/null +++ b/backend/app/api/messages.py @@ -0,0 +1,231 @@ +"""Message body/attachment endpoints (SPEC §9.1 Mail).""" + +from __future__ import annotations + +import html as html_lib +import io +import uuid +import zipfile +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import HTMLResponse, Response +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import raise_api_error +from app.api.schemas import AttachmentOut +from app.api.settings import add_allowed_image_sender, allowed_image_senders +from app.db import get_session +from app.identity.deps import CurrentUser, require_csrf +from app.indexer import blobstore +from app.indexer.mime import parse_message +from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html +from app.models import Attachment, BodyPart, Message + +router = APIRouter(prefix="/messages", tags=["mail"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + +# Content types safe to serve inline (SPEC §10.8); everything else is forced to +# an attachment download with a neutral type. +_INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"} + +# Base styles injected around the sanitized fragment so email content renders on a +# readable light canvas with images that fit the pane (the iframe is sandboxed). +_DOC_TEMPLATE = """ + +{body}""" + + +def _wrap(fragment: str) -> str: + return _DOC_TEMPLATE.format(body=fragment) + + +async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message: + msg = await db.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user_id) + ) + if msg is None: + raise_api_error(404, "not_found", "message not found") + return msg + + +def _sender_email(message: Message) -> str: + return message.from_addr[0].get("email", "").lower() if message.from_addr else "" + + +@router.get("/{message_id}/html", response_class=HTMLResponse) +async def message_html(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> HTMLResponse: + """Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8). + + Re-sanitizes from the raw blob when the sender is image-approved, or when the + stored copy predates the current sanitizer version (SPEC §10.4). + """ + message = await _owned_message(db, user.id, message_id) + allow_remote = _sender_email(message) in allowed_image_senders(user) + + parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id))) + html_part = next((p for p in parts if p.kind == "html_sanitized"), None) + + stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION + if html_part is not None and not allow_remote and not stale: + return HTMLResponse(content=_wrap(html_part.content)) + + # 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 allow_remote or stale: + try: + raw = blobstore.load(user.id, message.blob_sha256) + except FileNotFoundError: + raw = None + if raw is not None: + parsed = parse_message(raw) + if parsed.html_body is not None: + result = sanitize_html( + parsed.html_body, message.id, allow_remote_images=allow_remote + ) + if html_part is not None and not allow_remote: + html_part.content = result.html # persist the upgraded sanitization + html_part.sanitizer_v = result.sanitizer_v + await db.commit() + return HTMLResponse(content=_wrap(result.html)) + + if html_part is not None: + return HTMLResponse(content=_wrap(html_part.content)) + text_part = next((p for p in parts if p.kind == "text"), None) + body = html_lib.escape(text_part.content) if text_part else "" + return HTMLResponse(content=_wrap(f"
{body}
")) + + +@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)]) +async def allow_remote_images( + message_id: uuid.UUID, user: CurrentUser, db: DbSession +) -> dict[str, str]: + """Add the message's sender to the per-sender image allowlist (SPEC §9.4).""" + message = await _owned_message(db, user.id, message_id) + sender = _sender_email(message) + if not sender: + raise_api_error(400, "no_sender", "message has no sender address") + add_allowed_image_sender(user, sender) + await db.commit() + return {"status": "ok", "sender": sender} + + +@router.get("/{message_id}/cid/{content_id}") +async def message_cid( + message_id: uuid.UUID, content_id: str, user: CurrentUser, db: DbSession +) -> Response: + """Serve an inline (cid:) attachment image referenced by the sanitized HTML.""" + msg = await _owned_message(db, user.id, message_id) + att = await db.scalar( + select(Attachment).where( + Attachment.message_id == message_id, Attachment.content_id == content_id + ) + ) + if att is None: + raise_api_error(404, "not_found", "inline part not found") + data = blobstore.load(msg.user_id, att.blob_sha256) + media_type = att.mime_type if att.mime_type in _INLINE_SAFE else "application/octet-stream" + return Response(content=data, media_type=media_type) + + +async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]: + """Downloadable (non-inline) attachments for a message, in a stable order.""" + rows = await db.scalars( + select(Attachment) + .where(Attachment.message_id == message_id, Attachment.content_id.is_(None)) + .order_by(Attachment.filename) + ) + return list(rows) + + +@router.get("/{message_id}/attachments", response_model=list[AttachmentOut]) +async def list_attachments( + message_id: uuid.UUID, user: CurrentUser, db: DbSession +) -> list[AttachmentOut]: + """List a message's downloadable attachments (inline cid parts excluded).""" + await _owned_message(db, user.id, message_id) + return [ + AttachmentOut(id=a.id, filename=a.filename, mime_type=a.mime_type, size_bytes=a.size_bytes) + for a in await _real_attachments(db, message_id) + ] + + +@router.get("/{message_id}/attachments.zip") +async def download_attachments_zip( + message_id: uuid.UUID, + user: CurrentUser, + db: DbSession, + ids: str | None = Query(None, description="comma-separated attachment ids; all if omitted"), +) -> Response: + """Bundle a message's attachments into a single ``Attachments.zip`` download.""" + msg = await _owned_message(db, user.id, message_id) + wanted: set[uuid.UUID] | None = None + if ids: + try: + wanted = {uuid.UUID(v) for v in ids.split(",") if v.strip()} + except ValueError: + raise_api_error(400, "bad_ids", "invalid attachment id list") + attachments = [ + a for a in await _real_attachments(db, message_id) if wanted is None or a.id in wanted + ] + if not attachments: + raise_api_error(404, "not_found", "no attachments to download") + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + seen: dict[str, int] = {} + for att in attachments: + name = att.filename or "attachment" + # De-dupe identical filenames within the archive (name, name-2, …). + if name in seen: + seen[name] += 1 + stem, dot, ext = name.rpartition(".") + name = f"{stem}-{seen[name]}{dot}{ext}" if dot else f"{name}-{seen[name]}" + else: + seen[name] = 1 + zf.writestr(name, blobstore.load(msg.user_id, att.blob_sha256)) + + return Response( + content=buffer.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="Attachments.zip"'}, + ) + + +@router.get("/{message_id}/attachments/{attachment_id}") +async def message_attachment( + message_id: uuid.UUID, + attachment_id: uuid.UUID, + user: CurrentUser, + db: DbSession, + download: bool = Query(False), +) -> Response: + """Download an attachment (inline for safe types unless ``download`` forces it).""" + msg = await _owned_message(db, user.id, message_id) + att = await db.scalar( + select(Attachment).where( + Attachment.id == attachment_id, Attachment.message_id == message_id + ) + ) + if att is None: + raise_api_error(404, "not_found", "attachment not found") + data = blobstore.load(msg.user_id, att.blob_sha256) + inline = att.mime_type in _INLINE_SAFE and not download + media_type = att.mime_type if inline else "application/octet-stream" + disposition = "inline" if inline else "attachment" + return Response( + content=data, + media_type=media_type, + headers={"Content-Disposition": f'{disposition}; filename="{att.filename}"'}, + ) diff --git a/backend/app/api/recipients.py b/backend/app/api/recipients.py new file mode 100644 index 0000000..7f45a2a --- /dev/null +++ b/backend/app/api/recipients.py @@ -0,0 +1,52 @@ +"""Recipient autocomplete from known_recipients (SPEC §9.1, §4). + +Prefix match on email or name, ranked by ``times_used`` decayed by recency. The +composer merges these with plugin capability providers client-side (M3). +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.identity.deps import CurrentUser +from app.models import KnownRecipient + +router = APIRouter(prefix="/recipients", tags=["compose"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + + +class Suggestion(BaseModel): + name: str + email: str + + +@router.get("/suggest", response_model=list[Suggestion]) +async def suggest( + user: CurrentUser, db: DbSession, q: str = Query("", max_length=200) +) -> list[Suggestion]: + term = q.strip() + if not term: + return [] + like = f"{term}%" + # Recency-decayed score: times_used / (1 + age_in_days). Ranks frequent and + # recent correspondents ahead of stale ones (SPEC §9.1). + age_days = func.extract("epoch", func.now() - KnownRecipient.last_used_at) / 86400.0 + score = KnownRecipient.times_used / (1.0 + age_days) + stmt = ( + select(KnownRecipient.name, KnownRecipient.email) + .where( + KnownRecipient.user_id == user.id, + KnownRecipient.email.ilike(like) | KnownRecipient.name.ilike(like), + ) + .order_by(score.desc()) + .limit(10) + ) + rows = (await db.execute(stmt)).all() + return [Suggestion(name=row.name, email=row.email) for row in rows] diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..1e90cd7 --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,91 @@ +"""Shared API response models (SPEC §9.1).""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from pydantic import BaseModel + + +class Address(BaseModel): + name: str = "" + email: str = "" + + +class ThreadSummary(BaseModel): + id: uuid.UUID + subject: str + snippet: str + participants: list[Address] + account_ids: list[uuid.UUID] + message_count: int + unread_count: int + has_attachments: bool + pinned: bool + last_message_at: dt.datetime + + +class ThreadListOut(BaseModel): + threads: list[ThreadSummary] + cursor: str | None = None + + +class AttachmentOut(BaseModel): + id: uuid.UUID + filename: str + mime_type: str + size_bytes: int + + +class MessageEnvelope(BaseModel): + id: uuid.UUID + account_id: uuid.UUID + mailbox_id: uuid.UUID + thread_id: uuid.UUID | None + subject: str + from_addr: list[Address] + to_addr: list[Address] + cc_addr: list[Address] + date: dt.datetime + flags: list[str] + is_unread: bool + is_starred: bool + has_attachments: bool + has_remote_images: bool + snippet: str + + +class ThreadDetail(BaseModel): + id: uuid.UUID + subject: str + order: str # "asc" | "desc" + pinned: bool + messages: list[MessageEnvelope] # always ASC (SPEC §9.4) + + +class FlagChangeRequest(BaseModel): + add: list[str] = [] + remove: list[str] = [] + + +class MoveRequest(BaseModel): + mailbox_id: uuid.UUID + + +class AccountOut(BaseModel): + id: uuid.UUID + email: str + display_name: str + color: str + state: str + + +class SearchThreadHit(BaseModel): + thread: ThreadSummary + highlights: dict[str, str] + + +class SearchOut(BaseModel): + threads: list[SearchThreadHit] + cursor: str | None = None diff --git a/backend/app/api/search.py b/backend/app/api/search.py new file mode 100644 index 0000000..b9c1f74 --- /dev/null +++ b/backend/app/api/search.py @@ -0,0 +1,64 @@ +"""Search endpoint (SPEC §9.1 Search, §8). Results grouped by thread (FR-28).""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.schemas import SearchOut, SearchThreadHit +from app.api.threads import _summary +from app.db import get_session +from app.identity.deps import CurrentUser +from app.models import Thread +from app.search.factory import get_search_backend +from app.search.queryparser import to_search_query + +router = APIRouter(prefix="/search", tags=["search"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + + +@router.get("", response_model=SearchOut) +async def search( + user: CurrentUser, + db: DbSession, + q: str = Query(""), + cursor: str | None = None, + limit: int = Query(50, ge=1, le=200), +) -> SearchOut: + if not q.strip(): + return SearchOut(threads=[], cursor=None) + + backend = get_search_backend() + query = to_search_query(q, user.id) + hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor) + + # Group hits by thread, preserving order and keeping the first highlight. + order: list[uuid.UUID] = [] + highlights: dict[uuid.UUID, dict[str, str]] = {} + for hit in hits: + if hit.thread_id not in highlights: + order.append(hit.thread_id) + highlights[hit.thread_id] = hit.highlights + elif hit.highlights and not highlights[hit.thread_id]: + highlights[hit.thread_id] = hit.highlights + + if not order: + return SearchOut(threads=[], cursor=next_cursor) + + threads = { + t.id: t + for t in await db.scalars( + select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id) + ) + } + results = [ + SearchThreadHit(thread=_summary(threads[tid]), highlights=highlights.get(tid, {})) + for tid in order + if tid in threads + ] + return SearchOut(threads=results, cursor=next_cursor) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..62383e9 --- /dev/null +++ b/backend/app/api/settings.py @@ -0,0 +1,71 @@ +"""User settings + per-sender image allowlist (SPEC §9.1 Settings, §9.4).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_session +from app.identity.deps import CurrentUser, require_csrf +from app.models import User + +router = APIRouter(prefix="/settings", tags=["settings"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + +_ALLOWED_ORDER = {"asc", "desc"} + + +class SettingsOut(BaseModel): + thread_order: str = "asc" + tz: str = "UTC" + allowed_image_senders: list[str] = [] + + +class SettingsPatch(BaseModel): + thread_order: str | None = None + tz: str | None = None + + +def allowed_image_senders(user: User) -> set[str]: + value = user.settings.get("allowed_image_senders", []) + if isinstance(value, list): + return {str(e).lower() for e in value} + return set() + + +def add_allowed_image_sender(user: User, email: str) -> None: + current = sorted(allowed_image_senders(user)) + if email.lower() not in current: + current.append(email.lower()) + # Reassign the whole dict so SQLAlchemy detects the JSONB change. + user.settings = {**user.settings, "allowed_image_senders": current} + + +def _settings_out(user: User) -> SettingsOut: + s = user.settings + return SettingsOut( + thread_order=str(s.get("thread_order", "asc")), + tz=str(s.get("tz", "UTC")), + allowed_image_senders=sorted(allowed_image_senders(user)), + ) + + +@router.get("", response_model=SettingsOut) +async def get_settings_endpoint(user: CurrentUser) -> SettingsOut: + return _settings_out(user) + + +@router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)]) +async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut: + updates = dict(user.settings) + if body.thread_order is not None and body.thread_order in _ALLOWED_ORDER: + updates["thread_order"] = body.thread_order + if body.tz is not None: + updates["tz"] = body.tz + user.settings = updates + await db.commit() + return _settings_out(user) diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py new file mode 100644 index 0000000..b4c1c6b --- /dev/null +++ b/backend/app/api/threads.py @@ -0,0 +1,160 @@ +"""Thread listing and detail endpoints (SPEC §9.1 Mail).""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import literal, select, tuple_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.cursors import decode_cursor, encode_cursor +from app.api.errors import raise_api_error +from app.api.schemas import ( + Address, + MessageEnvelope, + ThreadDetail, + ThreadListOut, + ThreadSummary, +) +from app.db import get_session +from app.identity.deps import CurrentUser +from app.models import Mailbox, Message, Thread, User + +router = APIRouter(prefix="/threads", tags=["mail"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + + +def _addresses(raw: list[dict[str, str]]) -> list[Address]: + return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw] + + +def _summary(thread: Thread) -> ThreadSummary: + return ThreadSummary( + id=thread.id, + subject=thread.subject_norm or "(no subject)", + snippet=thread.snippet, + participants=_addresses(thread.participants), + account_ids=list(thread.account_ids), + message_count=thread.message_count, + unread_count=thread.unread_count, + has_attachments=thread.has_attachments, + pinned=thread.pinned, + last_message_at=thread.last_message_at, + ) + + +def _envelope(msg: Message) -> MessageEnvelope: + return MessageEnvelope( + id=msg.id, + account_id=msg.account_id, + mailbox_id=msg.mailbox_id, + thread_id=msg.thread_id, + subject=msg.subject, + from_addr=_addresses(msg.from_addr), + to_addr=_addresses(msg.to_addr), + cc_addr=_addresses(msg.cc_addr), + date=msg.date_hdr or msg.internal_date, + flags=list(msg.flags), + is_unread="\\Seen" not in msg.flags, + is_starred="\\Flagged" in msg.flags, + has_attachments=msg.has_attachments, + has_remote_images=msg.has_remote_images, + snippet=msg.snippet, + ) + + +@router.get("", response_model=ThreadListOut) +async def list_threads( + user: CurrentUser, + db: DbSession, + view: str = Query("unified"), + cursor: str | None = None, + limit: int = Query(50, ge=1, le=200), +) -> ThreadListOut: + stmt = select(Thread).where(Thread.user_id == user.id) + + if view.startswith("account:"): + # A single account's inbox: threads with a message in THIS account's + # inbox mailbox. Keeps the per-account view consistent with the unified + # Inbox (archived/trashed/sent threads don't leak in). + try: + account_id = uuid.UUID(view.split(":", 1)[1]) + except ValueError: + raise_api_error(400, "bad_view", "invalid account id in view") + in_account_inbox = ( + select(Message.thread_id) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where( + Message.user_id == user.id, + Message.account_id == account_id, + Mailbox.special_use == "inbox", + ) + ) + stmt = stmt.where(Thread.id.in_(in_account_inbox)) + elif view.startswith("folder:"): + # Threads with at least one message in a mailbox of this special_use, + # across all of the user's accounts (unified per-folder view). + special = view.split(":", 1)[1] + in_folder = ( + select(Message.thread_id) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where(Message.user_id == user.id, Mailbox.special_use == special) + ) + stmt = stmt.where(Thread.id.in_(in_folder)) + elif view != "unified": + raise_api_error(400, "bad_view", "unsupported view") + + decoded = decode_cursor(cursor) + if decoded is not None: + cur_date, cur_id = decoded + stmt = stmt.where( + tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id)) + ) + stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1) + + threads = list(await db.scalars(stmt)) + has_more = len(threads) > limit + threads = threads[:limit] + next_cursor = ( + encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None + ) + return ThreadListOut(threads=[_summary(t) for t in threads], cursor=next_cursor) + + +async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread: + thread = await db.scalar( + select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id) + ) + if thread is None: + raise_api_error(404, "not_found", "thread not found") + return thread + + +@router.get("/{thread_id}", response_model=ThreadDetail) +async def get_thread( + thread_id: uuid.UUID, + user: CurrentUser, + db: DbSession, + sort: str = Query("asc"), +) -> ThreadDetail: + thread = await _resolve_thread(db, user, thread_id) + # Messages are always returned ASC (oldest first); the client reverses for + # display per the requested order (invariant I-5 / §9.4). + messages = list( + await db.scalars( + select(Message) + .where(Message.thread_id == thread_id, Message.user_id == user.id) + .order_by(Message.internal_date.asc(), Message.id.asc()) + ) + ) + order = "desc" if sort == "desc" else "asc" + return ThreadDetail( + id=thread.id, + subject=thread.subject_norm or "(no subject)", + order=order, + pinned=thread.pinned, + messages=[_envelope(m) for m in messages], + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..49f5574 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,46 @@ +"""Application configuration. + +SPEC §14: the complete env list is read exactly once here (pydantic-settings). +No ``os.environ`` access is permitted anywhere else in the codebase. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """All runtime configuration, sourced from the environment (SPEC §14).""" + + model_config = SettingsConfigDict(env_file=None, extra="ignore") + + app_domain: str = Field(alias="APP_DOMAIN") + database_url: str = Field(alias="DATABASE_URL") + redis_url: str = Field(alias="REDIS_URL") + master_key: str = Field(alias="MASTER_KEY") # 32 bytes, base64 + + blob_root: str = Field(default="./var/blobs", alias="BLOB_ROOT") + + search_backend: str = Field(default="pg", alias="SEARCH_BACKEND") + meili_url: str = Field(default="http://127.0.0.1:7700", alias="MEILI_URL") + meili_key: str = Field(default="", alias="MEILI_KEY") + + undo_seconds: int = Field(default=10, alias="UNDO_SECONDS") + poll_interval: int = Field(default=120, alias="POLL_INTERVAL") + session_ttl_days: int = Field(default=30, alias="SESSION_TTL_DAYS") + max_attachment_mb: int = Field(default=25, alias="MAX_ATTACHMENT_MB") + log_level: str = Field(default="INFO", alias="LOG_LEVEL") + + oauth_google_client_id: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_ID") + oauth_google_client_secret: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_SECRET") + oauth_ms_client_id: str = Field(default="", alias="OAUTH_MS_CLIENT_ID") + oauth_ms_client_secret: str = Field(default="", alias="OAUTH_MS_CLIENT_SECRET") + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the process-wide settings singleton.""" + return Settings() # values are sourced from the environment diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..9204cfc --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,72 @@ +"""Async SQLAlchemy engine/session management (SPEC §2 ``app/db.py``).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import get_settings + +_engine: AsyncEngine | None = None +_sessionmaker: async_sessionmaker[AsyncSession] | None = None + + +class Base(DeclarativeBase): + """Declarative base for all ORM models.""" + + +def get_engine() -> AsyncEngine: + """Return (creating on first use) the process-wide async engine.""" + global _engine + if _engine is None: + _engine = create_async_engine( + get_settings().database_url, + pool_pre_ping=True, + future=True, + ) + return _engine + + +def get_sessionmaker() -> async_sessionmaker[AsyncSession]: + """Return the process-wide session factory.""" + global _sessionmaker + if _sessionmaker is None: + _sessionmaker = async_sessionmaker( + get_engine(), expire_on_commit=False, class_=AsyncSession + ) + return _sessionmaker + + +@asynccontextmanager +async def session_scope() -> AsyncIterator[AsyncSession]: + """Transactional session context manager for workers and scripts.""" + async with get_sessionmaker()() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def get_session() -> AsyncIterator[AsyncSession]: + """FastAPI dependency yielding a request-scoped session.""" + async with get_sessionmaker()() as session: + yield session + + +async def dispose_engine() -> None: + """Dispose the engine (used on app shutdown).""" + global _engine, _sessionmaker + if _engine is not None: + await _engine.dispose() + _engine = None + _sessionmaker = None diff --git a/backend/app/events.py b/backend/app/events.py new file mode 100644 index 0000000..f080534 --- /dev/null +++ b/backend/app/events.py @@ -0,0 +1,90 @@ +# INTERFACE VERSION: 1 +"""EventBus — Redis pub/sub fan-out + in-process durable consumers (SPEC §5.3, §8.3). + +``publish`` fans out to the Redis channel ``evt:{event}`` (the WS hub relays these +to the owning user) and invokes any in-process subscribers. Payloads always +include ``user_id`` (SPEC §5.3). Only the event names in SPEC §8.3 are allowed. + +NOTE(spec): the frozen contract also enqueues a durable arq job ``on_{event}`` for +out-of-process consumers (plugins). In M0 the only durable consumers (search index, +thread denormalization) run inline in the indexer per FR-30, so cross-process arq +fan-out is deferred to the plugin milestone (M3). The signatures here are frozen. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Protocol + +from app.redis_client import get_redis + +logger = logging.getLogger(__name__) + +Handler = Callable[[dict[str, object]], Awaitable[None]] + +# The complete, allowed event catalog (SPEC §8.3). Plugin events (plugin.{name}.*) +# are validated separately by the plugin loader (M3). +ALLOWED_EVENTS: frozenset[str] = frozenset( + { + "message.new", + "message.changed", + "message.deleted", + "message.sent", + "flags.changed", + "thread.updated", + "sync.progress", + "account.state_changed", + "draft.saved", + "outbox.state_changed", + } +) + + +def channel_for(event: str) -> str: + """Return the Redis pub/sub channel name for an event.""" + return f"evt:{event}" + + +class EventBusProtocol(Protocol): + async def publish(self, event: str, payload: dict[str, object]) -> None: ... + def subscribe(self, event: str, handler: Handler) -> None: ... + + +class EventBus: + """Concrete EventBus backed by Redis pub/sub with in-process subscribers.""" + + def __init__(self) -> None: + self._handlers: dict[str, list[Handler]] = {} + + def subscribe(self, event: str, handler: Handler) -> None: + """Register an in-process durable consumer for ``event``.""" + self._handlers.setdefault(event, []).append(handler) + + async def publish(self, event: str, payload: dict[str, object]) -> None: + """Fan out ``event`` to Redis subscribers and in-process handlers.""" + if event not in ALLOWED_EVENTS and not event.startswith("plugin."): + raise ValueError(f"unknown event {event!r} (see SPEC §8.3)") + if "user_id" not in payload: + raise ValueError("event payload must include user_id (SPEC §5.3)") + + message = json.dumps({"event": event, "payload": payload}, default=str) + await get_redis().publish(channel_for(event), message) + + for handler in self._handlers.get(event, ()): + try: + await handler(payload) + except Exception: # a failing consumer must not break the publisher + logger.exception("event handler failed for %s", event) + + +_bus: EventBus | None = None + + +def get_event_bus() -> EventBus: + """Return the process-wide EventBus singleton.""" + global _bus + if _bus is None: + _bus = EventBus() + return _bus diff --git a/backend/app/identity/__init__.py b/backend/app/identity/__init__.py new file mode 100644 index 0000000..edfeba0 --- /dev/null +++ b/backend/app/identity/__init__.py @@ -0,0 +1 @@ +"""Identity: app accounts, auth, sessions, handles (SPEC §6.1).""" diff --git a/backend/app/identity/cookies.py b/backend/app/identity/cookies.py new file mode 100644 index 0000000..5a84682 --- /dev/null +++ b/backend/app/identity/cookies.py @@ -0,0 +1,76 @@ +"""Session and CSRF cookie handling (SPEC §6.1, §10.3). + +Over HTTPS we emit the spec-mandated ``__Host-session`` prefix with ``Secure``. +Over plain HTTP (dev only) we fall back to a plain ``session`` name without +``Secure`` so login works behind the Vite HTTP proxy. See docs/open-questions.md +OQ-3. ``SameSite=Lax``, ``HttpOnly`` (session), ``Path=/`` are always applied. +""" + +from __future__ import annotations + +import secrets + +from fastapi import Request, Response + +SESSION_COOKIE_SECURE = "__Host-session" +SESSION_COOKIE_INSECURE = "session" +CSRF_COOKIE_SECURE = "__Host-csrf" +CSRF_COOKIE_INSECURE = "csrf" +CSRF_HEADER = "X-CSRF" + + +def _is_secure(request: Request) -> bool: + return request.url.scheme == "https" + + +def session_cookie_name(request: Request) -> str: + """Return the session cookie name appropriate to the request scheme.""" + return SESSION_COOKIE_SECURE if _is_secure(request) else SESSION_COOKIE_INSECURE + + +def csrf_cookie_name(request: Request) -> str: + """Return the CSRF cookie name appropriate to the request scheme.""" + return CSRF_COOKIE_SECURE if _is_secure(request) else CSRF_COOKIE_INSECURE + + +def read_session_token(request: Request) -> str: + """Read the session token from either cookie name (whichever is present).""" + return request.cookies.get(SESSION_COOKIE_SECURE) or request.cookies.get( + SESSION_COOKIE_INSECURE, "" + ) + + +def new_csrf_token() -> str: + """Return a fresh CSRF token for the double-submit pattern.""" + return secrets.token_urlsafe(32) + + +def set_auth_cookies( + request: Request, response: Response, session_token: str, csrf_token: str, max_age: int +) -> None: + """Set the session (HttpOnly) and CSRF (JS-readable) cookies.""" + secure = _is_secure(request) + response.set_cookie( + session_cookie_name(request), + session_token, + max_age=max_age, + httponly=True, + secure=secure, + samesite="lax", + path="/", + ) + response.set_cookie( + csrf_cookie_name(request), + csrf_token, + max_age=max_age, + httponly=False, # double-submit: client JS echoes it in the X-CSRF header + secure=secure, + samesite="lax", + path="/", + ) + + +def clear_auth_cookies(request: Request, response: Response) -> None: + """Clear both auth cookies (logout).""" + for name in (session_cookie_name(request), csrf_cookie_name(request)): + response.delete_cookie(name, path="/") diff --git a/backend/app/identity/deps.py b/backend/app/identity/deps.py new file mode 100644 index 0000000..2ea3fd9 --- /dev/null +++ b/backend/app/identity/deps.py @@ -0,0 +1,50 @@ +"""Auth dependencies: current user and CSRF enforcement (SPEC §6.1, §10.3).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import raise_api_error +from app.db import get_session +from app.identity.cookies import CSRF_HEADER, csrf_cookie_name, read_session_token +from app.identity.sessions import Session, resolve_session +from app.models import User + + +async def current_session(request: Request) -> Session: + """Resolve the caller's session or raise 401.""" + token = read_session_token(request) + session = await resolve_session(token) + if session is None: + raise_api_error(401, "not_authenticated", "authentication required") + return session + + +async def current_user( + session: Annotated[Session, Depends(current_session)], + db: Annotated[AsyncSession, Depends(get_session)], +) -> User: + """Load the authenticated :class:`User` (I-7: scoped by the session's user_id).""" + user = await db.scalar(select(User).where(User.id == session.user_id)) + if user is None: + # Session references a deleted user; treat as unauthenticated. + raise_api_error(401, "not_authenticated", "authentication required") + return user + + +async def require_csrf(request: Request) -> None: + """Enforce the double-submit CSRF token on mutating requests (SPEC §10.3).""" + if request.method in ("GET", "HEAD", "OPTIONS"): + return + header = request.headers.get(CSRF_HEADER, "") + cookie = request.cookies.get(csrf_cookie_name(request), "") + if not header or not cookie or header != cookie: + raise_api_error(403, "csrf_failed", "missing or invalid CSRF token") + + +CurrentUser = Annotated[User, Depends(current_user)] +CurrentSession = Annotated[Session, Depends(current_session)] diff --git a/backend/app/identity/handles.py b/backend/app/identity/handles.py new file mode 100644 index 0000000..637a891 --- /dev/null +++ b/backend/app/identity/handles.py @@ -0,0 +1,56 @@ +"""Handle (localpart) validation per SPEC §4. + +Rules (enforced in code, mail-grade from day one): + - localpart: 3-30 chars, characters ``[a-z0-9._-]`` + - no leading/trailing dot, no double dot + - case-insensitive (normalize to lowercase) + - not in the reserved list (:mod:`app.identity.reserved`) + +The stored handle is the full form ``localpart@{APP_DOMAIN}``. +""" + +from __future__ import annotations + +import re + +from app.config import get_settings +from app.identity.reserved import is_reserved + +_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$") +_MIN_LEN = 3 +_MAX_LEN = 30 + + +class InvalidHandle(ValueError): + """Raised when a proposed localpart violates the SPEC §4 rules.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def normalize_localpart(localpart: str) -> str: + """Lowercase and strip surrounding whitespace (case-insensitive handles).""" + return localpart.strip().lower() + + +def validate_localpart(localpart: str) -> str: + """Validate and normalize ``localpart``; return it or raise :class:`InvalidHandle`.""" + lp = normalize_localpart(localpart) + if not (_MIN_LEN <= len(lp) <= _MAX_LEN): + raise InvalidHandle("length", f"handle must be {_MIN_LEN}-{_MAX_LEN} characters") + if not _LOCALPART_RE.match(lp): + raise InvalidHandle("charset", "handle may contain only a-z, 0-9, '.', '_', '-'") + if lp.startswith(".") or lp.endswith("."): + raise InvalidHandle("dot_edge", "handle may not start or end with a dot") + if ".." in lp: + raise InvalidHandle("double_dot", "handle may not contain consecutive dots") + if is_reserved(lp): + raise InvalidHandle("reserved", "that handle is reserved") + return lp + + +def full_handle(localpart: str) -> str: + """Return the full handle ``localpart@{APP_DOMAIN}`` for a validated localpart.""" + return f"{validate_localpart(localpart)}@{get_settings().app_domain}" diff --git a/backend/app/identity/passwords.py b/backend/app/identity/passwords.py new file mode 100644 index 0000000..4ade7eb --- /dev/null +++ b/backend/app/identity/passwords.py @@ -0,0 +1,34 @@ +"""Argon2id password hashing (SPEC §6.1, §10.1). + +Params are fixed: ``time_cost=3``, ``memory_cost=64 MiB``, ``parallelism=4``. +No other password hashing is permitted anywhere (SPEC §10.1). +""" + +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import VerificationError, VerifyMismatchError + +_hasher = PasswordHasher( + time_cost=3, + memory_cost=64 * 1024, # KiB -> 64 MiB + parallelism=4, +) + + +def hash_password(password: str) -> str: + """Return an argon2id hash for ``password``.""" + return _hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + """Constant-time verify ``password`` against ``password_hash``.""" + try: + return _hasher.verify(password_hash, password) + except (VerifyMismatchError, VerificationError): + return False + + +def needs_rehash(password_hash: str) -> bool: + """Return True if ``password_hash`` was made with outdated parameters.""" + return _hasher.check_needs_rehash(password_hash) diff --git a/backend/app/identity/reserved.py b/backend/app/identity/reserved.py new file mode 100644 index 0000000..d3b2a6e --- /dev/null +++ b/backend/app/identity/reserved.py @@ -0,0 +1,50 @@ +"""Reserved localparts that may not be registered (SPEC §4/§6.1). + +Mail-grade from day one: role addresses (RFC 2142) plus common admin/system +handles. Comparison is case-insensitive; keep entries lowercase. +""" + +from __future__ import annotations + +RESERVED_LOCALPARTS: frozenset[str] = frozenset( + { + # RFC 2142 role addresses + "postmaster", + "abuse", + "hostmaster", + "webmaster", + "usenet", + "news", + "www", + "uucp", + "ftp", + "noc", + "security", + "mailer-daemon", + # Common system/admin handles + "admin", + "administrator", + "root", + "noreply", + "no-reply", + "donotreply", + "do-not-reply", + "support", + "help", + "info", + "contact", + "sales", + "billing", + "system", + "daemon", + "nobody", + "null", + "void", + "test", + } +) + + +def is_reserved(localpart: str) -> bool: + """Return True if ``localpart`` is reserved (case-insensitive).""" + return localpart.lower() in RESERVED_LOCALPARTS diff --git a/backend/app/identity/reset.py b/backend/app/identity/reset.py new file mode 100644 index 0000000..07dac71 --- /dev/null +++ b/backend/app/identity/reset.py @@ -0,0 +1,43 @@ +"""Password reset tokens (SPEC §6.1). + +Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a +recovery email on file. Actual delivery of the token to the recovery address +requires an app-level mailer (not part of M0/M1); until then the token is logged +server-side. See docs/open-questions.md. +""" + +from __future__ import annotations + +import logging +import secrets +import uuid + +from app.redis_client import aw, get_redis + +logger = logging.getLogger(__name__) + +_PREFIX = "pwreset:" +_TTL = 30 * 60 # 30 minutes + + +async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str: + """Create a single-use reset token bound to ``user_id`` and return it.""" + token = secrets.token_urlsafe(32) + await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL) + # TODO(spec): deliver via the recovery address once an app mailer exists. + logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email) + return token + + +async def consume_token(token: str) -> uuid.UUID | None: + """Atomically consume a reset token, returning the user_id (or None).""" + if not token: + return None + redis = get_redis() + raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically + if raw is None: + return None + try: + return uuid.UUID(raw) + except ValueError: + return None diff --git a/backend/app/identity/routes.py b/backend/app/identity/routes.py new file mode 100644 index 0000000..18dc8ef --- /dev/null +++ b/backend/app/identity/routes.py @@ -0,0 +1,213 @@ +"""Auth API routes (SPEC §9.1): register, login (2FA-aware), logout, me, +TOTP setup/enable, session list/revoke, password reset.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.errors import raise_api_error +from app.config import get_settings +from app.db import get_session +from app.identity import totp as totp_mod +from app.identity.cookies import ( + clear_auth_cookies, + new_csrf_token, + set_auth_cookies, +) +from app.identity.deps import CurrentSession, CurrentUser, require_csrf +from app.identity.passwords import hash_password, verify_password +from app.identity.reset import consume_token, issue_token +from app.identity.schemas import ( + LoginRequest, + MessageOut, + PasswordResetIn, + PasswordResetRequestIn, + RegisterRequest, + SessionOut, + TotpDisableRequest, + TotpEnableRequest, + TotpSetupResponse, + UserOut, +) +from app.identity.service import ( + RateLimited, + RegistrationError, + TotpInvalid, + TotpRequired, + authenticate, + register_user, + resolve_handle, +) +from app.identity.sessions import ( + create_session, + destroy_all_sessions, + destroy_session, + list_sessions, + revoke_session, +) +from app.models import User + +router = APIRouter(prefix="/auth", tags=["auth"]) + +DbSession = Annotated[AsyncSession, Depends(get_session)] + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +async def _establish_session(request: Request, response: Response, user: User) -> None: + token = await create_session(user.id, request.headers.get("user-agent", "")) + csrf = new_csrf_token() + max_age = get_settings().session_ttl_days * 24 * 3600 + set_auth_cookies(request, response, token, csrf, max_age) + + +def _user_out(user: User) -> UserOut: + return UserOut( + id=user.id, + handle=user.handle, + recovery_email=user.recovery_email, + settings=user.settings, + totp_enabled=user.totp_secret is not None, + ) + + +@router.post("/register", response_model=UserOut) +async def register( + body: RegisterRequest, request: Request, response: Response, db: DbSession +) -> UserOut: + try: + user = await register_user( + db, + localpart=body.localpart, + password=body.password, + recovery_email=body.recovery_email, + ) + except RegistrationError as exc: + status = 409 if exc.code == "handle_taken" else 400 + raise_api_error(status, exc.code, exc.message) + await db.commit() + await _establish_session(request, response, user) + return _user_out(user) + + +@router.post("/login", response_model=UserOut) +async def login(body: LoginRequest, request: Request, response: Response, db: DbSession) -> UserOut: + try: + user = await authenticate( + db, + handle_input=body.handle, + password=body.password, + ip=_client_ip(request), + totp=body.totp, + ) + except RateLimited as exc: + response.headers["Retry-After"] = str(exc.retry_after) + raise_api_error(429, "rate_limited", "too many attempts; try again later") + except TotpRequired: + raise_api_error(401, "totp_required", "a 2FA code is required") + except TotpInvalid: + raise_api_error(401, "totp_invalid", "incorrect 2FA code") + if user is None: + raise_api_error(401, "invalid_credentials", "incorrect handle or password") + await _establish_session(request, response, user) + return _user_out(user) + + +@router.post("/logout", response_model=MessageOut, dependencies=[Depends(require_csrf)]) +async def logout(request: Request, response: Response, session: CurrentSession) -> MessageOut: + await destroy_session(session.token) + clear_auth_cookies(request, response) + return MessageOut() + + +@router.get("/me", response_model=UserOut) +async def me(user: CurrentUser) -> UserOut: + return _user_out(user) + + +# --- Two-factor authentication (SPEC §6.1) ---------------------------------- + + +@router.post("/totp/setup", response_model=TotpSetupResponse, dependencies=[Depends(require_csrf)]) +async def totp_setup(user: CurrentUser) -> TotpSetupResponse: + if user.totp_secret is not None: + raise_api_error(409, "totp_already_enabled", "2FA is already enabled") + secret = await totp_mod.start_setup(user.id) + return TotpSetupResponse( + secret=secret, provisioning_uri=totp_mod.provisioning_uri(secret, user.handle) + ) + + +@router.post("/totp/enable", response_model=MessageOut, dependencies=[Depends(require_csrf)]) +async def totp_enable(body: TotpEnableRequest, user: CurrentUser, db: DbSession) -> MessageOut: + secret = await totp_mod.get_pending(user.id) + if secret is None: + raise_api_error(400, "totp_no_setup", "start TOTP setup first") + if not totp_mod.verify(secret, body.code): + raise_api_error(400, "totp_invalid", "incorrect 2FA code") + user.totp_secret = totp_mod.encrypt_totp_secret(secret) + await db.commit() + await totp_mod.clear_pending(user.id) + return MessageOut() + + +@router.post("/totp/disable", response_model=MessageOut, dependencies=[Depends(require_csrf)]) +async def totp_disable(body: TotpDisableRequest, user: CurrentUser, db: DbSession) -> MessageOut: + # Re-authenticate with the password before turning off 2FA. + if not verify_password(user.password_hash, body.password): + raise_api_error(401, "invalid_credentials", "incorrect password") + user.totp_secret = None + await db.commit() + return MessageOut() + + +# --- Session management (SPEC §9.1) ----------------------------------------- + + +@router.get("/sessions", response_model=list[SessionOut]) +async def sessions(user: CurrentUser, session: CurrentSession) -> list[SessionOut]: + infos = await list_sessions(user.id, session.token) + return [ + SessionOut(sid=i.sid, created=i.created.isoformat(), ua=i.ua, current=i.current) + for i in infos + ] + + +@router.delete("/sessions/{sid}", response_model=MessageOut, dependencies=[Depends(require_csrf)]) +async def revoke(sid: str, user: CurrentUser) -> MessageOut: + if not await revoke_session(user.id, sid): + raise_api_error(404, "not_found", "session not found") + return MessageOut() + + +# --- Password reset (SPEC §6.1) --------------------------------------------- + + +@router.post("/password/reset-request", response_model=MessageOut) +async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut: + # Always return ok (do not leak whether the handle exists / has recovery mail). + handle = resolve_handle(body.handle) + user = await db.scalar(select(User).where(User.handle == handle)) + if user is not None and user.recovery_email: + await issue_token(user.id, user.recovery_email) + return MessageOut() + + +@router.post("/password/reset", response_model=MessageOut) +async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut: + user_id = await consume_token(body.token) + if user_id is None: + raise_api_error(400, "invalid_token", "reset token is invalid or expired") + user = await db.get(User, user_id) + if user is None: + raise_api_error(400, "invalid_token", "reset token is invalid or expired") + user.password_hash = hash_password(body.password) + await db.commit() + await destroy_all_sessions(user_id) # force re-login everywhere after reset + return MessageOut() diff --git a/backend/app/identity/schemas.py b/backend/app/identity/schemas.py new file mode 100644 index 0000000..0cd6d11 --- /dev/null +++ b/backend/app/identity/schemas.py @@ -0,0 +1,61 @@ +"""Request/response models for the auth API (SPEC §9.1).""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + + +class RegisterRequest(BaseModel): + localpart: str = Field(min_length=3, max_length=30) + password: str = Field(min_length=8, max_length=1024) + recovery_email: str | None = None + + +class LoginRequest(BaseModel): + # Accepts either a bare localpart or the full ``localpart@domain`` handle. + handle: str = Field(min_length=1, max_length=320) + password: str = Field(min_length=1, max_length=1024) + totp: str | None = None # required when the account has 2FA enabled + + +class TotpSetupResponse(BaseModel): + secret: str # base32, for manual entry + provisioning_uri: str # otpauth:// URI to render as a QR code + + +class TotpEnableRequest(BaseModel): + code: str = Field(min_length=6, max_length=10) + + +class TotpDisableRequest(BaseModel): + password: str = Field(min_length=1, max_length=1024) + + +class SessionOut(BaseModel): + sid: str + created: str + ua: str + current: bool + + +class PasswordResetRequestIn(BaseModel): + handle: str = Field(min_length=1, max_length=320) + + +class PasswordResetIn(BaseModel): + token: str = Field(min_length=1) + password: str = Field(min_length=8, max_length=1024) + + +class UserOut(BaseModel): + id: uuid.UUID + handle: str + recovery_email: str | None + settings: dict[str, object] + totp_enabled: bool = False + + +class MessageOut(BaseModel): + status: str = "ok" diff --git a/backend/app/identity/service.py b/backend/app/identity/service.py new file mode 100644 index 0000000..187023f --- /dev/null +++ b/backend/app/identity/service.py @@ -0,0 +1,124 @@ +"""Registration, authentication, and login rate limiting (SPEC §6.1).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.identity.handles import InvalidHandle, full_handle, normalize_localpart +from app.identity.passwords import hash_password, verify_password +from app.models import User +from app.redis_client import get_redis + +# SPEC §6.1: 10 attempts / 15 min per handle AND per IP. +_RL_MAX = 10 +_RL_WINDOW = 15 * 60 + + +class RegistrationError(Exception): + """Registration failed for a user-actionable reason.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +@dataclass(frozen=True) +class RateLimited(Exception): + """Raised when the login rate limit is exceeded.""" + + retry_after: int + + +class TotpRequired(Exception): + """Password verified but the account has 2FA enabled and no code was given.""" + + +class TotpInvalid(Exception): + """Password verified but the supplied TOTP code was wrong.""" + + +def resolve_handle(handle_input: str) -> str: + """Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form.""" + text = handle_input.strip().lower() + if "@" in text: + return text + return f"{normalize_localpart(text)}@{get_settings().app_domain}" + + +async def register_user( + db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None +) -> User: + """Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.""" + try: + handle = full_handle(localpart) + except InvalidHandle as exc: + raise RegistrationError(exc.code, exc.message) from exc + + user = User( + handle=handle, + password_hash=hash_password(password), + recovery_email=(recovery_email.strip().lower() if recovery_email else None), + ) + db.add(user) + try: + await db.flush() + except IntegrityError as exc: + await db.rollback() + raise RegistrationError("handle_taken", "that handle is already registered") from exc + return user + + +async def _bump_rate_limit(key: str) -> int: + redis = get_redis() + count = await redis.incr(key) + if count == 1: + await redis.expire(key, _RL_WINDOW) + return int(count) + + +async def _check_rate_limit(handle: str, ip: str) -> None: + for key in (f"rl:login:handle:{handle}", f"rl:login:ip:{ip}"): + count = await _bump_rate_limit(key) + if count > _RL_MAX: + ttl = await get_redis().ttl(key) + raise RateLimited(retry_after=max(ttl, 1)) + + +async def authenticate( + db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None +) -> User | None: + """Verify credentials (constant-time). Returns the user or None; may rate-limit. + + When the account has TOTP enabled, raises :class:`TotpRequired` (no code given) + or :class:`TotpInvalid` (wrong code) after the password check passes. + """ + handle = resolve_handle(handle_input) + await _check_rate_limit(handle, ip) + + user = await db.scalar(select(User).where(User.handle == handle)) + # Always run a verify to keep timing uniform whether or not the user exists. + stored_hash = user.password_hash if user else _DUMMY_HASH + ok = verify_password(stored_hash, password) + if user is None or not ok: + return None + + if user.totp_secret is not None: + from app.identity.totp import decrypt_totp_secret + from app.identity.totp import verify as verify_totp + + if not totp: + raise TotpRequired + if not verify_totp(decrypt_totp_secret(user.totp_secret), totp): + raise TotpInvalid + return user + + +# A precomputed hash so authentication of a non-existent handle still spends +# argon2 time (mitigates user-enumeration via timing, SPEC §6.1 constant-time). +_DUMMY_HASH = hash_password("x" * 24) diff --git a/backend/app/identity/sessions.py b/backend/app/identity/sessions.py new file mode 100644 index 0000000..085dfe8 --- /dev/null +++ b/backend/app/identity/sessions.py @@ -0,0 +1,141 @@ +"""Server-side sessions in Redis (SPEC §4 "Sessions", §9.1 session management). + +``sess:{token}`` -> JSON ``{user_id, sid, created, ua}`` with a sliding TTL of +``SESSION_TTL_DAYS`` days. A per-user index ``usess:{user_id}`` (hash sid->token) +lets us list and revoke a user's sessions without exposing the raw token, which is +the session credential itself. Tokens are opaque and high-entropy. +""" + +from __future__ import annotations + +import json +import secrets +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime + +from app.config import get_settings +from app.redis_client import aw, get_redis + +_PREFIX = "sess:" +_INDEX = "usess:" +_TOKEN_BYTES = 32 + + +@dataclass(frozen=True) +class Session: + token: str + user_id: uuid.UUID + sid: str # public session id (safe to expose; not the token) + created: datetime + ua: str + + +@dataclass(frozen=True) +class SessionInfo: + sid: str + created: datetime + ua: str + current: bool + + +def _ttl_seconds() -> int: + return get_settings().session_ttl_days * 24 * 3600 + + +def _index_key(user_id: uuid.UUID | str) -> str: + return f"{_INDEX}{user_id}" + + +async def create_session(user_id: uuid.UUID, user_agent: str) -> str: + """Create a session for ``user_id`` and return its opaque token.""" + token = secrets.token_urlsafe(_TOKEN_BYTES) + sid = secrets.token_hex(8) + payload = { + "user_id": str(user_id), + "sid": sid, + "created": datetime.now(UTC).isoformat(), + "ua": user_agent[:256], + } + redis = get_redis() + ttl = _ttl_seconds() + await redis.set(_PREFIX + token, json.dumps(payload), ex=ttl) + await aw(redis.hset(_index_key(user_id), sid, token)) + await redis.expire(_index_key(user_id), ttl) + return token + + +async def resolve_session(token: str) -> Session | None: + """Resolve ``token`` to a :class:`Session`, refreshing its sliding TTL.""" + if not token: + return None + redis = get_redis() + raw = await redis.get(_PREFIX + token) + if raw is None: + return None + ttl = _ttl_seconds() + data = json.loads(raw) + await redis.expire(_PREFIX + token, ttl) # sliding + await redis.expire(_index_key(data["user_id"]), ttl) + return Session( + token=token, + user_id=uuid.UUID(data["user_id"]), + sid=data.get("sid", ""), + created=datetime.fromisoformat(data["created"]), + ua=data.get("ua", ""), + ) + + +async def destroy_session(token: str) -> None: + """Delete a session (logout).""" + if not token: + return + redis = get_redis() + raw = await redis.get(_PREFIX + token) + await redis.delete(_PREFIX + token) + if raw is not None: + data = json.loads(raw) + await aw(redis.hdel(_index_key(data["user_id"]), data.get("sid", ""))) + + +async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionInfo]: + """List a user's active sessions, pruning any whose token has expired.""" + redis = get_redis() + mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id))) + out: list[SessionInfo] = [] + for sid, token in mapping.items(): + raw = await redis.get(_PREFIX + token) + if raw is None: + await aw(redis.hdel(_index_key(user_id), sid)) # prune stale index entry + continue + data = json.loads(raw) + out.append( + SessionInfo( + sid=sid, + created=datetime.fromisoformat(data["created"]), + ua=data.get("ua", ""), + current=(token == current_token), + ) + ) + out.sort(key=lambda s: s.created, reverse=True) + return out + + +async def revoke_session(user_id: uuid.UUID, sid: str) -> bool: + """Revoke a specific session by its public sid. Returns False if not found.""" + redis = get_redis() + token = await aw(redis.hget(_index_key(user_id), sid)) + if token is None: + return False + await redis.delete(_PREFIX + token) + await aw(redis.hdel(_index_key(user_id), sid)) + return True + + +async def destroy_all_sessions(user_id: uuid.UUID) -> None: + """Revoke every session for a user (e.g. after a password reset).""" + redis = get_redis() + mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id))) + for token in mapping.values(): + await redis.delete(_PREFIX + token) + await redis.delete(_index_key(user_id)) diff --git a/backend/app/identity/totp.py b/backend/app/identity/totp.py new file mode 100644 index 0000000..8db9078 --- /dev/null +++ b/backend/app/identity/totp.py @@ -0,0 +1,59 @@ +"""TOTP two-factor authentication (SPEC §6.1). + +The base32 secret is stored envelope-encrypted in ``users.totp_secret``. A pending +(not-yet-enabled) secret lives in Redis until the user confirms one valid code. +""" + +from __future__ import annotations + +import uuid + +import pyotp + +from app.config import get_settings +from app.redis_client import get_redis +from app.security.crypto import decrypt_secret, encrypt_secret + +_PENDING_PREFIX = "totp_pending:" +_PENDING_TTL = 600 # 10 minutes to complete enrolment +_VALID_WINDOW = 1 # ±1 step tolerance (SPEC §6.1) + + +def generate_secret() -> str: + """Return a fresh base32 TOTP secret.""" + return pyotp.random_base32() + + +def provisioning_uri(secret: str, handle: str) -> str: + """Return the otpauth:// URI for QR provisioning.""" + return pyotp.TOTP(secret).provisioning_uri(name=handle, issuer_name=get_settings().app_domain) + + +def verify(secret: str, code: str) -> bool: + """Verify a TOTP ``code`` against ``secret`` (±1 window).""" + return bool(pyotp.TOTP(secret).verify(code.strip(), valid_window=_VALID_WINDOW)) + + +async def start_setup(user_id: uuid.UUID) -> str: + """Generate a pending secret (stored in Redis) and return it.""" + secret = generate_secret() + await get_redis().set(_PENDING_PREFIX + str(user_id), secret, ex=_PENDING_TTL) + return secret + + +async def get_pending(user_id: uuid.UUID) -> str | None: + """Return the pending (un-enabled) secret for a user, if any.""" + secret: str | None = await get_redis().get(_PENDING_PREFIX + str(user_id)) + return secret + + +async def clear_pending(user_id: uuid.UUID) -> None: + await get_redis().delete(_PENDING_PREFIX + str(user_id)) + + +def encrypt_totp_secret(secret: str) -> bytes: + return encrypt_secret(secret.encode("utf-8")) + + +def decrypt_totp_secret(blob: bytes) -> str: + return decrypt_secret(blob).decode("utf-8") diff --git a/backend/app/indexer/__init__.py b/backend/app/indexer/__init__.py new file mode 100644 index 0000000..36b6db4 --- /dev/null +++ b/backend/app/indexer/__init__.py @@ -0,0 +1 @@ +"""Indexer: MIME parsing, sanitization, blob storage, DB rows (SPEC §6.4).""" diff --git a/backend/app/indexer/blobstore.py b/backend/app/indexer/blobstore.py new file mode 100644 index 0000000..212e271 --- /dev/null +++ b/backend/app/indexer/blobstore.py @@ -0,0 +1,55 @@ +"""Content-addressed blob store (SPEC §4 "Blob store", §6.4). + +Layout: ``${BLOB_ROOT}/{user_id}/{sha256[:2]}/{sha256}`` (filesystem default). +Content-addressed, so identical payloads dedupe. Postgres holds metadata only; +deleting a user removes their blob subtree. +""" + +from __future__ import annotations + +import hashlib +import uuid +from pathlib import Path + +from app.config import get_settings + + +def _root() -> Path: + return Path(get_settings().blob_root) + + +def sha256_hex(data: bytes) -> str: + """Return the hex sha256 digest of ``data``.""" + return hashlib.sha256(data).hexdigest() + + +def blob_path(user_id: uuid.UUID, sha: str) -> Path: + """Return the on-disk path for a user's blob (does not create it).""" + return _root() / str(user_id) / sha[:2] / sha + + +def store(user_id: uuid.UUID, data: bytes) -> str: + """Store ``data`` content-addressed; return its sha256. Idempotent (dedupes).""" + sha = sha256_hex(data) + path = blob_path(user_id, sha) + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_bytes(data) + tmp.replace(path) # atomic within the same directory + return sha + + +def load(user_id: uuid.UUID, sha: str) -> bytes: + """Load a blob by sha256. Raises FileNotFoundError if absent.""" + return blob_path(user_id, sha).read_bytes() + + +def exists(user_id: uuid.UUID, sha: str) -> bool: + """Return True if the blob is present.""" + return blob_path(user_id, sha).exists() + + +def remove(user_id: uuid.UUID, sha: str) -> None: + """Delete a blob if present (used by account/user deletion). Idempotent.""" + blob_path(user_id, sha).unlink(missing_ok=True) diff --git a/backend/app/indexer/indexer.py b/backend/app/indexer/indexer.py new file mode 100644 index 0000000..d66e0aa --- /dev/null +++ b/backend/app/indexer/indexer.py @@ -0,0 +1,202 @@ +"""Indexer orchestration (SPEC §6.4). + +Turns a :class:`RemoteMessage` into persisted rows: raw blob (deduped), parsed +MIME, sanitized HTML, ``messages``/``body_parts``/``attachments`` rows, JWZ +threading, and a ``known_recipients`` upsert. Returns a :class:`SearchDoc` for +the caller to index *after commit* (FR-30 inline indexing) and to drive the +``message.new`` event. Idempotent on ``(mailbox_id, uid)``. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.indexer import blobstore +from app.indexer.mime import ParsedEmail, parse_message +from app.indexer.sanitize import sanitize_html +from app.models import Attachment, BodyPart, KnownRecipient, Message +from app.providers.base import RemoteMessage +from app.search.base import SearchDoc +from app.threading_.normalize import synthesize_message_id +from app.threading_.threader import thread_message + +_NO_RECIPIENT_HARVEST = {"junk", "sent", "drafts", "trash"} + + +@dataclass +class PersistResult: + message: Message + search_doc: SearchDoc + has_remote_images: bool + + +def _epoch(value: datetime) -> int: + return int(value.timestamp()) + + +def _build_search_doc(message: Message, parsed: ParsedEmail, special_use: str | None) -> SearchDoc: + return SearchDoc( + message_id=message.id, + thread_id=message.thread_id or message.id, + user_id=message.user_id, + account_id=message.account_id, + mailbox_special_use=special_use, + from_=parsed.from_addr, + to=parsed.to_addr, + cc=parsed.cc_addr, + subject=parsed.subject, + body_text=parsed.text_body, + attachment_names=[a.filename for a in parsed.attachments], + has_attachments=parsed.has_attachments, + is_unread="\\Seen" not in message.flags, + is_starred="\\Flagged" in message.flags, + date=_epoch(message.internal_date), + size_bytes=message.size_bytes, + ) + + +async def _harvest_recipient(session: AsyncSession, message: Message, parsed: ParsedEmail) -> None: + """Upsert the From address into known_recipients (weight 1) (SPEC §6.4).""" + if not parsed.from_addr: + return + sender = parsed.from_addr[0] + email = (sender.get("email") or "").lower() + if not email: + return + stmt = pg_insert(KnownRecipient).values( + user_id=message.user_id, + email=email, + name=sender.get("name", ""), + times_used=1, + last_used_at=message.internal_date, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[KnownRecipient.user_id, KnownRecipient.email], + set_={ + "times_used": KnownRecipient.times_used + 1, + "last_used_at": stmt.excluded.last_used_at, + "name": stmt.excluded.name, + }, + ) + await session.execute(stmt) + + +async def _resolve_message_id( + session: AsyncSession, + *, + user_id: uuid.UUID, + account_id: uuid.UUID, + parsed_id: str | None, + raw: bytes, +) -> str: + """Resolve the Message-ID to store, applying SPEC §7.1 duplicate handling. + + Missing id -> synthesize. A *duplicate* id already seen in a **different + account** is a separate delivery (e.g. a bulk sender that reuses one + Message-ID for every recipient) -> synthesize a non-authoritative id so the + two do not merge. A same-account duplicate (e.g. Gmail INBOX + All Mail + holding one message) keeps the real id so the copies stay in one thread. + """ + if parsed_id is None: + return synthesize_message_id(raw) + other_account = await session.scalar( + select(Message.account_id) + .where(Message.user_id == user_id, Message.message_id_hdr == parsed_id) + .limit(1) + ) + if other_account is not None and other_account != account_id: + return synthesize_message_id(raw) + return parsed_id + + +async def persist_message( + session: AsyncSession, + *, + user_id: uuid.UUID, + account_id: uuid.UUID, + mailbox_id: uuid.UUID, + mailbox_special_use: str | None, + remote: RemoteMessage, +) -> PersistResult | None: + """Persist one message. Returns None if it already exists (idempotent).""" + existing = await session.scalar( + select(Message.id).where(Message.mailbox_id == mailbox_id, Message.uid == remote.uid) + ) + if existing is not None: + return None + + sha = blobstore.store(user_id, remote.raw) + parsed = parse_message(remote.raw) + + message_id_hdr = await _resolve_message_id( + session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw + ) + message = Message( + id=uuid.uuid4(), + user_id=user_id, + account_id=account_id, + mailbox_id=mailbox_id, + uid=remote.uid, + message_id_hdr=message_id_hdr, + in_reply_to=parsed.in_reply_to, + references_hdr=parsed.references, + subject=parsed.subject, + from_addr=parsed.from_addr, + to_addr=parsed.to_addr, + cc_addr=parsed.cc_addr, + date_hdr=parsed.date, + internal_date=remote.internal_date or datetime.now(UTC), + flags=sorted(remote.flags), + size_bytes=len(remote.raw), + has_attachments=parsed.has_attachments, + snippet=parsed.snippet, + blob_sha256=sha, + ) + session.add(message) + await session.flush() + + # Body parts: plain text (for indexing) + sanitized HTML (for display). + has_remote_images = False + if parsed.text_body: + session.add(BodyPart(message_id=message.id, kind="text", content=parsed.text_body)) + if parsed.html_body is not None: + result = sanitize_html(parsed.html_body, message.id) + has_remote_images = result.has_remote_images + session.add( + BodyPart( + message_id=message.id, + kind="html_sanitized", + content=result.html, + sanitizer_v=result.sanitizer_v, + ) + ) + + for att in parsed.attachments: + att_sha = blobstore.store(user_id, att.payload) + session.add( + Attachment( + message_id=message.id, + user_id=user_id, + filename=att.filename, + mime_type=att.mime_type, + size_bytes=len(att.payload), + content_id=att.content_id, + blob_sha256=att_sha, + ) + ) + + message.has_remote_images = has_remote_images + await thread_message(session, message) + + if mailbox_special_use not in _NO_RECIPIENT_HARVEST: + await _harvest_recipient(session, message, parsed) + + await session.flush() + doc = _build_search_doc(message, parsed, mailbox_special_use) + return PersistResult(message=message, search_doc=doc, has_remote_images=has_remote_images) diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py new file mode 100644 index 0000000..f7030a6 --- /dev/null +++ b/backend/app/indexer/mime.py @@ -0,0 +1,147 @@ +"""MIME parsing via the stdlib ``email`` package with ``policy.default`` (SPEC §6.4). + +Primary parsing is stdlib per SPEC §3; ``mail-parser`` is available for helpers only. +Header RFC2047 decoding is automatic under ``policy.default``. +""" + +from __future__ import annotations + +import email +from dataclasses import dataclass, field +from datetime import datetime +from email.headerregistry import AddressHeader +from email.message import EmailMessage +from email.policy import default as default_policy + +import nh3 + +from app.threading_.normalize import normalize_message_id, normalize_reference_list + +_SNIPPET_LEN = 160 + + +@dataclass(frozen=True) +class ParsedAttachment: + filename: str + mime_type: str + content_id: str | None + payload: bytes + + +@dataclass +class ParsedEmail: + message_id: str | None + in_reply_to: str | None + references: list[str] # References + In-Reply-To, normalized, in order (§6.4) + subject: str + from_addr: list[dict[str, str]] + to_addr: list[dict[str, str]] + cc_addr: list[dict[str, str]] + date: datetime | None + text_body: str + html_body: str | None + attachments: list[ParsedAttachment] = field(default_factory=list) + + @property + def has_attachments(self) -> bool: + return len(self.attachments) > 0 + + @property + def snippet(self) -> str: + return " ".join(self.text_body.split())[:_SNIPPET_LEN] + + +def _addresses(msg: EmailMessage, header: str) -> list[dict[str, str]]: + value = msg[header] + if value is None: + return [] + out: list[dict[str, str]] = [] + if isinstance(value, AddressHeader): + for addr in value.addresses: + if addr.addr_spec: + out.append({"name": str(addr.display_name or ""), "email": addr.addr_spec}) + return out + + +def _html_to_text(html: str) -> str: + """Derive plain text from HTML by stripping all tags (SPEC §6.4 nh3 strip).""" + return nh3.clean(html, tags=set(), attributes={}, strip_comments=True) + + +def _get_body_content(msg: EmailMessage, subtype: str) -> str | None: + part = msg.get_body(preferencelist=(subtype,)) + if part is None: + return None + try: + content = part.get_content() + except (LookupError, ValueError, KeyError): + payload = part.get_payload(decode=True) + if not isinstance(payload, bytes): + return None + content = payload.decode("utf-8", "replace") + return content if isinstance(content, str) else None + + +def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]: + out: list[ParsedAttachment] = [] + for part in msg.iter_attachments(): + if not isinstance(part, EmailMessage): + continue + payload = part.get_payload(decode=True) + if not isinstance(payload, bytes): + continue + cid = part.get("Content-ID") + out.append( + ParsedAttachment( + filename=part.get_filename() or "attachment", + mime_type=part.get_content_type(), + content_id=cid.strip("<>") if cid else None, + payload=payload, + ) + ) + return out + + +def parse_message(raw: bytes) -> ParsedEmail: + """Parse a raw RFC822 message into a :class:`ParsedEmail`.""" + msg = email.message_from_bytes(raw, policy=default_policy) + assert isinstance(msg, EmailMessage) + + message_id = normalize_message_id(msg.get("Message-ID")) + in_reply_to_ids = normalize_reference_list(msg.get("In-Reply-To")) + in_reply_to = in_reply_to_ids[0] if in_reply_to_ids else None + + # references_hdr = References then In-Reply-To message-ids, in order, deduped (§6.4). + references: list[str] = [] + for ref in normalize_reference_list(msg.get("References")) + in_reply_to_ids: + if ref not in references: + references.append(ref) + + subject = str(msg.get("Subject", "") or "") + + date: datetime | None = None + date_header = msg["Date"] + if date_header is not None: + try: + date = date_header.datetime + except (AttributeError, ValueError): + date = None + + html_body = _get_body_content(msg, "html") + text_body = _get_body_content(msg, "plain") + if text_body is None and html_body is not None: + text_body = _html_to_text(html_body) + + return ParsedEmail( + message_id=message_id, + in_reply_to=in_reply_to, + references=references, + subject=subject, + from_addr=_addresses(msg, "From"), + to_addr=_addresses(msg, "To"), + cc_addr=_addresses(msg, "Cc"), + date=date, + text_body=text_body or "", + html_body=html_body, + attachments=_extract_attachments(msg), + ) diff --git a/backend/app/indexer/sanitize.py b/backend/app/indexer/sanitize.py new file mode 100644 index 0000000..e7406af --- /dev/null +++ b/backend/app/indexer/sanitize.py @@ -0,0 +1,195 @@ +"""HTML sanitization with nh3 (SPEC §6.4, §10.4, invariant I-8). + +Mail HTML is hostile: sanitized server-side at index time with a broad-but-safe +allowlist (structural + presentational tags, filtered inline CSS), and rendered +inside a sandboxed iframe. ``cid:`` images are rewritten to the per-message +endpoint; remote images (and CSS ``url()``) are blocked by default and flagged so +the UI can offer "load images" (SPEC §9.4). +""" + +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass + +import nh3 + +# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a +# render re-sanitizes stale content from the raw blob (SPEC §10.4). +SANITIZER_VERSION = 2 + +_ALLOWED_TAGS: set[str] = { + # structure + "a", + "abbr", + "address", + "article", + "aside", + "b", + "bdi", + "bdo", + "blockquote", + "br", + "caption", + "center", + "cite", + "code", + "col", + "colgroup", + "dd", + "del", + "details", + "dfn", + "div", + "dl", + "dt", + "em", + "figcaption", + "figure", + "font", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "i", + "img", + "ins", + "kbd", + "li", + "main", + "mark", + "nav", + "ol", + "p", + "pre", + "q", + "s", + "samp", + "section", + "small", + "span", + "strike", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "time", + "tr", + "tt", + "u", + "ul", + "var", + "wbr", +} + +# Attributes allowed on any tag (presentational email formatting). +_GENERIC_ATTRS: set[str] = { + "style", + "align", + "valign", + "dir", + "title", + "width", + "height", + "bgcolor", + "color", + "face", + "size", + "span", + "nowrap", + "lang", +} + +_TAG_SPECIFIC: dict[str, set[str]] = { + "a": {"href", "target"}, + "img": {"src", "alt"}, + "td": {"colspan", "rowspan"}, + "th": {"colspan", "rowspan", "scope"}, + "table": {"cellpadding", "cellspacing", "border"}, + "font": {"face", "color", "size"}, +} + +# nh3 has no "generic attributes" option, so expand the generic set onto every tag. +_ALLOWED_ATTRS: dict[str, set[str]] = { + tag: set(_GENERIC_ATTRS) | _TAG_SPECIFIC.get(tag, set()) for tag in _ALLOWED_TAGS +} + +_URL_SCHEMES: set[str] = {"http", "https", "mailto", "cid", "data"} + +_STYLE_DANGEROUS = re.compile( + r"(expression\s*\(|javascript:|vbscript:|@import|-moz-binding|behavior\s*:)", re.IGNORECASE +) +_STYLE_URL = re.compile(r"url\s*\([^)]*\)", re.IGNORECASE) + + +@dataclass(frozen=True) +class SanitizeResult: + html: str + has_remote_images: bool + sanitizer_v: int + + +def _sanitize_style(value: str, allow_remote: bool) -> str | None: + if _STYLE_DANGEROUS.search(value): + return None # drop the whole style rather than try to repair it + if not allow_remote and _STYLE_URL.search(value): + # Strip CSS url() so backgrounds can't smuggle in remote/tracking images. + return _STYLE_URL.sub("none", value) + return value + + +def sanitize_html( + html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False +) -> SanitizeResult: + """Sanitize ``html`` for the message ``message_id``. + + Returns the safe HTML, whether any remote images were blocked, and the + sanitizer version. Pass ``allow_remote_images=True`` after the user approves + a sender to render remote images and CSS backgrounds (SPEC §9.4). + """ + state = {"has_remote": False} + + def attribute_filter(tag: str, attr: str, value: str) -> str | None: + if attr == "style": + return _sanitize_style(value, allow_remote_images) + if tag == "a" and attr == "target": + return "_blank" + if tag == "img" and attr == "src": + lowered = value.strip().lower() + if lowered.startswith("cid:"): + content_id = value.strip()[4:] + return f"/api/messages/{message_id}/cid/{content_id}" + if lowered.startswith(("http:", "https:")): + state["has_remote"] = True + return value if allow_remote_images else None # block by default (FR-23) + if lowered.startswith("data:image/"): + return value + return None + return value + + cleaned = nh3.clean( + html, + tags=_ALLOWED_TAGS, + attributes=_ALLOWED_ATTRS, + url_schemes=_URL_SCHEMES, + link_rel="noopener noreferrer nofollow", + strip_comments=True, + attribute_filter=attribute_filter, + ) + return SanitizeResult( + html=cleaned, + has_remote_images=state["has_remote"], + sanitizer_v=SANITIZER_VERSION, + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d998e9f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,45 @@ +"""FastAPI application factory (SPEC §2 ``app/main.py``).""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from sqlalchemy import text + +from app.config import get_settings +from app.db import dispose_engine, get_engine +from app.redis_client import close_redis, get_redis + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Warm shared resources on startup, dispose them on shutdown.""" + # Touch settings early so misconfiguration fails fast. + get_settings() + yield + await close_redis() + await dispose_engine() + + +def create_app() -> FastAPI: + """Construct the FastAPI application.""" + app = FastAPI(title="Webmail", version="0.1.0", lifespan=lifespan) + + from app.api.errors import register_error_handlers + + register_error_handlers(app) + + @app.get("/api/healthz") + async def healthz() -> dict[str, str]: + """Liveness/readiness probe: checks DB and Redis (dev-setup §6).""" + async with get_engine().connect() as conn: + await conn.execute(text("SELECT 1")) + await get_redis().ping() + return {"status": "ok"} + + from app.api import register_routers + + register_routers(app) + return app diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..2081091 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,296 @@ +"""SQLAlchemy ORM models mirroring the authoritative DDL in SPEC §4. + +The Alembic initial migration is generated to match these models exactly. +Invariant I-7: every table carrying user data has an indexed ``user_id``. +""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from sqlalchemy import ( + ForeignKey, + Index, + Integer, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import ( + ARRAY, + BYTEA, + CITEXT, + JSONB, + TIMESTAMP, + TSVECTOR, + UUID, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +def _uuid_pk() -> Mapped[uuid.UUID]: + return mapped_column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()"), + ) + + +def _now() -> Mapped[dt.datetime]: + return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()")) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = _uuid_pk() + handle: Mapped[str] = mapped_column(CITEXT, unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(Text, nullable=False) + totp_secret: Mapped[bytes | None] = mapped_column(BYTEA) + recovery_email: Mapped[str | None] = mapped_column(CITEXT) + settings: Mapped[dict[str, object]] = mapped_column( + JSONB, nullable=False, server_default=text("'{}'") + ) + created_at: Mapped[dt.datetime] = _now() + + +class Account(Base): + __tablename__ = "accounts" + __table_args__ = (UniqueConstraint("user_id", "email", name="uq_accounts_user_email"),) + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + email: Mapped[str] = mapped_column(CITEXT, nullable=False) + display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'")) + imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False) + discovery_src: Mapped[str] = mapped_column(Text, nullable=False) + state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'")) + state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + created_at: Mapped[dt.datetime] = _now() + + +class Mailbox(Base): + __tablename__ = "mailboxes" + __table_args__ = (UniqueConstraint("account_id", "path", name="uq_mailboxes_account_path"),) + + id: Mapped[uuid.UUID] = _uuid_pk() + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + path: Mapped[str] = mapped_column(Text, nullable=False) + special_use: Mapped[str | None] = mapped_column(Text) + uidvalidity: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + highestmodseq: Mapped[int | None] = mapped_column(Integer) + last_seen_uid: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + + +class Thread(Base): + __tablename__ = "threads" + __table_args__ = (Index("ix_threads_user_last", "user_id", text("last_message_at DESC")),) + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + subject_norm: Mapped[str] = mapped_column(Text, nullable=False) + snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + participants: Mapped[list[dict[str, str]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) + account_ids: Mapped[list[uuid.UUID]] = mapped_column( + ARRAY(UUID(as_uuid=True)), nullable=False, server_default=text("'{}'") + ) + message_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + unread_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) + pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) + last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + + +class Message(Base): + __tablename__ = "messages" + __table_args__ = ( + UniqueConstraint("mailbox_id", "uid", name="uq_messages_mailbox_uid"), + Index("ix_messages_user_thread", "user_id", "thread_id"), + Index("ix_messages_account_msgid", "account_id", "message_id_hdr"), + Index("ix_messages_user_msgid", "user_id", "message_id_hdr"), + Index("ix_messages_body_indexed", "body_indexed", postgresql_using="gin"), + ) + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + mailbox_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False + ) + uid: Mapped[int] = mapped_column(Integer, nullable=False) + thread_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("threads.id") + ) + message_id_hdr: Mapped[str | None] = mapped_column(Text) + in_reply_to: Mapped[str | None] = mapped_column(Text) + references_hdr: Mapped[list[str]] = mapped_column( + ARRAY(Text), nullable=False, server_default=text("'{}'") + ) + subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + from_addr: Mapped[list[dict[str, str]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) + to_addr: Mapped[list[dict[str, str]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) + cc_addr: Mapped[list[dict[str, str]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) + date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + flags: Mapped[list[str]] = mapped_column( + ARRAY(Text), nullable=False, server_default=text("'{}'") + ) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) + has_remote_images: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) + snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) + body_indexed: Mapped[str | None] = mapped_column(TSVECTOR) + + +class BodyPart(Base): + __tablename__ = "body_parts" + + id: Mapped[uuid.UUID] = _uuid_pk() + message_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False + ) + kind: Mapped[str] = mapped_column(Text, nullable=False) # text | html_sanitized + content: Mapped[str] = mapped_column(Text, nullable=False) + # SPEC §10.4: re-sanitize on render if the sanitizer version changed. + sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + + +class Attachment(Base): + __tablename__ = "attachments" + + id: Mapped[uuid.UUID] = _uuid_pk() + message_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + filename: Mapped[str] = mapped_column(Text, nullable=False) + mime_type: Mapped[str] = mapped_column(Text, nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + content_id: Mapped[str | None] = mapped_column(Text) + blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) + + +class ComposeAttachment(Base): + """A file uploaded for an outgoing message, referenced by a draft's payload.""" + + __tablename__ = "compose_attachments" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + filename: Mapped[str] = mapped_column(Text, nullable=False) + mime_type: Mapped[str] = mapped_column(Text, nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[dt.datetime] = _now() + + +class Draft(Base): + __tablename__ = "drafts" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + thread_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("threads.id") + ) + reply_to_message_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("messages.id") + ) + kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'")) + payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + updated_at: Mapped[dt.datetime] = _now() + + +class Outbox(Base): + __tablename__ = "outbox" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'undo_window'")) + send_after: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + error: Mapped[str | None] = mapped_column(Text) + + +class SavedSearch(Base): + __tablename__ = "saved_searches" + __table_args__ = (UniqueConstraint("user_id", "name", name="uq_saved_searches_user_name"),) + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(Text, nullable=False) + query: Mapped[str] = mapped_column(Text, nullable=False) + pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true")) + + +class KnownRecipient(Base): + __tablename__ = "known_recipients" + __table_args__ = ( + Index( + "ix_known_recipients_user_email", + "user_id", + text("email text_pattern_ops"), + ), + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ) + email: Mapped[str] = mapped_column(CITEXT, primary_key=True) + name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + times_used: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + last_used_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + + +__all__ = [ + "Account", + "Attachment", + "BodyPart", + "Draft", + "KnownRecipient", + "Mailbox", + "Message", + "Outbox", + "SavedSearch", + "Thread", + "User", +] diff --git a/backend/app/outbox/__init__.py b/backend/app/outbox/__init__.py new file mode 100644 index 0000000..5dbcf92 --- /dev/null +++ b/backend/app/outbox/__init__.py @@ -0,0 +1 @@ +"""Outbox: drafts, MIME assembly, send queue, undo-send (SPEC §6.5).""" diff --git a/backend/app/outbox/jobs.py b/backend/app/outbox/jobs.py new file mode 100644 index 0000000..e9504d0 --- /dev/null +++ b/backend/app/outbox/jobs.py @@ -0,0 +1,161 @@ +"""The scheduled send job (SPEC §6.5). + +Fires ``UNDO_SECONDS`` after the user hits send. If the outbox row was cancelled +in the meantime, it does nothing (no SMTP traffic). Otherwise: SMTP send, append +to Sent (skipped for Gmail, which self-appends), harvest recipients into +``known_recipients`` (weight 3), and publish ``message.sent``. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import session_scope +from app.events import get_event_bus +from app.indexer import blobstore +from app.models import ( + Account, + ComposeAttachment, + Draft, + KnownRecipient, + Mailbox, + Message, + Outbox, +) +from app.outbox.mime_build import build_mime +from app.sync.provider_factory import build_provider + +logger = logging.getLogger(__name__) + + +def _is_gmail(account: Account) -> bool: + host = str(account.imap_config.get("host", "")).lower() + return "gmail.com" in host or "googlemail.com" in host + + +async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails: list[str]) -> None: + now = datetime.now(UTC) + for email in {e.lower() for e in emails if e}: + stmt = pg_insert(KnownRecipient).values( + user_id=user_id, email=email, name="", times_used=3, last_used_at=now + ) + stmt = stmt.on_conflict_do_update( + index_elements=[KnownRecipient.user_id, KnownRecipient.email], + set_={ + "times_used": KnownRecipient.times_used + 3, # sent-to weight (SPEC §4) + "last_used_at": stmt.excluded.last_used_at, + }, + ) + await session.execute(stmt) + + +async def _load_attachments( + session: AsyncSession, user_id: uuid.UUID, draft: Draft +) -> list[tuple[str, str, bytes]]: + """Resolve a draft's compose-attachment ids into (filename, mime, data) tuples.""" + raw_ids = draft.payload.get("attachment_ids") or [] + if not isinstance(raw_ids, list) or not raw_ids: + return [] + ids: list[uuid.UUID] = [] + for value in raw_ids: + try: + ids.append(uuid.UUID(str(value))) + except ValueError: + continue + if not ids: + return [] + rows = await session.scalars( + select(ComposeAttachment).where( + ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id + ) + ) + by_id = {r.id: r for r in rows} + out: list[tuple[str, str, bytes]] = [] + for i in ids: + att = by_id.get(i) + if att is not None: + out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256))) + return out + + +async def send_job(ctx: dict[str, Any], outbox_id: str) -> None: + """Send a queued message unless it was cancelled during the undo window.""" + oid = uuid.UUID(outbox_id) + async with session_scope() as session: + outbox = await session.get(Outbox, oid) + if outbox is None or outbox.state != "undo_window": + return # cancelled, or already handled -> no SMTP traffic + outbox.state = "sending" + draft = await session.get(Draft, outbox.draft_id) + if draft is None: + outbox.state = "failed" + outbox.error = "draft missing" + return + account = await session.get(Account, draft.account_id) + reply_to = ( + await session.get(Message, draft.reply_to_message_id) + if draft.reply_to_message_id + else None + ) + if account is None: + outbox.state = "failed" + outbox.error = "account missing" + return + user_id = outbox.user_id + attachments = await _load_attachments(session, user_id, draft) + raw, envelope_from, envelope_to = build_mime(account, draft, reply_to, attachments) + sent_path = await session.scalar( + select(Mailbox.path).where( + Mailbox.account_id == account.id, Mailbox.special_use == "sent" + ) + ) + gmail = _is_gmail(account) + account_obj = account + + provider = build_provider(account_obj) + try: + await provider.send(raw, envelope_from, envelope_to) + if sent_path and not gmail: + await provider.append(sent_path, raw, frozenset({"\\Seen"})) + except Exception as exc: # noqa: BLE001 - record and notify, keep the draft + async with session_scope() as session: + ob = await session.get(Outbox, oid) + if ob is not None: + ob.state = "failed" + ob.error = str(exc)[:500] + await get_event_bus().publish( + "outbox.state_changed", + {"user_id": str(user_id), "outbox_id": str(oid), "state": "failed"}, + ) + logger.exception("send failed for outbox %s", oid) + return + finally: + await provider.close() + + async with session_scope() as session: + ob = await session.get(Outbox, oid) + if ob is not None: + ob.state = "sent" + # The message left successfully: drop its draft so it no longer shows in + # the Drafts folder (undo/cancel returns earlier, keeping the draft). + draft_row = await session.get(Draft, draft.id) + if draft_row is not None: + await session.delete(draft_row) + await _harvest_recipients(session, user_id, envelope_to) + + bus = get_event_bus() + await bus.publish( + "message.sent", + {"user_id": str(user_id), "outbox_id": str(oid), "thread_id": str(draft.thread_id)}, + ) + await bus.publish( + "outbox.state_changed", + {"user_id": str(user_id), "outbox_id": str(oid), "state": "sent"}, + ) diff --git a/backend/app/outbox/mime_build.py b/backend/app/outbox/mime_build.py new file mode 100644 index 0000000..942cde9 --- /dev/null +++ b/backend/app/outbox/mime_build.py @@ -0,0 +1,102 @@ +"""Assemble an RFC822 message from a draft (SPEC §6.5). + +Builds a multipart/alternative (auto-generated text from HTML), sets threading +headers from the message being replied to, and returns the raw bytes plus the +SMTP envelope. Attachment assembly is deferred to M2 (attachment upload). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from email.message import EmailMessage +from email.utils import format_datetime, make_msgid + +import nh3 + +from app.models import Account, Draft, Message + + +def _html_to_text(html: str) -> str: + return nh3.clean(html, tags=set(), attributes={}, strip_comments=True) + + +def _addr_list(values: object) -> list[str]: + if not isinstance(values, list): + return [] + out: list[str] = [] + for v in values: + if isinstance(v, str) and v.strip(): + out.append(v.strip()) + elif isinstance(v, dict) and v.get("email"): + name, email = v.get("name", ""), v["email"] + out.append(f"{name} <{email}>" if name else str(email)) + return out + + +def _email_only(formatted: str) -> str: + if "<" in formatted and ">" in formatted: + return formatted.split("<", 1)[1].split(">", 1)[0].strip() + return formatted.strip() + + +def _bracket(mid: str) -> str: + mid = mid.strip() + return mid if mid.startswith("<") else f"<{mid}>" + + +def build_mime( + account: Account, + draft: Draft, + reply_to: Message | None, + attachments: list[tuple[str, str, bytes]] | None = None, +) -> tuple[bytes, str, list[str]]: + """Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``. + + ``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the + compose Attach action; each is added as a file attachment part. + """ + payload = draft.payload + to = _addr_list(payload.get("to")) + cc = _addr_list(payload.get("cc")) + bcc = _addr_list(payload.get("bcc")) + subject = str(payload.get("subject", "")) + html = str(payload.get("html", "") or "") + text = str(payload.get("text", "") or "") + + msg = EmailMessage() + from_name = account.display_name or "" + msg["From"] = f"{from_name} <{account.email}>" if from_name else account.email + if to: + msg["To"] = ", ".join(to) + if cc: + msg["Cc"] = ", ".join(cc) + msg["Subject"] = subject + msg["Date"] = format_datetime(datetime.now(UTC)) + domain = account.email.rsplit("@", 1)[-1] + msg["Message-ID"] = make_msgid(domain=domain) + + # Threading headers so the recipient's client threads the reply (SPEC M1). + if reply_to is not None and reply_to.message_id_hdr: + parent = _bracket(reply_to.message_id_hdr) + msg["In-Reply-To"] = parent + refs = [_bracket(r) for r in reply_to.references_hdr] + [parent] + msg["References"] = " ".join(refs) + + # Body: text + HTML alternative (auto text when only HTML given). + if html: + msg.set_content(text or _html_to_text(html)) + msg.add_alternative(html, subtype="html") + else: + msg.set_content(text) + + for filename, mime_type, data in attachments or []: + maintype, _, subtype = mime_type.partition("/") + msg.add_attachment( + data, + maintype=maintype or "application", + subtype=subtype or "octet-stream", + filename=filename, + ) + + envelope_to = [_email_only(a) for a in (to + cc + bcc)] + return msg.as_bytes(), account.email, envelope_to diff --git a/backend/app/outbox/schemas.py b/backend/app/outbox/schemas.py new file mode 100644 index 0000000..144f8a3 --- /dev/null +++ b/backend/app/outbox/schemas.py @@ -0,0 +1,49 @@ +"""Compose/draft/send request & response models (SPEC §9.1).""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel + + +class DraftPayload(BaseModel): + to: list[str] = [] + cc: list[str] = [] + bcc: list[str] = [] + subject: str = "" + html: str = "" + text: str = "" + attachment_ids: list[uuid.UUID] = [] + + +class DraftIn(BaseModel): + account_id: uuid.UUID + thread_id: uuid.UUID | None = None + reply_to_message_id: uuid.UUID | None = None + kind: str = "new" # reply | reply_all | forward | new + payload: DraftPayload + + +class DraftOut(BaseModel): + id: uuid.UUID + account_id: uuid.UUID + thread_id: uuid.UUID | None + reply_to_message_id: uuid.UUID | None + kind: str + payload: dict[str, object] + updated_at: str + + +class SendRequest(BaseModel): + draft_id: uuid.UUID + + +class SendResponse(BaseModel): + outbox_id: uuid.UUID + undo_until: str + + +class OutboxStateOut(BaseModel): + outbox_id: uuid.UUID + state: str diff --git a/backend/app/outbox/service.py b/backend/app/outbox/service.py new file mode 100644 index 0000000..326bb65 --- /dev/null +++ b/backend/app/outbox/service.py @@ -0,0 +1,76 @@ +"""Draft persistence and the send/undo flow (SPEC §6.5).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.models import Account, Draft, Outbox +from app.outbox.schemas import DraftIn + + +class DraftError(Exception): + """Draft could not be created/updated (e.g. account not owned).""" + + +async def upsert_draft( + session: AsyncSession, *, user_id: uuid.UUID, body: DraftIn, draft_id: uuid.UUID | None +) -> Draft: + """Create or update a draft owned by ``user_id``.""" + account = await session.scalar( + select(Account).where(Account.id == body.account_id, Account.user_id == user_id) + ) + if account is None: + raise DraftError("account not found") + + payload = body.payload.model_dump(mode="json") + if draft_id is None: + draft = Draft( + user_id=user_id, + account_id=body.account_id, + thread_id=body.thread_id, + reply_to_message_id=body.reply_to_message_id, + kind=body.kind, + payload=payload, + ) + session.add(draft) + else: + fetched = await session.scalar( + select(Draft).where(Draft.id == draft_id, Draft.user_id == user_id) + ) + if fetched is None: + raise DraftError("draft not found") + draft = fetched + draft.account_id = body.account_id + draft.thread_id = body.thread_id + draft.reply_to_message_id = body.reply_to_message_id + draft.kind = body.kind + draft.payload = payload + draft.updated_at = datetime.now(UTC) + await session.flush() + return draft + + +async def create_outbox(session: AsyncSession, *, user_id: uuid.UUID, draft: Draft) -> Outbox: + """Create an outbox row in the undo window for a draft.""" + send_after = datetime.now(UTC) + timedelta(seconds=get_settings().undo_seconds) + outbox = Outbox(user_id=user_id, draft_id=draft.id, state="undo_window", send_after=send_after) + session.add(outbox) + await session.flush() + return outbox + + +async def cancel_outbox(session: AsyncSession, *, user_id: uuid.UUID, outbox_id: uuid.UUID) -> bool: + """Cancel a send if still in the undo window. Returns False if too late/unknown.""" + outbox = await session.scalar( + select(Outbox).where(Outbox.id == outbox_id, Outbox.user_id == user_id) + ) + if outbox is None or outbox.state != "undo_window": + return False + outbox.state = "cancelled" + await session.flush() + return True diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py new file mode 100644 index 0000000..8f57ac2 --- /dev/null +++ b/backend/app/providers/__init__.py @@ -0,0 +1,2 @@ +"""Mail provider abstraction (SPEC §5.1). Nothing above this package may +import IMAP/SMTP libraries — only concrete providers do.""" diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py new file mode 100644 index 0000000..7b2fea0 --- /dev/null +++ b/backend/app/providers/base.py @@ -0,0 +1,111 @@ +# INTERFACE VERSION: 1 +"""MailProvider — the frozen provider contract (SPEC §5.1). + +This signature is a frozen contract (SPEC instruction 5): implementations may +evolve, signatures may not without bumping the version comment above. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + + +class ProviderError(Exception): + """Base class for provider errors; carries a human-readable ``.detail``.""" + + def __init__(self, detail: str) -> None: + super().__init__(detail) + self.detail = detail + + +class ProviderAuthError(ProviderError): + """Authentication failed (bad credentials / token).""" + + +class ProviderConnectError(ProviderError): + """Could not connect / TLS / greeting failure.""" + + +class ProviderNotSupported(ProviderError): + """A capability (e.g. IDLE) is not supported by the server.""" + + +@dataclass(frozen=True) +class RemoteMailbox: + path: str + special_use: str | None # "inbox"|"sent"|"drafts"|"trash"|"junk"|"archive"|None + uidvalidity: int + + +@dataclass(frozen=True) +class RemoteMessage: + uid: int + raw: bytes # full RFC822 + flags: frozenset[str] + internal_date: datetime + + +@dataclass(frozen=True) +class FlagChange: + uid: int + flags: frozenset[str] + + +@dataclass(frozen=True) +class MailboxDelta: + new: list[RemoteMessage] + flag_changes: list[FlagChange] + expunged_uids: list[int] + new_uidvalidity: int | None # non-None => UIDVALIDITY changed, caller must full-resync + new_highestmodseq: int | None + + +class MailProvider(Protocol): + async def verify(self) -> None: + """Raises ProviderAuthError / ProviderConnectError with a human-readable .detail.""" + ... + + async def list_mailboxes(self) -> list[RemoteMailbox]: ... + + def backfill( + self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200 + ) -> AsyncIterator[list[RemoteMessage]]: + """Yields batches until the mailbox is exhausted. Headers+body in one pass.""" + ... + + async def poll( + self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None + ) -> MailboxDelta: + """One reconciliation pass. Uses QRESYNC/CONDSTORE when highestmodseq is not None, + otherwise UID SEARCH + FETCH FLAGS diff.""" + ... + + async def idle(self, mailbox: str) -> None: + """Blocks until the server signals change or 25 min elapse (re-issue IDLE). May raise + ProviderNotSupported => sync loop falls back to poll() every POLL_INTERVAL (default 120 s). + """ + ... + + async def push_flags( + self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str] + ) -> None: ... + + async def move(self, uid: int, src: str, dst: str) -> int: # returns new UID + ... + + async def delete_messages(self, mailbox: str, uids: list[int]) -> None: + """Permanently delete messages: set \\Deleted then EXPUNGE (SPEC §6.3).""" + ... + + async def append( + self, mailbox: str, raw: bytes, flags: frozenset[str] + ) -> int: # Sent/Drafts upload + ... + + async def send( + self, raw: bytes, envelope_from: str, envelope_to: list[str] + ) -> None: # SMTP submission + ... diff --git a/backend/app/providers/imap_smtp/__init__.py b/backend/app/providers/imap_smtp/__init__.py new file mode 100644 index 0000000..48ca8a3 --- /dev/null +++ b/backend/app/providers/imap_smtp/__init__.py @@ -0,0 +1,7 @@ +"""IMAP+SMTP provider — the only M0-M2 MailProvider implementation (SPEC §5.1).""" + +from __future__ import annotations + +from app.providers.imap_smtp.provider import ImapSmtpProvider, ProviderConfig + +__all__ = ["ImapSmtpProvider", "ProviderConfig"] diff --git a/backend/app/providers/imap_smtp/parsing.py b/backend/app/providers/imap_smtp/parsing.py new file mode 100644 index 0000000..f89bc4c --- /dev/null +++ b/backend/app/providers/imap_smtp/parsing.py @@ -0,0 +1,165 @@ +"""Pure parsing helpers for IMAP responses (unit-testable, no I/O). + +aioimaplib returns responses as lists of ``bytes``/``bytearray`` lines. A FETCH +with ``BODY[]`` yields, per message, a header line ending ``... {}`` followed +by a ``bytearray`` literal, then a lone ``)``. LIST lines carry SPECIAL-USE flags. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime + +from imapclient.imap_utf7 import decode as imap_utf7_decode + +# --- SPECIAL-USE mapping (SPEC §4 special_use values) ----------------------- + +_SPECIAL_USE_FLAGS = { + "\\sent": "sent", + "\\drafts": "drafts", + "\\trash": "trash", + "\\junk": "junk", + "\\archive": "archive", + "\\all": "archive", # Gmail "All Mail" acts as archive +} + +_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+(?:"([^"]*)"|NIL)\s+(.+)$') +_UID_RE = re.compile(rb"UID\s+(\d+)") +_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)") +_INTERNALDATE_RE = re.compile(rb'INTERNALDATE\s+"([^"]+)"') +_LITERAL_RE = re.compile(rb"\{(\d+)\}\s*$") +_FETCH_HEADER_RE = re.compile(rb"^\d+\s+FETCH\s+\(", re.IGNORECASE) + + +@dataclass(frozen=True) +class ParsedMailbox: + path: str + special_use: str | None + + +@dataclass(frozen=True) +class ParsedFetch: + uid: int + flags: frozenset[str] + internal_date: datetime | None + raw: bytes | None # None when the fetch did not request the body + + +def _decode_mailbox_name(raw: bytes) -> str: + name = raw.strip() + if name.startswith(b'"') and name.endswith(b'"'): + name = name[1:-1] + # IMAP mailbox names are modified UTF-7 (SPEC §4: store UTF-7 decoded). + return str(imap_utf7_decode(bytes(name))) + + +def parse_list_line(line: bytes) -> ParsedMailbox | None: + """Parse one LIST response line into a :class:`ParsedMailbox` (or None).""" + m = _LIST_RE.match(line.strip()) + if not m: + return None + flags = m.group(1).lower().split() + name = _decode_mailbox_name(m.group(3)) + special: str | None = None + for flag in flags: + mapped = _SPECIAL_USE_FLAGS.get(flag.decode("ascii", "ignore")) + if mapped: + special = mapped + break + if special is None and name.upper() == "INBOX": + special = "inbox" + return ParsedMailbox(path=name, special_use=special) + + +def parse_internaldate(raw: bytes | str) -> datetime | None: + """Parse an IMAP INTERNALDATE (``d-Mon-Y H:M:S +ZZZZ``) to an aware datetime.""" + text = raw.decode("ascii", "ignore") if isinstance(raw, bytes) else raw + text = text.strip() + try: + return datetime.strptime(text, "%d-%b-%Y %H:%M:%S %z") + except ValueError: + return None + + +def _parse_flag_token(tok: bytes) -> str: + return tok.decode("ascii", "ignore") + + +def parse_fetch_header(line: bytes) -> tuple[int | None, frozenset[str], datetime | None]: + """Extract (uid, flags, internaldate) from a FETCH header line.""" + uid_m = _UID_RE.search(line) + uid = int(uid_m.group(1)) if uid_m else None + flags_m = _FLAGS_RE.search(line) + if flags_m: + flags = frozenset(_parse_flag_token(t) for t in flags_m.group(1).split()) + else: + flags = frozenset() + date_m = _INTERNALDATE_RE.search(line) + internal = parse_internaldate(date_m.group(1)) if date_m else None + return uid, flags, internal + + +def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]: + """Walk a full FETCH response, returning one :class:`ParsedFetch` per message. + + Handles both metadata-only fetches (FLAGS/INTERNALDATE) and body fetches + (a ``{size}`` literal announced on the header line, body on the next line). + """ + results: list[ParsedFetch] = [] + i = 0 + n = len(lines) + while i < n: + line = lines[i] + if not isinstance(line, (bytes, bytearray)) or not _FETCH_HEADER_RE.match(bytes(line)): + i += 1 + continue + header = bytes(line) + uid, flags, internal = parse_fetch_header(header) + raw: bytes | None = None + if _LITERAL_RE.search(header) and i + 1 < n: + body = lines[i + 1] + raw = bytes(body) + i += 2 + # Skip the trailing ")" continuation line if present. + if ( + i < n + and isinstance(lines[i], (bytes, bytearray)) + and bytes(lines[i]).strip() == b")" + ): + i += 1 + else: + i += 1 + if uid is not None: + results.append(ParsedFetch(uid=uid, flags=flags, internal_date=internal, raw=raw)) + return results + + +def parse_search_uids(lines: list[bytes | bytearray]) -> list[int]: + """Parse a UID SEARCH response's first line into a list of ints.""" + if not lines: + return [] + first = lines[0] + if not isinstance(first, (bytes, bytearray)): + return [] + return [int(tok) for tok in bytes(first).split() if tok.isdigit()] + + +def parse_select_untagged(lines: list[bytes | bytearray]) -> dict[str, int]: + """Extract UIDVALIDITY / UIDNEXT / HIGHESTMODSEQ / EXISTS from SELECT lines.""" + out: dict[str, int] = {} + patterns = { + "uidvalidity": re.compile(rb"UIDVALIDITY\s+(\d+)"), + "uidnext": re.compile(rb"UIDNEXT\s+(\d+)"), + "highestmodseq": re.compile(rb"HIGHESTMODSEQ\s+(\d+)"), + "exists": re.compile(rb"^(\d+)\s+EXISTS"), + } + for line in lines: + if not isinstance(line, (bytes, bytearray)): + continue + b = bytes(line) + for key, pat in patterns.items(): + m = pat.search(b) + if m: + out[key] = int(m.group(1)) + return out diff --git a/backend/app/providers/imap_smtp/provider.py b/backend/app/providers/imap_smtp/provider.py new file mode 100644 index 0000000..856295f --- /dev/null +++ b/backend/app/providers/imap_smtp/provider.py @@ -0,0 +1,312 @@ +"""``ImapSmtpProvider`` — IMAP (aioimaplib) + SMTP (aiosmtplib) MailProvider. + +One instance == one IMAP connection (SPEC §6.3 caps connections per account); +the sync engine creates separate instances for the IDLE loop vs. backfill/jobs. +Credentials arrive already decrypted (decryption lives only in workers / +accounts.test per SPEC §10.2). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import re +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import aiosmtplib +from aioimaplib import aioimaplib + +from app.providers.base import ( + FlagChange, + MailboxDelta, + MailProvider, + ProviderAuthError, + ProviderConnectError, + ProviderNotSupported, + RemoteMailbox, + RemoteMessage, +) +from app.providers.imap_smtp.parsing import ( + parse_fetch_lines, + parse_list_line, + parse_search_uids, + parse_select_untagged, +) + +_FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])" +_IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min +_CONNECT_TIMEOUT = 20 +_APPENDUID_RE = re.compile(rb"APPENDUID\s+\d+\s+(\d+)") +_COPYUID_RE = re.compile(rb"COPYUID\s+\d+\s+\S+\s+(\d+)") + + +@dataclass(frozen=True) +class ProviderConfig: + imap_host: str + imap_port: int + imap_tls: str # "ssl" | "starttls" | "plain" + smtp_host: str + smtp_port: int + smtp_tls: str # "ssl" | "starttls" | "plain" + username: str + password: str # plaintext; caller decrypts per §10.2 + + +class ImapSmtpProvider(MailProvider): + def __init__(self, config: ProviderConfig) -> None: + self._cfg = config + self._imap: aioimaplib.IMAP4 | None = None + self._selected: str | None = None + self._uidvalidity_seen: dict[str, int] = {} + + # --- connection lifecycle ------------------------------------------------ + + async def _connect(self) -> aioimaplib.IMAP4: + if self._imap is not None: + return self._imap + cfg = self._cfg + try: + if cfg.imap_tls == "ssl": + imap = aioimaplib.IMAP4_SSL( + host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT + ) + else: + imap = aioimaplib.IMAP4( + host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT + ) + await imap.wait_hello_from_server() + if cfg.imap_tls == "starttls": + await imap.starttls() + except (OSError, TimeoutError) as exc: + raise ProviderConnectError(f"cannot connect to {cfg.imap_host}: {exc}") from exc + + resp = await imap.login(cfg.username, cfg.password) + if resp.result != "OK": + detail = ( + b" ".join(resp.lines).decode("utf-8", "ignore") if resp.lines else "login failed" + ) + raise ProviderAuthError(detail) + self._imap = imap + return imap + + async def _select(self, mailbox: str) -> dict[str, int]: + imap = await self._connect() + resp = await imap.select(mailbox) + if resp.result != "OK": + raise ProviderConnectError(f"cannot select {mailbox!r}") + self._selected = mailbox + return parse_select_untagged(resp.lines) + + async def close(self) -> None: + if self._imap is not None: + with contextlib.suppress(Exception): # best-effort teardown + await self._imap.logout() + self._imap = None + self._selected = None + + # --- MailProvider protocol ---------------------------------------------- + + async def verify(self) -> None: + imap = await self._connect() + resp = await imap.list('""', "*") + if resp.result != "OK": + raise ProviderConnectError("LIST failed after login") + + async def list_mailboxes(self) -> list[RemoteMailbox]: + imap = await self._connect() + resp = await imap.list('""', "*") + if resp.result != "OK": + raise ProviderConnectError("LIST failed") + out: list[RemoteMailbox] = [] + for line in resp.lines: + if not isinstance(line, (bytes, bytearray)): + continue + parsed = parse_list_line(bytes(line)) + if parsed is None: + continue + uidvalidity = await self._mailbox_uidvalidity(parsed.path) + out.append( + RemoteMailbox( + path=parsed.path, special_use=parsed.special_use, uidvalidity=uidvalidity + ) + ) + return out + + async def _mailbox_uidvalidity(self, path: str) -> int: + imap = await self._connect() + try: + resp = await imap.status(path, "(UIDVALIDITY)") + except Exception: + return 0 + if resp.result != "OK": + return 0 + for line in resp.lines: + if isinstance(line, (bytes, bytearray)): + m = re.search(rb"UIDVALIDITY\s+(\d+)", bytes(line)) + if m: + return int(m.group(1)) + return 0 + + async def backfill( + self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200 + ) -> AsyncIterator[list[RemoteMessage]]: + info = await self._select(mailbox) + self._uidvalidity_seen[mailbox] = info.get("uidvalidity", 0) + imap = await self._connect() + search = await imap.uid_search("ALL") + uids = parse_search_uids(search.lines) + uids.sort(reverse=newest_first) + for start in range(0, len(uids), batch_size): + batch = uids[start : start + batch_size] + messages = await self._fetch_messages(imap, batch) + if messages: + yield messages + + async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]: + if not uids: + return [] + uid_set = ",".join(str(u) for u in uids) + resp = await imap.uid("fetch", uid_set, _FETCH_ITEMS) + if resp.result != "OK": + raise ProviderConnectError("FETCH failed") + out: list[RemoteMessage] = [] + for pf in parse_fetch_lines(resp.lines): + if pf.raw is None or pf.internal_date is None: + continue + out.append( + RemoteMessage( + uid=pf.uid, raw=pf.raw, flags=pf.flags, internal_date=pf.internal_date + ) + ) + return out + + async def poll( + self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None + ) -> MailboxDelta: + info = await self._select(mailbox) + imap = await self._connect() + current_uidvalidity = info.get("uidvalidity", 0) + new_uidvalidity: int | None = None + prev = self._uidvalidity_seen.get(mailbox) + if prev is not None and current_uidvalidity != prev: + new_uidvalidity = current_uidvalidity + self._uidvalidity_seen[mailbox] = current_uidvalidity + if new_uidvalidity is not None: + return MailboxDelta([], [], [], new_uidvalidity, info.get("highestmodseq")) + + # New messages: UID SEARCH beyond last_seen_uid (filter the M:* boundary echo). + search = await imap.uid_search(f"UID {last_seen_uid + 1}:*") + candidate_uids = [u for u in parse_search_uids(search.lines) if u > last_seen_uid] + new_messages = await self._fetch_messages(imap, sorted(candidate_uids)) + + # Flag changes via CONDSTORE when we have a modseq baseline. + flag_changes: list[FlagChange] = [] + if highestmodseq is not None: + fresp = await imap.uid("fetch", "1:*", f"(UID FLAGS) (CHANGEDSINCE {highestmodseq})") + if fresp.result == "OK": + new_uid_set = {m.uid for m in new_messages} + for pf in parse_fetch_lines(fresp.lines): + if pf.uid not in new_uid_set: + flag_changes.append(FlagChange(uid=pf.uid, flags=pf.flags)) + + # TODO(spec): expunge detection needs QRESYNC/VANISHED (M2 hardening). + return MailboxDelta( + new=new_messages, + flag_changes=flag_changes, + expunged_uids=[], + new_uidvalidity=None, + new_highestmodseq=info.get("highestmodseq"), + ) + + async def idle(self, mailbox: str) -> None: + imap = await self._connect() + if "IDLE" not in (imap.protocol.capabilities or set()): + raise ProviderNotSupported("server does not support IDLE") + await self._select(mailbox) + idle_task = await imap.idle_start(timeout=_IDLE_TIMEOUT) + try: + await imap.wait_server_push() + finally: + imap.idle_done() + with contextlib.suppress(Exception): + await asyncio.wait_for(idle_task, timeout=10) + + async def push_flags( + self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str] + ) -> None: + await self._select(mailbox) + imap = await self._connect() + if add: + await imap.uid("store", str(uid), "+FLAGS.SILENT", f"({' '.join(sorted(add))})") + if remove: + await imap.uid("store", str(uid), "-FLAGS.SILENT", f"({' '.join(sorted(remove))})") + + async def create_mailbox(self, path: str) -> None: + """Create a mailbox on the server (idempotent — ignores 'already exists').""" + imap = await self._connect() + with contextlib.suppress(Exception): + await imap.create(path) + + async def move(self, uid: int, src: str, dst: str) -> int: + await self._select(src) + imap = await self._connect() + resp = await imap.uid("move", str(uid), dst) + if resp.result != "OK": + raise ProviderConnectError(f"MOVE {uid} -> {dst} failed") + for line in resp.lines: + if isinstance(line, (bytes, bytearray)): + m = _COPYUID_RE.search(bytes(line)) + if m: + return int(m.group(1)) + return uid + + async def delete_messages(self, mailbox: str, uids: list[int]) -> None: + """Flag the given UIDs \\Deleted and EXPUNGE them from ``mailbox``.""" + if not uids: + return + await self._select(mailbox) + imap = await self._connect() + uid_set = ",".join(str(u) for u in uids) + await imap.uid("store", uid_set, "+FLAGS.SILENT", "(\\Deleted)") + try: + # UID EXPUNGE (UIDPLUS) removes only our UIDs; fall back to a plain + # EXPUNGE (all \Deleted in the mailbox) when unsupported. + await imap.uid("expunge", uid_set) + except Exception: + await imap.expunge() + + async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int: + imap = await self._connect() + flag_str = f"({' '.join(sorted(flags))})" if flags else None + resp = await imap.append(raw, mailbox=mailbox, flags=flag_str) + if resp.result != "OK": + raise ProviderConnectError(f"APPEND to {mailbox} failed") + for line in resp.lines: + if isinstance(line, (bytes, bytearray)): + m = _APPENDUID_RE.search(bytes(line)) + if m: + return int(m.group(1)) + return 0 + + async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None: + cfg = self._cfg + use_tls = cfg.smtp_tls == "ssl" + start_tls = cfg.smtp_tls == "starttls" + try: + await aiosmtplib.send( + raw, + sender=envelope_from, + recipients=envelope_to, + hostname=cfg.smtp_host, + port=cfg.smtp_port, + username=cfg.username, + password=cfg.password, + use_tls=use_tls, + start_tls=start_tls, + timeout=_CONNECT_TIMEOUT, + ) + except aiosmtplib.SMTPAuthenticationError as exc: + raise ProviderAuthError(f"SMTP auth failed: {exc}") from exc + except aiosmtplib.SMTPException as exc: + raise ProviderConnectError(f"SMTP send failed: {exc}") from exc diff --git a/backend/app/queue.py b/backend/app/queue.py new file mode 100644 index 0000000..ca99526 --- /dev/null +++ b/backend/app/queue.py @@ -0,0 +1,31 @@ +"""arq queue helpers (enqueue side, used by the API).""" + +from __future__ import annotations + +from arq import create_pool +from arq.connections import ArqRedis, RedisSettings + +from app.config import get_settings + +_pool: ArqRedis | None = None + + +def redis_settings() -> RedisSettings: + """Return arq RedisSettings derived from ``REDIS_URL``.""" + return RedisSettings.from_dsn(get_settings().redis_url) + + +async def get_arq_pool() -> ArqRedis: + """Return (creating on first use) the shared arq enqueue pool.""" + global _pool + if _pool is None: + _pool = await create_pool(redis_settings()) + return _pool + + +async def close_arq_pool() -> None: + """Close the arq pool.""" + global _pool + if _pool is not None: + await _pool.aclose() + _pool = None diff --git a/backend/app/redis_client.py b/backend/app/redis_client.py new file mode 100644 index 0000000..b78025c --- /dev/null +++ b/backend/app/redis_client.py @@ -0,0 +1,42 @@ +"""Shared async Redis client (sessions, rate limits, pub/sub, arq).""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, cast + +import redis.asyncio as aioredis + +from app.config import get_settings + + +def aw(result: Any) -> Awaitable[Any]: + """Normalize a redis-py ``ResponseT`` (``Awaitable | value``) to an awaitable. + + Some redis-py async commands (hash ops, getdel, ...) are typed as a + sync/async union; this keeps call sites awaitable under ``mypy --strict``. + """ + return cast("Awaitable[Any]", result) + + +_client: aioredis.Redis | None = None + + +def get_redis() -> aioredis.Redis: + """Return (creating on first use) the process-wide Redis client.""" + global _client + if _client is None: + _client = aioredis.Redis.from_url( + get_settings().redis_url, + encoding="utf-8", + decode_responses=True, + ) + return _client + + +async def close_redis() -> None: + """Close the Redis client (used on app shutdown).""" + global _client + if _client is not None: + await _client.aclose() + _client = None diff --git a/backend/app/search/__init__.py b/backend/app/search/__init__.py new file mode 100644 index 0000000..f9b455d --- /dev/null +++ b/backend/app/search/__init__.py @@ -0,0 +1 @@ +"""Search: engine-independent query grammar + pluggable backends (SPEC §5.2, §8).""" diff --git a/backend/app/search/base.py b/backend/app/search/base.py new file mode 100644 index 0000000..def5e52 --- /dev/null +++ b/backend/app/search/base.py @@ -0,0 +1,58 @@ +# INTERFACE VERSION: 1 +"""SearchBackend — the frozen search contract (SPEC §5.2). + +Both backends (``pg.py`` for M0-M1, ``meili.py`` for M2) index the same +:class:`SearchDoc` and honour invariant I-7 (every query carries ``user_id``). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol +from uuid import UUID + + +@dataclass(frozen=True) +class SearchQuery: + text: str | None # free-text remainder after operator extraction + filters: dict[str, object] # parsed operators, keys per §8.2 + user_id: UUID # ALWAYS set (invariant I-7) + + +@dataclass(frozen=True) +class SearchHit: + message_id: UUID + thread_id: UUID + score: float + highlights: dict[str, str] # field -> HTML-escaped snippet with + + +@dataclass(frozen=True) +class SearchDoc: + """The document indexed by every backend (SPEC §5.2).""" + + message_id: UUID + thread_id: UUID + user_id: UUID + account_id: UUID + mailbox_special_use: str | None + from_: list[dict[str, str]] # name+email + to: list[dict[str, str]] + cc: list[dict[str, str]] + subject: str + body_text: str + attachment_names: list[str] = field(default_factory=list) + has_attachments: bool = False + is_unread: bool = False + is_starred: bool = False + date: int = 0 # unix seconds + size_bytes: int = 0 + + +class SearchBackend(Protocol): + async def index(self, doc: SearchDoc) -> None: ... + async def delete(self, message_id: UUID) -> None: ... + async def delete_user(self, user_id: UUID) -> None: ... + async def query( + self, q: SearchQuery, *, limit: int, cursor: str | None + ) -> tuple[list[SearchHit], str | None]: ... diff --git a/backend/app/search/factory.py b/backend/app/search/factory.py new file mode 100644 index 0000000..66e50f0 --- /dev/null +++ b/backend/app/search/factory.py @@ -0,0 +1,21 @@ +"""Select the active SearchBackend from config (SPEC §8.1, env SEARCH_BACKEND).""" + +from __future__ import annotations + +from app.config import get_settings +from app.search.base import SearchBackend +from app.search.pg import PgSearchBackend + +_backend: SearchBackend | None = None + + +def get_search_backend() -> SearchBackend: + """Return the process-wide SearchBackend (``pg`` in M0-M1, ``meili`` in M2).""" + global _backend + if _backend is None: + which = get_settings().search_backend + if which == "meili": + # TODO(spec): Meilisearch backend lands in M2. + raise NotImplementedError("meili backend is implemented in M2") + _backend = PgSearchBackend() + return _backend diff --git a/backend/app/search/pg.py b/backend/app/search/pg.py new file mode 100644 index 0000000..7c3805f --- /dev/null +++ b/backend/app/search/pg.py @@ -0,0 +1,271 @@ +"""Postgres full-text SearchBackend (SPEC §5.2, §8; M0-M1). + +Indexing populates ``messages.body_indexed`` (a ``tsvector``) from the doc's +searchable fields. Querying translates the parsed §8.2 groups (AND of ORs) into +SQL over messages joined to mailboxes/accounts, always filtered by ``user_id`` +(invariant I-7). Relevance ranking / typo tolerance is Meilisearch's job (M2); +here results are date-ordered with keyset pagination. +""" + +from __future__ import annotations + +import base64 +import datetime as dt +import html +import re +from uuid import UUID + +from sqlalchemy import Text, any_, cast, exists, func, literal, not_, or_, select, tuple_, update +from sqlalchemy.sql.elements import ColumnElement + +from app.db import get_sessionmaker +from app.models import Attachment, Mailbox, Message +from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery + +_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0) +_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365} + + +def _doc_text_blob(doc: SearchDoc) -> str: + parts = [doc.subject, doc.body_text] + for field in (doc.from_, doc.to, doc.cc): + for addr in field: + parts.append(addr.get("name", "")) + parts.append(addr.get("email", "")) + parts.extend(doc.attachment_names) + return " ".join(p for p in parts if p) + + +def _parse_date(value: str) -> dt.datetime | None: + try: + return dt.datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=dt.UTC) + except ValueError: + return None + + +def _parse_relative(value: str) -> dt.timedelta | None: + m = re.fullmatch(r"(\d+)([dwmy])", value.strip().lower()) + if not m: + return None + n = int(m.group(1)) * _RELATIVE_MULT[m.group(2)] + return dt.timedelta(days=n) + + +def _parse_size(value: str) -> int | None: + m = re.fullmatch(r"(\d+)([km])", value.strip().lower()) + if not m: + return None + return int(m.group(1)) * (1024 if m.group(2) == "k" else 1024 * 1024) + + +def _flag_condition(flag: str) -> ColumnElement[bool]: + # flag = ANY(messages.flags) — the array membership operator, not func any(). + return literal(flag) == any_(Message.flags) + + +class PgSearchBackend(SearchBackend): + async def index(self, doc: SearchDoc) -> None: + blob = _doc_text_blob(doc) + async with get_sessionmaker()() as session: + await session.execute( + update(Message) + .where(Message.id == doc.message_id, Message.user_id == doc.user_id) + .values(body_indexed=func.to_tsvector(_TS_CONFIG, blob)) + ) + await session.commit() + + async def delete(self, message_id: UUID) -> None: + async with get_sessionmaker()() as session: + await session.execute( + update(Message).where(Message.id == message_id).values(body_indexed=None) + ) + await session.commit() + + async def delete_user(self, user_id: UUID) -> None: + # User deletion cascades message rows (FK ON DELETE CASCADE); clearing the + # tsvector here keeps the backend contract satisfied for partial re-index. + async with get_sessionmaker()() as session: + await session.execute( + update(Message).where(Message.user_id == user_id).values(body_indexed=None) + ) + await session.commit() + + # --- querying ------------------------------------------------------------ + + def _clause_condition(self, clause: dict[str, object]) -> ColumnElement[bool] | None: + value = str(clause.get("value", "")) + if clause.get("type") == "text": + return Message.body_indexed.op("@@")(func.websearch_to_tsquery(_TS_CONFIG, value)) + key = clause.get("key") + cond = self._filter_condition(str(key), value) + return cond + + def _filter_condition(self, key: str, value: str) -> ColumnElement[bool] | None: + like = f"%{value}%" + if key == "from": + return cast(Message.from_addr, Text).ilike(like) + if key == "to": + return cast(Message.to_addr, Text).ilike(like) + if key == "cc": + return cast(Message.cc_addr, Text).ilike(like) + if key == "subject": + return Message.subject.ilike(like) + if key == "in": + return or_(Mailbox.special_use == value.lower(), Mailbox.path.ilike(like)) + if key == "is": + v = value.lower() + if v == "unread": + return not_(_flag_condition("\\Seen")) + if v == "read": + return _flag_condition("\\Seen") + if v == "starred": + return _flag_condition("\\Flagged") + if v == "answered": + return _flag_condition("\\Answered") + return None + if key == "has": + return Message.has_attachments.is_(True) if value.lower() == "attachment" else None + if key == "filename": + return exists().where( + Attachment.message_id == Message.id, Attachment.filename.ilike(like) + ) + if key == "before": + d = _parse_date(value) + return Message.internal_date < d if d else None + if key == "after": + d = _parse_date(value) + return Message.internal_date >= d if d else None + if key == "newer_than": + delta = _parse_relative(value) + return Message.internal_date >= func.now() - delta if delta else None + if key == "older_than": + delta = _parse_relative(value) + return Message.internal_date < func.now() - delta if delta else None + if key == "larger": + n = _parse_size(value) + return Message.size_bytes > n if n is not None else None + if key == "smaller": + n = _parse_size(value) + return Message.size_bytes < n if n is not None else None + return None + + def _build_conditions(self, groups: list[list[dict[str, object]]]) -> list[ColumnElement[bool]]: + conditions: list[ColumnElement[bool]] = [] + for group in groups: + ors: list[ColumnElement[bool]] = [] + for clause in group: + cond = self._clause_condition(clause) + if cond is None: + continue + if clause.get("negate"): + cond = not_(cond) + ors.append(cond) + if ors: + conditions.append(or_(*ors)) + return conditions + + async def query( + self, q: SearchQuery, *, limit: int, cursor: str | None + ) -> tuple[list[SearchHit], str | None]: + groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else [] + assert isinstance(groups, list) + conditions = self._build_conditions(groups) + + rank = ( + func.ts_rank_cd(Message.body_indexed, func.websearch_to_tsquery(_TS_CONFIG, q.text)) + if q.text + else literal(0.0) + ) + stmt = ( + select( + Message.id, + Message.thread_id, + Message.subject, + Message.snippet, + Message.internal_date, + rank.label("score"), + ) + .select_from(Message) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where(Message.user_id == q.user_id, Message.thread_id.is_not(None)) + ) + for cond in conditions: + stmt = stmt.where(cond) + + decoded = _decode_cursor(cursor) + if decoded is not None: + cur_date, cur_id = decoded + stmt = stmt.where( + tuple_(Message.internal_date, Message.id) + < tuple_(literal(cur_date), literal(cur_id)) + ) + stmt = stmt.order_by(Message.internal_date.desc(), Message.id.desc()).limit(limit + 1) + + terms = _positive_text_terms(groups) + async with get_sessionmaker()() as session: + rows = (await session.execute(stmt)).all() + + has_more = len(rows) > limit + rows = rows[:limit] + hits = [ + SearchHit( + message_id=row.id, + thread_id=row.thread_id, + score=float(row.score or 0.0), + highlights=_highlights(row.subject, row.snippet, terms), + ) + for row in rows + ] + next_cursor = ( + _encode_cursor(rows[-1].internal_date, rows[-1].id) if has_more and rows else None + ) + return hits, next_cursor + + +def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]: + terms: list[str] = [] + for group in groups: + for clause in group: + if clause.get("type") == "text" and not clause.get("negate"): + terms.append(str(clause.get("value", ""))) + return terms + + +def _highlights(subject: str, snippet: str, terms: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + if not terms: + return out + marked_subject = _mark(subject, terms) + if "" in marked_subject: + out["subject"] = marked_subject + marked_body = _mark(snippet, terms) + if "" in marked_body: + out["body"] = marked_body + return out + + +def _mark(value: str, terms: list[str]) -> str: + """HTML-escape ``value`` and wrap term matches in (SPEC §5.2 highlights).""" + escaped = html.escape(value) + for term in terms: + if not term: + continue + pattern = re.compile(re.escape(html.escape(term)), re.IGNORECASE) + escaped = pattern.sub(lambda m: f"{m.group(0)}", escaped) + return escaped + + +def _encode_cursor(date: dt.datetime, message_id: UUID) -> str: + raw = f"{date.isoformat()}|{message_id}" + return base64.urlsafe_b64encode(raw.encode()).decode() + + +def _decode_cursor(cursor: str | None) -> tuple[dt.datetime, UUID] | None: + if not cursor: + return None + try: + raw = base64.urlsafe_b64decode(cursor.encode()).decode() + date_str, id_str = raw.split("|", 1) + return dt.datetime.fromisoformat(date_str), UUID(id_str) + except (ValueError, TypeError): + return None diff --git a/backend/app/search/queryparser.py b/backend/app/search/queryparser.py new file mode 100644 index 0000000..e327fc9 --- /dev/null +++ b/backend/app/search/queryparser.py @@ -0,0 +1,158 @@ +"""Operator query grammar (SPEC §8.2), engine-independent. + +Tokenization: whitespace split respecting ``"quoted phrases"``. A ``key:value`` +(or ``-key:value``) token with a known key becomes a filter; everything else is +free text. ``-`` negates. ``OR`` (uppercase) groups adjacent clauses into a +disjunction; the default combinator is AND. Unknown ``key:`` tokens are treated +as free text (forward compatibility). The parser never raises. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from app.search.base import SearchQuery + +# Known operator keys (SPEC §8.2). Unknown keys fall through to free text. +KNOWN_KEYS: frozenset[str] = frozenset( + { + "from", + "to", + "cc", + "subject", + "account", + "in", + "is", + "has", + "filename", + "before", + "after", + "newer_than", + "older_than", + "larger", + "smaller", + } +) + + +@dataclass(frozen=True) +class Clause: + """One parsed unit: a free-text term or a ``key:value`` filter.""" + + type: str # "text" | "filter" + key: str | None # operator key for filters, else None + value: str + negate: bool + phrase: bool # value was a quoted phrase + + def as_dict(self) -> dict[str, object]: + return { + "type": self.type, + "key": self.key, + "value": self.value, + "negate": self.negate, + "phrase": self.phrase, + } + + +# A parsed query is an AND of groups; each group is an OR of clauses. +ParsedQuery = list[list[Clause]] + + +@dataclass(frozen=True) +class _Token: + text: str + negate: bool + starts_with_quote: bool # a bare quoted phrase -> never a key:value filter + + +def _tokenize(query: str) -> list[_Token]: + """Split into tokens honouring quotes; never raises on unbalanced quotes. + + Tracks a leading ``-`` (negation) and whether the token began with a quote + (a bare phrase), so ``key:"quoted value"`` is still recognised as a filter + while ``"key: value"`` stays free text. + """ + tokens: list[_Token] = [] + i, n = 0, len(query) + while i < n: + while i < n and query[i].isspace(): + i += 1 + if i >= n: + break + negate = False + if query[i] == "-" and i + 1 < n and not query[i + 1].isspace(): + negate = True + i += 1 + buf: list[str] = [] + starts_with_quote = i < n and query[i] == '"' + while i < n and not query[i].isspace(): + if query[i] == '"': + i += 1 + while i < n and query[i] != '"': + buf.append(query[i]) + i += 1 + if i < n and query[i] == '"': + i += 1 # consume closing quote + else: + buf.append(query[i]) + i += 1 + tokens.append(_Token(text="".join(buf), negate=negate, starts_with_quote=starts_with_quote)) + return tokens + + +def _to_clause(token: _Token) -> Clause | None: + if not token.text and not token.starts_with_quote: + return None + if not token.starts_with_quote and ":" in token.text: + key, _, value = token.text.partition(":") + if key.lower() in KNOWN_KEYS and value: + return Clause( + type="filter", + key=key.lower(), + value=value, + negate=token.negate, + phrase=(" " in value), + ) + return Clause( + type="text", + key=None, + value=token.text, + negate=token.negate, + phrase=token.starts_with_quote or (" " in token.text), + ) + + +def parse(query: str) -> ParsedQuery: + """Parse ``query`` into AND-of-OR groups of :class:`Clause`.""" + groups: ParsedQuery = [] + pending_or = False + for token in _tokenize(query): + if token.text == "OR" and not token.negate and not token.starts_with_quote: + pending_or = True + continue + clause = _to_clause(token) + if clause is None: + continue + if pending_or and groups: + groups[-1].append(clause) + else: + groups.append([clause]) + pending_or = False + return groups + + +def free_text(groups: ParsedQuery) -> str | None: + """Return the positive free-text remainder (standalone, non-negated text terms).""" + terms = [g[0].value for g in groups if len(g) == 1 and g[0].type == "text" and not g[0].negate] + return " ".join(terms) if terms else None + + +def to_search_query(query: str, user_id: UUID) -> SearchQuery: + """Parse ``query`` into a :class:`SearchQuery` (I-7: ``user_id`` always set).""" + groups = parse(query) + filters: dict[str, object] = { + "groups": [[clause.as_dict() for clause in group] for group in groups] + } + return SearchQuery(text=free_text(groups), filters=filters, user_id=user_id) diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py new file mode 100644 index 0000000..d8d2e71 --- /dev/null +++ b/backend/app/security/__init__.py @@ -0,0 +1 @@ +"""Security primitives: credential vault, CSRF, secret types (SPEC §10).""" diff --git a/backend/app/security/crypto.py b/backend/app/security/crypto.py new file mode 100644 index 0000000..3aa8c2b --- /dev/null +++ b/backend/app/security/crypto.py @@ -0,0 +1,77 @@ +"""Credential vault — envelope encryption (SPEC §10.2). + +Mailbox secrets are encrypted with a fresh AES-256-GCM *data key*; the data key +is itself wrapped with the master key from ``MASTER_KEY`` (32 bytes, base64). A +key id is embedded to allow future master-key rotation. + +Wire format of ``secret_ct`` (all lengths fixed unless noted):: + + b"WMV1" # 4-byte magic + version + key_id # 1 byte (master key id, 0 for now) + wrap_nonce # 12 bytes (GCM nonce for the wrapped data key) + wrapped_key # 32 + 16 bytes (data key ciphertext + tag) + data_nonce # 12 bytes (GCM nonce for the payload) + payload_ciphertext # variable (payload + 16-byte tag) + +The decryption path exists only in workers and ``accounts/test`` (SPEC §10.2); +this module enforces nothing about callers but is imported only from there. +""" + +from __future__ import annotations + +import base64 +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from app.config import get_settings + +_MAGIC = b"WMV1" +_CURRENT_KEY_ID = 0 + + +class VaultError(Exception): + """Raised when a ciphertext is malformed or cannot be authenticated.""" + + +def _master_key() -> bytes: + raw = base64.b64decode(get_settings().master_key) + if len(raw) != 32: + raise VaultError("MASTER_KEY must decode to exactly 32 bytes") + return raw + + +def encrypt_secret(plaintext: bytes) -> bytes: + """Envelope-encrypt ``plaintext`` and return the ``secret_ct`` blob.""" + data_key = AESGCM.generate_key(256) + wrap_nonce = os.urandom(12) + wrapped = AESGCM(_master_key()).encrypt(wrap_nonce, data_key, _MAGIC) + + data_nonce = os.urandom(12) + ciphertext = AESGCM(data_key).encrypt(data_nonce, plaintext, _MAGIC) + + return _MAGIC + bytes([_CURRENT_KEY_ID]) + wrap_nonce + wrapped + data_nonce + ciphertext + + +def decrypt_secret(blob: bytes) -> bytes: + """Authenticate and decrypt a ``secret_ct`` blob back to plaintext bytes.""" + if len(blob) < 4 + 1 + 12 + 48 + 12 or blob[:4] != _MAGIC: + raise VaultError("malformed secret ciphertext") + pos = 4 + key_id = blob[pos] + pos += 1 + if key_id != _CURRENT_KEY_ID: + raise VaultError(f"unknown master key id {key_id}") + wrap_nonce = blob[pos : pos + 12] + pos += 12 + wrapped = blob[pos : pos + 48] # 32-byte key + 16-byte GCM tag + pos += 48 + data_nonce = blob[pos : pos + 12] + pos += 12 + ciphertext = blob[pos:] + + try: + data_key = AESGCM(_master_key()).decrypt(wrap_nonce, wrapped, _MAGIC) + return AESGCM(data_key).decrypt(data_nonce, ciphertext, _MAGIC) + except Exception as exc: # cryptography raises InvalidTag etc. + raise VaultError("secret decryption failed") from exc diff --git a/backend/app/sync/__init__.py b/backend/app/sync/__init__.py new file mode 100644 index 0000000..5ed92ac --- /dev/null +++ b/backend/app/sync/__init__.py @@ -0,0 +1 @@ +"""Sync engine: bootstrap, backfill, live IDLE/poll loops (SPEC §6.3).""" diff --git a/backend/app/sync/actions.py b/backend/app/sync/actions.py new file mode 100644 index 0000000..f7d2028 --- /dev/null +++ b/backend/app/sync/actions.py @@ -0,0 +1,287 @@ +"""Outbound user actions: flag changes and moves (SPEC §6.3). + +The API writes the change to the DB first, then enqueues a job here to push it to +the mail server. On provider failure the job retries (arq, ×5 exponential) and, on +final failure, reverts the DB change and emits ``flags.changed`` with +``reverted=true`` so the UI can roll back. +""" + +from __future__ import annotations + +import logging +import uuid +from collections.abc import Iterable +from typing import Any + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import session_scope +from app.events import get_event_bus +from app.models import Account, Draft, Mailbox, Message, Thread +from app.sync.provider_factory import build_provider +from app.threading_.threader import denormalize_thread + +logger = logging.getLogger(__name__) + +# User-settable IMAP flags (SPEC §8.2 is:read/starred/answered). \Deleted/\Draft +# are managed by move/expunge and drafts, not this surface. +ALLOWED_FLAGS = frozenset({"\\Seen", "\\Flagged", "\\Answered"}) +_MAX_TRIES = 5 + +# Folder names to create when a target special-use folder is missing on the server. +_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"} + + +def sanitize_flags(flags: Iterable[str]) -> frozenset[str]: + return frozenset(f for f in flags if f in ALLOWED_FLAGS) + + +async def apply_flags_to_messages( + session: AsyncSession, messages: list[Message], add: frozenset[str], remove: frozenset[str] +) -> set[uuid.UUID]: + """Apply a flag delta to messages in the DB and re-denormalize their threads.""" + thread_ids: set[uuid.UUID] = set() + for message in messages: + message.flags = sorted((set(message.flags) | add) - remove) + if message.thread_id is not None: + thread_ids.add(message.thread_id) + await session.flush() + for tid in thread_ids: + await denormalize_thread(session, tid) + return thread_ids + + +# --- worker jobs ------------------------------------------------------------- + + +async def push_flags_job( + ctx: dict[str, Any], message_id: str, add: list[str], remove: list[str] +) -> None: + """Push a flag change to the mail server; revert the DB on final failure.""" + mid = uuid.UUID(message_id) + async with session_scope() as session: + message = await session.get(Message, mid) + if message is None: + return + account = await session.get(Account, message.account_id) + mailbox = await session.get(Mailbox, message.mailbox_id) + if account is None or mailbox is None: + return + user_id, uid, path = message.user_id, message.uid, mailbox.path + + provider = build_provider(account) + try: + await provider.push_flags(path, uid, frozenset(add), frozenset(remove)) + except Exception: + if ctx.get("job_try", 1) >= _MAX_TRIES: + await _revert_flags(mid, user_id, add, remove) + return + raise # arq retries with backoff + finally: + await provider.close() + + +async def _revert_flags( + message_id: uuid.UUID, user_id: uuid.UUID, add: list[str], remove: list[str] +) -> None: + async with session_scope() as session: + message = await session.get(Message, message_id) + if message is None: + return + # Undo the change: remove what we added, add back what we removed. + message.flags = sorted((set(message.flags) - set(add)) | set(remove)) + await session.flush() + if message.thread_id is not None: + await denormalize_thread(session, message.thread_id) + flags = list(message.flags) + await get_event_bus().publish( + "flags.changed", + {"user_id": str(user_id), "message_id": str(message_id), "flags": flags, "reverted": True}, + ) + logger.warning("reverted flag change for message %s after push failures", message_id) + + +async def move_job( + ctx: dict[str, Any], message_id: str, dst_mailbox_id: str, src_mailbox_id: str +) -> None: + """Move a message on the server; update its uid, or revert the DB on failure.""" + mid = uuid.UUID(message_id) + dst_id = uuid.UUID(dst_mailbox_id) + async with session_scope() as session: + message = await session.get(Message, mid) + if message is None: + return + account = await session.get(Account, message.account_id) + dst = await session.get(Mailbox, dst_id) + src = await session.get(Mailbox, uuid.UUID(src_mailbox_id)) + if account is None or dst is None or src is None: + return + src_path = src.path + uid, dst_path, user_id = message.uid, dst.path, message.user_id + + provider = build_provider(account) + try: + new_uid = await provider.move(uid, src_path, dst_path) + except Exception: + if ctx.get("job_try", 1) >= _MAX_TRIES: + logger.warning("move failed for message %s; leaving optimistic state", mid) + return + raise + finally: + await provider.close() + + async with session_scope() as session: + message = await session.get(Message, mid) + if message is not None: + message.uid = new_uid + message.mailbox_id = dst_id + await get_event_bus().publish( + "message.changed", + {"user_id": str(user_id), "message_id": str(mid), "mailbox_id": str(dst_id)}, + ) + + +async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special_use: str) -> None: + """Move every message in a thread to each account's special-use folder. + + Creates the target folder (e.g. Archive) if the account doesn't have one. + Used by the Archive and Delete (-> Trash) thread actions. + """ + tid = uuid.UUID(thread_id) + async with session_scope() as session: + messages = list(await session.scalars(select(Message).where(Message.thread_id == tid))) + if not messages: + return + user_id = messages[0].user_id + account_ids = list({m.account_id for m in messages}) + accounts = { + a.id: a + for a in await session.scalars(select(Account).where(Account.id.in_(account_ids))) + } + mailboxes = list( + await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids))) + ) + path_by_id = {mb.id: mb.path for mb in mailboxes} + target_by_account = {mb.account_id: mb for mb in mailboxes if mb.special_use == special_use} + target_id_path = {mb.account_id: (mb.id, mb.path) for mb in target_by_account.values()} + snapshot = [(m.id, m.account_id, m.mailbox_id, m.uid) for m in messages] + + # (message_id, account_id, target_path, new_uid) applied after the network moves. + moves: list[tuple[uuid.UUID, uuid.UUID, str, int]] = [] + to_create: list[tuple[uuid.UUID, str, int]] = [] # account_id, path, uidvalidity + + for account_id in account_ids: + provider = build_provider(accounts[account_id]) + try: + if account_id in target_id_path: + target_id, target_path = target_id_path[account_id] + else: + target_id, target_path = None, _DEFAULT_FOLDER_NAME.get(special_use, special_use) + await provider.create_mailbox(target_path) + remote = await provider.list_mailboxes() + uidv = next((r.uidvalidity for r in remote if r.path == target_path), 0) + to_create.append((account_id, target_path, uidv)) + for mid, m_account, m_mailbox, m_uid in snapshot: + if m_account != account_id or m_mailbox == target_id: + continue + src_path = path_by_id.get(m_mailbox, "INBOX") + try: + new_uid = await provider.move(m_uid, src_path, target_path) + except Exception: + logger.exception("move failed for message %s -> %s", mid, target_path) + continue + moves.append((mid, account_id, target_path, new_uid)) + finally: + await provider.close() + + async with session_scope() as session: + created: dict[tuple[uuid.UUID, str], uuid.UUID] = {} + for account_id, path, uidv in to_create: + mailbox = Mailbox( + account_id=account_id, + user_id=user_id, + path=path, + special_use=special_use, + uidvalidity=uidv, + ) + session.add(mailbox) + await session.flush() + created[(account_id, path)] = mailbox.id + for mid, account_id, target_path, new_uid in moves: + resolved = target_id_path.get(account_id, (None, ""))[0] or created.get( + (account_id, target_path) + ) + message = await session.get(Message, mid) + if message is not None and resolved is not None: + message.mailbox_id = resolved + message.uid = new_uid + + await get_event_bus().publish( + "thread.updated", {"user_id": str(user_id), "thread_id": str(tid)} + ) + + +async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None: + """Permanently delete every message in a thread (server EXPUNGE + DB rows). + + Used by the Trash folder's Delete action. Best-effort per message on the + server; the DB rows (and cascaded attachments/body parts) are always removed. + """ + tid = uuid.UUID(thread_id) + async with session_scope() as session: + messages = list(await session.scalars(select(Message).where(Message.thread_id == tid))) + if not messages: + return + user_id = messages[0].user_id + account_ids = list({m.account_id for m in messages}) + accounts = { + a.id: a + for a in await session.scalars(select(Account).where(Account.id.in_(account_ids))) + } + mailboxes = list( + await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids))) + ) + path_by_id = {mb.id: mb.path for mb in mailboxes} + # (account_id, mailbox_path) -> [uids] to expunge on the server. + by_box: dict[tuple[uuid.UUID, str], list[int]] = {} + for m in messages: + by_box.setdefault((m.account_id, path_by_id.get(m.mailbox_id, "INBOX")), []).append( + m.uid + ) + + for account_id in account_ids: + provider = build_provider(accounts[account_id]) + try: + for (acct, path), uids in by_box.items(): + if acct != account_id: + continue + try: + await provider.delete_messages(path, uids) + except Exception: + logger.exception("expunge failed for %s in %s", uids, path) + finally: + await provider.close() + + async with session_scope() as session: + msgs = list(await session.scalars(select(Message).where(Message.thread_id == tid))) + msg_ids = [m.id for m in msgs] + # Drop draft references to the thread/messages (FKs lack ON DELETE CASCADE). + if msg_ids: + await session.execute( + update(Draft) + .where(Draft.reply_to_message_id.in_(msg_ids)) + .values(reply_to_message_id=None) + ) + await session.execute( + update(Draft).where(Draft.thread_id == tid).values(thread_id=None) + ) + for m in msgs: + await session.delete(m) + thread = await session.get(Thread, tid) + if thread is not None: + await session.delete(thread) + + await get_event_bus().publish( + "thread.updated", {"user_id": str(user_id), "thread_id": str(tid)} + ) diff --git a/backend/app/sync/engine.py b/backend/app/sync/engine.py new file mode 100644 index 0000000..e81578b --- /dev/null +++ b/backend/app/sync/engine.py @@ -0,0 +1,276 @@ +"""Sync engine: bootstrap mailboxes, backfill, and the live IDLE/poll loop (SPEC §6.3).""" + +from __future__ import annotations + +import asyncio +import logging +import uuid + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings +from app.events import get_event_bus +from app.indexer.indexer import PersistResult, persist_message +from app.models import Account, Mailbox, Message +from app.providers.base import MailboxDelta, ProviderNotSupported +from app.providers.imap_smtp import ImapSmtpProvider +from app.search.factory import get_search_backend +from app.sync.folder_names import infer_special_use +from app.sync.provider_factory import build_provider + +logger = logging.getLogger(__name__) + +# Backfill priority: inbox, then sent/drafts, then everything else (SPEC §6.3). +_PRIORITY = {"inbox": 0, "sent": 1, "drafts": 2} + + +async def _load_account(session: AsyncSession, account_id: uuid.UUID) -> Account: + account = await session.get(Account, account_id) + if account is None: + raise ValueError(f"account {account_id} not found") + return account + + +async def _set_state(account_id: uuid.UUID, state: str, detail: str = "") -> None: + from app.db import session_scope + + async with session_scope() as session: + await session.execute( + update(Account).where(Account.id == account_id).values(state=state, state_detail=detail) + ) + bus = get_event_bus() + account = None + async with session_scope() as session: + account = await session.get(Account, account_id) + if account is not None: + await bus.publish( + "account.state_changed", + {"user_id": str(account.user_id), "account_id": str(account_id), "state": state}, + ) + + +async def bootstrap_mailboxes( + session: AsyncSession, provider: ImapSmtpProvider, account: Account +) -> list[Mailbox]: + """List remote mailboxes and upsert local ``mailboxes`` rows (SPEC §6.3).""" + remote = await provider.list_mailboxes() + result: list[Mailbox] = [] + for rm in remote: + special = rm.special_use or infer_special_use(rm.path) + existing = await session.scalar( + select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.path == rm.path) + ) + if existing is None: + mailbox = Mailbox( + account_id=account.id, + user_id=account.user_id, + path=rm.path, + special_use=special, + uidvalidity=rm.uidvalidity, + ) + session.add(mailbox) + else: + existing.special_use = special + existing.uidvalidity = rm.uidvalidity + mailbox = existing + result.append(mailbox) + await session.flush() + return result + + +def _mailbox_order(mailboxes: list[Mailbox]) -> list[Mailbox]: + return sorted(mailboxes, key=lambda m: _PRIORITY.get(m.special_use or "", 99)) + + +async def _emit_batch( + results: list[PersistResult], account: Account, mailbox_path: str, done: int +) -> None: + backend = get_search_backend() + bus = get_event_bus() + for r in results: + await backend.index(r.search_doc) + await bus.publish( + "message.new", + { + "user_id": str(account.user_id), + "account_id": str(account.id), + "message_id": str(r.message.id), + "thread_id": str(r.message.thread_id), + }, + ) + await bus.publish( + "sync.progress", + { + "user_id": str(account.user_id), + "account_id": str(account.id), + "mailbox": mailbox_path, + "done": done, + }, + ) + + +async def backfill_mailbox(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> int: + """Backfill one mailbox newest-first, indexing inline per batch (FR-30).""" + from app.db import session_scope + + account_id, user_id, mailbox_id = account.id, account.user_id, mailbox.id + special_use, path = mailbox.special_use, mailbox.path + done = 0 + async for batch in provider.backfill(path, batch_size=200): + results: list[PersistResult] = [] + max_uid = 0 + async with session_scope() as session: + for remote in batch: + r = await persist_message( + session, + user_id=user_id, + account_id=account_id, + mailbox_id=mailbox_id, + mailbox_special_use=special_use, + remote=remote, + ) + if r is not None: + results.append(r) + max_uid = max(max_uid, remote.uid) + await session.execute( + update(Mailbox) + .where(Mailbox.id == mailbox_id, Mailbox.last_seen_uid < max_uid) + .values(last_seen_uid=max_uid) + ) + done += len(results) + await _emit_batch(results, account, path, done) + return done + + +async def bootstrap_and_backfill(account_id: uuid.UUID) -> int: + """Full initial sync for an account: mailboxes + backfill; sets state=live.""" + from app.db import session_scope + + async with session_scope() as session: + account = await _load_account(session, account_id) + provider = build_provider(account) + mailboxes = _mailbox_order(await bootstrap_mailboxes(session, provider, account)) + # Detach: reuse account/mailboxes values after the session closes. + account_obj = account + mailbox_list = list(mailboxes) + + total = 0 + try: + for mailbox in mailbox_list: + total += await backfill_mailbox(provider, account_obj, mailbox) + await _set_state(account_id, "live") + except Exception as exc: # noqa: BLE001 - surface provider errors as account state + logger.exception("backfill failed for account %s", account_id) + await _set_state(account_id, "error", str(exc)[:500]) + raise + finally: + await provider.close() + return total + + +async def apply_delta( + provider: ImapSmtpProvider, account: Account, mailbox: Mailbox, delta: MailboxDelta +) -> None: + """Apply a poll delta: new messages, flag changes, uidvalidity resets (SPEC §6.3).""" + from app.db import session_scope + + bus = get_event_bus() + + if delta.new_uidvalidity is not None: + # UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3). + async with session_scope() as session: + await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id)) + await session.execute( + update(Mailbox) + .where(Mailbox.id == mailbox.id) + .values(uidvalidity=delta.new_uidvalidity, last_seen_uid=0) + ) + await backfill_mailbox(provider, account, mailbox) + return + + results: list[PersistResult] = [] + max_uid = mailbox.last_seen_uid + async with session_scope() as session: + for remote in delta.new: + r = await persist_message( + session, + user_id=account.user_id, + account_id=account.id, + mailbox_id=mailbox.id, + mailbox_special_use=mailbox.special_use, + remote=remote, + ) + if r is not None: + results.append(r) + max_uid = max(max_uid, remote.uid) + for change in delta.flag_changes: + await session.execute( + update(Message) + .where(Message.mailbox_id == mailbox.id, Message.uid == change.uid) + .values(flags=sorted(change.flags)) + ) + if delta.expunged_uids: + await session.execute( + delete(Message).where( + Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids) + ) + ) + updates = {"last_seen_uid": max_uid} + if delta.new_highestmodseq is not None: + updates["highestmodseq"] = delta.new_highestmodseq + await session.execute(update(Mailbox).where(Mailbox.id == mailbox.id).values(**updates)) + + await _emit_batch(results, account, mailbox.path, len(results)) + for change in delta.flag_changes: + await bus.publish( + "flags.changed", + { + "user_id": str(account.user_id), + "account_id": str(account.id), + "mailbox": mailbox.path, + "uid": change.uid, + "flags": sorted(change.flags), + }, + ) + + +async def live_loop( + account_id: uuid.UUID, mailbox_path: str = "INBOX", stop: asyncio.Event | None = None +) -> None: + """Maintain a live IDLE/poll loop for one mailbox (SPEC §6.3).""" + from app.db import session_scope + + stop = stop or asyncio.Event() + poll_interval = get_settings().poll_interval + + async with session_scope() as session: + account = await _load_account(session, account_id) + account_obj = account + + provider = build_provider(account_obj) + try: + while not stop.is_set(): + try: + await provider.idle(mailbox_path) + except ProviderNotSupported: + await asyncio.sleep(poll_interval) + if stop.is_set(): + break + async with session_scope() as session: + mailbox = await session.scalar( + select(Mailbox).where( + Mailbox.account_id == account_id, Mailbox.path == mailbox_path + ) + ) + mailbox_obj = mailbox + if mailbox_obj is None: + break + delta = await provider.poll( + mailbox_path, + last_seen_uid=mailbox_obj.last_seen_uid, + highestmodseq=mailbox_obj.highestmodseq, + ) + await apply_delta(provider, account_obj, mailbox_obj, delta) + finally: + await provider.close() diff --git a/backend/app/sync/folder_names.py b/backend/app/sync/folder_names.py new file mode 100644 index 0000000..0571e24 --- /dev/null +++ b/backend/app/sync/folder_names.py @@ -0,0 +1,48 @@ +"""Special-use inference from folder names (SPEC §6.3 fallback heuristics). + +Used only when the server does not advertise SPECIAL-USE. Includes Polish names +(Wysłane, Kosz, ...) alongside English/German, per the spec's i18n intent. +""" + +from __future__ import annotations + +_NAME_MAP: dict[str, str] = { + # sent + "sent": "sent", + "sent items": "sent", + "sent mail": "sent", + "wysłane": "sent", + "wyslane": "sent", + "elementy wysłane": "sent", + "gesendet": "sent", + # drafts + "drafts": "drafts", + "draft": "drafts", + "robocze": "drafts", + "kopie robocze": "drafts", + "entwürfe": "drafts", + # trash + "trash": "trash", + "deleted": "trash", + "deleted items": "trash", + "kosz": "trash", + "papierkorb": "trash", + # junk + "junk": "junk", + "spam": "junk", + "bulk mail": "junk", + "wiadomości-śmieci": "junk", + # archive + "archive": "archive", + "archiwum": "archive", + "all mail": "archive", + "archiv": "archive", +} + + +def infer_special_use(path: str) -> str | None: + """Infer a special_use value from a mailbox path/name (case-insensitive).""" + if path.upper() == "INBOX": + return "inbox" + leaf = path.replace("\\", "/").split("/")[-1].strip().lower() + return _NAME_MAP.get(leaf) or _NAME_MAP.get(path.strip().lower()) diff --git a/backend/app/sync/provider_factory.py b/backend/app/sync/provider_factory.py new file mode 100644 index 0000000..ccb48fd --- /dev/null +++ b/backend/app/sync/provider_factory.py @@ -0,0 +1,30 @@ +"""Build a MailProvider from an account row (SPEC §10.2 decryption boundary). + +This module runs only inside the worker/sync context; ``decrypt_secret`` (the +credential vault) is invoked here to obtain the plaintext mailbox password. It +must never be imported by API request code. +""" + +from __future__ import annotations + +from app.models import Account +from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig +from app.security.crypto import decrypt_secret + + +def build_provider(account: Account) -> ImapSmtpProvider: + """Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0).""" + imap = account.imap_config + smtp = account.smtp_config + password = decrypt_secret(account.secret_ct).decode("utf-8") + config = ProviderConfig( + imap_host=str(imap["host"]), + imap_port=int(str(imap["port"])), + imap_tls=str(imap.get("tls", "ssl")), + smtp_host=str(smtp["host"]), + smtp_port=int(str(smtp["port"])), + smtp_tls=str(smtp.get("tls", "ssl")), + username=account.email, + password=password, + ) + return ImapSmtpProvider(config) diff --git a/backend/app/sync/supervisor.py b/backend/app/sync/supervisor.py new file mode 100644 index 0000000..b4ba0ec --- /dev/null +++ b/backend/app/sync/supervisor.py @@ -0,0 +1,61 @@ +"""Live-sync supervisor (SPEC §6.3). + +Runs inside the arq worker: on startup it launches one IDLE/poll loop per live +account so new mail and flag changes flow to the UI over WebSocket. Loops +self-heal (reconnect with backoff) and are cancelled on worker shutdown. + +NOTE(spec): accounts that become live *after* worker startup are picked up on the +next worker restart; reacting to ``account.state_changed`` live is a later refinement. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import uuid +from typing import Any + +from sqlalchemy import select + +from app.db import session_scope +from app.models import Account +from app.sync.engine import live_loop + +logger = logging.getLogger(__name__) + +_RECONNECT_BACKOFF = 30.0 + + +async def _run_account(account_id: uuid.UUID, stop: asyncio.Event) -> None: + while not stop.is_set(): + try: + await live_loop(account_id, "INBOX", stop) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("live loop for account %s crashed; reconnecting", account_id) + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=_RECONNECT_BACKOFF) + + +async def start_supervisor(ctx: dict[str, Any]) -> None: + """Launch a live loop per live account (called from the worker on_startup).""" + async with session_scope() as session: + account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live"))) + stop = asyncio.Event() + ctx["supervisor_stop"] = stop + ctx["supervisor_tasks"] = [asyncio.create_task(_run_account(aid, stop)) for aid in account_ids] + logger.info("live supervisor started for %d account(s)", len(account_ids)) + + +async def stop_supervisor(ctx: dict[str, Any]) -> None: + """Signal and cancel all live loops (called from the worker on_shutdown).""" + stop = ctx.get("supervisor_stop") + if stop is not None: + stop.set() + tasks = ctx.get("supervisor_tasks", []) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/backend/app/threading_/__init__.py b/backend/app/threading_/__init__.py new file mode 100644 index 0000000..92796f6 --- /dev/null +++ b/backend/app/threading_/__init__.py @@ -0,0 +1 @@ +"""JWZ threading (SPEC §7). Trailing underscore avoids the stdlib ``threading`` clash.""" diff --git a/backend/app/threading_/normalize.py b/backend/app/threading_/normalize.py new file mode 100644 index 0000000..64750c9 --- /dev/null +++ b/backend/app/threading_/normalize.py @@ -0,0 +1,66 @@ +"""Message-ID and subject normalization (SPEC §7.1, §7.3).""" + +from __future__ import annotations + +import hashlib +import re + +# Repeated reply/forward prefixes to strip, case-insensitive (incl. Polish Odp:/PD:). +_PREFIX_RE = re.compile(r"^\s*(re|fw|fwd|odp|pd)\s*:\s*", re.IGNORECASE) +_WS_RE = re.compile(r"\s+") +_ANGLE_RE = re.compile(r"<([^>]*)>") + + +def normalize_message_id(raw: str | None) -> str | None: + """Normalize a Message-ID per SPEC §7.1. + + Strip angle brackets and whitespace, lowercase the domain part only. + Return None for obviously invalid ids (no ``@``). + """ + if raw is None: + return None + text = raw.strip() + m = _ANGLE_RE.search(text) + if m: + text = m.group(1) + text = text.strip().strip("<>").strip() + if "@" not in text: + return None + local, _, domain = text.rpartition("@") + if not local or not domain: + return None + return f"{local}@{domain.lower()}" + + +def normalize_reference_list(raw: str | None) -> list[str]: + """Parse a References / In-Reply-To header value into normalized ids, in order.""" + if not raw: + return [] + out: list[str] = [] + for token in _ANGLE_RE.findall(raw) or raw.split(): + norm = normalize_message_id(token) + if norm and norm not in out: + out.append(norm) + return out + + +def synthesize_message_id(raw_message: bytes) -> str: + """Synthesize a non-authoritative Message-ID for a message that lacks one (§7.1).""" + digest = hashlib.sha256(raw_message).hexdigest()[:32] + return f"{digest}@synthetic.local" + + +def is_synthetic(message_id: str | None) -> bool: + """Return True if ``message_id`` is a synthesized (non-authoritative) id.""" + return message_id is not None and message_id.endswith("@synthetic.local") + + +def normalize_subject(subject: str) -> str: + """Normalize a subject for the §7.3 fallback: strip repeated Re:/Fw:/... prefixes.""" + text = subject or "" + while True: + stripped = _PREFIX_RE.sub("", text, count=1) + if stripped == text: + break + text = stripped + return _WS_RE.sub(" ", text).strip() diff --git a/backend/app/threading_/threader.py b/backend/app/threading_/threader.py new file mode 100644 index 0000000..1857b61 --- /dev/null +++ b/backend/app/threading_/threader.py @@ -0,0 +1,213 @@ +"""JWZ threading — incremental per-message algorithm (SPEC §7.2, §7.3). + +Cross-account by design: candidate lookup is scoped by ``user_id`` only (I-3). +Called after the new :class:`Message` row is flushed (so it has an ``id`` and its +normalized id columns); assigns ``thread_id`` and recomputes the thread's +denormalized fields. +""" + +from __future__ import annotations + +import uuid +from datetime import timedelta + +from sqlalchemy import ColumnElement, delete, literal, select, update +from sqlalchemy import any_ as sa_any +from sqlalchemy import or_ as sa_or +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Message, Thread +from app.threading_.normalize import is_synthetic, normalize_subject + +_SUBJECT_FALLBACK_WINDOW = timedelta(days=7) + + +def _recipient_emails(message: Message) -> set[str]: + """Recipient (To/Cc) emails of a message, lowercased. + + Subject-fallback joining keys on recipients rather than all participants so a + bulk sender that reuses one subject to many recipients (e.g. a newsletter or + a static welcome mail) does not collapse unrelated deliveries into one thread. + A genuine reply is always addressed to someone already in the thread. + """ + emails: set[str] = set() + for field in (message.to_addr, message.cc_addr): + for addr in field: + email = addr.get("email") + if email: + emails.add(email.lower()) + return emails + + +def _candidate_ids(message: Message) -> list[str]: + candidates: list[str] = list(message.references_hdr) + if message.in_reply_to: + candidates.append(message.in_reply_to) + # Own id may let an earlier reply (that referenced us) join when we arrive late. + if message.message_id_hdr and not is_synthetic(message.message_id_hdr): + candidates.append(message.message_id_hdr) + # Dedupe, drop synthetic ids (never used to join, §7.1). + seen: list[str] = [] + for c in candidates: + if c and not is_synthetic(c) and c not in seen: + seen.append(c) + return seen + + +async def _find_by_references( + session: AsyncSession, + user_id: uuid.UUID, + message_id: uuid.UUID, + candidates: list[str], + own_id: str | None, +) -> list[uuid.UUID]: + """Threads joined by reference (SPEC §7.2.2). + + Matches bidirectionally: existing messages whose id is one of my candidate ids + (I reference them / duplicate id), OR existing messages that reference *my* id + (handles a late-arriving parent). Synthetic own-ids are never used to join (§7.1). + """ + conditions: list[ColumnElement[bool]] = [] + if candidates: + conditions.append(Message.message_id_hdr.in_(candidates)) + if own_id is not None and not is_synthetic(own_id): + # own_id = ANY(references_hdr): an existing message that references me. + conditions.append(literal(own_id) == sa_any(Message.references_hdr)) + conditions.append(Message.in_reply_to == own_id) + if not conditions: + return [] + stmt = ( + select(Message.thread_id) + .where( + Message.user_id == user_id, + Message.thread_id.is_not(None), + Message.id != message_id, + sa_or(*conditions), + ) + .distinct() + ) + rows = await session.scalars(stmt) + return [tid for tid in rows if tid is not None] + + +async def _find_by_subject( + session: AsyncSession, user_id: uuid.UUID, message: Message +) -> uuid.UUID | None: + subject_norm = normalize_subject(message.subject) + # Never join when this message both lacks references and has an empty subject (§7.3). + if not subject_norm: + return None + anchor = message.internal_date + lo = anchor - _SUBJECT_FALLBACK_WINDOW + hi = anchor + _SUBJECT_FALLBACK_WINDOW + stmt = ( + select(Thread) + .where( + Thread.user_id == user_id, + Thread.subject_norm == subject_norm, + Thread.last_message_at >= lo, + Thread.last_message_at <= hi, + ) + .order_by(Thread.last_message_at.desc()) + ) + my_recipients = _recipient_emails(message) + if not my_recipients: + return None + for thread in await session.scalars(stmt): + participant_emails = { + (p.get("email") or "").lower() for p in thread.participants if p.get("email") + } + if my_recipients & participant_emails: + return thread.id + return None + + +async def _merge_threads( + session: AsyncSession, thread_ids: list[uuid.UUID] +) -> tuple[uuid.UUID, list[uuid.UUID]]: + """Merge ``thread_ids`` keeping the oldest ``last_message_at`` as canonical (§7.2.5).""" + threads = list(await session.scalars(select(Thread).where(Thread.id.in_(thread_ids)))) + canonical = min(threads, key=lambda t: t.last_message_at) + others = [t.id for t in threads if t.id != canonical.id] + if others: + await session.execute( + update(Message).where(Message.thread_id.in_(others)).values(thread_id=canonical.id) + ) + await session.execute(delete(Thread).where(Thread.id.in_(others))) + return canonical.id, others + + +async def _create_thread(session: AsyncSession, message: Message) -> uuid.UUID: + thread = Thread( + user_id=message.user_id, + subject_norm=normalize_subject(message.subject), + last_message_at=message.internal_date, + ) + session.add(thread) + await session.flush() + return thread.id + + +async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None: + """Recompute a thread's denormalized fields from its messages (SPEC §7.2.6).""" + messages = list( + await session.scalars( + select(Message) + .where(Message.thread_id == thread_id) + .order_by(Message.internal_date.desc()) + ) + ) + thread = await session.get(Thread, thread_id) + if thread is None: + return + if not messages: + await session.execute(delete(Thread).where(Thread.id == thread_id)) + return + + newest = messages[0] + participants: list[dict[str, str]] = [] + seen_emails: set[str] = set() + for msg in messages: # newest first => recency order + for addr in list(msg.from_addr) + list(msg.to_addr) + list(msg.cc_addr): + email = (addr.get("email") or "").lower() + if email and email not in seen_emails: + seen_emails.add(email) + participants.append({"name": addr.get("name", ""), "email": addr.get("email", "")}) + + thread.message_count = len(messages) + thread.unread_count = sum(1 for m in messages if "\\Seen" not in m.flags) + thread.has_attachments = any(m.has_attachments for m in messages) + thread.pinned = any("\\Flagged" in m.flags for m in messages) + thread.account_ids = sorted({m.account_id for m in messages}) + thread.last_message_at = newest.internal_date + thread.snippet = newest.snippet + thread.subject_norm = normalize_subject(newest.subject) or thread.subject_norm + thread.participants = participants + + +async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID: + """Assign ``message`` to a thread (creating/attaching/merging) and denormalize. + + Returns the resulting thread id and sets ``message.thread_id``. + """ + candidates = _candidate_ids(message) + own_id = message.message_id_hdr + thread_ids = await _find_by_references(session, message.user_id, message.id, candidates, own_id) + + if not thread_ids: + subject_thread = await _find_by_subject(session, message.user_id, message) + if subject_thread is not None: + thread_ids = [subject_thread] + + if not thread_ids: + thread_id = await _create_thread(session, message) + elif len(thread_ids) == 1: + thread_id = thread_ids[0] + else: + thread_id, _merged_away = await _merge_threads(session, thread_ids) + + message.thread_id = thread_id + await session.flush() + await denormalize_thread(session, thread_id) + await session.flush() + return thread_id diff --git a/backend/app/ws.py b/backend/app/ws.py new file mode 100644 index 0000000..4731835 --- /dev/null +++ b/backend/app/ws.py @@ -0,0 +1,70 @@ +"""WebSocket hub (SPEC §9.2). + +Auth via session cookie. Relays every §8.3 event belonging to the user as +``{event, payload}`` frames; the client refetches via TanStack Query invalidation. +Client -> server: ``{"type":"ping"}`` only. +""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE +from app.identity.sessions import resolve_session +from app.redis_client import get_redis + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.websocket("/ws") +async def ws_endpoint(websocket: WebSocket) -> None: + token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get( + SESSION_COOKIE_INSECURE, "" + ) + session = await resolve_session(token) + if session is None: + await websocket.close(code=1008) # policy violation + return + + await websocket.accept() + user_id = str(session.user_id) + pubsub = get_redis().pubsub() + await pubsub.psubscribe("evt:*") + + async def forward() -> None: + async for message in pubsub.listen(): + if message.get("type") != "pmessage": + continue + try: + data = json.loads(message["data"]) + except (ValueError, TypeError): + continue + payload = data.get("payload", {}) + if str(payload.get("user_id")) == user_id: + await websocket.send_json(data) + + async def receive() -> None: + while True: + raw = await websocket.receive_text() + try: + if json.loads(raw).get("type") == "ping": + await websocket.send_json({"type": "pong"}) + except (ValueError, TypeError): + continue + + forward_task = asyncio.create_task(forward()) + receive_task = asyncio.create_task(receive()) + try: + await asyncio.wait({forward_task, receive_task}, return_when=asyncio.FIRST_COMPLETED) + except WebSocketDisconnect: + pass + finally: + for task in (forward_task, receive_task): + task.cancel() + await pubsub.aclose() # type: ignore[no-untyped-call] diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..f8fe313 --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,57 @@ +"""Alembic environment — async engine, config sourced from app settings.""" + +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlalchemy.pool import NullPool + +# Import models so Base.metadata is populated for autogenerate. +import app.models # noqa: F401 +from app.config import get_settings +from app.db import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=get_settings().database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: object) -> None: + context.configure(connection=connection, target_metadata=target_metadata) # type: ignore[arg-type] + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..948ea71 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/0001_initial.py b/backend/migrations/versions/0001_initial.py new file mode 100644 index 0000000..53834bd --- /dev/null +++ b/backend/migrations/versions/0001_initial.py @@ -0,0 +1,200 @@ +"""initial schema (SPEC §4) + +Revision ID: 0001 +Revises: +Create Date: 2026-07-16 + +The authoritative DDL lives in SPEC §4; this migration reproduces it verbatim. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +UPGRADE_STMTS: list[str] = [ + "CREATE EXTENSION IF NOT EXISTS citext", + """ + CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + handle citext UNIQUE NOT NULL, + password_hash text NOT NULL, + totp_secret bytea, + recovery_email citext, + settings jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() + ) + """, + """ + CREATE TABLE accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email citext NOT NULL, + display_name text NOT NULL DEFAULT '', + color text NOT NULL DEFAULT '#888888', + imap_config jsonb NOT NULL, + smtp_config jsonb NOT NULL, + secret_ct bytea NOT NULL, + discovery_src text NOT NULL, + state text NOT NULL DEFAULT 'backfilling', + state_detail text NOT NULL DEFAULT '', + last_sync_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, email) + ) + """, + "CREATE INDEX ix_accounts_user_id ON accounts (user_id)", + """ + CREATE TABLE mailboxes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + path text NOT NULL, + special_use text, + uidvalidity bigint NOT NULL DEFAULT 0, + highestmodseq bigint, + last_seen_uid bigint NOT NULL DEFAULT 0, + UNIQUE (account_id, path) + ) + """, + """ + CREATE TABLE threads ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject_norm text NOT NULL, + snippet text NOT NULL DEFAULT '', + participants jsonb NOT NULL DEFAULT '[]', + account_ids uuid[] NOT NULL DEFAULT '{}', + message_count int NOT NULL DEFAULT 0, + unread_count int NOT NULL DEFAULT 0, + has_attachments boolean NOT NULL DEFAULT false, + last_message_at timestamptz NOT NULL + ) + """, + "CREATE INDEX ix_threads_user_last ON threads (user_id, last_message_at DESC)", + """ + CREATE TABLE messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + mailbox_id uuid NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + uid bigint NOT NULL, + thread_id uuid REFERENCES threads(id), + message_id_hdr text, + in_reply_to text, + references_hdr text[] NOT NULL DEFAULT '{}', + subject text NOT NULL DEFAULT '', + from_addr jsonb NOT NULL DEFAULT '[]', + to_addr jsonb NOT NULL DEFAULT '[]', + cc_addr jsonb NOT NULL DEFAULT '[]', + date_hdr timestamptz, + internal_date timestamptz NOT NULL, + flags text[] NOT NULL DEFAULT '{}', + size_bytes int NOT NULL DEFAULT 0, + has_attachments boolean NOT NULL DEFAULT false, + snippet text NOT NULL DEFAULT '', + blob_sha256 text NOT NULL, + body_indexed tsvector, + UNIQUE (mailbox_id, uid) + ) + """, + "CREATE INDEX ix_messages_user_thread ON messages (user_id, thread_id)", + "CREATE INDEX ix_messages_account_msgid ON messages (account_id, message_id_hdr)", + "CREATE INDEX ix_messages_body_indexed ON messages USING gin (body_indexed)", + """ + CREATE TABLE body_parts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + kind text NOT NULL, + content text NOT NULL, + sanitizer_v int NOT NULL DEFAULT 0 + ) + """, + """ + CREATE TABLE attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id uuid NOT NULL, + filename text NOT NULL, + mime_type text NOT NULL, + size_bytes int NOT NULL, + content_id text, + blob_sha256 text NOT NULL + ) + """, + """ + CREATE TABLE drafts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + thread_id uuid REFERENCES threads(id), + reply_to_message_id uuid REFERENCES messages(id), + kind text NOT NULL DEFAULT 'reply', + payload jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() + ) + """, + """ + CREATE TABLE outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + draft_id uuid NOT NULL, + state text NOT NULL DEFAULT 'undo_window', + send_after timestamptz NOT NULL, + error text + ) + """, + """ + CREATE TABLE saved_searches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + query text NOT NULL, + pinned boolean NOT NULL DEFAULT true, + UNIQUE (user_id, name) + ) + """, + """ + CREATE TABLE known_recipients ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email citext NOT NULL, + name text NOT NULL DEFAULT '', + times_used int NOT NULL DEFAULT 0, + last_used_at timestamptz NOT NULL, + PRIMARY KEY (user_id, email) + ) + """, + "CREATE INDEX ix_known_recipients_user_email " + "ON known_recipients (user_id, email text_pattern_ops)", +] + +DOWNGRADE_STMTS: list[str] = [ + "DROP TABLE IF EXISTS known_recipients", + "DROP TABLE IF EXISTS saved_searches", + "DROP TABLE IF EXISTS outbox", + "DROP TABLE IF EXISTS drafts", + "DROP TABLE IF EXISTS attachments", + "DROP TABLE IF EXISTS body_parts", + "DROP TABLE IF EXISTS messages", + "DROP TABLE IF EXISTS threads", + "DROP TABLE IF EXISTS mailboxes", + "DROP TABLE IF EXISTS accounts", + "DROP TABLE IF EXISTS users", +] + + +def upgrade() -> None: + for stmt in UPGRADE_STMTS: + op.execute(stmt) + + +def downgrade() -> None: + for stmt in DOWNGRADE_STMTS: + op.execute(stmt) diff --git a/backend/migrations/versions/0002_user_msgid_index.py b/backend/migrations/versions/0002_user_msgid_index.py new file mode 100644 index 0000000..ad70bf1 --- /dev/null +++ b/backend/migrations/versions/0002_user_msgid_index.py @@ -0,0 +1,28 @@ +"""index messages(user_id, message_id_hdr) + +Speeds the per-user Message-ID lookups used by JWZ threading (§7.2 candidate +set) and by the duplicate-Message-ID resolution in the indexer (§7.1). + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-07-17 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0002" +down_revision: str | None = "0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("CREATE INDEX ix_messages_user_msgid ON messages (user_id, message_id_hdr)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_messages_user_msgid") diff --git a/backend/migrations/versions/0003_has_remote_images.py b/backend/migrations/versions/0003_has_remote_images.py new file mode 100644 index 0000000..8172146 --- /dev/null +++ b/backend/migrations/versions/0003_has_remote_images.py @@ -0,0 +1,28 @@ +"""add messages.has_remote_images + +Surfaces whether the sanitizer blocked remote images so the UI can show the +"load images" bar (SPEC §9.4). + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-07-17 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0003" +down_revision: str | None = "0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE messages ADD COLUMN has_remote_images boolean NOT NULL DEFAULT false") + + +def downgrade() -> None: + op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS has_remote_images") diff --git a/backend/migrations/versions/0004_thread_pinned.py b/backend/migrations/versions/0004_thread_pinned.py new file mode 100644 index 0000000..2fdcdde --- /dev/null +++ b/backend/migrations/versions/0004_thread_pinned.py @@ -0,0 +1,28 @@ +"""add threads.pinned + +Denormalized "pinned" state for a thread (SPEC extension for the UI pin action): +true when any message in the thread carries the \\Flagged flag. + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-07-17 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0004" +down_revision: str | None = "0003" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE threads ADD COLUMN pinned boolean NOT NULL DEFAULT false") + + +def downgrade() -> None: + op.execute("ALTER TABLE threads DROP COLUMN IF EXISTS pinned") diff --git a/backend/migrations/versions/0005_compose_attachments.py b/backend/migrations/versions/0005_compose_attachments.py new file mode 100644 index 0000000..b22d1e7 --- /dev/null +++ b/backend/migrations/versions/0005_compose_attachments.py @@ -0,0 +1,53 @@ +"""add compose_attachments + +Files uploaded for outgoing mail (the compose "Attach" action). Referenced by a +draft's payload.attachment_ids and assembled into the MIME at send time. + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-07-17 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005" +down_revision: str | None = "0004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "compose_attachments", + sa.Column( + "id", + sa.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "user_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("filename", sa.Text(), nullable=False), + sa.Column("mime_type", sa.Text(), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("blob_sha256", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + + +def downgrade() -> None: + op.drop_table("compose_attachments") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..39703e8 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "webmail-backend" +version = "0.1.0" +description = "Webmail client backend (sync-first, see webmail-client-spec.md)" +requires-python = "==3.11.*" +# Pinned per SPEC §3. Do not swap libraries or add deps without a `# DEPENDENCY:` comment. +dependencies = [ + "fastapi~=0.115", + "uvicorn[standard]~=0.30", + "sqlalchemy[asyncio]~=2.0", + "asyncpg~=0.29", + "alembic~=1.13", + "pydantic~=2.8", + "pydantic-settings~=2.4", + "arq~=0.26", + "redis~=5.0", + "aioimaplib~=1.1", + "imapclient~=3.0", + "aiosmtplib~=3.0", + "argon2-cffi~=23.1", + "pyotp~=2.9", + "dnspython~=2.6", + "httpx~=0.27", + "nh3~=0.2", + "cryptography~=43.0", + "mail-parser~=4.0", + "python-multipart~=0.0.9", + # DEPENDENCY: SPEC §6.2 mandates accounts/presets.yaml (config, not code); the + # stdlib has no YAML parser, so PyYAML is required to load it. + "pyyaml~=6.0", + # DEPENDENCY: autoconfig/ISPDB return XML from untrusted servers; defusedxml + # blocks XXE/billion-laughs that stdlib ElementTree is vulnerable to (§10 hostile input). + "defusedxml~=0.7", +] + +[project.optional-dependencies] +dev = [ + "ruff~=0.6", + "mypy~=1.11", + "pytest~=8.3", + "pytest-asyncio~=0.24", + "respx~=0.21", + "aiosqlite~=0.20", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*", "workers*"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "ASYNC", "C4", "SIM"] +ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic + +[tool.mypy] +python_version = "3.11" +strict = true +plugins = ["pydantic.mypy"] +mypy_path = "." +explicit_package_bases = true +namespace_packages = true + +[[tool.mypy.overrides]] +module = ["aioimaplib.*", "mailparser.*", "imapclient.*", "arq.*", "yaml", "defusedxml.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py new file mode 100644 index 0000000..b0e5cb6 --- /dev/null +++ b/backend/scripts/seed_demo.py @@ -0,0 +1,75 @@ +"""Dev-only: seed a demo user with a synced test mailbox so the UI has data. + +Run from backend/ with the conda env active and services up: + python scripts/seed_demo.py + +Idempotent: reuses the demo user/account if they already exist and re-syncs. +""" + +from __future__ import annotations + +import asyncio + +from sqlalchemy import select + +from app.db import session_scope +from app.identity.passwords import hash_password +from app.models import Account, User +from app.security.crypto import encrypt_secret +from app.sync.engine import bootstrap_and_backfill + +DEMO_LOCALPART = "demo" +DEMO_PASSWORD = "webmailDemo1" +MAILBOX_EMAIL = "test@imaginary-pixels.com" +MAILBOX_PASSWORD = b"webmail!@A" +HOST = "serwer2295308.home.pl" + + +async def main() -> None: + from app.config import get_settings + + domain = get_settings().app_domain + handle = f"{DEMO_LOCALPART}@{domain}" + + async with session_scope() as session: + user = await session.scalar(select(User).where(User.handle == handle)) + if user is None: + user = User(handle=handle, password_hash=hash_password(DEMO_PASSWORD)) + session.add(user) + await session.flush() + print(f"created user {handle}") + else: + # Reset password so the printed credentials always work. + user.password_hash = hash_password(DEMO_PASSWORD) + print(f"reusing user {handle}") + + account = await session.scalar( + select(Account).where(Account.user_id == user.id, Account.email == MAILBOX_EMAIL) + ) + if account is None: + account = Account( + user_id=user.id, + email=MAILBOX_EMAIL, + display_name="Test Mailbox", + color="#22aa77", + imap_config={"host": HOST, "port": 993, "tls": "ssl", "auth": "password"}, + smtp_config={"host": HOST, "port": 465, "tls": "ssl", "auth": "password"}, + secret_ct=encrypt_secret(MAILBOX_PASSWORD), + discovery_src="manual", + state="backfilling", + ) + session.add(account) + await session.flush() + print(f"created account {MAILBOX_EMAIL}") + account_id = account.id + + print("syncing mailbox (this hits the real IMAP server)…") + count = await bootstrap_and_backfill(account_id) + print(f"synced {count} messages") + print("\n=== Log in at http://localhost:5173 ===") + print(f" handle: {DEMO_LOCALPART}") + print(f" password: {DEMO_PASSWORD}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/accounts/test_account_api.py b/backend/tests/accounts/test_account_api.py new file mode 100644 index 0000000..aa3963b --- /dev/null +++ b/backend/tests/accounts/test_account_api.py @@ -0,0 +1,174 @@ +"""Account CRUD API (SPEC §9.1) driven through the ASGI app.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.api import accounts as accounts_api +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.main import create_app +from app.models import Account, User + +CSRF = "test-csrf-token" +GOOD_CFG = { + "imap": {"host": "imap.example.com", "port": 993, "tls": "ssl"}, + "smtp": {"host": "smtp.example.com", "port": 465, "tls": "ssl"}, +} + + +@dataclass +class Ctx: + client: httpx.AsyncClient + user_id: uuid.UUID + session: AsyncSession + + +def _hdr() -> dict[str, str]: + return {"X-CSRF": CSRF} + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"acc{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")) + session.add(user) + await session.commit() + token = await create_session(user.id, "pytest") + transport = ASGITransport(app=create_app()) + client = httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + ) + try: + yield Ctx(client=client, user_id=user.id, session=session) + finally: + await client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +async def test_discover_gmail_preset(ctx: Ctx) -> None: + resp = await ctx.client.post( + "/api/accounts/discover", json={"email": "someone@gmail.com"}, headers=_hdr() + ) + assert resp.status_code == 200 + data = resp.json() + assert data["candidates"][0]["imap"]["host"] == "imap.gmail.com" + assert data["candidates"][0]["source"] == "preset" + assert data["steps"][0]["method"] == "preset" + + +async def test_discover_requires_csrf(ctx: Ctx) -> None: + resp = await ctx.client.post( + "/api/accounts/discover", json={"email": "someone@gmail.com"} + ) # no X-CSRF header + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == "csrf_failed" + + +async def test_discover_rate_limited(ctx: Ctx) -> None: + last = None + for _ in range(11): + last = await ctx.client.post( + "/api/accounts/discover", json={"email": "x@gmail.com"}, headers=_hdr() + ) + assert last is not None and last.status_code == 429 + + +async def test_test_endpoint_maps_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + from app.accounts.testing import AccountTestError + + async def fake_test(**_kw: object) -> None: + raise AccountTestError("auth_failed", "bad password") + + monkeypatch.setattr(accounts_api, "test_account", fake_test) + resp = await ctx.client.post( + "/api/accounts/test", + json={**GOOD_CFG, "email": "me@example.com", "password": "x"}, + headers=_hdr(), + ) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "auth_failed" + + +async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + async def fake_test(**_kw: object) -> None: + return None + + enqueued: list[tuple[str, str]] = [] + + class FakePool: + async def enqueue_job(self, name: str, arg: str) -> None: + enqueued.append((name, arg)) + + async def fake_pool() -> FakePool: + return FakePool() + + monkeypatch.setattr(accounts_api, "test_account", fake_test) + monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool) + + resp = await ctx.client.post( + "/api/accounts", + json={ + **GOOD_CFG, + "email": "me@example.com", + "display_name": "Me", + "color": "#ff0000", + "password": "secret", + }, + headers=_hdr(), + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["email"] == "me@example.com" + assert body["state"] == "backfilling" + + account = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(body["id"]))) + assert account is not None + assert account.secret_ct # credentials stored encrypted, non-empty + assert account.imap_config["host"] == "imap.example.com" + assert enqueued == [("sync_bootstrap_account", body["id"])] + + +async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + async def fake_test(**_kw: object) -> None: + return None + + class FakePool: + async def enqueue_job(self, name: str, arg: str) -> None: + return None + + async def fake_pool() -> FakePool: + return FakePool() + + monkeypatch.setattr(accounts_api, "test_account", fake_test) + monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool) + + created = await ctx.client.post( + "/api/accounts", + json={**GOOD_CFG, "email": "del@example.com", "password": "secret"}, + headers=_hdr(), + ) + account_id = created.json()["id"] + + resp = await ctx.client.request("DELETE", f"/api/accounts/{account_id}", headers=_hdr()) + assert resp.status_code == 200 + + gone = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(account_id))) + assert gone is None diff --git a/backend/tests/accounts/test_discovery.py b/backend/tests/accounts/test_discovery.py new file mode 100644 index 0000000..4840852 --- /dev/null +++ b/backend/tests/accounts/test_discovery.py @@ -0,0 +1,185 @@ +"""Autodiscovery resolver (SPEC §6.2), with respx-mocked HTTP and a DNS stub.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from app.accounts import discovery, ssrf +from app.accounts.discovery import discover, domain_of, parse_autoconfig_xml +from app.accounts.types import ServerConfig + +AUTOCONFIG_XML = """ + + + example.com + + imap.example.com + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + smtp.example.com + 587 + STARTTLS + password-cleartext + %EMAILADDRESS% + + + +""" + + +@pytest.fixture(autouse=True) +def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Bypass the real DNS/public-IP check so respx can intercept the fetch.""" + + async def ok(_host: str) -> None: + return None + + monkeypatch.setattr(ssrf, "assert_host_is_public", ok) + + +# --- XML parsing ------------------------------------------------------------- + + +def test_parse_autoconfig_xml() -> None: + cand = parse_autoconfig_xml(AUTOCONFIG_XML) + assert cand is not None + assert cand.imap == ServerConfig("imap.example.com", 993, "ssl", "password") + assert cand.smtp == ServerConfig("smtp.example.com", 587, "starttls", "password") + + +def test_parse_autoconfig_xml_rejects_garbage() -> None: + assert parse_autoconfig_xml("not xml <<<") is None + assert parse_autoconfig_xml("") is None + + +def test_domain_of() -> None: + assert domain_of("Alice@Example.COM") == "example.com" + + +# --- preset step ------------------------------------------------------------- + + +async def test_preset_hit_short_circuits() -> None: + result = await discover("someone@gmail.com") + assert len(result.candidates) == 1 + c = result.candidates[0] + assert c.source == "preset" + assert c.imap.host == "imap.gmail.com" + assert c.oauth_provider == "google" + assert result.steps[0].method == "preset" and result.steps[0].status == "found" + + +async def test_preset_polish_provider() -> None: + result = await discover("jan@wp.pl") + assert result.candidates[0].imap.host == "imap.wp.pl" + assert result.candidates[0].source == "preset" + + +# --- autoconfig / ispdb via HTTP -------------------------------------------- + + +@respx.mock +async def test_autoconfig_found() -> None: + respx.get(url__regex=r"https://autoconfig\.example\.com/mail/.*").mock( + return_value=httpx.Response(200, text=AUTOCONFIG_XML) + ) + result = await discover("user@example.com") + assert result.candidates[0].source == "autoconfig" + assert result.candidates[0].imap.host == "imap.example.com" + # preset step was recorded as not_found before autoconfig succeeded + assert result.steps[0].status == "not_found" + + +@respx.mock +async def test_ispdb_found_after_autoconfig_misses() -> None: + respx.get(url__regex=r"https://autoconfig\.example\.com/.*").mock( + return_value=httpx.Response(404) + ) + respx.get(url__regex=r"https://example\.com/\.well-known/.*").mock( + return_value=httpx.Response(404) + ) + respx.get(url__regex=r"https://autoconfig\.thunderbird\.net/v1\.1/example\.com").mock( + return_value=httpx.Response(200, text=AUTOCONFIG_XML) + ) + result = await discover("user@example.com") + assert result.candidates[0].source == "ispdb" + methods = {s.method for s in result.steps} + assert {"preset", "autoconfig", "ispdb"} <= methods + + +# --- SRV via DNS stub -------------------------------------------------------- + + +class _FakeSrv: + def __init__(self, priority: int, weight: int, port: int, target: str) -> None: + self.priority = priority + self.weight = weight + self.port = port + self.target = target + + +@respx.mock +async def test_srv_found(monkeypatch) -> None: # type: ignore[no-untyped-def] + # autoconfig + ispdb all miss + respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404)) + + async def fake_srv_lookup(name: str, tls: str) -> ServerConfig | None: + if name.startswith("_imaps._tcp"): + return ServerConfig("imap.example.com", 993, "ssl") + if name.startswith("_submissions._tcp"): + return ServerConfig("smtp.example.com", 465, "ssl") + return None + + monkeypatch.setattr(discovery, "_srv_lookup", fake_srv_lookup) + result = await discover("user@example.com") + assert result.candidates[0].source == "srv" + assert result.candidates[0].imap.port == 993 + + +# --- probe fallback ---------------------------------------------------------- + + +@respx.mock +async def test_probe_found_when_all_else_misses(monkeypatch) -> None: # type: ignore[no-untyped-def] + respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404)) + + async def no_srv(_name: str, _tls: str) -> ServerConfig | None: + return None + + async def fake_probe(host: str, port: int, tls: str, kind: str) -> ServerConfig | None: + # only imap.example.com:993 and smtp.example.com:465 "answer" + if (host, port) in {("imap.example.com", 993), ("smtp.example.com", 465)}: + return ServerConfig(host, port, tls) + return None + + monkeypatch.setattr(discovery, "_srv_lookup", no_srv) + monkeypatch.setattr(discovery, "_probe_one", fake_probe) + result = await discover("user@example.com") + assert result.candidates[0].source == "probe" + assert result.candidates[0].imap.host == "imap.example.com" + assert result.candidates[0].smtp.host == "smtp.example.com" + + +@respx.mock +async def test_all_steps_fail_records_every_step(monkeypatch) -> None: # type: ignore[no-untyped-def] + respx.get(url__regex=r"https://.*").mock(return_value=httpx.Response(404)) + + async def no_srv(_name: str, _tls: str) -> ServerConfig | None: + return None + + async def no_probe(_h: str, _p: int, _t: str, _k: str) -> ServerConfig | None: + return None + + monkeypatch.setattr(discovery, "_srv_lookup", no_srv) + monkeypatch.setattr(discovery, "_probe_one", no_probe) + result = await discover("user@nowhere.invalid") + assert result.candidates == [] + methods = [s.method for s in result.steps] + assert methods == ["preset", "autoconfig", "ispdb", "srv", "probe"] + assert all(s.status in ("not_found", "error") for s in result.steps) diff --git a/backend/tests/accounts/test_ssrf.py b/backend/tests/accounts/test_ssrf.py new file mode 100644 index 0000000..cf07dfa --- /dev/null +++ b/backend/tests/accounts/test_ssrf.py @@ -0,0 +1,55 @@ +"""SSRF guard for autodiscovery (SPEC §10.6).""" + +from __future__ import annotations + +import pytest + +from app.accounts import ssrf +from app.accounts.ssrf import SsrfError, _is_public_ip, assert_host_is_public, guarded_get + + +@pytest.mark.parametrize( + "ip,public", + [ + ("8.8.8.8", True), + ("1.1.1.1", True), + ("2606:4700:4700::1111", True), + ("127.0.0.1", False), + ("10.0.0.1", False), + ("192.168.1.1", False), + ("172.16.5.4", False), + ("169.254.1.1", False), # link-local + ("::1", False), + ("fe80::1", False), # link-local v6 + ("0.0.0.0", False), + ], +) +def test_is_public_ip(ip: str, public: bool) -> None: + assert _is_public_ip(ip) is public + + +async def test_assert_host_public_accepts_public(monkeypatch) -> None: # type: ignore[no-untyped-def] + async def fake_resolve(_host: str) -> list[str]: + return ["8.8.8.8"] + + monkeypatch.setattr(ssrf, "_resolve_all", fake_resolve) + await assert_host_is_public("mail.example.com") # no raise + + +async def test_assert_host_rejects_private(monkeypatch) -> None: # type: ignore[no-untyped-def] + async def fake_resolve(_host: str) -> list[str]: + return ["8.8.8.8", "127.0.0.1"] # one private address is enough to reject + + monkeypatch.setattr(ssrf, "_resolve_all", fake_resolve) + with pytest.raises(SsrfError): + await assert_host_is_public("evil.example.com") + + +async def test_guarded_get_rejects_non_https() -> None: + with pytest.raises(SsrfError): + await guarded_get("http://example.com/config.xml") + + +async def test_guarded_get_rejects_missing_host() -> None: + with pytest.raises(SsrfError): + await guarded_get("https:///config.xml") diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py new file mode 100644 index 0000000..b6b3a4e --- /dev/null +++ b/backend/tests/api/test_api.py @@ -0,0 +1,283 @@ +"""REST API integration tests (SPEC §9.1) driving the ASGI app with seeded data.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from email.message import EmailMessage + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.indexer.indexer import persist_message +from app.main import create_app +from app.models import Account, Mailbox, Message, User +from app.providers.base import RemoteMessage +from app.search.pg import PgSearchBackend + + +@dataclass +class ApiCtx: + client: httpx.AsyncClient + user_id: uuid.UUID + account_id: uuid.UUID | None = None + archive_id: uuid.UUID | None = None + session: AsyncSession | None = None + thread_ids: list[uuid.UUID] = field(default_factory=list) + message_ids: list[uuid.UUID] = field(default_factory=list) + + +def _raw( + subject: str, body: str, *, mid: str, refs: str | None = None, html: str | None = None +) -> bytes: + m = EmailMessage() + m["Subject"] = subject + m["From"] = "Alice " + m["To"] = "me@example.com" + m["Message-ID"] = mid + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + if refs: + m["References"] = refs + m["In-Reply-To"] = refs + m.set_content(body) + if html: + m.add_alternative(html, subtype="html") + return m.as_bytes() + + +@pytest_asyncio.fixture +async def api() -> AsyncIterator[ApiCtx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"api{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() + mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive") + session.add_all([mailbox, archive]) + await session.commit() + + backend = PgSearchBackend() + ctx = ApiCtx( + client=None, # type: ignore[arg-type] + user_id=user.id, + account_id=account.id, + archive_id=archive.id, + session=session, + ) + specs = [ + (_raw("Project kickoff", "kickoff meeting notes and revenue plan", mid=""), 1), + ( + _raw( + "Re: Project kickoff", + "sounds good, revenue looks strong", + mid="", + refs="", + ), + 2, + ), + ( + _raw( + "Lunch", + "grab lunch tomorrow?", + mid="", + html="

grab lunch tomorrow?

", + ), + 3, + ), + ] + for raw, u in specs: + remote = RemoteMessage(uid=u, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC)) + result = await persist_message( + session, + user_id=user.id, + account_id=account.id, + mailbox_id=mailbox.id, + mailbox_special_use="inbox", + remote=remote, + ) + await session.commit() + assert result is not None + ctx.message_ids.append(result.message.id) + if result.message.thread_id not in ctx.thread_ids: + ctx.thread_ids.append(result.message.thread_id) + await backend.index(result.search_doc) + + token = await create_session(user.id, "pytest") + transport = ASGITransport(app=create_app()) + ctx.client = httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies={"session": token} + ) + try: + yield ctx + finally: + await ctx.client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +async def test_requires_auth() -> None: + transport = ASGITransport(app=create_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get("/api/threads") + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "not_authenticated" + + +async def test_list_threads(api: ApiCtx) -> None: + resp = await api.client.get("/api/threads") + assert resp.status_code == 200 + data = resp.json() + # 2 threads: the kickoff reply-chain + lunch + assert len(data["threads"]) == 2 + # newest first + subjects = [t["subject"] for t in data["threads"]] + assert "Lunch" in subjects + kickoff = next(t for t in data["threads"] if "kickoff" in t["subject"].lower()) + assert kickoff["message_count"] == 2 + + +async def test_folder_view(api: ApiCtx) -> None: + # all seeded mail is in an inbox mailbox + inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"}) + assert inbox.status_code == 200 + assert len(inbox.json()["threads"]) == 2 # kickoff chain + lunch + # no messages are in a sent folder + sent = await api.client.get("/api/threads", params={"view": "folder:sent"}) + assert sent.status_code == 200 + assert sent.json()["threads"] == [] + + +async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None: + assert api.session is not None + # The account view starts equal to the inbox (all seeded mail is in inbox). + resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"}) + assert resp.status_code == 200 + assert len(resp.json()["threads"]) == 2 + + # Move the whole "Lunch" thread out of the inbox into Archive. + lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch") + msgs = list( + await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id))) + ) + for m in msgs: + m.mailbox_id = api.archive_id + await api.session.commit() + + # It must drop out of both the unified inbox and the account view... + inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"}) + assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"] + account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"}) + assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"] + # ...but still be reachable under the Archive folder. + archive = await api.client.get("/api/threads", params={"view": "folder:archive"}) + assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"] + + +async def test_attachments_list_and_zip(api: ApiCtx) -> None: + import io + import zipfile + + from app.indexer import blobstore + from app.models import Attachment + + assert api.session is not None + mid = api.message_ids[-1] # the "Lunch" message + sha = blobstore.store(api.user_id, b"hello attachment") + api.session.add( + Attachment( + message_id=mid, + user_id=api.user_id, + filename="notes.txt", + mime_type="text/plain", + size_bytes=16, + content_id=None, + blob_sha256=sha, + ) + ) + await api.session.commit() + + listing = await api.client.get(f"/api/messages/{mid}/attachments") + assert listing.status_code == 200 + body = listing.json() + assert [a["filename"] for a in body] == ["notes.txt"] + assert body[0]["size_bytes"] == 16 + + zipped = await api.client.get(f"/api/messages/{mid}/attachments.zip") + assert zipped.status_code == 200 + assert zipped.headers["content-type"] == "application/zip" + assert "Attachments.zip" in zipped.headers["content-disposition"] + archive = zipfile.ZipFile(io.BytesIO(zipped.content)) + assert archive.namelist() == ["notes.txt"] + assert archive.read("notes.txt") == b"hello attachment" + + +async def test_thread_detail_messages_ascending(api: ApiCtx) -> None: + # find the kickoff thread (2 messages) + resp = await api.client.get("/api/threads") + kickoff = next(t for t in resp.json()["threads"] if t["message_count"] == 2) + detail = await api.client.get(f"/api/threads/{kickoff['id']}") + assert detail.status_code == 200 + body = detail.json() + assert body["order"] == "asc" + dates = [m["date"] for m in body["messages"]] + assert dates == sorted(dates) # ascending + + +async def test_message_html_is_sanitized(api: ApiCtx) -> None: + # the lunch message has HTML with a script + resp = await api.client.get("/api/threads") + lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch") + detail = (await api.client.get(f"/api/threads/{lunch['id']}")).json() + mid = detail["messages"][0]["id"] + html = await api.client.get(f"/api/messages/{mid}/html") + assert html.status_code == 200 + assert " None: + resp = await api.client.get("/api/search", params={"q": "revenue"}) + assert resp.status_code == 200 + data = resp.json() + assert len(data["threads"]) == 1 # both kickoff messages -> one thread + assert data["threads"][0]["thread"]["message_count"] == 2 + + +async def test_accounts_list(api: ApiCtx) -> None: + resp = await api.client.get("/api/accounts") + assert resp.status_code == 200 + assert resp.json()[0]["email"] == "me@example.com" + + +async def test_thread_pagination_cursor(api: ApiCtx) -> None: + first = await api.client.get("/api/threads", params={"limit": 1}) + body = first.json() + assert len(body["threads"]) == 1 + assert body["cursor"] is not None + second = await api.client.get("/api/threads", params={"limit": 1, "cursor": body["cursor"]}) + body2 = second.json() + assert len(body2["threads"]) == 1 + assert body2["threads"][0]["id"] != body["threads"][0]["id"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0bc676c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,65 @@ +"""Shared test fixtures and environment defaults. + +Ensures the SPEC §14 env vars exist even when tests run outside the conda +activate hook (e.g. CI), so ``app.config`` imports cleanly. +""" + +from __future__ import annotations + +import base64 +import os + +os.environ.setdefault("APP_DOMAIN", "test.webmail.local") +os.environ.setdefault( + "DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev" +) +# Force a dedicated Redis DB for tests (index 15) so the suite is isolated from the +# dev app's Redis (DB 0) and can be flushed between tests without touching real data. +os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15" +os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode()) +os.environ.setdefault("BLOB_ROOT", "./var/blobs-test") + +from collections.abc import AsyncIterator # noqa: E402 + +import pytest_asyncio # noqa: E402 +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # noqa: E402 +from sqlalchemy.pool import NullPool # noqa: E402 + +from app.config import get_settings # noqa: E402 + + +@pytest_asyncio.fixture(autouse=True) +async def _reset_global_engine() -> AsyncIterator[None]: + """Dispose process-wide async resources after each test. + + Each test runs on its own event loop; disposing here ensures the next test + (and any code using the global engine/redis, e.g. the search backend) rebinds + to its own loop rather than reusing a closed one. + """ + from app.redis_client import get_redis + + # Flush the dedicated test Redis DB so rate-limit / session / token state does + # not leak between tests (each test starts from a clean slate). + await get_redis().flushdb() + yield + from app.db import dispose_engine + from app.redis_client import close_redis + + await dispose_engine() + await close_redis() + + +@pytest_asyncio.fixture +async def db_session() -> AsyncIterator[AsyncSession]: + """Rollback-per-test session (nothing persists to the dev database).""" + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + conn = await engine.connect() + trans = await conn.begin() + session = AsyncSession(bind=conn, expire_on_commit=False) + try: + yield session + finally: + await session.close() + await trans.rollback() + await conn.close() + await engine.dispose() diff --git a/backend/tests/identity/test_handles.py b/backend/tests/identity/test_handles.py new file mode 100644 index 0000000..72b76b7 --- /dev/null +++ b/backend/tests/identity/test_handles.py @@ -0,0 +1,67 @@ +"""Handle validation rules (SPEC §4).""" + +from __future__ import annotations + +import pytest + +from app.config import get_settings +from app.identity.handles import InvalidHandle, full_handle, validate_localpart +from app.identity.reserved import is_reserved +from app.identity.service import resolve_handle + +_DOMAIN = get_settings().app_domain + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("alice", "alice"), + ("Alice", "alice"), # case-insensitive + (" bob ", "bob"), # trimmed + ("a.b_c-d", "a.b_c-d"), + ("user123", "user123"), + ("abc", "abc"), # min length + ("a" * 30, "a" * 30), # max length + ], +) +def test_valid_localparts(raw: str, expected: str) -> None: + assert validate_localpart(raw) == expected + + +@pytest.mark.parametrize( + "raw,code", + [ + ("ab", "length"), # too short + ("a" * 31, "length"), # too long + (".alice", "dot_edge"), + ("alice.", "dot_edge"), + ("al..ice", "double_dot"), + ("al ice", "charset"), # space + ("aliçe", "charset"), # non-ascii + ("al@ice", "charset"), # '@' + ("UPPER!", "charset"), + ("postmaster", "reserved"), + ("admin", "reserved"), + ("root", "reserved"), + ("NoReply", "reserved"), # reserved, case-insensitive + ], +) +def test_invalid_localparts(raw: str, code: str) -> None: + with pytest.raises(InvalidHandle) as exc: + validate_localpart(raw) + assert exc.value.code == code + + +def test_full_handle_appends_domain() -> None: + assert full_handle("alice") == f"alice@{_DOMAIN}" + + +def test_reserved_is_case_insensitive() -> None: + assert is_reserved("ADMIN") + assert is_reserved("admin") + assert not is_reserved("alice") + + +def test_resolve_handle_accepts_localpart_or_full() -> None: + assert resolve_handle("Alice") == f"alice@{_DOMAIN}" + assert resolve_handle("alice@other.example") == "alice@other.example" diff --git a/backend/tests/identity/test_identity_m1.py b/backend/tests/identity/test_identity_m1.py new file mode 100644 index 0000000..ba83b42 --- /dev/null +++ b/backend/tests/identity/test_identity_m1.py @@ -0,0 +1,188 @@ +"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1).""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import httpx +import pyotp +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.main import create_app +from app.models import User +from app.redis_client import get_redis + +CSRF = "csrf-token" +PASSWORD = "hunter2pass" + + +@dataclass +class Ctx: + app: object + client: httpx.AsyncClient + user_id: uuid.UUID + handle: str + session: AsyncSession + + +def _hdr() -> dict[str, str]: + return {"X-CSRF": CSRF} + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + handle = f"id{uuid.uuid4().hex[:8]}@test.local" + user = User(handle=handle, password_hash=hash_password(PASSWORD), recovery_email="r@x.com") + session.add(user) + await session.commit() + token = await create_session(user.id, "pytest") + app = create_app() + client = httpx.AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + ) + try: + yield Ctx(app=app, client=client, user_id=user.id, handle=handle, session=session) + finally: + await client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +def _fresh_client(app: object) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") + + +async def test_totp_setup_enable_and_login(ctx: Ctx) -> None: + setup = await ctx.client.post("/api/auth/totp/setup", headers=_hdr()) + assert setup.status_code == 200 + secret = setup.json()["secret"] + assert setup.json()["provisioning_uri"].startswith("otpauth://") + + # wrong code rejected + bad = await ctx.client.post("/api/auth/totp/enable", json={"code": "000000"}, headers=_hdr()) + assert bad.status_code == 400 + + code = pyotp.TOTP(secret).now() + ok = await ctx.client.post("/api/auth/totp/enable", json={"code": code}, headers=_hdr()) + assert ok.status_code == 200 + + # login now requires the 2FA code + async with _fresh_client(ctx.app) as anon: + no_totp = await anon.post( + "/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD} + ) + assert no_totp.status_code == 401 + assert no_totp.json()["error"]["code"] == "totp_required" + + with_totp = await anon.post( + "/api/auth/login", + json={"handle": ctx.handle, "password": PASSWORD, "totp": pyotp.TOTP(secret).now()}, + ) + assert with_totp.status_code == 200 + + +async def test_totp_disable(ctx: Ctx) -> None: + # enable first + secret = (await ctx.client.post("/api/auth/totp/setup", headers=_hdr())).json()["secret"] + await ctx.client.post( + "/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers=_hdr() + ) + assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True + + # wrong password is rejected + bad = await ctx.client.post("/api/auth/totp/disable", json={"password": "nope"}, headers=_hdr()) + assert bad.status_code == 401 + assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True + + # correct password disables it + ok = await ctx.client.post( + "/api/auth/totp/disable", json={"password": PASSWORD}, headers=_hdr() + ) + assert ok.status_code == 200 + assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is False + + # login now works without a code + async with _fresh_client(ctx.app) as anon: + resp = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}) + assert resp.status_code == 200 + + +async def test_sessions_list_and_revoke(ctx: Ctx) -> None: + # create a second session for the same user (a different device) + other_token = await create_session(ctx.user_id, "other-device") + + listing = await ctx.client.get("/api/auth/sessions") + assert listing.status_code == 200 + rows = listing.json() + assert len(rows) == 2 + current = [r for r in rows if r["current"]] + assert len(current) == 1 + + other_sid = next(r["sid"] for r in rows if not r["current"]) + revoked = await ctx.client.request("DELETE", f"/api/auth/sessions/{other_sid}", headers=_hdr()) + assert revoked.status_code == 200 + + # the revoked token no longer resolves + async with _fresh_client(ctx.app) as anon: + anon.cookies.set("session", other_token) + me = await anon.get("/api/auth/me") + assert me.status_code == 401 + + +async def test_password_reset_flow(ctx: Ctx) -> None: + # request a reset; token is stored in redis (not returned) + req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle}) + assert req.status_code == 200 + + redis = get_redis() + keys = await redis.keys("pwreset:*") + # Pick the token bound to THIS user (redis DB may hold others from prior runs). + token = None + for key in keys: + if await redis.get(key) == str(ctx.user_id): + token = key.split(":", 1)[1] + break + assert token is not None, "a reset token should have been issued for this user" + + new_password = "brandNewPass9" + reset = await ctx.client.post( + "/api/auth/password/reset", json={"token": token, "password": new_password} + ) + assert reset.status_code == 200 + + # token is single-use + again = await ctx.client.post( + "/api/auth/password/reset", json={"token": token, "password": new_password} + ) + assert again.status_code == 400 + + # new password works, old one does not + async with _fresh_client(ctx.app) as anon: + good = await anon.post( + "/api/auth/login", json={"handle": ctx.handle, "password": new_password} + ) + assert good.status_code == 200 + bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}) + assert bad.status_code == 401 + + +async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None: + # must not leak whether a handle exists + resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"}) + assert resp.status_code == 200 diff --git a/backend/tests/indexer/test_blobstore.py b/backend/tests/indexer/test_blobstore.py new file mode 100644 index 0000000..ceee360 --- /dev/null +++ b/backend/tests/indexer/test_blobstore.py @@ -0,0 +1,43 @@ +"""Content-addressed blob store (SPEC §4, §6.4).""" + +from __future__ import annotations + +import uuid + +import pytest + +from app.indexer import blobstore + + +@pytest.fixture(autouse=True) +def _blob_root(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] + from app.config import get_settings + + settings = get_settings() + monkeypatch.setattr(settings, "blob_root", str(tmp_path)) + + +def test_store_and_load_roundtrip() -> None: + uid = uuid.uuid4() + data = b"hello world" + sha = blobstore.store(uid, data) + assert blobstore.load(uid, sha) == data + assert blobstore.exists(uid, sha) + + +def test_store_is_content_addressed_and_dedupes() -> None: + uid = uuid.uuid4() + sha1 = blobstore.store(uid, b"same payload") + sha2 = blobstore.store(uid, b"same payload") + assert sha1 == sha2 + # path layout: {user}/{sha[:2]}/{sha} + path = blobstore.blob_path(uid, sha1) + assert path.parent.name == sha1[:2] + assert path.parent.parent.name == str(uid) + + +def test_different_users_isolated() -> None: + u1, u2 = uuid.uuid4(), uuid.uuid4() + sha = blobstore.store(u1, b"payload") + assert blobstore.exists(u1, sha) + assert not blobstore.exists(u2, sha) diff --git a/backend/tests/indexer/test_duplicate_msgid.py b/backend/tests/indexer/test_duplicate_msgid.py new file mode 100644 index 0000000..01ca909 --- /dev/null +++ b/backend/tests/indexer/test_duplicate_msgid.py @@ -0,0 +1,124 @@ +"""Duplicate Message-ID handling in the indexer (SPEC §7.1). + +Real-world trigger: home.pl's welcome mailer reuses one static Message-ID for +every recipient, so the same id lands in different accounts as different messages. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from email.message import EmailMessage + +import pytest_asyncio +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.indexer.indexer import persist_message +from app.models import Account, Mailbox, Message, User +from app.providers.base import RemoteMessage + + +class Dupenv: + def __init__(self, session: AsyncSession, user_id: uuid.UUID) -> None: + self.session = session + self.user_id = user_id + self.accounts: dict[str, uuid.UUID] = {} + self.mailboxes: dict[str, uuid.UUID] = {} + self._uid = 0 + + async def add_account(self, label: str) -> None: + acc = Account( + user_id=self.user_id, + email=f"{label}@example.com", + imap_config={}, + smtp_config={}, + secret_ct=b"", + discovery_src="manual", + ) + self.session.add(acc) + await self.session.flush() + self.accounts[label] = acc.id + + async def add_mailbox(self, account_label: str, path: str) -> None: + mb = Mailbox( + account_id=self.accounts[account_label], + user_id=self.user_id, + path=path, + special_use="inbox", + ) + self.session.add(mb) + await self.session.flush() + self.mailboxes[f"{account_label}/{path}"] = mb.id + + async def ingest( + self, account_label: str, path: str, *, mid: str, minutes: int = 0 + ) -> Message | None: + self._uid += 1 + m = EmailMessage() + m["Subject"] = "Pierwsze kroki z pocztą w home.pl" + m["From"] = "info@home.pl" + m["Message-ID"] = mid + m["Date"] = "Sun, 23 Nov 2025 12:47:30 +0000" + m.set_content("welcome template body") + remote = RemoteMessage( + uid=self._uid, + raw=m.as_bytes(), + flags=frozenset(), + internal_date=datetime(2026, 1, 1, tzinfo=UTC) + timedelta(minutes=minutes), + ) + result = await persist_message( + self.session, + user_id=self.user_id, + account_id=self.accounts[account_label], + mailbox_id=self.mailboxes[f"{account_label}/{path}"], + mailbox_special_use="inbox", + remote=remote, + ) + return result.message if result else None + + +@pytest_asyncio.fixture +async def dup() -> AsyncIterator[Dupenv]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"dup{uuid.uuid4().hex[:8]}@test.local", password_hash="x") + session.add(user) + await session.flush() + env = Dupenv(session, user.id) + await env.add_account("a") + await env.add_account("b") + await env.add_mailbox("a", "INBOX") + await env.add_mailbox("a", "Archive") + await env.add_mailbox("b", "INBOX") + try: + yield env + finally: + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +async def test_cross_account_duplicate_isolates(dup: Dupenv) -> None: + # Same static Message-ID delivered to two different accounts -> two threads. + a = await dup.ingest("a", "INBOX", mid="", minutes=0) + b = await dup.ingest("b", "INBOX", mid="", minutes=60) + assert a is not None and b is not None + assert a.thread_id != b.thread_id + # The second one was stored with a synthesized, non-authoritative id. + assert a.message_id_hdr == "static@home.pl" + assert b.message_id_hdr.endswith("@synthetic.local") + + +async def test_same_account_duplicate_merges(dup: Dupenv) -> None: + # Same message in INBOX + Archive of ONE account (Gmail-style) -> one thread. + a = await dup.ingest("a", "INBOX", mid="", minutes=0) + b = await dup.ingest("a", "Archive", mid="", minutes=1) + assert a is not None and b is not None + assert a.thread_id == b.thread_id + assert b.message_id_hdr == "msg@x.com" diff --git a/backend/tests/indexer/test_mime.py b/backend/tests/indexer/test_mime.py new file mode 100644 index 0000000..e7cbfb5 --- /dev/null +++ b/backend/tests/indexer/test_mime.py @@ -0,0 +1,103 @@ +"""MIME parsing (SPEC §6.4).""" + +from __future__ import annotations + +from email.message import EmailMessage + +from app.indexer.mime import parse_message + + +def _build( + *, + subject: str = "Hello", + from_addr: str = "Alice ", + to_addr: str = "bob@example.com", + message_id: str = "", + references: str | None = None, + in_reply_to: str | None = None, + text: str | None = "plain body", + html: str | None = None, +) -> bytes: + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = to_addr + msg["Message-ID"] = message_id + msg["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + if references: + msg["References"] = references + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + if text is not None: + msg.set_content(text) + if html is not None: + if text is not None: + msg.add_alternative(html, subtype="html") + else: + msg.set_content(html, subtype="html") + return msg.as_bytes() + + +def test_parse_basic_headers_and_body() -> None: + parsed = parse_message(_build()) + assert parsed.subject == "Hello" + assert parsed.message_id == "m1@example.com" + assert parsed.from_addr == [{"name": "Alice", "email": "alice@example.com"}] + assert parsed.to_addr == [{"name": "", "email": "bob@example.com"}] + assert "plain body" in parsed.text_body + assert parsed.date is not None + assert parsed.snippet == "plain body" + + +def test_references_include_in_reply_to_in_order() -> None: + parsed = parse_message(_build(references=" ", in_reply_to="")) + # References first, then In-Reply-To, deduped + assert parsed.references == ["a@x.com", "b@x.com"] + assert parsed.in_reply_to == "b@x.com" + + +def test_html_only_derives_text() -> None: + parsed = parse_message(_build(text=None, html="

Hello world

")) + assert parsed.html_body is not None + assert "Hello" in parsed.text_body + assert "world" in parsed.text_body + assert "" not in parsed.text_body # tags stripped for indexing text + + +def test_multipart_alternative_prefers_html_for_display() -> None: + parsed = parse_message(_build(text="text version", html="

html version

")) + assert parsed.html_body is not None + assert "html version" in parsed.html_body + assert "text version" in parsed.text_body + + +def test_rfc2047_encoded_subject_is_decoded() -> None: + # "Próba" base64-encoded UTF-8 + parsed = parse_message(_build(subject="=?UTF-8?B?UHLDs2Jh?=")) + assert parsed.subject == "Próba" + + +def test_attachment_extracted() -> None: + msg = EmailMessage() + msg["Subject"] = "with attachment" + msg["From"] = "a@b.com" + msg["To"] = "c@d.com" + msg["Message-ID"] = "" + msg.set_content("see attached") + msg.add_attachment( + b"\x00\x01\x02data", maintype="application", subtype="octet-stream", filename="file.bin" + ) + parsed = parse_message(msg.as_bytes()) + assert parsed.has_attachments + assert parsed.attachments[0].filename == "file.bin" + assert parsed.attachments[0].payload == b"\x00\x01\x02data" + + +def test_missing_message_id_returns_none() -> None: + msg = EmailMessage() + msg["Subject"] = "no id" + msg["From"] = "a@b.com" + msg["To"] = "c@d.com" + msg.set_content("body") + parsed = parse_message(msg.as_bytes()) + assert parsed.message_id is None diff --git a/backend/tests/indexer/test_pipeline.py b/backend/tests/indexer/test_pipeline.py new file mode 100644 index 0000000..d7aa699 --- /dev/null +++ b/backend/tests/indexer/test_pipeline.py @@ -0,0 +1,186 @@ +"""End-to-end indexer pipeline + Postgres search (SPEC §6.4, §8). + +Uses a committed session (search reads through its own connection) and deletes +the user on teardown; the user FK cascade removes all created rows. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from email.message import EmailMessage + +import pytest_asyncio +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.indexer.indexer import persist_message +from app.models import Account, BodyPart, KnownRecipient, Mailbox, Message, User +from app.providers.base import RemoteMessage +from app.search.pg import PgSearchBackend +from app.search.queryparser import to_search_query + + +class Fixture: + def __init__( + self, + session: AsyncSession, + user_id: uuid.UUID, + account_id: uuid.UUID, + mailbox_id: uuid.UUID, + ) -> None: + self.session = session + self.user_id = user_id + self.account_id = account_id + self.mailbox_id = mailbox_id + self.backend = PgSearchBackend() + self._uid = 0 + + async def ingest(self, raw: bytes, *, flags: frozenset[str] = frozenset()) -> Message | None: + self._uid += 1 + remote = RemoteMessage(uid=self._uid, raw=raw, flags=flags, internal_date=datetime.now(UTC)) + result = await persist_message( + self.session, + user_id=self.user_id, + account_id=self.account_id, + mailbox_id=self.mailbox_id, + mailbox_special_use="inbox", + remote=remote, + ) + await self.session.commit() + if result is not None: + await self.backend.index(result.search_doc) + return result.message + return None + + +@pytest_asyncio.fixture +async def fx() -> AsyncIterator[Fixture]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"pipe{uuid.uuid4().hex[:8]}@test.local", password_hash="x") + 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() + mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + session.add(mailbox) + await session.commit() + try: + yield Fixture(session, user.id, account.id, mailbox.id) + finally: + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +def _msg( + subject: str, + body: str, + *, + message_id: str, + frm: str = "Alice ", + html: str | None = None, +) -> bytes: + m = EmailMessage() + m["Subject"] = subject + m["From"] = frm + m["To"] = "me@example.com" + m["Message-ID"] = message_id + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + m.set_content(body) + if html: + m.add_alternative(html, subtype="html") + return m.as_bytes() + + +async def test_ingest_creates_rows_and_thread(fx: Fixture) -> None: + msg = await fx.ingest( + _msg("Invoice 2026", "Please find the invoice attached.", message_id="") + ) + assert msg is not None + assert msg.thread_id is not None + body = (await fx.session.scalars(select(BodyPart).where(BodyPart.message_id == msg.id))).all() + kinds = {b.kind for b in body} + assert "text" in kinds + # known_recipients harvested the sender + kr = await fx.session.scalar( + select(KnownRecipient).where(KnownRecipient.email == "alice@example.com") + ) + assert kr is not None and kr.times_used == 1 + + +async def test_idempotent_reingest(fx: Fixture) -> None: + raw = _msg("Dup", "body", message_id="") + first = await fx.ingest(raw) + assert first is not None + # Same (mailbox_id, uid) — but ingest increments uid; force same uid path: + remote = RemoteMessage(uid=1, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC)) + dup = await persist_message( + fx.session, + user_id=fx.user_id, + account_id=fx.account_id, + mailbox_id=fx.mailbox_id, + mailbox_special_use="inbox", + remote=remote, + ) + assert dup is None # (mailbox_id, uid=1) already exists + + +async def test_search_finds_indexed_message(fx: Fixture) -> None: + await fx.ingest( + _msg("Quarterly report", "revenue numbers and projections", message_id="") + ) + await fx.ingest(_msg("Lunch plans", "let us grab lunch tomorrow", message_id="")) + + q = to_search_query("revenue", fx.user_id) + hits, cursor = await fx.backend.query(q, limit=50, cursor=None) + assert len(hits) == 1 + assert hits[0].highlights # revenue highlighted + + q2 = to_search_query("lunch", fx.user_id) + hits2, _ = await fx.backend.query(q2, limit=50, cursor=None) + assert len(hits2) == 1 + + +async def test_search_operator_from_filter(fx: Fixture) -> None: + await fx.ingest( + _msg("Hi", "hello there", message_id="", frm="Bob ") + ) + await fx.ingest( + _msg("Yo", "hello again", message_id="", frm="Carol ") + ) + q = to_search_query("hello from:bob", fx.user_id) + hits, _ = await fx.backend.query(q, limit=50, cursor=None) + assert len(hits) == 1 + + +async def test_html_sanitized_stored(fx: Fixture) -> None: + msg = await fx.ingest( + _msg( + "HTML", + "text fallback", + message_id="", + html="

Hello world

", + ) + ) + assert msg is not None + html_part = await fx.session.scalar( + select(BodyPart).where(BodyPart.message_id == msg.id, BodyPart.kind == "html_sanitized") + ) + assert html_part is not None + assert " None: + html = '
hi
' + result = sanitize_html(html, MID) + assert " None: + html = '

see this

' + result = sanitize_html(html, MID) + assert result.has_remote_images + assert "tracker.example" not in result.html + + +def test_cid_image_is_rewritten_to_endpoint() -> None: + html = '' + result = sanitize_html(html, MID) + assert f"/api/messages/{MID}/cid/logo123" in result.html + assert not result.has_remote_images + + +def test_data_image_is_preserved() -> None: + html = '' + result = sanitize_html(html, MID) + assert "data:image/png" in result.html + assert not result.has_remote_images + + +def test_safe_links_survive_with_rel() -> None: + html = 'link' + result = sanitize_html(html, MID) + assert "https://example.com" in result.html + assert "javascript:" not in result.html.lower() + + +def test_javascript_uri_is_stripped() -> None: + html = 'x' + result = sanitize_html(html, MID) + assert "javascript:" not in result.html.lower() + + +def test_sanitizer_version_reported() -> None: + result = sanitize_html("

hi

", MID) + assert result.sanitizer_v >= 1 diff --git a/backend/tests/mail/test_actions.py b/backend/tests/mail/test_actions.py new file mode 100644 index 0000000..18923ba --- /dev/null +++ b/backend/tests/mail/test_actions.py @@ -0,0 +1,303 @@ +"""Flag / move actions (SPEC §6.3, §9.1): DB-first + enqueue + revert.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from email.message import EmailMessage + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.api import actions as actions_api +from app.config import get_settings +from app.db import session_scope +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.indexer.indexer import persist_message +from app.main import create_app +from app.models import Account, Mailbox, Message, Thread, User +from app.providers.base import RemoteMessage +from app.sync import actions as sync_actions + +CSRF = "csrf" + + +@dataclass +class Ctx: + client: httpx.AsyncClient + session: AsyncSession + user_id: uuid.UUID + inbox_id: uuid.UUID + archive_id: uuid.UUID + message_ids: list[uuid.UUID] = field(default_factory=list) + thread_id: uuid.UUID | None = None + enqueued: list[tuple] = field(default_factory=list) + + +def _hdr() -> dict[str, str]: + return {"X-CSRF": CSRF} + + +def _raw(subject: str, mid: str, refs: str | None = None) -> bytes: + m = EmailMessage() + m["Subject"] = subject + m["From"] = "a@x.com" + m["To"] = "me@example.com" + m["Message-ID"] = mid + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + if refs: + m["References"] = refs + m.set_content("body") + return m.as_bytes() + + +@pytest_asyncio.fixture +async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def] + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"act{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") + archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive") + session.add_all([inbox, archive]) + await session.commit() + + c = Ctx( + client=None, + session=session, + user_id=user.id, + inbox_id=inbox.id, # type: ignore[arg-type] + archive_id=archive.id, + ) + for i, (subj, mid, refs) in enumerate( + [ + ("Topic", "", None), + ("Re: Topic", "", ""), + ] + ): + remote = RemoteMessage( + uid=i + 1, raw=_raw(subj, mid, refs), flags=frozenset(), internal_date=datetime.now(UTC) + ) + result = await persist_message( + session, + user_id=user.id, + account_id=account.id, + mailbox_id=inbox.id, + mailbox_special_use="inbox", + remote=remote, + ) + await session.commit() + assert result is not None + c.message_ids.append(result.message.id) + c.thread_id = result.message.thread_id + + # capture enqueues instead of hitting a real arq pool + class FakePool: + async def enqueue_job(self, *args: object) -> None: + c.enqueued.append(args) + + async def fake_pool() -> FakePool: + return FakePool() + + monkeypatch.setattr(actions_api, "get_arq_pool", fake_pool) + + token = await create_session(user.id, "pytest") + c.client = httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + ) + try: + yield c + finally: + await c.client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +async def test_mark_message_read_enqueues_push(ctx: Ctx) -> None: + mid = ctx.message_ids[0] + resp = await ctx.client.post( + f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr() + ) + assert resp.status_code == 200 + assert "\\Seen" in resp.json()["flags"] + + await ctx.session.refresh(await ctx.session.get(Message, mid)) + msg = await ctx.session.get(Message, mid) + assert msg is not None and "\\Seen" in msg.flags + assert ctx.enqueued == [("push_flags_job", str(mid), ["\\Seen"], [])] + + +async def test_noop_flag_change_does_not_enqueue(ctx: Ctx) -> None: + mid = ctx.message_ids[0] + await ctx.client.post(f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr()) + ctx.enqueued.clear() + # already \Seen -> no-op + resp = await ctx.client.post( + f"/api/messages/{mid}/flags", json={"add": ["\\Seen"]}, headers=_hdr() + ) + assert resp.status_code == 200 + assert ctx.enqueued == [] + + +async def test_thread_flags_mark_all_read(ctx: Ctx) -> None: + resp = await ctx.client.post( + f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Seen"]}, headers=_hdr() + ) + assert resp.status_code == 200 + thread = await ctx.session.get(Thread, ctx.thread_id) + await ctx.session.refresh(thread) + assert thread is not None and thread.unread_count == 0 + assert len(ctx.enqueued) == 2 # both messages pushed + + +async def test_thread_flag_sets_pinned(ctx: Ctx) -> None: + resp = await ctx.client.post( + f"/api/threads/{ctx.thread_id}/flags", json={"add": ["\\Flagged"]}, headers=_hdr() + ) + assert resp.status_code == 200 + thread = await ctx.session.get(Thread, ctx.thread_id) + await ctx.session.refresh(thread) + assert thread is not None and thread.pinned is True + + # Unpinning clears it again. + await ctx.client.post( + f"/api/threads/{ctx.thread_id}/flags", json={"remove": ["\\Flagged"]}, headers=_hdr() + ) + await ctx.session.refresh(thread) + assert thread.pinned is False + + +async def test_move_message(ctx: Ctx) -> None: + mid = ctx.message_ids[0] + resp = await ctx.client.post( + f"/api/messages/{mid}/move", json={"mailbox_id": str(ctx.archive_id)}, headers=_hdr() + ) + assert resp.status_code == 200 + msg = await ctx.session.get(Message, mid) + await ctx.session.refresh(msg) + assert msg is not None and msg.mailbox_id == ctx.archive_id + assert ctx.enqueued[0][0] == "move_job" + + +async def test_archive_thread_enqueues_move(ctx: Ctx) -> None: + resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/archive", headers=_hdr()) + assert resp.status_code == 200 + assert resp.json() == {"status": "queued"} + assert ctx.enqueued == [ + ("move_thread_to_folder_job", str(ctx.thread_id), "archive"), + ] + + +async def test_trash_thread_enqueues_move(ctx: Ctx) -> None: + resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/trash", headers=_hdr()) + assert resp.status_code == 200 + assert ctx.enqueued == [ + ("move_thread_to_folder_job", str(ctx.thread_id), "trash"), + ] + + +async def test_archive_unknown_thread_404(ctx: Ctx) -> None: + resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/archive", headers=_hdr()) + assert resp.status_code == 404 + assert ctx.enqueued == [] + + +async def test_restore_thread_enqueues_move_to_inbox(ctx: Ctx) -> None: + resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr()) + assert resp.status_code == 200 + assert ctx.enqueued == [ + ("move_thread_to_folder_job", str(ctx.thread_id), "inbox"), + ] + + +async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None: + resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr()) + assert resp.status_code == 200 + assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))] + + +async def test_ignores_unknown_flags(ctx: Ctx) -> None: + mid = ctx.message_ids[0] + resp = await ctx.client.post( + f"/api/messages/{mid}/flags", json={"add": ["\\Deleted", "bogus"]}, headers=_hdr() + ) + assert resp.status_code == 200 + msg = await ctx.session.get(Message, mid) + await ctx.session.refresh(msg) + assert msg is not None and "\\Deleted" not in msg.flags # not user-settable here + + +async def test_delete_thread_job_expunges_and_removes_rows(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + tid = ctx.thread_id + assert tid is not None + deleted: list[tuple[str, list[int]]] = [] + + class FakeProvider: + async def delete_messages(self, mailbox: str, uids: list[int]) -> None: + deleted.append((mailbox, uids)) + + async def close(self) -> None: + return None + + monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FakeProvider()) + + await sync_actions.delete_thread_job({}, str(tid)) + + # server expunge happened for the thread's INBOX uids + assert deleted and deleted[0][0] == "INBOX" + # DB rows for the thread's messages are gone (verify in a fresh session; the + # job deleted them in its own session, so drop ctx's stale identity map). + ctx.session.expunge_all() + async with session_scope() as verify: + remaining = await verify.scalar( + select(func.count()).select_from(Message).where(Message.thread_id == tid) + ) + assert remaining == 0 + assert await verify.scalar(select(Thread).where(Thread.id == tid)) is None + + +async def test_push_flags_job_reverts_on_failure(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + mid = ctx.message_ids[0] + # mark read in DB first (as the endpoint would) + msg = await ctx.session.get(Message, mid) + assert msg is not None + msg.flags = ["\\Seen"] + await ctx.session.commit() + + class FailingProvider: + async def push_flags(self, *a: object, **k: object) -> None: + raise RuntimeError("imap down") + + async def close(self) -> None: + return None + + monkeypatch.setattr(sync_actions, "build_provider", lambda _account: FailingProvider()) + + # final try -> should revert (remove the \Seen we added) + await sync_actions.push_flags_job({"job_try": 5}, str(mid), ["\\Seen"], []) + reverted = await ctx.session.get(Message, mid) + await ctx.session.refresh(reverted) + assert reverted is not None and "\\Seen" not in reverted.flags diff --git a/backend/tests/mail/test_recipients_settings.py b/backend/tests/mail/test_recipients_settings.py new file mode 100644 index 0000000..ec70adc --- /dev/null +++ b/backend/tests/mail/test_recipients_settings.py @@ -0,0 +1,188 @@ +"""Recipient suggest, settings, and per-sender remote-image control (SPEC §9.1/§9.4).""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from email.message import EmailMessage + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.indexer.indexer import persist_message +from app.main import create_app +from app.models import Account, KnownRecipient, Mailbox, User +from app.providers.base import RemoteMessage +from app.search.pg import PgSearchBackend + +CSRF = "csrf" + + +@dataclass +class Ctx: + client: httpx.AsyncClient + session: AsyncSession + user_id: uuid.UUID + account_id: uuid.UUID + mailbox_id: uuid.UUID + + +def _hdr() -> dict[str, str]: + return {"X-CSRF": CSRF} + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"rs{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() + mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + session.add(mailbox) + await session.commit() + token = await create_session(user.id, "pytest") + client = httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + ) + try: + yield Ctx( + client=client, + session=session, + user_id=user.id, + account_id=account.id, + mailbox_id=mailbox.id, + ) + finally: + await client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +async def test_recipient_suggest_prefix_and_ranking(ctx: Ctx) -> None: + now = datetime.now(UTC) + ctx.session.add_all( + [ + KnownRecipient( + user_id=ctx.user_id, + email="alice@example.com", + name="Alice", + times_used=10, + last_used_at=now, + ), + KnownRecipient( + user_id=ctx.user_id, + email="alan@example.com", + name="Alan", + times_used=1, + last_used_at=now - timedelta(days=200), + ), + KnownRecipient( + user_id=ctx.user_id, + email="bob@example.com", + name="Bob", + times_used=99, + last_used_at=now, + ), + ] + ) + await ctx.session.commit() + + resp = await ctx.client.get("/api/recipients/suggest", params={"q": "al"}) + assert resp.status_code == 200 + emails = [r["email"] for r in resp.json()] + assert set(emails) == {"alice@example.com", "alan@example.com"} # prefix match, bob excluded + assert emails[0] == "alice@example.com" # higher score (recent + frequent) ranks first + + +async def test_recipient_suggest_empty_query(ctx: Ctx) -> None: + resp = await ctx.client.get("/api/recipients/suggest", params={"q": ""}) + assert resp.status_code == 200 and resp.json() == [] + + +async def test_settings_get_and_patch(ctx: Ctx) -> None: + got = await ctx.client.get("/api/settings") + assert got.status_code == 200 + assert got.json()["thread_order"] == "asc" + + patched = await ctx.client.patch( + "/api/settings", json={"thread_order": "desc", "tz": "Europe/Warsaw"}, headers=_hdr() + ) + assert patched.status_code == 200 + assert patched.json()["thread_order"] == "desc" + assert patched.json()["tz"] == "Europe/Warsaw" + + # invalid order is ignored + patched2 = await ctx.client.patch( + "/api/settings", json={"thread_order": "sideways"}, headers=_hdr() + ) + assert patched2.json()["thread_order"] == "desc" + + +async def _ingest_html(ctx: Ctx, *, sender: str, html: str) -> uuid.UUID: + m = EmailMessage() + m["Subject"] = "Newsletter" + m["From"] = sender + m["To"] = "me@example.com" + m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>" + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + m.set_content("text fallback") + m.add_alternative(html, subtype="html") + result = await persist_message( + ctx.session, + user_id=ctx.user_id, + account_id=ctx.account_id, + mailbox_id=ctx.mailbox_id, + mailbox_special_use="inbox", + remote=RemoteMessage( + uid=1, raw=m.as_bytes(), flags=frozenset(), internal_date=datetime.now(UTC) + ), + ) + await ctx.session.commit() + assert result is not None + await PgSearchBackend().index(result.search_doc) + return result.message.id + + +async def test_remote_images_blocked_then_allowed(ctx: Ctx) -> None: + mid = await _ingest_html( + ctx, + sender="News ", + html='

Hi

', + ) + # blocked by default + before = await ctx.client.get(f"/api/messages/{mid}/html") + assert "tracker.example" not in before.text + + # approve the sender + allow = await ctx.client.post(f"/api/messages/{mid}/allow-remote-images", headers=_hdr()) + assert allow.status_code == 200 + assert allow.json()["sender"] == "news@sender.com" + + # now the remote image is rendered + after = await ctx.client.get(f"/api/messages/{mid}/html") + assert "http://tracker.example/pixel.gif" in after.text diff --git a/backend/tests/outbox/test_outbox.py b/backend/tests/outbox/test_outbox.py new file mode 100644 index 0000000..0cd4a7a --- /dev/null +++ b/backend/tests/outbox/test_outbox.py @@ -0,0 +1,361 @@ +"""Outbox: MIME assembly, drafts, send, undo, send job (SPEC §6.5).""" + +from __future__ import annotations + +import email +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from datetime import UTC, datetime +from email.policy import default as default_policy + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.api import compose as compose_api +from app.config import get_settings +from app.db import session_scope +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.main import create_app +from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox, User +from app.outbox import jobs as outbox_jobs +from app.outbox.mime_build import build_mime + +CSRF = "csrf" + + +# --- pure MIME assembly ------------------------------------------------------ + + +def test_build_mime_sets_threading_and_alternative() -> None: + account = Account( + email="me@example.com", + display_name="Me", + imap_config={}, + smtp_config={}, + secret_ct=b"", + discovery_src="manual", + user_id=uuid.uuid4(), + ) + parent = Message( + message_id_hdr="orig@x.com", + references_hdr=["root@x.com"], + user_id=uuid.uuid4(), + account_id=uuid.uuid4(), + mailbox_id=uuid.uuid4(), + uid=1, + internal_date=datetime.now(UTC), + blob_sha256="0", + ) + draft = Draft( + user_id=uuid.uuid4(), + account_id=uuid.uuid4(), + kind="reply", + payload={ + "to": ["bob@example.com"], + "cc": ["c@example.com"], + "bcc": ["secret@example.com"], + "subject": "Re: Hi", + "html": "

Hello there

", + "text": "", + }, + ) + raw, env_from, env_to = build_mime(account, draft, parent) + + assert env_from == "me@example.com" + assert set(env_to) == {"bob@example.com", "c@example.com", "secret@example.com"} + msg = email.message_from_bytes(raw, policy=default_policy) + assert msg["From"] == "Me " + assert msg["To"] == "bob@example.com" + assert "secret@example.com" not in (raw.decode()) # bcc not in headers + assert msg["In-Reply-To"] == "" + assert "" in msg["References"] and "" in msg["References"] + # multipart/alternative with an auto-generated text part + body_types = {p.get_content_type() for p in msg.walk()} + assert "text/plain" in body_types and "text/html" in body_types + + +def test_build_mime_includes_attachments() -> None: + account = Account( + email="me@example.com", + display_name="Me", + imap_config={}, + smtp_config={}, + secret_ct=b"", + discovery_src="manual", + user_id=uuid.uuid4(), + ) + draft = Draft( + user_id=uuid.uuid4(), + account_id=uuid.uuid4(), + kind="new", + payload={"to": ["bob@example.com"], "subject": "Files", "html": "

hi

", "text": ""}, + ) + raw, _from, _to = build_mime( + account, draft, None, [("report.pdf", "application/pdf", b"%PDF-1.4 data")] + ) + msg = email.message_from_bytes(raw, policy=default_policy) + attached = [p for p in msg.walk() if p.get_filename() == "report.pdf"] + assert len(attached) == 1 + assert attached[0].get_content_type() == "application/pdf" + assert attached[0].get_payload(decode=True) == b"%PDF-1.4 data" + + +# --- API + send job ---------------------------------------------------------- + + +@dataclass +class Ctx: + client: httpx.AsyncClient + session: AsyncSession + user_id: uuid.UUID + account_id: uuid.UUID + sent_path: str + reply_to_id: uuid.UUID + enqueued: list[tuple] = field(default_factory=list) + + +def _hdr() -> dict[str, str]: + return {"X-CSRF": CSRF} + + +@pytest_asyncio.fixture +async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def] + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"ob{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", + display_name="Me", + imap_config={"host": "imap.example.com", "port": 993, "tls": "ssl"}, + smtp_config={"host": "smtp.example.com", "port": 465, "tls": "ssl"}, + 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") + sent = Mailbox(account_id=account.id, user_id=user.id, path="Sent", special_use="sent") + session.add_all([inbox, sent]) + await session.flush() + parent = Message( + user_id=user.id, + account_id=account.id, + mailbox_id=inbox.id, + uid=1, + message_id_hdr="orig@x.com", + references_hdr=[], + subject="Hi", + internal_date=datetime.now(UTC), + blob_sha256="0" * 64, + ) + session.add(parent) + await session.commit() + + c = Ctx( + client=None, + session=session, + user_id=user.id, + account_id=account.id, # type: ignore[arg-type] + sent_path="Sent", + reply_to_id=parent.id, + ) + + class FakePool: + async def enqueue_job(self, *args: object, **kwargs: object) -> None: + c.enqueued.append((args, kwargs)) + + async def fake_pool() -> FakePool: + return FakePool() + + monkeypatch.setattr(compose_api, "get_arq_pool", fake_pool) + + token = await create_session(user.id, "pytest") + c.client = httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + ) + try: + yield c + finally: + await c.client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +def _draft_body(ctx: Ctx) -> dict: + return { + "account_id": str(ctx.account_id), + "reply_to_message_id": str(ctx.reply_to_id), + "kind": "reply", + "payload": {"to": ["bob@example.com"], "subject": "Re: Hi", "text": "hello"}, + } + + +async def test_draft_crud(ctx: Ctx) -> None: + created = await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr()) + assert created.status_code == 200 + did = created.json()["id"] + + body = _draft_body(ctx) + body["payload"]["subject"] = "Re: Hi (edited)" + updated = await ctx.client.put(f"/api/drafts/{did}", json=body, headers=_hdr()) + assert updated.status_code == 200 + assert updated.json()["payload"]["subject"] == "Re: Hi (edited)" + + deleted = await ctx.client.request("DELETE", f"/api/drafts/{did}", headers=_hdr()) + assert deleted.status_code == 200 + + +async def test_upload_and_list_compose_attachment(ctx: Ctx) -> None: + resp = await ctx.client.post( + "/api/attachments", + files={"file": ("hello.txt", b"file contents", "text/plain")}, + headers=_hdr(), + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["filename"] == "hello.txt" + assert data["mime_type"] == "text/plain" + assert data["size_bytes"] == len(b"file contents") + + listing = await ctx.client.get(f"/api/attachments?ids={data['id']}", headers=_hdr()) + assert listing.status_code == 200 + assert [a["id"] for a in listing.json()] == [data["id"]] + + +async def test_upload_rejects_oversized_attachment(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + from app.api import compose as compose_module + + monkeypatch.setattr(compose_module, "MAX_ATTACHMENT_BYTES", 4) + resp = await ctx.client.post( + "/api/attachments", + files={"file": ("big.bin", b"12345", "application/octet-stream")}, + headers=_hdr(), + ) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "too_large" + + +async def test_send_creates_outbox_and_defers(ctx: Ctx) -> None: + did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"] + resp = await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr()) + assert resp.status_code == 200 + outbox_id = resp.json()["outbox_id"] + + ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id)) + assert ob is not None and ob.state == "undo_window" + # scheduled with a defer (the undo window) + assert ctx.enqueued and ctx.enqueued[-1][0][0] == "send_job" + assert "_defer_by" in ctx.enqueued[-1][1] + + +async def test_undo_cancels(ctx: Ctx) -> None: + did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"] + outbox_id = (await ctx.client.post("/api/send", json={"draft_id": did}, headers=_hdr())).json()[ + "outbox_id" + ] + undo = await ctx.client.post(f"/api/send/{outbox_id}/undo", headers=_hdr()) + assert undo.status_code == 200 + ob = await ctx.session.get(Outbox, uuid.UUID(outbox_id)) + await ctx.session.refresh(ob) + assert ob is not None and ob.state == "cancelled" + + +class FakeProvider: + def __init__(self) -> None: + self.sent: list = [] + self.appended: list = [] + self.raise_on_send = False + + async def send(self, raw: bytes, env_from: str, env_to: list) -> None: + if self.raise_on_send: + raise RuntimeError("smtp down") + self.sent.append((env_from, env_to)) + + async def append(self, mailbox: str, raw: bytes, flags: frozenset) -> int: + self.appended.append(mailbox) + return 99 + + async def close(self) -> None: + return None + + +async def _make_outbox(ctx: Ctx, state: str = "undo_window") -> uuid.UUID: + draft = Draft( + user_id=ctx.user_id, + account_id=ctx.account_id, + reply_to_message_id=ctx.reply_to_id, + kind="reply", + payload={"to": ["bob@example.com"], "subject": "Re: Hi", "text": "hello"}, + ) + ctx.session.add(draft) + await ctx.session.flush() + outbox = Outbox( + user_id=ctx.user_id, draft_id=draft.id, state=state, send_after=datetime.now(UTC) + ) + ctx.session.add(outbox) + await ctx.session.commit() + return outbox.id + + +async def test_send_job_success(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + oid = await _make_outbox(ctx) + fake = FakeProvider() + monkeypatch.setattr(outbox_jobs, "build_provider", lambda _a: fake) + + ob0 = await ctx.session.get(Outbox, oid) + assert ob0 is not None + draft_id = ob0.draft_id + + await outbox_jobs.send_job({"job_try": 1}, str(oid)) + + ob = await ctx.session.get(Outbox, oid) + await ctx.session.refresh(ob) + assert ob is not None and ob.state == "sent" + assert len(fake.sent) == 1 + assert fake.appended == ["Sent"] # non-Gmail -> we append + + # A successfully sent message's draft is deleted (verify in a fresh session). + ctx.session.expunge_all() + async with session_scope() as verify: + assert await verify.get(Draft, draft_id) is None + kr = await ctx.session.scalar( + select(KnownRecipient).where( + KnownRecipient.user_id == ctx.user_id, KnownRecipient.email == "bob@example.com" + ) + ) + assert kr is not None and kr.times_used == 3 # sent-to weight + + +async def test_send_job_skips_when_cancelled(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + oid = await _make_outbox(ctx, state="cancelled") + fake = FakeProvider() + monkeypatch.setattr(outbox_jobs, "build_provider", lambda _a: fake) + + await outbox_jobs.send_job({"job_try": 1}, str(oid)) + assert fake.sent == [] # no SMTP traffic for a cancelled send + + +async def test_send_job_failure_marks_failed(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + oid = await _make_outbox(ctx) + fake = FakeProvider() + fake.raise_on_send = True + monkeypatch.setattr(outbox_jobs, "build_provider", lambda _a: fake) + + await outbox_jobs.send_job({"job_try": 1}, str(oid)) + ob = await ctx.session.get(Outbox, oid) + await ctx.session.refresh(ob) + assert ob is not None and ob.state == "failed" and ob.error diff --git a/backend/tests/providers/test_parsing.py b/backend/tests/providers/test_parsing.py new file mode 100644 index 0000000..ee79524 --- /dev/null +++ b/backend/tests/providers/test_parsing.py @@ -0,0 +1,122 @@ +"""IMAP response parsing (SPEC §5.1 provider), using real home.pl wire samples.""" + +from __future__ import annotations + +from datetime import datetime + +from app.providers.imap_smtp.parsing import ( + parse_fetch_lines, + parse_internaldate, + parse_list_line, + parse_search_uids, + parse_select_untagged, +) + + +def test_parse_list_special_use() -> None: + assert parse_list_line(rb'(\HasNoChildren \UnMarked \Sent) "/" SENT') is not None + cases = { + rb'(\HasNoChildren \UnMarked \Sent) "/" SENT': ("SENT", "sent"), + rb'(\HasNoChildren \UnMarked \Drafts) "/" DRAFTS': ("DRAFTS", "drafts"), + rb'(\HasNoChildren \UnMarked \Trash) "/" TRASH': ("TRASH", "trash"), + rb'(\HasNoChildren \UnMarked \Junk) "/" SPAM': ("SPAM", "junk"), + rb'(\HasNoChildren) "/" INBOX': ("INBOX", "inbox"), + } + for line, (path, special) in cases.items(): + parsed = parse_list_line(line) + assert parsed is not None + assert parsed.path == path + assert parsed.special_use == special + + +def test_parse_list_plain_folder_has_no_special_use() -> None: + parsed = parse_list_line(rb'(\HasNoChildren) "/" "Work Stuff"') + assert parsed is not None + assert parsed.path == "Work Stuff" + assert parsed.special_use is None + + +def test_parse_list_utf7_name() -> None: + # Modified UTF-7: "&AOk-" decodes to "é" -> folder "Réponses" + parsed = parse_list_line(rb'(\HasNoChildren) "/" "R&AOk-ponses"') + assert parsed is not None + assert parsed.path == "Réponses" + + +def test_parse_internaldate() -> None: + dt = parse_internaldate("27-May-2026 19:08:09 +0200") + assert dt == datetime.fromisoformat("2026-05-27T19:08:09+02:00") + # single-digit day (space padded) + assert parse_internaldate(" 1-Jan-2026 05:06:07 +0000") is not None + assert parse_internaldate("garbage") is None + + +def test_parse_search_uids() -> None: + assert parse_search_uids([b"1 2 3 4 5 8", b"Search completed."]) == [1, 2, 3, 4, 5, 8] + assert parse_search_uids([b""]) == [] + assert parse_search_uids([]) == [] + + +def test_parse_select_untagged() -> None: + lines = [ + b"6 EXISTS", + b"0 RECENT", + b"OK [UIDVALIDITY 1772887273] UIDs valid", + b"OK [UIDNEXT 9] Predicted next UID", + b"OK [HIGHESTMODSEQ 31] Highest", + ] + info = parse_select_untagged(lines) + assert info["uidvalidity"] == 1772887273 + assert info["uidnext"] == 9 + assert info["highestmodseq"] == 31 + assert info["exists"] == 6 + + +def test_parse_fetch_body() -> None: + raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\nBody" + header = ( + b"6 FETCH (UID 8 FLAGS (\\Answered \\Seen $label1) " + b'INTERNALDATE "27-May-2026 19:08:09 +0200" RFC822.SIZE 34 BODY[] {' + + str(len(raw)).encode() + + b"}" + ) + lines: list[bytes | bytearray] = [header, bytearray(raw), b")", b"Fetch completed."] + parsed = parse_fetch_lines(lines) + assert len(parsed) == 1 + pf = parsed[0] + assert pf.uid == 8 + assert pf.raw == raw + assert "\\Seen" in pf.flags + assert "\\Answered" in pf.flags + assert pf.internal_date is not None + + +def test_parse_fetch_flags_only() -> None: + lines: list[bytes | bytearray] = [ + b"1 FETCH (UID 5 FLAGS (\\Seen))", + b"2 FETCH (UID 6 FLAGS (\\Flagged \\Seen))", + b"Fetch completed.", + ] + parsed = parse_fetch_lines(lines) + assert [p.uid for p in parsed] == [5, 6] + assert parsed[0].raw is None + assert parsed[1].flags == frozenset({"\\Flagged", "\\Seen"}) + + +def test_parse_fetch_multiple_bodies() -> None: + raw1 = b"msg-one" + raw2 = b"message-two-longer" + lines: list[bytes | bytearray] = [ + b'1 FETCH (UID 1 FLAGS () INTERNALDATE "1-Jan-2026 00:00:00 +0000" BODY[] {%d}' % len(raw1), + bytearray(raw1), + b")", + b'2 FETCH (UID 2 FLAGS (\\Seen) INTERNALDATE "2-Jan-2026 00:00:00 +0000" BODY[] {%d}' + % len(raw2), + bytearray(raw2), + b")", + b"Fetch completed.", + ] + parsed = parse_fetch_lines(lines) + assert [p.uid for p in parsed] == [1, 2] + assert parsed[0].raw == raw1 + assert parsed[1].raw == raw2 diff --git a/backend/tests/search/test_queryparser.py b/backend/tests/search/test_queryparser.py new file mode 100644 index 0000000..909a4bc --- /dev/null +++ b/backend/tests/search/test_queryparser.py @@ -0,0 +1,109 @@ +"""Query grammar parser (SPEC §8.2). Covers every operator, negation, quoting, +OR, and garbage input (the parser must never raise).""" + +from __future__ import annotations + +import uuid + +import pytest + +from app.search.queryparser import KNOWN_KEYS, free_text, parse, to_search_query + + +def _flat(query: str) -> list[dict[str, object]]: + return [clause.as_dict() for group in parse(query) for clause in group] + + +def test_plain_free_text() -> None: + groups = parse("hello world") + assert len(groups) == 2 + assert all(len(g) == 1 for g in groups) + assert free_text(groups) == "hello world" + + +def test_quoted_phrase_is_one_term() -> None: + groups = parse('"hello world" foo') + assert groups[0][0].value == "hello world" + assert groups[0][0].phrase is True + assert groups[1][0].value == "foo" + + +@pytest.mark.parametrize("key", sorted(KNOWN_KEYS)) +def test_every_operator_parses_as_filter(key: str) -> None: + groups = parse(f"{key}:value") + clause = groups[0][0] + assert clause.type == "filter" + assert clause.key == key + assert clause.value == "value" + + +def test_filter_with_quoted_value() -> None: + clause = parse('from:"John Doe"')[0][0] + assert clause.type == "filter" + assert clause.key == "from" + assert clause.value == "John Doe" + + +def test_negation_of_term_and_filter() -> None: + clauses = _flat("-spam -from:boss@x.com") + assert clauses[0] == { + "type": "text", + "key": None, + "value": "spam", + "negate": True, + "phrase": False, + } + assert clauses[1]["type"] == "filter" + assert clauses[1]["negate"] is True + assert clauses[1]["key"] == "from" + + +def test_or_groups_adjacent_clauses() -> None: + groups = parse("cat OR dog fish") + # (cat OR dog) AND fish + assert len(groups) == 2 + assert [c.value for c in groups[0]] == ["cat", "dog"] + assert [c.value for c in groups[1]] == ["fish"] + + +def test_or_mixing_text_and_filter() -> None: + groups = parse("is:unread OR is:starred") + assert len(groups) == 1 + assert [c.value for c in groups[0]] == ["unread", "starred"] + + +def test_unknown_key_is_free_text() -> None: + clause = parse("bogus:value")[0][0] + assert clause.type == "text" + assert clause.value == "bogus:value" + + +def test_case_insensitive_keys_lowercased() -> None: + clause = parse("FROM:alice")[0][0] + assert clause.type == "filter" + assert clause.key == "from" + + +@pytest.mark.parametrize( + "garbage", + ['unbalanced "quote', "'", '"', "-", ":", "from:", " ", "", "OR OR OR", "a OR"], +) +def test_garbage_never_raises(garbage: str) -> None: + # Must not raise; returns some (possibly empty) parse. + result = parse(garbage) + assert isinstance(result, list) + + +def test_to_search_query_sets_user_id_and_text() -> None: + uid = uuid.uuid4() + sq = to_search_query("invoice from:alice", uid) + assert sq.user_id == uid + assert sq.text == "invoice" + assert "groups" in sq.filters + + +def test_dash_only_token_is_text() -> None: + # A lone "-" must not be treated as negation of an empty term. + clause = parse("-")[0][0] + assert clause.value == "-" + assert clause.negate is False diff --git a/backend/tests/search/test_search_api.py b/backend/tests/search/test_search_api.py new file mode 100644 index 0000000..a76fb5d --- /dev/null +++ b/backend/tests/search/test_search_api.py @@ -0,0 +1,178 @@ +"""Operator search through the API (SPEC §8.2, §9.1), on the Postgres backend.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from datetime import UTC, datetime +from email.message import EmailMessage + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import get_settings +from app.identity.passwords import hash_password +from app.identity.sessions import create_session +from app.indexer.indexer import persist_message +from app.main import create_app +from app.models import Account, Mailbox, User +from app.providers.base import RemoteMessage +from app.search.pg import PgSearchBackend + + +@dataclass +class Ctx: + client: httpx.AsyncClient + session: AsyncSession + user_id: uuid.UUID + account_id: uuid.UUID + mailbox_id: uuid.UUID + uid: int = 0 + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"sq{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() + mailbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + session.add(mailbox) + await session.commit() + token = await create_session(user.id, "pytest") + client = httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token}, + ) + try: + yield Ctx( + client=client, + session=session, + user_id=user.id, + account_id=account.id, + mailbox_id=mailbox.id, + ) + finally: + await client.aclose() + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +async def _ingest( + ctx: Ctx, + *, + frm: str, + subject: str, + body: str, + flags: frozenset[str] = frozenset(), + attach: bool = False, +) -> None: + ctx.uid += 1 + m = EmailMessage() + m["Subject"] = subject + m["From"] = frm + m["To"] = "me@example.com" + m["Message-ID"] = f"<{uuid.uuid4().hex}@x.com>" + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + m.set_content(body) + if attach: + m.add_attachment(b"data", maintype="application", subtype="pdf", filename="report.pdf") + result = await persist_message( + ctx.session, + user_id=ctx.user_id, + account_id=ctx.account_id, + mailbox_id=ctx.mailbox_id, + mailbox_special_use="inbox", + remote=RemoteMessage( + uid=ctx.uid, raw=m.as_bytes(), flags=flags, internal_date=datetime.now(UTC) + ), + ) + await ctx.session.commit() + assert result is not None + await PgSearchBackend().index(result.search_doc) + + +async def _search(ctx: Ctx, q: str) -> int: + resp = await ctx.client.get("/api/search", params={"q": q}) + assert resp.status_code == 200, resp.text + return len(resp.json()["threads"]) + + +@pytest_asyncio.fixture +async def seeded(ctx: Ctx) -> Ctx: + await _ingest( + ctx, + frm="Bob ", + subject="Quarterly revenue", + body="revenue numbers up", + flags=frozenset(), + ) # unread + await _ingest( + ctx, + frm="Carol ", + subject="Lunch", + body="grab lunch", + flags=frozenset({"\\Seen"}), + ) # read + await _ingest( + ctx, + frm="Dave ", + subject="Report attached", + body="see the pdf", + flags=frozenset({"\\Seen"}), + attach=True, + ) + return ctx + + +async def test_free_text(seeded: Ctx) -> None: + assert await _search(seeded, "revenue") == 1 + assert await _search(seeded, "lunch") == 1 + + +async def test_from_operator(seeded: Ctx) -> None: + assert await _search(seeded, "from:bob") == 1 + assert await _search(seeded, "from:corp.com") == 3 # all three share the domain + + +async def test_is_unread_operator(seeded: Ctx) -> None: + assert await _search(seeded, "is:unread") == 1 + assert await _search(seeded, "is:read") == 2 + + +async def test_has_attachment_operator(seeded: Ctx) -> None: + assert await _search(seeded, "has:attachment") == 1 + + +async def test_subject_operator(seeded: Ctx) -> None: + assert await _search(seeded, "subject:revenue") == 1 + + +async def test_negation(seeded: Ctx) -> None: + # everything unread that is NOT from bob -> none + assert await _search(seeded, "is:unread -from:bob") == 0 + + +async def test_combined_operators(seeded: Ctx) -> None: + assert await _search(seeded, "from:dave has:attachment") == 1 + assert await _search(seeded, "from:bob has:attachment") == 0 diff --git a/backend/tests/sync/test_folder_names.py b/backend/tests/sync/test_folder_names.py new file mode 100644 index 0000000..fb7cb55 --- /dev/null +++ b/backend/tests/sync/test_folder_names.py @@ -0,0 +1,34 @@ +"""Folder-name special-use heuristics (SPEC §6.3).""" + +from __future__ import annotations + +import pytest + +from app.sync.folder_names import infer_special_use + + +@pytest.mark.parametrize( + "path,expected", + [ + ("INBOX", "inbox"), + ("inbox", "inbox"), + ("Sent", "sent"), + ("Wysłane", "sent"), # Polish + ("Elementy wysłane", "sent"), + ("Gesendet", "sent"), # German + ("Drafts", "drafts"), + ("Robocze", "drafts"), + ("Trash", "trash"), + ("Kosz", "trash"), # Polish + ("Papierkorb", "trash"), # German + ("Spam", "junk"), + ("Junk", "junk"), + ("Archive", "archive"), + ("Archiwum", "archive"), + ("INBOX/Sent", "sent"), # nested leaf + ("Work", None), + ("Random Folder", None), + ], +) +def test_infer_special_use(path: str, expected: str | None) -> None: + assert infer_special_use(path) == expected diff --git a/backend/tests/threading/conftest.py b/backend/tests/threading/conftest.py new file mode 100644 index 0000000..61e77ec --- /dev/null +++ b/backend/tests/threading/conftest.py @@ -0,0 +1,15 @@ +"""Fixtures for threading tests: the shared rollback session + a threading env.""" + +from __future__ import annotations + +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from tests.threading.env import ThreadingEnv + + +@pytest_asyncio.fixture +async def env(db_session: AsyncSession) -> ThreadingEnv: + e = ThreadingEnv(db_session) + await e.setup() + return e diff --git a/backend/tests/threading/env.py b/backend/tests/threading/env.py new file mode 100644 index 0000000..6da9e31 --- /dev/null +++ b/backend/tests/threading/env.py @@ -0,0 +1,87 @@ +"""Test helper: a user with two accounts/mailboxes + a message factory. + +Kept out of ``conftest.py`` so tests can import :class:`ThreadingEnv` without +re-importing the conftest module. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Account, Mailbox, Message, User +from app.threading_.threader import thread_message + +BASE = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + + +class ThreadingEnv: + """A user with two accounts + one mailbox each, plus a message factory.""" + + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.user_id: uuid.UUID + self.account_a: uuid.UUID + self.account_b: uuid.UUID + self.mailbox_a: uuid.UUID + self.mailbox_b: uuid.UUID + self._uid = 0 + + async def setup(self) -> None: + user = User(handle=f"t{uuid.uuid4().hex[:8]}@test.local", password_hash="x") + self.session.add(user) + await self.session.flush() + self.user_id = user.id + for label in ("a", "b"): + account = Account( + user_id=self.user_id, + email=f"{label}@example.com", + imap_config={}, + smtp_config={}, + secret_ct=b"", + discovery_src="manual", + ) + self.session.add(account) + await self.session.flush() + mailbox = Mailbox(account_id=account.id, user_id=self.user_id, path="INBOX") + self.session.add(mailbox) + await self.session.flush() + setattr(self, f"account_{label}", account.id) + setattr(self, f"mailbox_{label}", mailbox.id) + + async def add( + self, + *, + message_id: str | None, + subject: str = "Subject", + references: list[str] | None = None, + in_reply_to: str | None = None, + from_email: str = "alice@example.com", + to_email: str = "bob@example.com", + minutes: int = 0, + account: str = "a", + flags: list[str] | None = None, + ) -> Message: + self._uid += 1 + msg = Message( + user_id=self.user_id, + account_id=getattr(self, f"account_{account}"), + mailbox_id=getattr(self, f"mailbox_{account}"), + uid=self._uid, + message_id_hdr=message_id, + in_reply_to=in_reply_to, + references_hdr=references or [], + subject=subject, + from_addr=[{"name": "", "email": from_email}], + to_addr=[{"name": "", "email": to_email}], + internal_date=BASE + timedelta(minutes=minutes), + flags=flags or [], + blob_sha256="0" * 64, + snippet=subject, + ) + self.session.add(msg) + await self.session.flush() + await thread_message(self.session, msg) + return msg diff --git a/backend/tests/threading/test_normalize.py b/backend/tests/threading/test_normalize.py new file mode 100644 index 0000000..43aae98 --- /dev/null +++ b/backend/tests/threading/test_normalize.py @@ -0,0 +1,55 @@ +"""Message-ID and subject normalization (SPEC §7.1, §7.3).""" + +from __future__ import annotations + +from app.threading_.normalize import ( + is_synthetic, + normalize_message_id, + normalize_reference_list, + normalize_subject, + synthesize_message_id, +) + + +def test_normalize_message_id_strips_brackets_and_lowercases_domain() -> None: + assert normalize_message_id("") == "Abc.123@example.com" + assert normalize_message_id(" ") == "x@y.z" + # local part case is preserved, domain lowercased + assert normalize_message_id("") == "CaseSensitive@domain.com" + + +def test_normalize_message_id_rejects_invalid() -> None: + assert normalize_message_id(None) is None + assert normalize_message_id("no-at-sign") is None + assert normalize_message_id("<>") is None + assert normalize_message_id("@nolocal.com") is None + + +def test_normalize_reference_list_order_and_dedupe() -> None: + raw = " " + assert normalize_reference_list(raw) == ["a@x.com", "b@y.com"] + assert normalize_reference_list("") == [] + assert normalize_reference_list(None) == [] + + +def test_normalize_reference_list_whitespace_separated_without_brackets() -> None: + assert normalize_reference_list("a@x.com b@y.com") == ["a@x.com", "b@y.com"] + + +def test_synthetic_id() -> None: + sid = synthesize_message_id(b"raw bytes here") + assert sid.endswith("@synthetic.local") + assert is_synthetic(sid) + assert not is_synthetic("real@example.com") + assert not is_synthetic(None) + # deterministic + assert synthesize_message_id(b"raw bytes here") == sid + + +def test_normalize_subject_strips_prefixes() -> None: + assert normalize_subject("Re: Hello") == "Hello" + assert normalize_subject("RE: FW: Fwd: Hello world") == "Hello world" + assert normalize_subject("Odp: Cześć") == "Cześć" # Polish reply prefix + assert normalize_subject("PD: X") == "X" + assert normalize_subject("No prefix") == "No prefix" + assert normalize_subject("") == "" diff --git a/backend/tests/threading/test_threading.py b/backend/tests/threading/test_threading.py new file mode 100644 index 0000000..2e31c53 --- /dev/null +++ b/backend/tests/threading/test_threading.py @@ -0,0 +1,137 @@ +"""JWZ threading test vectors (SPEC §7.4). All must pass.""" + +from __future__ import annotations + +from tests.threading.env import ThreadingEnv + + +async def test_linear_chain(env: ThreadingEnv) -> None: + a = await env.add(message_id="a@x.com", subject="Hello") + b = await env.add( + message_id="b@x.com", + subject="Re: Hello", + in_reply_to="a@x.com", + references=["a@x.com"], + minutes=1, + ) + c = await env.add( + message_id="c@x.com", + subject="Re: Hello", + in_reply_to="b@x.com", + references=["a@x.com", "b@x.com"], + minutes=2, + ) + assert a.thread_id == b.thread_id == c.thread_id + + +async def test_chain_out_of_order(env: ThreadingEnv) -> None: + # C arrives first, then A, then B — all must end in one thread. + c = await env.add( + message_id="c@x.com", subject="Re: Hi", references=["a@x.com", "b@x.com"], minutes=2 + ) + a = await env.add(message_id="a@x.com", subject="Hi", minutes=0) + b = await env.add(message_id="b@x.com", subject="Re: Hi", references=["a@x.com"], minutes=1) + assert a.thread_id == b.thread_id == c.thread_id + + +async def test_reply_missing_in_reply_to_but_has_references(env: ThreadingEnv) -> None: + a = await env.add(message_id="a@x.com", subject="Topic") + b = await env.add( + message_id="b@x.com", subject="Re: Topic", references=["a@x.com"], minutes=1 + ) # no In-Reply-To + assert a.thread_id == b.thread_id + + +async def test_thread_merge_on_late_parent(env: ThreadingEnv) -> None: + # Two children of A arrive first (each referencing only A) forming separate + # threads; the late parent A must merge them into one. + b = await env.add(message_id="b@x.com", subject="Re: T", references=["a@x.com"], minutes=1) + c = await env.add(message_id="c@x.com", subject="Re: T", references=["a@x.com"], minutes=2) + # b and c both reference a but not each other -> initially could be separate threads + a = await env.add(message_id="a@x.com", subject="T", minutes=0) + await env.session.refresh(b) + await env.session.refresh(c) + assert a.thread_id == b.thread_id == c.thread_id + + +async def test_broken_client_subject_only_joins(env: ThreadingEnv) -> None: + # A client that drops References; joins by subject + shared participant within 7 days. + a = await env.add( + message_id="a@x.com", + subject="Project update", + from_email="alice@example.com", + to_email="bob@example.com", + ) + b = await env.add( + message_id="b@x.com", + subject="Re: Project update", + from_email="bob@example.com", + to_email="alice@example.com", + minutes=60, + ) # no references, shares participants + assert a.thread_id == b.thread_id + + +async def test_distinct_topic_same_subject_does_not_join(env: ThreadingEnv) -> None: + a = await env.add( + message_id="a@x.com", + subject="hi", + from_email="alice@example.com", + to_email="bob@example.com", + ) + b = await env.add( + message_id="b@x.com", + subject="hi", + from_email="carol@example.com", + to_email="dave@example.com", + minutes=5, + ) + assert a.thread_id != b.thread_id + + +async def test_cross_account_merge(env: ThreadingEnv) -> None: + # A message in account A references a message in account B -> one thread (I-3). + a = await env.add(message_id="a@x.com", subject="Shared", account="a") + b = await env.add( + message_id="b@x.com", subject="Re: Shared", references=["a@x.com"], account="b", minutes=1 + ) + assert a.thread_id == b.thread_id + assert a.account_id != b.account_id + + +async def test_duplicate_message_id_same_account_merges(env: ThreadingEnv) -> None: + # Same message in two mailboxes of ONE account (e.g. Gmail INBOX + All Mail): + # the shared Message-ID is authoritative -> one thread. (Threading-level view; + # the indexer decides authoritative vs synthetic per SPEC §7.1 — see indexer tests.) + a1 = await env.add(message_id="dup@x.com", subject="Dup", account="a") + a2 = await env.add(message_id="dup@x.com", subject="Dup", account="a", minutes=1) + assert a1.thread_id == a2.thread_id + + +async def test_synthetic_ids_do_not_join(env: ThreadingEnv) -> None: + # Two messages that were given synthetic ids (§7.1 duplicate handling) with no + # references must not merge, even with the same subject far apart in time. + a = await env.add(message_id="deadbeef@synthetic.local", subject="Welcome", minutes=0) + b = await env.add( + message_id="cafebabe@synthetic.local", subject="Welcome", account="b", minutes=60 * 24 * 30 + ) + assert a.thread_id != b.thread_id + + +async def test_synthetic_id_isolation(env: ThreadingEnv) -> None: + # Two messages lacking Message-ID and References with empty subjects never join. + a = await env.add(message_id=None, subject="") + b = await env.add(message_id=None, subject="", minutes=1) + assert a.thread_id != b.thread_id + + +async def test_denormalization_counts(env: ThreadingEnv) -> None: + from app.models import Thread + + a = await env.add(message_id="a@x.com", subject="Count", flags=["\\Seen"]) + await env.add(message_id="b@x.com", subject="Re: Count", references=["a@x.com"], minutes=1) + thread = await env.session.get(Thread, a.thread_id) + assert thread is not None + assert thread.message_count == 2 + assert thread.unread_count == 1 # only b is unread + assert thread.snippet == "Re: Count" # newest message's snippet diff --git a/backend/workers/__init__.py b/backend/workers/__init__.py new file mode 100644 index 0000000..5a9d463 --- /dev/null +++ b/backend/workers/__init__.py @@ -0,0 +1 @@ +"""arq worker package (SPEC §2 workers/).""" diff --git a/backend/workers/worker.py b/backend/workers/worker.py new file mode 100644 index 0000000..b13ef58 --- /dev/null +++ b/backend/workers/worker.py @@ -0,0 +1,68 @@ +"""arq worker entrypoint (SPEC §2 ``workers/worker.py``). + +Run with: ``arq workers.worker.WorkerSettings`` (from ``backend/``). +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from app.config import get_settings +from app.outbox.jobs import send_job +from app.queue import redis_settings as arq_redis_settings +from app.sync.actions import ( + delete_thread_job, + move_job, + move_thread_to_folder_job, + push_flags_job, +) +from app.sync.engine import bootstrap_and_backfill + +logger = logging.getLogger(__name__) + + +async def sync_bootstrap_account(ctx: dict[str, Any], account_id: str) -> int: + """Bootstrap + backfill an account (enqueued on account creation).""" + logger.info("sync.bootstrap_account start account=%s", account_id) + count = await bootstrap_and_backfill(uuid.UUID(account_id)) + logger.info("sync.bootstrap_account done account=%s messages=%d", account_id, count) + return count + + +async def on_startup(ctx: dict[str, Any]) -> None: + logging.basicConfig(level=get_settings().log_level) + from app.sync.supervisor import start_supervisor + + await start_supervisor(ctx) + logger.info("webmail worker started") + + +async def on_shutdown(ctx: dict[str, Any]) -> None: + from app.db import dispose_engine + from app.redis_client import close_redis + from app.sync.supervisor import stop_supervisor + + await stop_supervisor(ctx) + await dispose_engine() + await close_redis() + logger.info("webmail worker stopped") + + +class WorkerSettings: + """arq worker configuration.""" + + functions = [ + sync_bootstrap_account, + push_flags_job, + move_job, + move_thread_to_folder_job, + delete_thread_job, + send_job, + ] + on_startup = on_startup + on_shutdown = on_shutdown + max_jobs = 10 # global worker cap (SPEC §6.3) + job_timeout = 3600 # backfill of a large account can be long + redis_settings = arq_redis_settings() diff --git a/dev-environment-setup.md b/dev-environment-setup.md new file mode 100644 index 0000000..930036c --- /dev/null +++ b/dev-environment-setup.md @@ -0,0 +1,227 @@ +# Development Environment — Agent Setup Instructions + +Companion to `SPEC.md` (webmail-client-spec). This file tells the implementing agent exactly how to build and verify the development environment **before writing any application code**. Approach: **conda for the toolchain** (Python 3.11, Node 20), **Docker for stateful services** (PostgreSQL, Redis, Meilisearch, test mail servers) — identical images in dev and CI. + +Rules: +- Do not install anything into the host system Python/apt beyond verifying prerequisites. All language tooling lives in the conda env; all services live in containers. +- Do not substitute versions or images. If something is unavailable, stop and record it in `docs/open-questions.md`. +- Every step below has a verification command. A step is not done until its verification passes. + +--- + +## 1. Prerequisites (verify, don't install blindly) + +```bash +docker --version # need >= 24.x +docker compose version # need v2 plugin +conda --version # need conda >= 23.x from Miniforge +``` + +- If conda is missing, install **Miniforge** (NOT Anaconda — conda-forge channel only, avoids `defaults`-channel licensing): + `curl -fsSL https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh -o /tmp/miniforge.sh && bash /tmp/miniforge.sh -b && ~/miniforge3/bin/conda init bash` +- If Docker is missing, halt and report — Docker installation is a host-admin decision, not the agent's. +- Verify the host user can run Docker without sudo: `docker run --rm hello-world`. + +## 2. Conda environment (toolchain only — no services here) + +Create `environment.yml` at repo root: + +```yaml +name: webmail +channels: + - conda-forge +dependencies: + - python=3.11 + - nodejs=20 + - pip +``` + +```bash +conda env create -f environment.yml +conda activate webmail +python --version # must print 3.11.x +node --version # must print v20.x +``` + +Install backend tooling and deps (after `backend/pyproject.toml` exists per SPEC §3): + +```bash +pip install -e "./backend[dev]" # dev extra: ruff, mypy, pytest, pytest-asyncio, respx +cd frontend && npm ci && cd .. +``` + +**Auto-config on activate** — create `$CONDA_PREFIX/etc/conda/activate.d/webmail.sh`: + +```bash +#!/usr/bin/env bash +set -a +[ -f "$(git rev-parse --show-toplevel 2>/dev/null)/.env.dev" ] && . "$(git rev-parse --show-toplevel)/.env.dev" +set +a +``` + +## 3. Service containers + +Create `docker/docker-compose.yml`. Host ports are deliberately **non-default** (5433/6380) so they never collide with anything already running on the host — do not change them. + +```yaml +name: webmail +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: webmail + POSTGRES_PASSWORD: webmail_dev + POSTGRES_DB: webmail_dev + ports: ["127.0.0.1:5433:5432"] + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U webmail -d webmail_dev"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7 + ports: ["127.0.0.1:6380:6379"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + meilisearch: + image: getmeili/meilisearch:v1.8 + environment: + MEILI_MASTER_KEY: dev_master_key_change_me + MEILI_ENV: development + ports: ["127.0.0.1:7700:7700"] + volumes: [meilidata:/meili_data] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:7700/health | grep -q available"] + interval: 5s + timeout: 3s + retries: 10 + + # Test mail servers (integration tests + manual dev against a throwaway mailbox) + greenmail: + image: greenmail/standalone:2.0.1 + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.users=dev:devpass@dev.local -Dgreenmail.verbose + ports: + - "127.0.0.1:3025:3025" # SMTP + - "127.0.0.1:3143:3143" # IMAP + - "127.0.0.1:3993:3993" # IMAPS + +volumes: + pgdata: + meilidata: +``` + +```bash +docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch +docker compose -f docker/docker-compose.yml ps # all must be "healthy" +``` + +Greenmail is started on demand by integration tests and for manual testing (`--profile` it later if noise bothers; keep the definition here). + +## 4. Environment file + +Create `.env.dev` at repo root (add to `.gitignore`; commit a `.env.example` copy with the same content — these are dev-only credentials, still never reuse them anywhere): + +```bash +APP_DOMAIN=dev.webmail.local +DATABASE_URL=postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev +REDIS_URL=redis://127.0.0.1:6380/0 +SEARCH_BACKEND=pg # switch to meili in M2 work +MEILI_URL=http://127.0.0.1:7700 +MEILI_KEY=dev_master_key_change_me +MASTER_KEY=$(python -c "import os,base64;print(base64.b64encode(os.urandom(32)).decode())") +BLOB_ROOT=./var/blobs +UNDO_SECONDS=10 +POLL_INTERVAL=120 +SESSION_TTL_DAYS=30 +MAX_ATTACHMENT_MB=25 +LOG_LEVEL=DEBUG +``` + +Generate `MASTER_KEY` once and paste the literal value — do not leave the `$(...)` in the file. `mkdir -p var/blobs`. + +## 5. Initialize and run + +```bash +conda activate webmail # loads .env.dev via activate hook +cd backend && alembic upgrade head && cd .. # migrations against 5433 + +# Terminal 1 — API +uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend + +# Terminal 2 — worker +arq workers.worker.WorkerSettings # from backend/ + +# Terminal 3 — frontend +cd frontend && npm run dev # Vite on 5173, proxies /api -> 8000 +``` + +Vite dev proxy (in `vite.config.ts`): `/api` → `http://127.0.0.1:8000` with `ws: true` for `/api/ws`. + +## 6. Verification checklist (run in order; all must pass) + +```bash +docker compose -f docker/docker-compose.yml ps # postgres, redis, meilisearch healthy +psql postgresql://webmail:webmail_dev@127.0.0.1:5433/webmail_dev -c 'select 1' +redis-cli -p 6380 ping # PONG (via docker exec if no local cli) +curl -s http://127.0.0.1:7700/health # {"status":"available"} +curl -s http://127.0.0.1:8000/api/healthz # implement: checks DB + Redis, returns 200 +cd backend && ruff check . && mypy --strict app && pytest -q +cd frontend && npx tsc --noEmit && npm run lint && npx vitest run +``` + +Integration smoke (once M0 sync exists): start greenmail, configure account `dev@dev.local` / `devpass` against `127.0.0.1:3143` (plain, dev-only), send a message into Greenmail via SMTP 3025, assert it appears via the API within 10 s. + +## 7. Day-to-day commands (put these in a root `Makefile`) + +```make +up: ## start services + docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch +down: ## stop services (data kept) + docker compose -f docker/docker-compose.yml down +reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate + docker compose -f docker/docker-compose.yml down -v + rm -rf var/blobs && mkdir -p var/blobs + docker compose -f docker/docker-compose.yml up -d --wait postgres redis meilisearch + cd backend && alembic upgrade head +api: + uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend +worker: + cd backend && arq workers.worker.WorkerSettings +web: + cd frontend && npm run dev +test: + cd backend && pytest -q && cd ../frontend && npx vitest run +mail-test: ## start throwaway IMAP/SMTP server + docker compose -f docker/docker-compose.yml up -d greenmail +``` + +## 8. CI parity + +`docker/docker-compose.ci.yml` extends the same file: adds Dovecot (`dovecot/dovecot:2.3`) for provider-compat tests and runs Playwright against a production build. CI must use the same image tags as this file — when bumping a service version, bump it in exactly one place (this compose file) and update SPEC §3 in the same commit. + +## 9. Troubleshooting + +- **Port already in use** — something on the host occupies 5433/6380/7700/8000/5173. Do not change this file's ports without recording it in `docs/open-questions.md`; prefer stopping the conflicting service. +- **`alembic upgrade` connection refused** — services not healthy yet; `docker compose ... up -d --wait`. +- **mypy/ruff not found** — you're outside the conda env; `conda activate webmail`. +- **Frontend can't reach WS** — check the Vite proxy `ws: true` and that the session cookie is set (login first). +- **Blob permission errors** — `BLOB_ROOT` must be inside the repo (`./var/blobs`), never a system path. + +## 10. Testing + +Use: test@imaginary-pixels.com account for testing. Login: test@imaginary-pixels.com, password: webmail!@A +and: test@tank-mouse.com. Login: test@tank-mouse.com, password: webmail!@A +SMTP: serwer2295308.home.pl; Port: 587  Port SSL: 465 +IMAP: serwer2295308.home.pl; Port: 143  Port SSL: 993 + + + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..a142ad9 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,53 @@ +name: webmail +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: webmail + POSTGRES_PASSWORD: webmail_dev + POSTGRES_DB: webmail_dev + ports: ["127.0.0.1:5433:5432"] + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U webmail -d webmail_dev"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7 + ports: ["127.0.0.1:6380:6379"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + meilisearch: + image: getmeili/meilisearch:v1.8 + environment: + MEILI_MASTER_KEY: dev_master_key_change_me + MEILI_ENV: development + ports: ["127.0.0.1:7700:7700"] + volumes: [meilidata:/meili_data] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7700/health | grep -q available"] + interval: 5s + timeout: 3s + retries: 10 + + # Test mail servers (integration tests + manual dev against a throwaway mailbox) + greenmail: + image: greenmail/standalone:2.0.1 + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.users=dev:devpass@dev.local -Dgreenmail.verbose + ports: + - "127.0.0.1:3025:3025" # SMTP + - "127.0.0.1:3143:3143" # IMAP + - "127.0.0.1:3993:3993" # IMAPS + +volumes: + pgdata: + meilidata: diff --git a/docs/open-questions.md b/docs/open-questions.md new file mode 100644 index 0000000..680dd29 --- /dev/null +++ b/docs/open-questions.md @@ -0,0 +1,102 @@ +# Open Questions + +Per the setup rules, deviations and unresolved decisions are recorded here rather +than guessed silently. + +## OQ-1 — Meilisearch container healthcheck reports `unhealthy` (IPv6 `localhost`) + +**Status:** resolved — approved deviation applied. The healthcheck URL in +`docker/docker-compose.yml` was changed from `localhost` to `127.0.0.1`; the +container now reports `healthy`. + +**Symptom:** `docker compose ps` shows `meilisearch` as `unhealthy`, even though the +service is fully up and reachable from the host (`curl http://127.0.0.1:7700/health` +returns `{"status":"available"}`). + +**Root cause:** the spec's healthcheck is +`wget -qO- http://localhost:7700/health | grep -q available`. Inside the +`getmeili/meilisearch:v1.8` container, `/etc/hosts` maps `localhost` to both +`127.0.0.1` and `::1`, and busybox `wget` tries the IPv6 `::1` first. Meilisearch +listens IPv4-only on `0.0.0.0:7700` (`MEILI_HTTP_ADDR=0.0.0.0:7700`), so the `::1` +connection is refused and the healthcheck fails. `wget http://127.0.0.1:7700/health` +from inside the container succeeds. + +**Impact:** cosmetic for direct use (the service works), but real for automation: +`docker compose up --wait` (used by the `reset-db` Make target) and the +"all must be healthy" verification step will hang/fail on this host. + +**Proposed minimal fix (one word):** change the healthcheck URL from `localhost` +to `127.0.0.1`: +`test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7700/health | grep -q available"]`. +This is a deviation from the frozen setup doc, so it is left for explicit approval. + +## OQ-5 — Relative BLOB_ROOT breaks across processes with different CWDs + +**Status:** resolved in dev by using an absolute path. + +`BLOB_ROOT=./var/blobs` (relative) resolves against each process's working +directory. Per the dev-setup doc the API runs from the repo root (`uvicorn +--app-dir backend`) while the worker runs "from backend/", so blobs written by the +worker land in `backend/var/blobs` but the API reads `./var/blobs` at the repo +root — a different, empty directory. This only surfaced once the API began reading +blobs (M1 re-sanitize-on-render / attachments); before that the API only served +sanitized HTML from Postgres. + +**Fix:** `.env.dev` now sets an absolute `BLOB_ROOT`. Additionally +`GET /messages/{id}/html` falls back to the stored (possibly stale) sanitized HTML +if a blob is unreadable, so a path/permission issue degrades gracefully instead of +500-ing. `.env.example` keeps the relative default with a note; production should +always use an absolute path or run both processes from the same CWD. + +## OQ-4 — Duplicate Message-ID / bulk-sender over-merging (threading) + +**Status:** resolved with two refinements (both consistent with §7.1's stated intent). + +**Symptom (found while testing):** home.pl's welcome mailer reuses one static +`Message-ID` (and `Date`) for every recipient. Two welcome mails — one per test +account, 104 days apart, byte-identical — collapsed into a single thread. + +**Cause & fixes:** +1. **Duplicate Message-ID (§7.1, was only half-implemented).** The indexer now + synthesizes a non-authoritative id when a Message-ID is already present in a + **different account** (a separate delivery). A **same-account** duplicate keeps + the real id, so Gmail-style INBOX + All Mail copies of one message still thread + together. Implemented in `app/indexer/indexer.py::_resolve_message_id`; new + index `ix_messages_user_msgid` (migration 0002). +2. **Subject fallback (§7.3 refinement).** The literal rule ("same subject + within + 7 days + shares ≥1 participant") over-merges bulk mail, because the shared + participant can be just the common sender. Subject joining now keys on the + **incoming message's recipients (To/Cc)** overlapping the thread — a genuine + reply is always addressed to someone in the thread, a bulk blast to another + account is not. `app/threading_/threader.py::_find_by_subject`. + +Both are minor tightenings of the spec's anti-over-merge intent, not behavior the +spec forbids. Covered by `tests/indexer/test_duplicate_msgid.py` and updated +threading vectors. + +## OQ-3 — `__Host-` session cookie vs. plain-HTTP dev + +**Status:** resolved with a documented dev fallback. + +SPEC §6.1/§10.3 mandate the `__Host-session` cookie prefix, which the browser +only accepts when the cookie is `Secure` **and** sent over HTTPS. The dev setup +(`dev-environment-setup.md`) serves the API over plain HTTP (uvicorn :8000 behind +the Vite HTTP proxy), so a `__Host-`/`Secure` cookie would be silently dropped and +login could never work in dev. + +**Decision:** cookie naming/flags are derived per-request from the scheme +(`app/identity/cookies.py`): over HTTPS we emit the spec-mandated +`__Host-session` + `Secure`; over plain HTTP (dev only) we fall back to `session` +without `Secure`. Production (behind TLS) is fully spec-compliant. `SameSite=Lax`, +`HttpOnly`, `Path=/` always. The CSRF cookie follows the same scheme rule but is +readable by JS (double-submit, SPEC §10.3). + +## OQ-2 — Repository was not a git repo (resolved) + +**Status:** resolved. + +The setup doc's conda `activate.d` hook locates `.env.dev` via +`git rev-parse --show-toplevel`, and the overall workflow (commit-per-increment, +per-plugin alembic branches) assumes git. The working directory was not a git repo, +so `.env.dev` did not auto-load on `conda activate`. Ran `git init` (branch `main`); +the hook now loads the env correctly. No spec deviation — the doc already assumes git. diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..e8c2585 --- /dev/null +++ b/environment.yml @@ -0,0 +1,7 @@ +name: webmail +channels: + - conda-forge +dependencies: + - python=3.11 + - nodejs=20 + - pip diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..60c2255 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,19 @@ +/* eslint-env node */ +module.exports = { + root: true, + env: { browser: true, es2021: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + plugins: ["react-hooks", "react-refresh"], + settings: { react: { version: "18.3" } }, + ignorePatterns: ["dist", "src/api/schema.ts", ".eslintrc.cjs"], + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, +}; diff --git a/frontend/icons/archive.svg b/frontend/icons/archive.svg new file mode 100644 index 0000000..37a1387 --- /dev/null +++ b/frontend/icons/archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/compose.svg b/frontend/icons/compose.svg new file mode 100644 index 0000000..7128a65 --- /dev/null +++ b/frontend/icons/compose.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/drafts-28-regular.svg b/frontend/icons/drafts-28-regular.svg new file mode 100644 index 0000000..bd7d076 --- /dev/null +++ b/frontend/icons/drafts-28-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/forward.svg b/frontend/icons/forward.svg new file mode 100644 index 0000000..62b2e6a --- /dev/null +++ b/frontend/icons/forward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/inbox.svg b/frontend/icons/inbox.svg new file mode 100644 index 0000000..a1c8d75 --- /dev/null +++ b/frontend/icons/inbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/log-out.svg b/frontend/icons/log-out.svg new file mode 100644 index 0000000..dcfbb87 --- /dev/null +++ b/frontend/icons/log-out.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/pin.svg b/frontend/icons/pin.svg new file mode 100644 index 0000000..3ab0869 --- /dev/null +++ b/frontend/icons/pin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/reply-all-line.svg b/frontend/icons/reply-all-line.svg new file mode 100644 index 0000000..d9174bc --- /dev/null +++ b/frontend/icons/reply-all-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/reply.svg b/frontend/icons/reply.svg new file mode 100644 index 0000000..f4232b3 --- /dev/null +++ b/frontend/icons/reply.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/sent-02.svg b/frontend/icons/sent-02.svg new file mode 100644 index 0000000..b621045 --- /dev/null +++ b/frontend/icons/sent-02.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/settings.svg b/frontend/icons/settings.svg new file mode 100644 index 0000000..0c0509f --- /dev/null +++ b/frontend/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/spam.svg b/frontend/icons/spam.svg new file mode 100644 index 0000000..2eafa99 --- /dev/null +++ b/frontend/icons/spam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/trash.svg b/frontend/icons/trash.svg new file mode 100644 index 0000000..33d7276 --- /dev/null +++ b/frontend/icons/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..38b7e3f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Webmail + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fcf7b52 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6094 @@ +{ + "name": "webmail-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webmail-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "~5.51.0", + "@tanstack/react-virtual": "~3.8.0", + "qrcode.react": "~4.1.0", + "react": "~18.3.1", + "react-dom": "~18.3.1", + "react-router-dom": "~6.26.0", + "zod": "~3.23.8" + }, + "devDependencies": { + "@testing-library/jest-dom": "~6.4.8", + "@testing-library/react": "~16.0.0", + "@testing-library/user-event": "~14.5.2", + "@types/react": "~18.3.3", + "@types/react-dom": "~18.3.0", + "@typescript-eslint/eslint-plugin": "~8.0.0", + "@typescript-eslint/parser": "~8.0.0", + "@vitejs/plugin-react": "~4.3.1", + "autoprefixer": "~10.4.20", + "eslint": "~8.57.0", + "eslint-plugin-react-hooks": "~4.6.2", + "eslint-plugin-react-refresh": "~0.4.9", + "jsdom": "~24.1.1", + "openapi-typescript": "~7.3.0", + "postcss": "~8.4.41", + "tailwindcss": "~3.4.10", + "typescript": "~5.5.4", + "vite": "~5.4.1", + "vitest": "~2.0.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", + "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.51.24", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.24.tgz", + "integrity": "sha512-qtIR0FMHUDIWyIQw87q4C+so7XaN59MsGfWrc6rgi2VTHrVZF3Hd0St2dbpqRetHf6XW5yY5lzTrXpTilPlxUg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.51.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.24.tgz", + "integrity": "sha512-sW1qRwoCDqOFku67xng4Y5z6NPK1DS347jR4RiX9wFHrmyqpbXgUjPIjT3fodezdJAaSJD/6CvWb0cl05J8zNQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.51.24" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.8.6.tgz", + "integrity": "sha512-YcOQAxccjIqiC8cQ8QQiDU6F+JZtfpKNvYsw/ju5Q6S5/m9KDs5SaJvKz1kLj3RKNAOBMIFA9snN2MDmyT9lBQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.8.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.8.6.tgz", + "integrity": "sha512-UJeU4SBrx3hqULNzJ3oC0kgJ5miIAg+FwomxMTlQNxob6ppTInifANHd9ukETvzdzxr6zt3CjQ0rttQpVjbt6Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.4.8", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", + "integrity": "sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", + "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.1.tgz", + "integrity": "sha512-5g3Y7GDFsJAnY4Yhvk8sZtFfV6YNF2caLzjrRPUBzewjPCaj0yokePB4LJSobyCzGMzjZZYFbwuzbfDHlimXbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.0.1", + "@typescript-eslint/type-utils": "8.0.1", + "@typescript-eslint/utils": "8.0.1", + "@typescript-eslint/visitor-keys": "8.0.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.1.tgz", + "integrity": "sha512-5IgYJ9EO/12pOUwiBKFkpU7rS3IU21mtXzB81TNwq2xEybcmAZrE9qwDtsb5uQd9aVO9o0fdabFyAmKveXyujg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.0.1", + "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/typescript-estree": "8.0.1", + "@typescript-eslint/visitor-keys": "8.0.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.1.tgz", + "integrity": "sha512-NpixInP5dm7uukMiRyiHjRKkom5RIFA4dfiHvalanD2cF0CLUuQqxfg8PtEUo9yqJI2bBhF+pcSafqnG3UBnRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/visitor-keys": "8.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.1.tgz", + "integrity": "sha512-+/UT25MWvXeDX9YaHv1IS6KI1fiuTto43WprE7pgSMswHbn1Jm9GEM4Txp+X74ifOWV8emu2AWcbLhpJAvD5Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.0.1", + "@typescript-eslint/utils": "8.0.1", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.1.tgz", + "integrity": "sha512-PpqTVT3yCA/bIgJ12czBuE3iBlM3g4inRSC5J0QOdQFAn07TYrYEQBBKgXH1lQpglup+Zy6c1fxuwTk4MTNKIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.1.tgz", + "integrity": "sha512-8V9hriRvZQXPWU3bbiUV4Epo7EvgM6RTs+sUmxp5G//dBGy402S7Fx0W0QkB2fb4obCF8SInoUzvTYtc3bkb5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/visitor-keys": "8.0.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.1.tgz", + "integrity": "sha512-CBFR0G0sCt0+fzfnKaciu9IBsKvEKYwN9UZ+eeogK1fYHg4Qxk1yf/wLQkLXlq8wbU2dFlgAesxt8Gi76E8RTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.0.1", + "@typescript-eslint/types": "8.0.1", + "@typescript-eslint/typescript-estree": "8.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.1.tgz", + "integrity": "sha512-W5E+o0UfUcK5EgchLZsyVWqARmsM7v54/qEq6PY3YI5arkgmCzHiuk0zKSJJbm71V0xdRna4BGomkCTXz2/LkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.0.1", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", + "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.0.5", + "@vitest/utils": "2.0.5", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", + "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.0.5", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", + "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.0.5", + "magic-string": "^0.30.10", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", + "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", + "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", + "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.0.5", + "estree-walker": "^3.0.3", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", + "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.3.3.tgz", + "integrity": "sha512-NkUBI8fr5mg/3s001UPfUiBpKmHtSjkvFQO/IipCrQal5d5nGFoev1OXdxr7J9PHTswrAqU2hKdpoCL6OnammA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.16.0", + "ansi-colors": "^4.1.3", + "parse-json": "^8.1.0", + "supports-color": "^9.4.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode.react": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.1.0.tgz", + "integrity": "sha512-uqXVIIVD/IPgWLYxbOczCNAQw80XCM/LulYDADF+g2xDsPj5OoRwSWtIS4jGyp295wyjKstfG1qIv/I2/rNWpQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", + "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.19.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz", + "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.19.2", + "react-router": "6.26.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", + "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.5", + "pathe": "^1.1.2", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", + "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@vitest/expect": "2.0.5", + "@vitest/pretty-format": "^2.0.5", + "@vitest/runner": "2.0.5", + "@vitest/snapshot": "2.0.5", + "@vitest/spy": "2.0.5", + "@vitest/utils": "2.0.5", + "chai": "^5.1.1", + "debug": "^4.3.5", + "execa": "^8.0.1", + "magic-string": "^0.30.10", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.8.0", + "tinypool": "^1.0.0", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.0.5", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.0.5", + "@vitest/ui": "2.0.5", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b1b917f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "webmail-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint . --max-warnings 0", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "gen:api": "openapi-typescript http://127.0.0.1:8000/openapi.json -o src/api/schema.ts" + }, + "dependencies": { + "@tanstack/react-query": "~5.51.0", + "@tanstack/react-virtual": "~3.8.0", + "qrcode.react": "~4.1.0", + "react": "~18.3.1", + "react-dom": "~18.3.1", + "react-router-dom": "~6.26.0", + "zod": "~3.23.8" + }, + "devDependencies": { + "@testing-library/jest-dom": "~6.4.8", + "@testing-library/react": "~16.0.0", + "@testing-library/user-event": "~14.5.2", + "@types/react": "~18.3.3", + "@types/react-dom": "~18.3.0", + "@typescript-eslint/eslint-plugin": "~8.0.0", + "@typescript-eslint/parser": "~8.0.0", + "@vitejs/plugin-react": "~4.3.1", + "autoprefixer": "~10.4.20", + "eslint": "~8.57.0", + "eslint-plugin-react-hooks": "~4.6.2", + "eslint-plugin-react-refresh": "~0.4.9", + "jsdom": "~24.1.1", + "openapi-typescript": "~7.3.0", + "postcss": "~8.4.41", + "tailwindcss": "~3.4.10", + "typescript": "~5.5.4", + "vite": "~5.4.1", + "vitest": "~2.0.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..36fb36b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,73 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; + +import { useMe } from "./lib/auth"; +import { connectWs } from "./lib/ws"; +import { ComposeWindow } from "./routes/ComposeWindow"; +import { Login } from "./routes/Login"; +import { MailLayout } from "./routes/MailLayout"; +import { Register } from "./routes/Register"; +import { Settings } from "./routes/settings/Settings"; +import { AccountWizard } from "./routes/wizard/AccountWizard"; + +function RequireAuth({ children }: { children: JSX.Element }) { + const { data, isLoading, isError } = useMe(); + const location = useLocation(); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!data) return; + return connectWs(queryClient); + }, [data, queryClient]); + + if (isLoading) { + return
Loading…
; + } + if (isError || !data) { + return ; + } + return children; +} + +export function App() { + return ( + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} diff --git a/frontend/src/components/FolderIcon.tsx b/frontend/src/components/FolderIcon.tsx new file mode 100644 index 0000000..8e3b953 --- /dev/null +++ b/frontend/src/components/FolderIcon.tsx @@ -0,0 +1,6 @@ +// Back-compat shim: folder icons are now part of the generic Icon component. +import { Icon } from "./Icon"; + +export function FolderIcon({ name }: { name: string }) { + return ; +} diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx new file mode 100644 index 0000000..d0dd67d --- /dev/null +++ b/frontend/src/components/Icon.tsx @@ -0,0 +1,62 @@ +// Inline SVG icons (user-provided SVGs in frontend/icons/). They draw with +// `currentColor` but pin a fixed color via an inline `style`; we strip that and +// let the size come from CSS so each glyph inherits the surrounding text color +// and adapts to light/dark themes. +import archive from "../../icons/archive.svg?raw"; +import compose from "../../icons/compose.svg?raw"; +import drafts from "../../icons/drafts-28-regular.svg?raw"; +import forward from "../../icons/forward.svg?raw"; +import inbox from "../../icons/inbox.svg?raw"; +import pin from "../../icons/pin.svg?raw"; +import replyAll from "../../icons/reply-all-line.svg?raw"; +import reply from "../../icons/reply.svg?raw"; +import sent from "../../icons/sent-02.svg?raw"; +import spam from "../../icons/spam.svg?raw"; +import trash from "../../icons/trash.svg?raw"; + +const RAW: Record = { + archive, + compose, + drafts, + forward, + inbox, + pin, + reply, + "reply-all": replyAll, + sent, + spam, + trash, +}; + +export type IconName = keyof typeof RAW; + +// Drop the baked-in size/color so the glyph fills its box and inherits color. +function clean(svg: string): string { + return svg + .replace(/\sstyle="[^"]*"/i, "") + .replace(/\swidth="[^"]*"/i, "") + .replace(/\sheight="[^"]*"/i, ""); +} + +const CLEANED: Record = Object.fromEntries( + Object.entries(RAW).map(([name, svg]) => [name, clean(svg)]), +); + +export function Icon({ + name, + size = 18, + className = "", +}: { + name: IconName | string; + size?: number; + className?: string; +}) { + return ( + svg]:h-full [&>svg]:w-full ${className}`} + style={{ width: size, height: size }} + dangerouslySetInnerHTML={{ __html: CLEANED[name] ?? "" }} + /> + ); +} diff --git a/frontend/src/components/Resizer.tsx b/frontend/src/components/Resizer.tsx new file mode 100644 index 0000000..9e9eb60 --- /dev/null +++ b/frontend/src/components/Resizer.tsx @@ -0,0 +1,32 @@ +// A vertical drag handle for adjusting the width of the panel to its left. +export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) { + const onMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + let lastX = e.clientX; + const onMove = (ev: MouseEvent) => { + onDelta(ev.clientX - lastX); + lastX = ev.clientX; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + }; + + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..48a31c6 --- /dev/null +++ b/frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,18 @@ +import { useT } from "../i18n"; +import { useTheme } from "../lib/theme"; + +// Quick light/dark flip (the full light/dark/system selector lives in Settings). +export function ThemeToggle() { + const t = useT(); + const { resolved, setTheme } = useTheme(); + return ( + + ); +} diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx new file mode 100644 index 0000000..d2545a6 --- /dev/null +++ b/frontend/src/components/compose/Composer.tsx @@ -0,0 +1,549 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useT } from "../../i18n"; +import { api, ApiError } from "../../lib/api"; +import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types"; +import { Icon } from "../Icon"; +import { RecipientChips } from "./RecipientChips"; +import { RichTextEditor } from "./RichTextEditor"; + +const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file + +export interface ComposerInit { + accountId: string; + threadId?: string | null; + replyToMessageId?: string | null; + kind: string; // reply | reply_all | forward | new + to?: string; + cc?: string; + subject?: string; + text?: string; + html?: string; // initial body when reopening an existing draft + draftId?: string; // when reopening an existing draft + attachmentIds?: string[]; // compose-attachment ids when reopening a draft + // A reply/forward quotes the original: this attribution line sits above the + // quoted message body (fetched from the original message's sanitized HTML). + quote?: { attribution: string; messageId: string }; +} + +// Build the composer seed from a saved draft (used to reopen it in place or in a +// popped-out window). +export function draftToInit(draft: DraftOut): ComposerInit { + return { + accountId: draft.account_id, + threadId: draft.thread_id, + replyToMessageId: draft.reply_to_message_id, + kind: draft.kind, + to: draft.payload.to.join(", "), + cc: draft.payload.cc.join(", "), + subject: draft.payload.subject, + text: draft.payload.text, + html: draft.payload.html || undefined, + attachmentIds: draft.payload.attachment_ids, + draftId: draft.id, + }; +} + +function splitList(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function htmlToText(html: string): string { + const div = document.createElement("div"); + div.innerHTML = html; + return div.textContent ?? ""; +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +function textToHtml(text: string): string { + return text + .split("\n") + .map((line) => `
${escapeHtml(line) || "
"}
`) + .join(""); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// A single header icon-button (Attach / window controls / etc.). +function HeaderButton({ + onClick, + title, + active = false, + children, +}: { + onClick?: () => void; + title: string; + active?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} + +const caret = ( + + + +); + +export function Composer({ + init, + onClose, + expanded = false, + onToggleExpand, + fullWindow = false, +}: { + init: ComposerInit; + onClose: () => void; + expanded?: boolean; + onToggleExpand?: () => void; + fullWindow?: boolean; +}) { + const t = useT(); + const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); + const accounts: AccountOut[] = accountsQuery.data ?? []; + + const [accountId, setAccountId] = useState(init.accountId); + const [to, setTo] = useState(init.to ?? ""); + const [cc, setCc] = useState(init.cc ?? ""); + const [bcc, setBcc] = useState(""); + const [showCc, setShowCc] = useState(Boolean(init.cc)); + const [subject, setSubject] = useState(init.subject ?? ""); + const [bodyHtml, setBodyHtml] = useState(init.html ?? ""); + const [quoteHtml, setQuoteHtml] = useState(""); + const [quoteResolved, setQuoteResolved] = useState(!init.quote); + const [showToolbar, setShowToolbar] = useState(true); + const [fromOpen, setFromOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + const [attachments, setAttachments] = useState([]); + const [attachError, setAttachError] = useState(""); + const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">(""); + const [undo, setUndo] = useState<{ outboxId: string } | null>(null); + const draftId = useRef(init.draftId ?? null); + const undoTimer = useRef(null); + const fileInputRef = useRef(null); + + const account = accounts.find((a) => a.id === accountId); + const fromName = account?.display_name || ""; + const fromEmail = account?.email ?? ""; + + // Fetch the original message's sanitized body to quote in a reply/forward. + useEffect(() => { + const quote = init.quote; + if (!quote) return; + let cancelled = false; + (async () => { + try { + const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" }); + const doc = new DOMParser().parseFromString(await resp.text(), "text/html"); + if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? ""); + } catch { + /* leave the quote empty on failure */ + } finally { + if (!cancelled) setQuoteResolved(true); + } + })(); + return () => { + cancelled = true; + }; + }, [init.quote]); + + // Resolve attachment metadata when reopening a draft that already has some. + useEffect(() => { + const ids = init.attachmentIds ?? []; + if (ids.length === 0) return; + let cancelled = false; + (async () => { + try { + const rows = await api.composeAttachments(ids); + if (!cancelled) setAttachments(rows); + } catch { + /* ignore */ + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const addFiles = async (files: FileList) => { + setAttachError(""); + for (const file of Array.from(files)) { + if (file.size > MAX_ATTACHMENT_BYTES) { + setAttachError(t("compose.attachTooLarge")); + continue; + } + try { + const att = await api.uploadComposeAttachment(file); + setAttachments((prev) => [...prev, att]); + } catch (e) { + setAttachError(e instanceof ApiError ? e.message : t("compose.attachTooLarge")); + } + } + }; + + const removeAttachment = (id: string) => + setAttachments((prev) => prev.filter((a) => a.id !== id)); + + // The editor's seed content: an empty line to type into, then (for replies) + // the attribution line and the quoted original inside a blockquote. + const initialHtml = useMemo(() => { + if (init.html) return init.html; + if (init.text) return textToHtml(init.text); + if (init.quote) { + return ( + `

` + + `
${escapeHtml(init.quote.attribution)}
` + + `
${quoteHtml}
` + ); + } + return `

`; + // Only the quote resolving should (re)compute this; it mounts the editor once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [quoteResolved]); + + const bodyReady = quoteResolved; + + const payload = (): DraftInput => { + const html = bodyHtml || initialHtml; + return { + account_id: accountId, + thread_id: init.threadId ?? null, + reply_to_message_id: init.replyToMessageId ?? null, + kind: init.kind, + payload: { + to: splitList(to), + cc: splitList(cc), + bcc: splitList(bcc), + subject, + html, + text: htmlToText(html), + attachment_ids: attachments.map((a) => a.id), + }, + }; + }; + + useEffect(() => { + if (undo) return; + const handle = window.setTimeout(async () => { + if (!to && !subject && !bodyHtml) return; + setStatus("saving"); + try { + if (draftId.current) { + await api.updateDraft(draftId.current, payload()); + } else { + draftId.current = (await api.createDraft(payload())).id; + } + setStatus("saved"); + } catch { + setStatus("error"); + } + }, 2000); + return () => window.clearTimeout(handle); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountId, to, cc, bcc, subject, bodyHtml, attachments]); + + useEffect( + () => () => { + if (undoTimer.current) window.clearTimeout(undoTimer.current); + }, + [], + ); + + const send = async () => { + setStatus("sending"); + try { + if (!draftId.current) draftId.current = (await api.createDraft(payload())).id; + else await api.updateDraft(draftId.current, payload()); + const res = await api.send(draftId.current); + setUndo({ outboxId: res.outbox_id }); + undoTimer.current = window.setTimeout(onClose, 10000); + } catch (e) { + setStatus("error"); + if (!(e instanceof ApiError)) throw e; + } + }; + + const doUndo = async () => { + if (!undo) return; + try { + await api.undoSend(undo.outboxId); + } catch { + /* too late */ + } + if (undoTimer.current) window.clearTimeout(undoTimer.current); + setUndo(null); + setStatus(""); + }; + + const discard = async () => { + if (draftId.current) { + try { + await api.deleteDraft(draftId.current); + } catch { + /* ignore */ + } + draftId.current = null; + } + onClose(); + }; + + const popOut = async () => { + // Persist first so the new window can load this exact draft. + try { + if (!draftId.current) draftId.current = (await api.createDraft(payload())).id; + else await api.updateDraft(draftId.current, payload()); + } catch { + return; + } + // A window a quarter of the screen area (half width × half height), centered. + const w = Math.round(window.screen.width / 2); + const h = Math.round(window.screen.height / 2); + const left = Math.round((window.screen.width - w) / 2); + const top = Math.round((window.screen.height - h) / 2); + window.open( + `/compose/${draftId.current}`, + `compose-${draftId.current}`, + `width=${w},height=${h},left=${left},top=${top}`, + ); + onClose(); + }; + + if (undo) { + return ( +
+ {t("compose.sent")} + +
+ ); + } + + return ( +
+ {/* Header: Send · Attach · formatting toggle · More · window controls */} +
+ + fileInputRef.current?.click()} title={t("compose.attach")}> + + + + {t("compose.attach")} + + { + if (e.target.files && e.target.files.length) void addFiles(e.target.files); + e.target.value = ""; + }} + /> + setShowToolbar((v) => !v)} + title={t("compose.fmtBold")} + active={showToolbar} + > + A + +
+ setMoreOpen((v) => !v)} title={t("compose.more")}> + {t("compose.more")} + + {moreOpen && ( +
+ + +
+ )} +
+
+ {status === "saving" && {t("compose.savingDraft")}} + {status === "saved" && {t("compose.draftSaved")}} + {status === "error" && {t("compose.sendFailed")}} + {!fullWindow && onToggleExpand && ( + + + + + + )} + {!fullWindow && ( + + + + + + )} + + + + + +
+
+ + {/* From */} +
+ {t("compose.from")} + +
+ + {t("compose.replyLabel")} +
+ {fromOpen && ( +
+ {accounts.map((a) => ( + + ))} +
+ )} +
+ + {/* Recipients */} + setShowCc(true)} + > + {t("compose.cc")} {t("compose.bcc")} + + ) : undefined + } + /> + {showCc && ( + <> + + + + )} + setSubject(e.target.value)} + /> + + {(attachments.length > 0 || attachError) && ( +
+ {attachments.map((att) => ( + + + + + {att.filename} + {formatBytes(att.size_bytes)} + + + ))} + {attachError && {attachError}} +
+ )} + + {/* Body */} + {bodyReady ? ( + + ) : ( +
…
+ )} +
+ ); +} diff --git a/frontend/src/components/compose/RecipientChips.tsx b/frontend/src/components/compose/RecipientChips.tsx new file mode 100644 index 0000000..09e2905 --- /dev/null +++ b/frontend/src/components/compose/RecipientChips.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from "react"; + +import { useT } from "../../i18n"; +import { api } from "../../lib/api"; +import type { RecipientSuggestion } from "../../lib/types"; + +function splitList(value: string): string[] { + return value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseAddr(token: string): { name: string; email: string } { + const m = token.match(/^(.*?)\s*<([^>]+)>$/); + if (m) return { name: m[1].trim(), email: m[2].trim() }; + return { name: "", email: token }; +} + +// A recipient field rendering committed addresses as removable chips with an +// inline autocomplete input (Fastmail-style). The value stays a comma-joined +// string ("Name " or bare email) so the draft payload is unchanged. +export function RecipientChips({ + label, + value, + onChange, + rightSlot, + autoFocus, +}: { + label: string; + value: string; + onChange: (value: string) => void; + rightSlot?: React.ReactNode; + autoFocus?: boolean; +}) { + const t = useT(); + const chips = splitList(value); + const [draft, setDraft] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [open, setOpen] = useState(false); + const boxRef = useRef(null); + + const commit = (token: string) => { + const tok = token.trim().replace(/,+$/, "").trim(); + if (!tok) return; + onChange([...chips, tok].join(", ")); + setDraft(""); + setOpen(false); + setSuggestions([]); + }; + const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", ")); + + useEffect(() => { + if (draft.trim().length < 2) { + setSuggestions([]); + return; + } + let cancelled = false; + const handle = window.setTimeout(async () => { + try { + const results = await api.suggestRecipients(draft.trim()); + if (!cancelled) { + setSuggestions(results); + setOpen(results.length > 0); + } + } catch { + /* ignore */ + } + }, 150); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [draft]); + + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + return ( +
+ +
+ {chips.map((chip, i) => { + const { name, email } = parseAddr(chip); + return ( + + {name && {name}} + {name ? `<${email}>` : email} + + + ); + })} + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commit(draft); + } else if (e.key === "Backspace" && draft === "" && chips.length > 0) { + removeAt(chips.length - 1); + } + }} + onBlur={() => draft.trim() && commit(draft)} + onFocus={() => setOpen(suggestions.length > 0)} + /> +
+ {rightSlot &&
{rightSlot}
} + {open && ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/compose/RichTextEditor.tsx b/frontend/src/components/compose/RichTextEditor.tsx new file mode 100644 index 0000000..7f6fcc3 --- /dev/null +++ b/frontend/src/components/compose/RichTextEditor.tsx @@ -0,0 +1,426 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useT } from "../../i18n"; + +// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source +// of truth (uncontrolled) so the caret never jumps; we read innerHTML on input. +// Formatting uses document.execCommand — deprecated but universally supported and +// the pragmatic choice for a compose surface of this size. + +type Cmd = + | "bold" + | "italic" + | "underline" + | "strikeThrough" + | "insertUnorderedList" + | "insertOrderedList" + | "justifyLeft" + | "justifyCenter" + | "justifyRight"; + +const STATE_CMDS: Cmd[] = [ + "bold", + "italic", + "underline", + "strikeThrough", + "insertUnorderedList", + "insertOrderedList", + "justifyLeft", + "justifyCenter", + "justifyRight", +]; + +// A fixed swatch palette (10 columns × 8 rows, row-major): a grayscale top row, +// then a saturated hue row followed by tints (lighter) and shades (darker). +const PALETTE: string[] = [ + "#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#f3f3f3", "#ffffff", + "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", + "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", + "#dd7e6b", "#ea9999", "#f9cb9c", "#ffe599", "#b6d7a8", "#a2c4c9", "#a4c2f4", "#9fc5e8", "#b4a7d6", "#d5a6bd", + "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", + "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79", + "#85200c", "#990000", "#b45f06", "#bf9000", "#38761d", "#134f5c", "#1155cc", "#0b5394", "#351c75", "#741b47", + "#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130", +]; + +function Svg({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +const ICONS = { + bulletList: ( + + + + + + + ), + numberList: ( + + + + + + ), + quote: ( + + + + + ), + link: ( + + + + + ), + alignLeft: ( + + + + ), + alignCenter: ( + + + + ), + alignRight: ( + + + + ), + clear: ( + + + + + ), + highlight: ( + + + + + + ), + image: ( + + + + + + ), +} as const; + +export function RichTextEditor({ + initialHtml, + onInput, + onReady, + showToolbar = true, +}: { + initialHtml: string; + onInput: (html: string) => void; + onReady?: (el: HTMLDivElement) => void; + showToolbar?: boolean; +}) { + const t = useT(); + const ref = useRef(null); + const savedRange = useRef(null); + const imageInputRef = useRef(null); + const paletteRef = useRef(null); + const [active, setActive] = useState>({}); + const [colorMenu, setColorMenu] = useState(null); + + // Seed the editable region exactly once. + useEffect(() => { + const el = ref.current; + if (!el) return; + el.innerHTML = initialHtml; + onReady?.(el); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Remember the caret/selection so native pickers (color/file) that steal focus + // can restore where formatting should apply. + const saveSelection = useCallback(() => { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0 && ref.current?.contains(sel.anchorNode)) { + savedRange.current = sel.getRangeAt(0).cloneRange(); + } + }, []); + + const restoreSelection = () => { + const sel = window.getSelection(); + if (sel && savedRange.current) { + sel.removeAllRanges(); + sel.addRange(savedRange.current); + } + }; + + const refreshActive = useCallback(() => { + const el = ref.current; + if (!el || document.activeElement !== el) return; + saveSelection(); + const next: Record = {}; + for (const cmd of STATE_CMDS) { + try { + next[cmd] = document.queryCommandState(cmd); + } catch { + next[cmd] = false; + } + } + setActive(next); + }, [saveSelection]); + + useEffect(() => { + document.addEventListener("selectionchange", refreshActive); + return () => document.removeEventListener("selectionchange", refreshActive); + }, [refreshActive]); + + // Open/close the swatch palette (snapshotting the selection to apply color to). + const openColorMenu = (kind: "fore" | "hilite") => { + saveSelection(); + setColorMenu((prev) => (prev === kind ? null : kind)); + }; + + useEffect(() => { + if (!colorMenu) return; + const onDown = (e: MouseEvent) => { + if (paletteRef.current && !paletteRef.current.contains(e.target as Node)) { + setColorMenu(null); + } + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [colorMenu]); + + const emit = () => { + if (ref.current) onInput(ref.current.innerHTML); + }; + + const exec = (command: string, value?: string) => { + ref.current?.focus(); + document.execCommand(command, false, value); + emit(); + refreshActive(); + }; + + const link = () => { + const url = window.prompt(t("compose.linkPrompt"), "https://"); + if (url) exec("createLink", url); + }; + + const applyColor = (kind: "fore" | "hilite", color: string) => { + ref.current?.focus(); + restoreSelection(); + document.execCommand("styleWithCSS", false, "true"); + if (kind === "fore") { + document.execCommand("foreColor", false, color); + } else if (!document.execCommand("hiliteColor", false, color)) { + document.execCommand("backColor", false, color); + } + document.execCommand("styleWithCSS", false, "false"); + emit(); + }; + + const insertImage = (file: File) => { + if (file.size > 5 * 1024 * 1024) { + window.alert(t("compose.imageTooLarge")); + return; + } + const reader = new FileReader(); + reader.onload = () => { + ref.current?.focus(); + restoreSelection(); + document.execCommand("insertImage", false, String(reader.result)); + emit(); + }; + reader.readAsDataURL(file); + }; + + const Btn = ({ + onClick, + title, + isActive = false, + children, + }: { + onClick: () => void; + title: string; + isActive?: boolean; + children: React.ReactNode; + }) => ( + + ); + + const Sep = () => ; + + return ( +
+
+ exec("bold")} title={t("compose.fmtBold")} isActive={active.bold}> + B + + exec("italic")} title={t("compose.fmtItalic")} isActive={active.italic}> + I + + exec("underline")} + title={t("compose.fmtUnderline")} + isActive={active.underline} + > + U + + exec("strikeThrough")} + title={t("compose.fmtStrike")} + isActive={active.strikeThrough} + > + S + + + exec("insertUnorderedList")} + title={t("compose.fmtBulletList")} + isActive={active.insertUnorderedList} + > + {ICONS.bulletList} + + exec("insertOrderedList")} + title={t("compose.fmtNumberList")} + isActive={active.insertOrderedList} + > + {ICONS.numberList} + + exec("formatBlock", "blockquote")} title={t("compose.fmtQuote")}> + {ICONS.quote} + + + {ICONS.link} + + +
+ openColorMenu("fore")} title={t("compose.fmtColor")}> + + A + + + + openColorMenu("hilite")} title={t("compose.fmtHighlight")}> + {ICONS.highlight} + + {colorMenu && ( +
+
+ {PALETTE.map((color) => ( +
+
+ )} +
+ { + saveSelection(); + imageInputRef.current?.click(); + }} + title={t("compose.fmtImage")} + > + {ICONS.image} + + { + const f = e.target.files?.[0]; + if (f) insertImage(f); + e.target.value = ""; + }} + /> + + exec("justifyLeft")} + title={t("compose.fmtAlignLeft")} + isActive={active.justifyLeft} + > + {ICONS.alignLeft} + + exec("justifyCenter")} + title={t("compose.fmtAlignCenter")} + isActive={active.justifyCenter} + > + {ICONS.alignCenter} + + exec("justifyRight")} + title={t("compose.fmtAlignRight")} + isActive={active.justifyRight} + > + {ICONS.alignRight} + + + exec("removeFormat")} title={t("compose.fmtClear")}> + {ICONS.clear} + +
+
onInput((e.target as HTMLDivElement).innerHTML)} + onKeyUp={refreshActive} + onMouseUp={refreshActive} + className="wmc-editor min-h-[250px] flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none" + /> +
+ ); +} diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx new file mode 100644 index 0000000..1d904c6 --- /dev/null +++ b/frontend/src/components/list/DraftsList.tsx @@ -0,0 +1,49 @@ +import { useT } from "../../i18n"; +import type { DraftOut } from "../../lib/types"; + +function timeLabel(iso: string): string { + const d = new Date(iso); + const now = new Date(); + if (d.toDateString() === now.toDateString()) + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return d.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +// Local drafts live in the `drafts` table (not an IMAP mailbox), so the Drafts +// folder lists them here rather than through the thread list. +export function DraftsList({ + drafts, + onOpen, +}: { + drafts: DraftOut[]; + onOpen: (draft: DraftOut) => void; +}) { + const t = useT(); + if (drafts.length === 0) { + return
{t("compose.noDrafts")}
; + } + return ( +
+ {drafts.map((draft) => { + const recipients = draft.payload.to.join(", ") || t("compose.noRecipient"); + const preview = draft.payload.text.trim(); + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx new file mode 100644 index 0000000..8fb4f22 --- /dev/null +++ b/frontend/src/components/list/MessageList.tsx @@ -0,0 +1,109 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef } from "react"; + +import { useT } from "../../i18n"; +import type { AccountOut, ThreadSummary } from "../../lib/types"; +import { Icon } from "../Icon"; + +function timeLabel(iso: string): string { + const d = new Date(iso); + const now = new Date(); + if (d.toDateString() === now.toDateString()) + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (d.getFullYear() === now.getFullYear()) + return d.toLocaleDateString([], { month: "short", day: "numeric" }); + return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" }); +} + +// Virtualized thread list (SPEC §2 list/MessageList.tsx). +export function MessageList({ + threads, + accounts, + selectedId, + onSelect, +}: { + threads: ThreadSummary[]; + accounts: AccountOut[]; + selectedId?: string; + onSelect: (id: string) => void; +}) { + const t = useT(); + const parentRef = useRef(null); + const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888"; + + const virtualizer = useVirtualizer({ + count: threads.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 82, + overscan: 8, + }); + + if (threads.length === 0) { + return
{t("mail.noConversations")}
; + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => { + const thread = threads[item.index]; + const unread = thread.unread_count > 0; + const selected = selectedId === thread.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/thread/Attachments.tsx b/frontend/src/components/thread/Attachments.tsx new file mode 100644 index 0000000..fa2df26 --- /dev/null +++ b/frontend/src/components/thread/Attachments.tsx @@ -0,0 +1,119 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { useT } from "../../i18n"; +import { api } from "../../lib/api"; +import type { AttachmentOut } from "../../lib/types"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function FileGlyph() { + return ( + + + + + + ); +} + +function DownloadGlyph() { + return ( + + + + ); +} + +export function Attachments({ messageId }: { messageId: string }) { + const t = useT(); + const [selected, setSelected] = useState>(new Set()); + + const { data } = useQuery({ + queryKey: ["attachments", messageId], + queryFn: () => api.messageAttachments(messageId), + }); + + const attachments = data ?? []; + if (attachments.length === 0) return null; + + const toggle = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const selectedIds = [...selected]; + + return ( +
+ {selectedIds.length > 0 && ( + + )} +
+ {attachments.map((att: AttachmentOut) => ( +
+ toggle(att.id)} + className="h-3.5 w-3.5 shrink-0 accent-app-accent" + aria-label={att.filename} + /> + + + + + + {att.filename} + + {formatSize(att.size_bytes)} + + + + +
+ ))} +
+ +
+ ); +} diff --git a/frontend/src/components/thread/Composer.tsx b/frontend/src/components/thread/Composer.tsx new file mode 100644 index 0000000..fe88ad8 --- /dev/null +++ b/frontend/src/components/thread/Composer.tsx @@ -0,0 +1,14 @@ +// Composer anchor slot (SPEC §9.4). M0: a reply placeholder attached to the +// newest message. The single placement rule ("composer is always attached to the +// newest message") lives in ThreadView; this is the slot it renders. + +export function Composer({ subject }: { subject: string }) { + return ( +
+ Reply to “{subject}” — composer (M1) +
+ ); +} diff --git a/frontend/src/components/thread/MessageBody.tsx b/frontend/src/components/thread/MessageBody.tsx new file mode 100644 index 0000000..d224526 --- /dev/null +++ b/frontend/src/components/thread/MessageBody.tsx @@ -0,0 +1,90 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useT } from "../../i18n"; +import { api } from "../../lib/api"; +import type { MessageEnvelope } from "../../lib/types"; +import { Attachments } from "./Attachments"; + +// Sanitized message HTML in a sandboxed iframe (SPEC §9.4, I-8). We use +// sandbox="allow-same-origin" WITHOUT allow-scripts: scripts still never run +// (the content is nh3-sanitized and script-less), but same-origin lets us (a) +// measure the content to auto-size the frame — so the message shows in full and +// only the thread panel scrolls — and (b) send the session cookie so inline +// (cid:) images authenticate and load. +export function MessageBody({ message }: { message: MessageEnvelope }) { + const t = useT(); + const queryClient = useQueryClient(); + const [allowed, setAllowed] = useState(false); + const frameRef = useRef(null); + const observerRef = useRef(null); + const [height, setHeight] = useState(80); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["message-html", message.id, allowed], + queryFn: async () => { + const resp = await fetch(`/api/messages/${message.id}/html`, { credentials: "include" }); + if (!resp.ok) throw new Error("failed to load message"); + return resp.text(); + }, + }); + + const resize = useCallback(() => { + const doc = frameRef.current?.contentDocument; + if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight); + }, []); + + const onLoad = () => { + const doc = frameRef.current?.contentDocument; + if (!doc) return; + resize(); + // Re-measure as images finish loading (they change the content height). + doc.querySelectorAll("img").forEach((img) => { + if (!img.complete) img.addEventListener("load", resize, { once: true }); + }); + observerRef.current?.disconnect(); + const ro = new ResizeObserver(resize); + if (doc.body) ro.observe(doc.body); + observerRef.current = ro; + }; + + useEffect(() => () => observerRef.current?.disconnect(), []); + + const loadImages = async (persist: boolean) => { + if (persist) { + await api.allowRemoteImages(message.id); + queryClient.invalidateQueries({ queryKey: ["message-html", message.id] }); + } + setAllowed(true); + }; + + return ( +
+ {message.has_remote_images && !allowed && ( +
+ {t("mail.imagesBlocked")} + + +
+ )} + {isLoading &&
{t("mail.loadingThread")}
} + {isError &&
{t("mail.loadFailed")}
} + {data !== undefined && !isLoading && ( +