diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80df665 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +**/node_modules +**/dist +.git +.env +server/data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5f1ea7 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ---- ihasmail server configuration ---- + +# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers +# the JMAP session at /.well-known/jmap. +STALWART_URL=https://mail.example.com + +# Random secret used to derive encryption keys for persisted sessions. +# Generate with: openssl rand -base64 48 +APP_SECRET=change-me + +# Listen address +HOST=0.0.0.0 +PORT=8080 + +# Set to "1" when running behind a TLS-terminating reverse proxy (trusts +# X-Forwarded-* and marks cookies Secure). Set to "0" for plain-HTTP dev. +TRUST_PROXY=1 +SECURE_COOKIES=auto + +# Session lifetime (idle timeout) in seconds. "Remember me" extends to SESSION_REMEMBER_TTL. +SESSION_TTL=43200 +SESSION_REMEMBER_TTL=2592000 + +# Where to persist sessions so restarts don't log everyone out (optional). +SESSION_FILE=./data/sessions.json + +# Upstream timeouts / limits +UPSTREAM_TIMEOUT=30000 +MAX_UPLOAD_BYTES=52428800 + +# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly. +IMAGE_PROXY=1 + +# Branding +APP_NAME=ihasmail diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a26027 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI +on: + push: + branches: [main] + pull_request: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - run: npm run typecheck + - run: npm test + - run: npm run build + - name: Docker build + run: docker build -t ihasmail:ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..117c18b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +server/data/ +.vite/ +coverage/ diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..268680b --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,9 @@ +# Example reverse proxy (Caddy) in front of ihasmail. +# TLS is automatic. ihasmail sets Secure cookies and HSTS when X-Forwarded-Proto is https. +mail.example.com { + encode zstd gzip + reverse_proxy 127.0.0.1:8080 { + # Keep SSE (push) connections open + flush_interval -1 + } +} diff --git a/Dockerfile b/Dockerfile index 695ec9e..c5b86eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,29 @@ -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 - -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +# ---- build stage ---- +FROM node:22-alpine AS build WORKDIR /app +COPY package.json package-lock.json* ./ +COPY server/package.json server/ +COPY web/package.json web/ +RUN npm ci --ignore-scripts +COPY . . +RUN npm run build -COPY pyproject.toml README.md /app/ -RUN pip install --no-cache-dir -e . - -COPY app /app/app -COPY .env.example /app/.env.example - -ENV PORT=8000 -EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"] +# ---- runtime stage ---- +FROM node:22-alpine AS runtime +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=8080 \ + STATIC_DIR=/app/web/dist \ + SESSION_FILE=/data/sessions.json +WORKDIR /app +COPY package.json ./ +COPY server/package.json server/ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/web/dist ./web/dist +RUN mkdir -p /data && chown -R node:node /data /app +USER node +VOLUME ["/data"] +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1 +CMD ["node", "server/dist/index.js"] diff --git a/Makefile b/Makefile deleted file mode 100644 index 40d8ae6..0000000 --- a/Makefile +++ /dev/null @@ -1,13 +0,0 @@ -.PHONY: run dev test build - -run: - uvicorn app.main:app --host 0.0.0.0 --port 8000 - -dev: - uvicorn app.main:app --reload - -test: - pytest - -build: - docker compose build diff --git a/README.md b/README.md index d3699b7..345a01f 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,142 @@ +

+ ihasmail +

+ # ihasmail -![ihasmail logo](app/static/img/logo.png) +**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.** -A polished, FastAPI + HTMX/Jinja webmail for Stalwart, with JMAP mail/contacts/calendar, Sieve UI, DAV browsing, and reverse-proxy friendly deploy. +ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, in a responsive single-page app that works equally well on a desktop monitor and a phone. It talks only JMAP (plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no database. -A production-leaning, **FastAPI** + **HTMX/Jinja** webmail for [Stalwart Mail Server](https://stalw.art/), using **JMAP** for mail, contacts, and calendar, plus simple **WebDAV/CalDAV** helpers. Authenticates with the user's Stalwart mailbox (like Roundcube). Designed to run behind a reverse proxy. +> Status: 2.0 rewrite, in QA against a live Stalwart 1.0 server. The previous FastAPI/HTMX prototype has been removed entirely (only the logo survived). + +## Screenshots + +*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.* + +| | | +| --- | --- | +| **Inbox & conversation view (dark)** ![Inbox, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation view (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) | +| **Reply composer** — identities, Reply-To, rich text, signature, quoted text ![Composer](docs/screenshots/compose.jpg) | **Calendar (month view)** ![Calendar](docs/screenshots/calendar.jpg) | +| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** — also reachable from a message's right-click menu ![Filters](docs/screenshots/filters.jpg) | +| **Sign-in** ![Login](docs/screenshots/login.jpg) | **Mobile layout** Mobile | ## Features -- Login with Stalwart mailbox (HTTP Basic against JMAP session or bearer token if provided) -- Inbox listing, read messages (plain text), compose & send via JMAP (`Email`, `EmailSubmission`) -- Contacts/Directory via JMAP `Contact` -- Calendar view via JMAP `CalendarEvent` -- WebDAV browser (read-only sample) and CalDAV endpoints (external DAV clients) -- CSRF on POST, signed session cookie, proxy-friendly -- Dockerfile + docker-compose for easy deploy -> HTML rendering and attachment streaming are stubbed—extend using the JMAP `downloadUrl` and sanitize HTML before display. +**Mail** +- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours +- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …) +- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo** +- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP) +- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's) +- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer +- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click +- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel +- Composer: multiple floating/minimised/maximised composers, rich-text editor (formatting, lists, links, colours, images pasted/dropped inline, emoji), plain-text mode, recipient chips with autocomplete from **contacts, the directory (GAL) and recent recipients**, multiple identities with HTML signatures, Cc/Bcc, priority, read-receipt request, templates/canned responses, attachment upload with progress, drag & drop, attachment reminder, **undo send**, autosaved drafts, reply/reply-all/forward with quoting and inline images preserved +- Live updates via JMAP push (EventSource proxied server-side) with polling fallback; desktop notifications, sound, title/favicon unread badge +- A–Z folder list with Inbox pinned on top (other special folders mixed in), subfolders nested and collapsed by default, folder management (create/rename/hide/share/empty), quota bar, Outlook-style module bar (Mail · Calendar · Contacts · Files) at the bottom of the pane, multi-account switching for shared accounts -## Quick Start (Docker) +**Calendar** (JMAP Calendars / JSCalendar) +- Month / week / day / agenda views, mini calendar, multiple calendars with colours, show/hide, create/edit/share calendars +- Create events by click or drag, edit everything: all-day, time zones, recurrence (presets + custom rule builder), location, meeting link, description, reminders, status/privacy/free-busy, colour +- Attendees with invitations (`sendSchedulingMessages`), RSVP, and **free/busy lookup** via `Principal/getAvailability` +- **Right-click menus** on events (open, edit, duplicate, colour, category, delete) and on empty slots/days (new event here, go to day/week) +- **Outlook-style colour categories**: named colours managed in Settings, assigned from the context menu or editor; stored as JSCalendar `categories` (+ `color`) so they sync + +**Contacts** (JMAP Contacts / JSContact) +- Address books (create/rename/share/default), contact list with search and letter index, full contact editor (names, emails, phones, addresses, org/title, birthday, website, notes, photo), **groups**, vCard import/export, compose-to-contact + +**Files** (JMAP FileNode) +- Browse folders, upload (drag & drop), download, create folders, rename, move, delete + +**Settings** +- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings + +**Platform** +- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB +- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers + +## Architecture + +``` +browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ──(JMAP over HTTPS)──► Stalwart + React SPA • session cookie ⇄ Basic auth + JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image +``` + +- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates, vCard, …). +- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos. + +Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push. Features degrade gracefully when a capability is missing. + +## Quick start (Docker) ```bash -# 1) Configure environment cp .env.example .env -# Edit JMAP_BASE, CALDAV_BASE, WEBDAV_BASE, APP_SECRET - -# 2) Build & run +# edit: STALWART_URL=https://mail.example.com and APP_SECRET=$(openssl rand -base64 48) docker compose up --build -d - -# 3) Reverse proxy (Nginx/Caddy) to http://127.0.0.1:8080 +# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf) ``` -## Environment Variables -- `APP_SECRET` – random string for signing cookies (required) -- `JMAP_BASE` – e.g., `https://mail.example.com/jmap` -- `CALDAV_BASE` – e.g., `https://mail.example.com/caldav/` -- `WEBDAV_BASE` – e.g., `https://mail.example.com/webdav/` -- `COOKIE_NAME` – cookie name (default: `stalwart_webmail`) -- `TRUST_PROXY` – `1` to honor `X-Forwarded-*` (default: `1`) -- `UPSTREAM_TIMEOUT` – seconds for upstream HTTP (default: `15`) +Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`). + +## Development + +Requirements: Node ≥ 20.10 (22 recommended), npm ≥ 10. -## Dev ```bash -python -m venv .venv && source .venv/bin/activate -pip install -e ".[dev]" -uvicorn app.main:app --reload -pytest +npm install + +# against a real Stalwart (default: mail.example.com, change STALWART_URL in .env or the environment) +npm run dev # server on :8080 (tsx watch) + Vite dev server on :5173 (proxying /api) + +# against the built-in mock Stalwart (demo@example.com / demo) — no real mailbox needed +npm run dev:mock # mock on :8788, server on :8080, Vite on :5173 + +npm run typecheck # tsc for both packages +npm test # vitest (web) + node:test (server) +npm run build # web/dist + server/dist +npm start # serve the production build ``` -## Security & Hardening -- Prefer **bearer tokens** if Stalwart issues them; update `jmap_session()` to store `accessToken` -- Set explicit `accountId` from the JMAP session `primaryAccounts` -- Add mailbox/folder navigation via `Mailbox/query` + `Mailbox/get` -- Sanitize HTML bodies (e.g., `bleach`) before rendering -- Add Sieve UI via `urn:ietf:params:jmap:sieve` -- Consider rate limiting and security headers in the reverse proxy -- Serve static assets via proxy/CDN +Open http://localhost:5173 in dev (or http://localhost:8080 for the production build). + +## Configuration + +All configuration is via environment variables (see `.env.example`): + +| Variable | Default | Description | +| --- | --- | --- | +| `STALWART_URL` | `https://mail.example.com` | Base URL of Stalwart; the JMAP session is discovered at `/.well-known/jmap` | +| `APP_SECRET` | *(required in production)* | Secret used to derive session encryption keys | +| `PORT` / `HOST` | `8080` / `0.0.0.0` | Listen address | +| `TRUST_PROXY` | `1` | Honour `X-Forwarded-*` from a reverse proxy | +| `SECURE_COOKIES` | `auto` | `auto` (Secure on https), `1`, or `0` for plain-HTTP dev | +| `SESSION_TTL` / `SESSION_REMEMBER_TTL` | `43200` / `2592000` | Idle session lifetime (seconds), with/without "keep me signed in" | +| `SESSION_FILE` | *(unset)* | Persist sessions across restarts (ciphertext only) | +| `IMAGE_PROXY` | `1` | Route remote images through the privacy proxy | +| `MAX_UPLOAD_BYTES` | `52428800` | Upload size limit (Stalwart has its own limit too) | +| `APP_NAME` | `ihasmail` | Branding | + +## Keyboard shortcuts + +Press `?` anywhere. Highlights: `c` compose · `/` search · `j`/`k` navigate · `o`/`Enter` open · `u` back · `e` archive · `#` delete · `!` spam · `s` star · `r`/`a`/`f` reply/reply-all/forward · `v` move · `l` label · `x` select · `⇧I`/`⇧U` read/unread · `g i` inbox · `g l` calendar · `g c` contacts · `Ctrl+Enter` send. + +## Known issues / pending QA + +Verified against the mock server and, for the core mail flows, against a live Stalwart 1.0 (`mail.example.com`). Still pending live verification: + +- **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server. +- **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass. +- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet). + +## Roadmap / not yet + +- Snooze and scheduled send (needs server-side support) +- Read-receipt (MDN) sending, S/MIME / OpenPGP +- Self-service password / app-password / 2FA management (Stalwart exposes this through its own account portal) +- Translations (strings are English-only for now) ## License -GPL-3.0-or-later + +GPL-3.0-or-later. See [LICENSE](LICENSE). diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/config.py b/app/config.py deleted file mode 100644 index 74abbb2..0000000 --- a/app/config.py +++ /dev/null @@ -1,9 +0,0 @@ -import os, secrets - -APP_SECRET = os.getenv("APP_SECRET") or secrets.token_urlsafe(32) -COOKIE_NAME = os.getenv("COOKIE_NAME", "stalwart_webmail") -JMAP_BASE = os.getenv("JMAP_BASE", "https://mail.example.com/jmap") -CALDAV_BASE = os.getenv("CALDAV_BASE", "https://mail.example.com/caldav/") -WEBDAV_BASE = os.getenv("WEBDAV_BASE", "https://mail.example.com/webdav/") -TRUST_PROXY = os.getenv("TRUST_PROXY", "1") == "1" -UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "15")) diff --git a/app/dav.py b/app/dav.py deleted file mode 100644 index 1d315ab..0000000 --- a/app/dav.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import List, Dict, Any, Tuple, Optional -import httpx -from urllib.parse import urljoin -from . import config - -DAV_PROPFIND = """ - - - - - - -""" - -async def propfind(ac: httpx.AsyncClient, base: str, path: Optional[str], auth: Tuple[str,str]) -> List[Dict[str, Any]]: - href = urljoin(base, path or "/") - r = await ac.request("PROPFIND", href, content=DAV_PROPFIND, headers={"Depth": "1"}, auth=auth) - if r.status_code not in (207, 200): - raise RuntimeError(f"WebDAV error {r.status_code}") - import xml.etree.ElementTree as ET - tree = ET.fromstring(r.text) - ns = {"d":"DAV:"} - items: List[Dict[str, Any]] = [] - for resp in tree.findall("d:response", ns): - href_el = resp.find("d:href", ns) - prop = resp.find("d:propstat/d:prop", ns) - if href_el is None or prop is None: - continue - name = prop.find("d:displayname", ns) - cl = prop.find("d:getcontentlength", ns) - rtype = prop.find("d:resourcetype", ns) - is_collection = rtype is not None and rtype.find("d:collection", ns) is not None - items.append({ - "href": href_el.text, - "name": (name.text if name is not None and name.text else href_el.text.rstrip("/").split("/")[-1] or "/"), - "type": "directory" if is_collection else "file", - "size": int(cl.text) if (cl is not None and cl.text and cl.text.isdigit()) else None - }) - if items: - items = items[1:] - return items diff --git a/app/jmap.py b/app/jmap.py deleted file mode 100644 index 975a79d..0000000 --- a/app/jmap.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Any, Dict, List, Tuple -import httpx -from . import config - -def client() -> httpx.AsyncClient: - limits = httpx.Limits(max_connections=20, max_keepalive_connections=10) - return httpx.AsyncClient(timeout=config.UPSTREAM_TIMEOUT, limits=limits, trust_env=True) - -async def get_session(ac: httpx.AsyncClient, base: str, username: str, password: str) -> Dict[str, Any]: - r = await ac.get(base, auth=(username, password)) - if r.status_code == 401: - raise PermissionError("Invalid credentials") - r.raise_for_status() - return r.json() - -async def call(ac: httpx.AsyncClient, api_url: str, auth: Tuple[str,str] | None, method_calls: List[list]) -> Dict[str, Any]: - payload = { - "using": [ - "urn:ietf:params:jmap:core", - "urn:ietf:params:jmap:mail", - "urn:ietf:params:jmap:contacts", - "urn:ietf:params:jmap:calendars" - ], - "methodCalls": method_calls - } - kwargs: Dict[str, Any] = {"json": payload} - if auth: - kwargs["auth"] = auth - r = await ac.post(api_url, **kwargs) - r.raise_for_status() - return r.json() diff --git a/app/main.py b/app/main.py deleted file mode 100644 index a9d13e4..0000000 --- a/app/main.py +++ /dev/null @@ -1,34 +0,0 @@ -import bleach -from fastapi import FastAPI, Request -from fastapi.staticfiles import StaticFiles -from starlette.middleware.sessions import SessionMiddleware -from starlette.middleware.proxy_headers import ProxyHeadersMiddleware -from . import config -from .routes import auth, mail, contacts, calendar, webdav, sieve - -app = FastAPI(title="Stalwart Webmail (Python)") - -if config.TRUST_PROXY: - app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") - -app.add_middleware(SessionMiddleware, secret_key=config.APP_SECRET, session_cookie=config.COOKIE_NAME, same_site="lax", https_only=True) - -app.mount("/static", StaticFiles(directory="app/static"), name="static") - -@app.get("/", include_in_schema=False) -async def root(request: Request): - from fastapi.responses import RedirectResponse - return RedirectResponse("/mail" if request.session.get("user") else "/login") - -# Routers -app.include_router(auth.router) -app.include_router(mail.router) -app.include_router(contacts.router) -app.include_router(calendar.router) -app.include_router(webdav.router) -app.include_router(sieve.router) - - -@app.get("/healthz", include_in_schema=False) -async def healthz(): - return {"ok": True} diff --git a/app/routes/__init__.py b/app/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/routes/auth.py b/app/routes/auth.py deleted file mode 100644 index 7989108..0000000 --- a/app/routes/auth.py +++ /dev/null @@ -1,45 +0,0 @@ -from fastapi import APIRouter, Request, Form, HTTPException -from fastapi.responses import RedirectResponse, HTMLResponse -from starlette.middleware.sessions import SessionMiddleware -from starlette.responses import PlainTextResponse -from .. import config, jmap -from fastapi.templating import Jinja2Templates -from jinja2 import FileSystemLoader, Environment, select_autoescape -import pathlib, base64, os - -router = APIRouter() - -templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates")) - -def make_csrf(session: dict) -> str: - token = base64.urlsafe_b64encode(os.urandom(24)).decode() - session["csrf"] = token - return token - -def check_csrf(session: dict, token: str): - if not token or token != session.get("csrf"): - raise HTTPException(status_code=400, detail="CSRF token invalid") - -@router.get("/login", response_class=HTMLResponse) -async def login_form(request: Request): - csrf = make_csrf(request.session) - return templates.TemplateResponse("login.html", {"request": request, "csrf": csrf, "jmap_base": config.JMAP_BASE}) - -@router.post("/login") -async def login_submit(request: Request, username: str = Form(...), password: str = Form(...), jmap_base: str = Form(...), csrf: str = Form(...)): - check_csrf(request.session, csrf) - async with jmap.client() as ac: - try: - session = await jmap.get_session(ac, jmap_base, username, password) - except PermissionError: - raise HTTPException(status_code=401, detail="Invalid credentials") - api_url = session.get("apiUrl") or jmap_base - download_url = session.get("downloadUrl") or "" - primary = session.get("primaryAccounts") or {} - request.session["user"] = {"username": username, "jmap_base": jmap_base, "api_url": api_url, "auth": (username, password), "download_url": download_url, "primary": primary, "session": session} - return RedirectResponse("/mail", status_code=303) - -@router.get("/logout") -async def logout(request: Request): - request.session.clear() - return RedirectResponse("/login", status_code=303) diff --git a/app/routes/calendar.py b/app/routes/calendar.py deleted file mode 100644 index 115855c..0000000 --- a/app/routes/calendar.py +++ /dev/null @@ -1,39 +0,0 @@ -from fastapi import APIRouter, Request, Depends, HTTPException -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -import pathlib, datetime -from .. import jmap -from ..utils import fmt_when - -router = APIRouter() -templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates")) - -def require_user(request: Request): - user = request.session.get("user") - if not user: - raise HTTPException(status_code=401) - return user - -@router.get("/calendar", response_class=HTMLResponse) -async def calendar(request: Request, user=Depends(require_user)): - async with jmap.client() as ac: - api = user["api_url"] - account_id = None - now = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) - until = now + datetime.timedelta(days=30) - res = await jmap.call(ac, api, tuple(user["auth"]), [ - ["CalendarEvent/query", {"accountId": account_id, "limit": 200, "sort":[{"property":"start","isAscending": True}]}, "q1"], - ["CalendarEvent/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"CalendarEvent/query","path":"ids"}, "properties":["id","title","start","end","location"]}, "g1"] - ]) - events = [] - for name, data, _ in res.get("methodResponses", []): - if name == "CalendarEvent/get": - for e in data.get("list", []): - try: - s = datetime.datetime.fromisoformat((e.get("start") or "").replace("Z","+00:00")) - if s < now - datetime.timedelta(days=1) or s > until: - continue - except Exception: - pass - events.append({"title": e.get("title") or "(no title)", "start": fmt_when(e.get("start")), "end": fmt_when(e.get("end")), "loc": e.get("location")}) - return templates.TemplateResponse("calendar.html", {"request": request, "events": events, "user": user}) diff --git a/app/routes/contacts.py b/app/routes/contacts.py deleted file mode 100644 index 222aa7b..0000000 --- a/app/routes/contacts.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Optional -from fastapi import APIRouter, Request, Depends, HTTPException -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -import pathlib -from .. import jmap - -router = APIRouter() -templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates")) - -def require_user(request: Request): - user = request.session.get("user") - if not user: - raise HTTPException(status_code=401) - return user - -@router.get("/contacts", response_class=HTMLResponse) -async def contacts(request: Request, q: Optional[str] = None, user=Depends(require_user)): - async with jmap.client() as ac: - api = user["api_url"] - account_id = None - filter_cond = {"text": q} if q else {} - res = await jmap.call(ac, api, tuple(user["auth"]), [ - ["Contact/query", {"accountId": account_id, "filter": filter_cond, "limit": 100}, "c1"], - ["Contact/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Contact/query","path":"ids"}, "properties":["id","firstName","lastName","emails","company"]}, "c2"] - ]) - contacts = [] - for name, data, _ in res.get("methodResponses", []): - if name == "Contact/get": - for c in data.get("list", []): - emails = [e.get("email","") for e in (c.get("emails") or [])] - contacts.append({"name": f"{c.get('firstName','')} {c.get('lastName','')}".strip() or (emails[0] if emails else ""), - "email": ", ".join(emails), - "org": c.get("company")}) - return templates.TemplateResponse("contacts.html", {"request": request, "contacts": contacts, "q": q, "user": user}) diff --git a/app/routes/mail.py b/app/routes/mail.py deleted file mode 100644 index 8e076c7..0000000 --- a/app/routes/mail.py +++ /dev/null @@ -1,316 +0,0 @@ -import json -import io -import bleach -from typing import Optional -from fastapi import APIRouter, Request, Depends, HTTPException, Form, UploadFile, File -from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse -from fastapi.templating import Jinja2Templates -import pathlib -from .. import jmap -from ..utils import human_size, fmt_when - -router = APIRouter() -templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates")) - -def require_user(request: Request): - user = request.session.get("user") - if not user: - raise HTTPException(status_code=401) - return user - -def make_csrf(session: dict) -> str: - import os, base64 - token = base64.urlsafe_b64encode(os.urandom(24)).decode() - session["csrf"] = token - return token - -def check_csrf(session: dict, token: str): - if not token or token != session.get("csrf"): - raise HTTPException(status_code=400, detail="CSRF token invalid") - -@router.get("/mail", response_class=HTMLResponse) -async def inbox(request: Request, q: Optional[str] = None, mailbox: Optional[str] = None, user=Depends(require_user)): - async with jmap.client() as ac: - api = user["api_url"] - primary = user.get("primary", {}) - account_id = primary.get("urn:ietf:params:jmap:mail") - boxes, inbox_id = await get_mailboxes(ac, api, tuple(user["auth"]), account_id) - box_id = mailbox or inbox_id - filt = {"text": q} if q else ({"inMailbox": box_id} if box_id else {}) - res = await jmap.call(ac, api, tuple(user["auth"]), [ - ["Email/query", {"accountId": account_id, "filter": filt, "sort": [{"property":"receivedAt","isAscending": False}], "limit": 50}, "c1"], - ["Email/get", {"accountId": account_id, "#ids": {"resultOf":"c1","name":"Email/query","path":"ids"}, "properties": ["id","subject","from","size","receivedAt"]}, "c2"] - ]) - emails = [] - - for name, data, _ in res.get("methodResponses", []): - if name == "Email/get": - for e in data.get("list", []): - from_str = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])]) - emails.append({"id": e["id"], "subject": e.get("subject") or "(no subject)", "from": from_str, "when": fmt_when(e.get("receivedAt")), "size": human_size(e.get("size"))}) - return templates.TemplateResponse("mail.html", {"request": request, "messages": emails, "q": q, "user": user, "mailboxes": boxes, "selected": box_id}) - -@router.get("/mail/{email_id}", response_class=HTMLResponse) -async def read_message(request: Request, email_id: str, user=Depends(require_user)): - async with jmap.client() as ac: - api = user["api_url"] - res = await jmap.call(ac, api, tuple(user["auth"]), [ - ["Email/get", {"ids": [email_id], "properties": ["id","subject","from","to","receivedAt","size","keywords","preview","bodyStructure","htmlBody","textBody"]}, "c1"] - ]) - msg = {"id": email_id, "subject":"", "from":"", "to":[], "when":"", "textBody":"", "htmlBody":"", "attachments":[]} - bstruct = None - cid_map = {} - for name, data, _ in res.get("methodResponses", []): - if name == "Email/get": - lst = data.get("list", []) - if lst: - e = lst[0] - msg["subject"] = e.get("subject") or msg["subject"] - msg["from"] = ", ".join([a.get("name") or a.get("email","") for a in (e.get("from") or [])]) or msg["from"] - msg["to"] = [a.get("email","") for a in (e.get("to") or [])] or msg["to"] - msg["when"] = fmt_when(e.get("receivedAt")) or msg["when"] - if "textBody" in e: - msg["textBody"] = e.get("textBody") or msg["textBody"] - if "htmlBody" in e: - raw_html = e.get("htmlBody") - if raw_html: - msg["htmlBody"] = bleach.clean(raw_html, tags=bleach.sanitizer.ALLOWED_TAGS.union({"p","span","div","br","hr","pre","code","blockquote","ul","ol","li","table","thead","tbody","tr","th","td","img","a","b","i","strong","em"}), attributes={"a":["href","title"],"img":["src","alt","title","width","height"]}, strip=True) - bstruct = bstruct or e.get("bodyStructure") - def walk_cid(bs): - if not isinstance(bs, dict): return - cid = bs.get("cid") - if cid and bs.get("blobId"): - cid_map[cid.strip("<>")] = {"blobId": bs["blobId"], "name": bs.get("name") or "inline"} - for p in bs.get("subParts", []) or []: - walk_cid(p) - if bstruct: - walk_cid(bstruct) - - def walk_bs(bs, out): - if not isinstance(bs, dict): return - if bs.get("disposition") == "attachment": - out.append({"name": bs.get("name") or "attachment", "type": bs.get("type") or "application/octet-stream", "size": bs.get("size"), "blobId": bs.get("blobId")}) - for p in bs.get("subParts", []) or []: - walk_bs(p, out) - att = [] - walk_bs(bstruct, att) - msg["attachments"] = att - # Inline CID images via internal route - if msg.get("htmlBody") and cid_map: - import re as _re - def _repl(m): - cid = m.group(1) - return f'src="/mail/{email_id}/cid/{cid}"' - msg["htmlBody"] = _re.sub(r'src=\"cid:([^\"]+)\"', _repl, msg["htmlBody"]) # cid_rewrite - return templates.TemplateResponse("message.html", {"request": request, "msg": msg, "user": user}) - -@router.get("/compose", response_class=HTMLResponse) -async def compose_form(request: Request, user=Depends(require_user)): - csrf = make_csrf(request.session) - return templates.TemplateResponse("compose.html", {"request": request, "csrf": csrf, "user": user}) - -@router.post("/compose") -async def compose_send(request: Request, to: str = Form(...), subject: str = Form(""), body: str = Form(""), csrf: str = Form(...), action: str = Form("send"), files: list[UploadFile] = File(default=[]), user=Depends(require_user)): - check_csrf(request.session, csrf) - async with jmap.client() as ac: - api = user["api_url"] - primary = user.get("primary", {}) - account_id = primary.get("urn:ietf:params:jmap:mail") - # Upload attachments if any - upload_url = user.get("upload_url") - blobs = [] - form = await request.form() - for k, v in form.multi_items(): - if k == 'preblob': - try: - b = json.loads(v) - if b.get('blobId'): blobs.append(b) - except Exception: - pass - if files: - for f in files: - data = await f.read() - if upload_url: - url = upload_url.replace("{accountId}", account_id or "") - ru = await ac.post(url, content=data, headers={"Content-Type": f.content_type or "application/octet-stream"}, auth=tuple(user["auth"])) - ru.raise_for_status() - up = ru.json() - blobs.append({"blobId": up.get("blobId"), "type": f.content_type or "application/octet-stream", "name": f.filename, "size": len(data)}) - email_creation_id = "k1" - submission_creation_id = "k2" - create_email = { - "accountId": account_id, - "create": { - email_creation_id: { - "mailboxIds": {}, - "from": [{"email": user["username"]}], - "to": [{"email": x.strip()} for x in to.split(",") if x.strip()], - "subject": subject, - "textBody": body, - "attachments": [{"blobId": b["blobId"], "type": b["type"], "name": b["name"]} for b in blobs] - } - } - } - # Move to Drafts if requested, else submit and move to Sent - special = await get_special_mailboxes(ac, api, tuple(user["auth"]), account_id) - sent_id = special.get("sent") - drafts_id = special.get("drafts") - calls = [] - calls.append(["Email/set", create_email, "s1"]) - if action == "draft": - if drafts_id: - calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {drafts_id: True}}}}, "sdraft"]) - else: - calls.append(["EmailSubmission/set", {"accountId": account_id, "create": {submission_creation_id: {"emailId": {"resultOf":"s1","name":"Email/set","path": f"created/{email_creation_id}/id"}}}}, "s2"]) - if sent_id: - calls.append(["Email/set", {"accountId": account_id, "onSuccessUpdateEmail": {"#kEmail": {"mailboxIds": {sent_id: True}}}}, "ssent"]) - await jmap.call(ac, api, tuple(user["auth"]), calls) - return RedirectResponse("/mail", status_code=303) - -async def get_mailboxes(ac, api, auth, account_id): - res = await jmap.call(ac, api, auth, [ - ["Mailbox/query", {"accountId": account_id, "sort":[{"property":"sortOrder","isAscending": True},{"property":"name","isAscending": True}], "limit": 200}, "q1"], - ["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","name","role","totalEmails","unreadEmails"]}, "g1"] - ]) - boxes = [] - inbox_id = None - for name, data, _ in res.get("methodResponses", []): - if name == "Mailbox/get": - for b in data.get("list", []): - boxes.append({"id": b["id"], "name": b.get("name",""), "role": b.get("role"), "total": b.get("totalEmails",0), "unread": b.get("unreadEmails",0)}) - if b.get("role") == "inbox": - inbox_id = b["id"] - return boxes, inbox_id or (boxes[0]["id"] if boxes else None) - -@router.get("/mail/{email_id}/attach/{index}") -async def download_attachment(request: Request, email_id: str, index: int, user=Depends(require_user)): - atts = request.query_params.get("atts") - # Re-fetch message to resolve bodyStructure (simple approach; could cache) - async with jmap.client() as ac: - api = user["api_url"] - res = await jmap.call(ac, api, tuple(user["auth"]), [ - ["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"] - ]) - bstruct = None - cid_map = {} - for name, data, _ in res.get("methodResponses", []): - if name == "Email/get": - lst = data.get("list", []) - if lst: - bstruct = lst[0].get("bodyStructure") - parts = [] - def walk(bs, out): - if not isinstance(bs, dict): return - if bs.get("disposition") == "attachment": - out.append(bs) - for p in bs.get("subParts", []) or []: - walk(p, out) - walk(bstruct, parts) - if index < 0 or index >= len(parts): - raise HTTPException(status_code=404, detail="Attachment not found") - p = parts[index] - blob = p.get("blobId") - name = p.get("name") or "attachment" - ctype = p.get("type") or "application/octet-stream" - - # Build download URL from session template - tmpl = user.get("download_url") or "" - primary = user.get("primary", {}) - account_id = primary.get("urn:ietf:params:jmap:mail") - url = tmpl - if "{accountId}" in url: - url = url.replace("{accountId}", account_id or "") - if "{blobId}" in url: - url = url.replace("{blobId}", blob or "") - if "{name}" in url: - from urllib.parse import quote - url = url.replace("{name}", quote(name)) - # Fallback naive pattern if template missing - if not url or "{" in url: - from urllib.parse import urljoin, quote - base = user.get("jmap_base") - url = urljoin(base, f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}") - - async with jmap.client() as ac: - r = await ac.get(url, auth=tuple(user["auth"])) - r.raise_for_status() - return StreamingResponse(io.BytesIO(r.content), media_type=ctype, headers={"Content-Disposition": f'attachment; filename="{name}"'}) - - -async def get_special_mailboxes(ac, api, auth, account_id): - res = await jmap.call(ac, api, auth, [ - ["Mailbox/query", {"accountId": account_id, "limit": 200}, "q1"], - ["Mailbox/get", {"accountId": account_id, "#ids": {"resultOf":"q1","name":"Mailbox/query","path":"ids"}, "properties":["id","role","name"]}, "g1"] - ]) - sent_id = drafts_id = inbox_id = None - boxes = {} - for name, data, _ in res.get("methodResponses", []): - if name == "Mailbox/get": - for b in data.get("list", []): - boxes[b["id"]] = b - role = b.get("role") - if role == "sent": sent_id = b["id"] - if role == "drafts": drafts_id = b["id"] - if role == "inbox": inbox_id = b["id"] - return {"sent": sent_id, "drafts": drafts_id, "inbox": inbox_id, "all": boxes} - - -@router.get("/mail/{email_id}/cid/{cid}") -async def fetch_cid(request: Request, email_id: str, cid: str, user=Depends(require_user)): - # Walk bodyStructure to find matching cid, then download via downloadUrl - async with jmap.client() as ac: - api = user["api_url"] - res = await jmap.call(ac, api, tuple(user["auth"]), [ - ["Email/get", {"ids": [email_id], "properties": ["bodyStructure"]}, "c1"] - ]) - bstruct = None - for name, data, _ in res.get("methodResponses", []): - if name == "Email/get": - lst = data.get("list", []) - if lst: - bstruct = lst[0].get("bodyStructure") - target = None - def walk(bs): - nonlocal target - if not isinstance(bs, dict) or target is not None: return - if bs.get("cid") and bs.get("cid").strip("<>") == cid: - target = bs - return - for p in bs.get("subParts", []) or []: - walk(p) - walk(bstruct) - if not target: - raise HTTPException(status_code=404, detail="Inline part not found") - blob = target.get("blobId") - ctype = target.get("type") or "application/octet-stream" - name = target.get("name") or "inline" - tmpl = user.get("download_url") or "" - primary = user.get("primary", {}) - account_id = primary.get("urn:ietf:params:jmap:mail") - from urllib.parse import quote, urljoin - if tmpl and "{accountId}" in tmpl and "{blobId}" in tmpl: - url = tmpl.replace("{accountId}", account_id or "").replace("{blobId}", blob or "") - if "{name}" in url: - url = url.replace("{name}", quote(name)) - else: - url = urljoin(user.get("jmap_base"), f"/download/{quote(account_id or '')}/{quote(blob or '')}/{quote(name)}") - async with jmap.client() as ac: - r = await ac.get(url, auth=tuple(user["auth"])) - r.raise_for_status() - return StreamingResponse(io.BytesIO(r.content), media_type=ctype) - - -@router.post("/upload") -async def upload_file(request: Request, file: UploadFile = File(...), user=Depends(require_user)): - async with jmap.client() as ac: - primary = user.get("primary", {}) - account_id = user.get("active_account") or primary.get("urn:ietf:params:jmap:mail") - upload_url = user.get("upload_url") - if not upload_url or not account_id: - raise HTTPException(status_code=400, detail="Upload not available") - url = upload_url.replace("{accountId}", account_id) - data = await file.read() - r = await ac.post(url, content=data, headers={"Content-Type": file.content_type or "application/octet-stream"}, auth=tuple(user["auth"])) - r.raise_for_status() - up = r.json() - return JSONResponse({"blobId": up.get("blobId"), "type": file.content_type or "application/octet-stream", "name": file.filename, "size": len(data)}) diff --git a/app/routes/webdav.py b/app/routes/webdav.py deleted file mode 100644 index 807554e..0000000 --- a/app/routes/webdav.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Optional -from fastapi import APIRouter, Request, Depends, HTTPException -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -import pathlib -from .. import dav, jmap, config - -router = APIRouter() -templates = Jinja2Templates(directory=str(pathlib.Path(__file__).resolve().parent.parent / "templates")) - -def require_user(request: Request): - user = request.session.get("user") - if not user: - raise HTTPException(status_code=401) - return user - -@router.get("/webdav", response_class=HTMLResponse) -async def webdav_browse(request: Request, path: Optional[str]=None, user=Depends(require_user)): - async with jmap.client() as ac: - items = await dav.propfind(ac, config.WEBDAV_BASE, path, tuple(user["auth"])) - return templates.TemplateResponse("webdav.html", {"request": request, "items": items, "base": config.WEBDAV_BASE, "user": user}) diff --git a/app/static/css/style.css b/app/static/css/style.css deleted file mode 100644 index 7c3c50b..0000000 --- a/app/static/css/style.css +++ /dev/null @@ -1,30 +0,0 @@ -:root { color-scheme: light dark; --header-bg: #f6f7f9; --header-fg: #111; --card-bg: #fff; } -@media (prefers-color-scheme: dark) { :root { --header-bg: #0f172a; --header-fg: #e5e7eb; --card-bg: #0b1222; } } -body { margin:0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } -header, footer { padding: 10px 14px; border-bottom: 1px solid #4443; background: var(--header-bg); color: var(--header-fg); } -main { padding: 14px; max-width: 1100px; margin: 0 auto; } -nav a { margin-right: 12px; } -.btn { display:inline-block; padding:6px 10px; border:1px solid #6665; border-radius:8px; text-decoration:none; } -table { border-collapse: collapse; width: 100%; } -th, td { padding: 8px; border-bottom: 1px solid #6662; text-align: left; vertical-align: top; } -.muted { color: #888; } -input, textarea, select { padding:6px 8px; width:100%; box-sizing: border-box; } -form .row { display:grid; grid-template-columns: 160px 1fr; gap: 8px; align-items: center; margin-bottom:10px; } -.msg { cursor:pointer; } -.pill { display:inline-block; font-size:12px; padding:2px 6px; border:1px solid #6663; border-radius:999px; margin-right:6px;} -.nowrap { white-space: nowrap; } -.right { text-align:right; } -.toolbar { display:flex; gap:8px; align-items:center; margin:8px 0; } -.panel { border:1px solid #6663;padding:10px;border-radius:8px;margin:10px 0;white-space:pre-wrap } - -#dropzone{padding:16px;border:2px dashed #6665;border-radius:8px;text-align:center;margin:10px 0} - -.brand { display:flex; align-items:center; gap:10px; } -.brand .logo { height:28px; vertical-align:middle; } -.brand-link { text-decoration:none; color:inherit; } -header nav { margin-top:6px; } -.badge { display:inline-block; padding:0 6px; border-radius:10px; font-size:12px; background:#6662; margin-left:6px; } - -.card{background:var(--card-bg); border:1px solid #6663; border-radius:12px; padding:18px; box-shadow:0 2px 6px #0001;} -.center{display:grid; place-items:center; min-height:60vh;} -.logo-lg{height:64px;} diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index 4b203e0..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - {{ title or "ihasmail" }} - - - - - - - - -
- - -
-
- {% block content %}{% endblock %} -
- - - diff --git a/app/templates/calendar.html b/app/templates/calendar.html deleted file mode 100644 index 25c71f1..0000000 --- a/app/templates/calendar.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Calendar (JMAP & CalDAV)

-

Listing upcoming events via JMAP. CalDAV endpoints available for DAV clients.

- - - {% for e in events %} - - - - - - {% endfor %} -
WhenSummaryWhere
{{ e.start }} – {{ e.end }}{{ e.title }}{{ e.loc or "" }}
-{% endblock %} diff --git a/app/templates/compose.html b/app/templates/compose.html deleted file mode 100644 index 151c8bd..0000000 --- a/app/templates/compose.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Compose

-
- -
-
-
- -
-{% endblock %} diff --git a/app/templates/contacts.html b/app/templates/contacts.html deleted file mode 100644 index cf2806e..0000000 --- a/app/templates/contacts.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Contacts (Directory via JMAP)

-
-
- -
-
- - - {% for c in contacts %} - - - - - - {% endfor %} -
NameEmailOrg
{{ c.name }}{{ c.email }}{{ c.org or "" }}
-{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html deleted file mode 100644 index c5d84d6..0000000 --- a/app/templates/login.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
ihasmail
-

Sign in

-
- -
- - -
-
- - -
-
- - -
- -
-
-

Credentials are sent to your JMAP server to obtain a session/auth token; they are not stored on the server.

-{% endblock %} diff --git a/app/templates/mail.html b/app/templates/mail.html deleted file mode 100644 index 3de29e6..0000000 --- a/app/templates/mail.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Inbox

-
-
- - -
- Compose -
- - - {% for m in messages %} - - - - - - - {% endfor %} -
WhenFromSubjectSize
{{ m.when }}{{ m.from }}{{ m.subject }}{{ m.size }}
-{% endblock %} diff --git a/app/templates/message.html b/app/templates/message.html deleted file mode 100644 index 6556c2c..0000000 --- a/app/templates/message.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

{{ msg.subject or "(no subject)" }}

-

From {{ msg.from }} To {{ msg.to|join(", ") }}

-

{{ msg.when }}

-{% if msg.htmlBody %} -
{{ (msg.htmlBody | safe) }}
-{% elif msg.textBody %} -
{{ msg.textBody }}
-{% else %} -
(no body)
-{% endif %} -
- Reply - Forward -
-{% if msg.attachments %} -

Attachments

- -{% endif %} -{% endblock %} diff --git a/app/templates/webdav.html b/app/templates/webdav.html deleted file mode 100644 index d2ed6d4..0000000 --- a/app/templates/webdav.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

WebDAV

-

Browsing {{ base }}

- - - {% for i in items %} - - - - - - {% endfor %} -
NameTypeSize
{{ i.name }}{{ i.type }}{% if i.size is not none %}{{ i.size }}{% endif %}
-{% endblock %} diff --git a/app/utils.py b/app/utils.py deleted file mode 100644 index f97bf2e..0000000 --- a/app/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -import datetime - -def human_size(n: int | None) -> str: - if n is None: return "" - units = ["B","KB","MB","GB","TB","PB"] - i = 0 - x = float(n) - while x >= 1024 and i < len(units)-1: - x /= 1024.0 - i += 1 - return f"{x:.0f} {units[i]}" - -def fmt_when(iso: str | None) -> str: - if not iso: return "" - try: - dt = datetime.datetime.fromisoformat(iso.replace("Z","+00:00")).astimezone() - return dt.strftime("%Y-%m-%d %H:%M") - except Exception: - return iso or "" diff --git a/charts/ihasmail/Chart.yaml b/charts/ihasmail/Chart.yaml deleted file mode 100644 index 9c39854..0000000 --- a/charts/ihasmail/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: ihasmail -description: ihasmail — JMAP webmail for Stalwart (FastAPI) -type: application -version: 0.1.0 -appVersion: "0.2.0" diff --git a/charts/ihasmail/templates/NOTES.txt b/charts/ihasmail/templates/NOTES.txt deleted file mode 100644 index 7bea5f9..0000000 --- a/charts/ihasmail/templates/NOTES.txt +++ /dev/null @@ -1,7 +0,0 @@ -Thanks for installing ihasmail! - -Get the service URL by running these commands: - export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ihasmail.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') - echo http://$SERVICE_IP:{{ .Values.service.port }}/ - -If using Ingress and DNS, browse to the configured host (e.g., https://ihasmail.example.com). diff --git a/charts/ihasmail/templates/_helpers.tpl b/charts/ihasmail/templates/_helpers.tpl deleted file mode 100644 index a8643fe..0000000 --- a/charts/ihasmail/templates/_helpers.tpl +++ /dev/null @@ -1,20 +0,0 @@ -{{- define "ihasmail.name" -}} -{{- .Chart.Name -}} -{{- end -}} - -{{- define "ihasmail.fullname" -}} -{{- .Release.Name | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{- define "ihasmail.labels" -}} -helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} -app.kubernetes.io/name: {{ include "ihasmail.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -app.kubernetes.io/version: {{ .Chart.AppVersion }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end -}} - -{{- define "ihasmail.selectorLabels" -}} -app.kubernetes.io/name: {{ include "ihasmail.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end -}} diff --git a/charts/ihasmail/templates/deployment.yaml b/charts/ihasmail/templates/deployment.yaml deleted file mode 100644 index dcf21ff..0000000 --- a/charts/ihasmail/templates/deployment.yaml +++ /dev/null @@ -1,60 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "ihasmail.fullname" . }} - labels: - {{- include "ihasmail.labels" . | nindent 4 }} -spec: - replicas: 1 - selector: - matchLabels: - {{- include "ihasmail.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "ihasmail.selectorLabels" . | nindent 8 }} - spec: - containers: - - name: app - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - env: - - name: APP_SECRET - valueFrom: - secretKeyRef: - name: {{ include "ihasmail.fullname" . }}-secret - key: APP_SECRET - - name: JMAP_BASE - value: {{ .Values.env.JMAP_BASE | quote }} - - name: CALDAV_BASE - value: {{ .Values.env.CALDAV_BASE | quote }} - - name: WEBDAV_BASE - value: {{ .Values.env.WEBDAV_BASE | quote }} - - name: COOKIE_NAME - value: {{ .Values.env.COOKIE_NAME | quote }} - - name: TRUST_PROXY - value: {{ .Values.env.TRUST_PROXY | quote }} - - name: UPSTREAM_TIMEOUT - value: {{ .Values.env.UPSTREAM_TIMEOUT | quote }} - ports: - - containerPort: 8000 - readinessProbe: - httpGet: - path: /healthz - port: 8000 - initialDelaySeconds: 5 - periodSeconds: 10 - livenessProbe: - httpGet: - path: /healthz - port: 8000 - initialDelaySeconds: 10 - periodSeconds: 20 ---- -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "ihasmail.fullname" . }}-secret -type: Opaque -stringData: - APP_SECRET: {{ .Values.env.APP_SECRET | quote }} diff --git a/charts/ihasmail/templates/ingress.yaml b/charts/ihasmail/templates/ingress.yaml deleted file mode 100644 index f3f99ba..0000000 --- a/charts/ihasmail/templates/ingress.yaml +++ /dev/null @@ -1,30 +0,0 @@ -{{- if .Values.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ include "ihasmail.fullname" . }} - {{- if .Values.ingress.className }} - annotations: - kubernetes.io/ingress.class: {{ .Values.ingress.className }} - {{- end }} -spec: - rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host }} - http: - paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ .pathType }} - backend: - service: - name: {{ include "ihasmail.fullname" $ }} - port: - number: {{ $.Values.service.port }} - {{- end }} - {{- end }} - {{- if .Values.ingress.tls }} - tls: - {{- toYaml .Values.ingress.tls | nindent 4 }} - {{- end }} -{{- end }} diff --git a/charts/ihasmail/templates/service.yaml b/charts/ihasmail/templates/service.yaml deleted file mode 100644 index 3c300bf..0000000 --- a/charts/ihasmail/templates/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "ihasmail.fullname" . }} - labels: - {{- include "ihasmail.labels" . | nindent 4 }} -spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: 8000 - protocol: TCP - name: http - selector: - {{- include "ihasmail.selectorLabels" . | nindent 4 }} diff --git a/charts/ihasmail/values.yaml b/charts/ihasmail/values.yaml deleted file mode 100644 index 3e37d9c..0000000 --- a/charts/ihasmail/values.yaml +++ /dev/null @@ -1,32 +0,0 @@ -image: - repository: ghcr.io/your-org/ihasmail - tag: latest - pullPolicy: IfNotPresent - -service: - type: ClusterIP - port: 8000 - -ingress: - enabled: false - className: "" - hosts: - - host: ihasmail.example.com - paths: - - path: / - pathType: Prefix - tls: [] - -env: - APP_SECRET: "CHANGE_ME" - JMAP_BASE: "https://mail.example.com/jmap" - CALDAV_BASE: "https://mail.example.com/caldav/" - WEBDAV_BASE: "https://mail.example.com/webdav/" - COOKIE_NAME: "ihasmail" - TRUST_PROXY: "1" - UPSTREAM_TIMEOUT: "15" - -resources: {} -nodeSelector: {} -tolerations: [] -affinity: {} diff --git a/docker-compose.yml b/docker-compose.yml index bd3e870..2c1ddbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,17 @@ services: ihasmail: build: . - image: ihasmail:latest - env_file: .env + image: ihasmail:2 restart: unless-stopped - networks: [edge] ports: - - "127.0.0.1:8080:8000" -networks: - edge: {} + - "8080:8080" + environment: + STALWART_URL: ${STALWART_URL:-https://mail.example.com} + APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} + APP_NAME: ${APP_NAME:-ihasmail} + TRUST_PROXY: "1" + IMAGE_PROXY: "1" + volumes: + - ihasmail-data:/data +volumes: + ihasmail-data: diff --git a/docs/screenshots/calendar.jpg b/docs/screenshots/calendar.jpg new file mode 100644 index 0000000..4479d06 Binary files /dev/null and b/docs/screenshots/calendar.jpg differ diff --git a/docs/screenshots/compose.jpg b/docs/screenshots/compose.jpg new file mode 100644 index 0000000..8df10f3 Binary files /dev/null and b/docs/screenshots/compose.jpg differ diff --git a/docs/screenshots/contacts.jpg b/docs/screenshots/contacts.jpg new file mode 100644 index 0000000..9b7ee0c Binary files /dev/null and b/docs/screenshots/contacts.jpg differ diff --git a/docs/screenshots/filters.jpg b/docs/screenshots/filters.jpg new file mode 100644 index 0000000..f20f98f Binary files /dev/null and b/docs/screenshots/filters.jpg differ diff --git a/docs/screenshots/inbox-dark.jpg b/docs/screenshots/inbox-dark.jpg new file mode 100644 index 0000000..87669ef Binary files /dev/null and b/docs/screenshots/inbox-dark.jpg differ diff --git a/docs/screenshots/inbox-light.jpg b/docs/screenshots/inbox-light.jpg new file mode 100644 index 0000000..fe2a2c1 Binary files /dev/null and b/docs/screenshots/inbox-light.jpg differ diff --git a/docs/screenshots/login.jpg b/docs/screenshots/login.jpg new file mode 100644 index 0000000..d466088 Binary files /dev/null and b/docs/screenshots/login.jpg differ diff --git a/docs/screenshots/mobile.jpg b/docs/screenshots/mobile.jpg new file mode 100644 index 0000000..aacfd4f Binary files /dev/null and b/docs/screenshots/mobile.jpg differ diff --git a/nginx.example.conf b/nginx.example.conf new file mode 100644 index 0000000..50b8989 --- /dev/null +++ b/nginx.example.conf @@ -0,0 +1,19 @@ +# Example nginx location block for ihasmail behind TLS termination. +server { + listen 443 ssl http2; + server_name mail.example.com; + # ssl_certificate ...; ssl_certificate_key ...; + + client_max_body_size 60m; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Server-Sent Events (push notifications) + proxy_buffering off; + proxy_read_timeout 3600s; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bb00554 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3845 @@ +{ + "name": "ihasmail", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ihasmail", + "version": "2.0.0", + "license": "GPL-3.0-or-later", + "workspaces": [ + "server", + "web" + ], + "devDependencies": { + "concurrently": "^9.1.2", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=20.10" + } + }, + "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/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@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-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.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "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/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.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "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.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@ihasmail/server": { + "resolved": "server", + "link": true + }, + "node_modules/@ihasmail/web": { + "resolved": "web", + "link": true + }, + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.10.tgz", + "integrity": "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", + "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "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/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/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/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/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "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.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "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/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "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": "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/chalk/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/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/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "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/concurrently": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "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/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": 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/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "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/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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/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/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-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/hono": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "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/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/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "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/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/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/lucide-react": { + "version": "0.477.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.477.0.tgz", + "integrity": "sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "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/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "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": "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/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "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": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "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/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/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "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/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": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "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/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "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/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "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/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/wouter": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.10.0.tgz", + "integrity": "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q==", + "license": "Unlicense", + "dependencies": { + "mitt": "^3.0.1", + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "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/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "server": { + "name": "@ihasmail/server", + "version": "2.0.0", + "dependencies": { + "@hono/node-server": "^1.13.8", + "hono": "^4.7.4" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "tsx": "^4.19.3", + "typescript": "^5.7.3" + } + }, + "web": { + "name": "@ihasmail/web", + "version": "2.0.0", + "dependencies": { + "@tanstack/react-virtual": "^3.13.2", + "dompurify": "^3.2.4", + "lucide-react": "^0.477.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "wouter": "^3.6.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vitest": "^3.0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c363e6 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "ihasmail", + "version": "2.0.0", + "private": true, + "description": "ihasmail \u2014 a fast, modern JMAP webmail for Stalwart Mail Server", + "license": "GPL-3.0-or-later", + "type": "module", + "workspaces": [ + "server", + "web" + ], + "engines": { + "node": ">=20.10" + }, + "scripts": { + "dev": "concurrently -n server,web -c blue,magenta \"npm run dev -w server\" \"npm run dev -w web\"", + "build": "npm run build -w web && npm run build -w server", + "start": "node server/dist/index.js", + "typecheck": "npm run typecheck -w web && npm run typecheck -w server", + "test": "npm run test -w web && npm run test -w server", + "lint": "npm run typecheck", + "mock": "npm run mock -w server", + "dev:mock": "concurrently -n mock,server,web -c yellow,blue,magenta \"npm run mock -w server\" \"STALWART_URL=http://127.0.0.1:8788 npm run dev -w server\" \"npm run dev -w web\"" + }, + "devDependencies": { + "concurrently": "^9.1.2", + "typescript": "^5.7.3" + } +} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index d4f37b1..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "ihasmail" -version = "0.2.0" -description = "ihasmail — JMAP webmail for Stalwart (FastAPI, HTMX/Jinja)" -authors = [{name = "John Coffey", email = "john@example.com"}] -readme = "README.md" -requires-python = ">=3.10" -license = {text = "GPL-3.0-or-later"} -dependencies = [ - "fastapi>=0.111", - "uvicorn[standard]>=0.30", - "httpx>=0.27", - "jinja2>=3.1", - "bleach>=6.1", - "python-multipart>=0.0.9", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.2", - "anyio>=4.4", - "httpx>=0.27", -] - -[tool.pytest.ini_options] -addopts = "-q" diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..a70f9d8 --- /dev/null +++ b/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ihasmail/server", + "version": "2.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch --clear-screen=false src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsx --test src/*.test.ts", + "mock": "tsx src/mock/index.ts" + }, + "dependencies": { + "@hono/node-server": "^1.13.8", + "hono": "^4.7.4" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "tsx": "^4.19.3", + "typescript": "^5.7.3" + } +} diff --git a/server/src/app.test.ts b/server/src/app.test.ts new file mode 100644 index 0000000..97aaefa --- /dev/null +++ b/server/src/app.test.ts @@ -0,0 +1,37 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +process.env.STALWART_URL = "http://127.0.0.1:1"; +const { createApp } = await import("./app.js"); + +test("CSRF guard rejects API POSTs without the custom header", async () => { + const app = createApp(); + const res = await app.request("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); + assert.equal(res.status, 403); +}); + +test("unauthenticated JMAP calls are rejected", async () => { + const app = createApp(); + const res = await app.request("/api/jmap", { method: "POST", headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" }); + assert.equal(res.status, 401); +}); + +test("cross-site fetches are rejected", async () => { + const app = createApp(); + const res = await app.request("/api/health", { headers: { "sec-fetch-site": "cross-site" } }); + assert.equal(res.status, 403); +}); + +test("health and security headers", async () => { + const app = createApp(); + const res = await app.request("/api/health"); + assert.equal(res.status, 200); + assert.equal(res.headers.get("x-content-type-options"), "nosniff"); + assert.equal(res.headers.get("x-frame-options"), "DENY"); +}); + +test("image proxy refuses private targets", async () => { + const app = createApp(); + // no session -> 401 first; so exercise the handler directly via a logged-in-less path is not possible; check the URL validation ordering instead + const res = await app.request("/api/image?url=http://127.0.0.1/x"); + assert.equal(res.status, 401); +}); diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..78a7828 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,404 @@ +import { Hono } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +import { getCookie, setCookie, deleteCookie } from "hono/cookie"; +import { getConnInfo } from "@hono/node-server/conninfo"; +import { config } from "./config.js"; +import { SessionStore, type LiveSession } from "./sessions.js"; +import { RateLimiter } from "./ratelimit.js"; +import { + UpstreamError, + absoluteUpstream, + expandTemplate, + fetchUpstreamSession, + forgetUpstreamSession, + getUpstreamSession, + localizeSession, +} from "./upstream.js"; +import { imageProxyHandler } from "./imageproxy.js"; +import { staticHandler } from "./static.js"; + +type Env = { Variables: { session: LiveSession } }; + +export const sessions = new SessionStore(config.sessionFile); +const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000); + +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-encoding", + "content-length", +]); + +export function clientIp(c: Context): string { + if (config.trustProxy) { + const xff = c.req.header("x-forwarded-for"); + if (xff) return xff.split(",")[0]!.trim(); + const realIp = c.req.header("x-real-ip"); + if (realIp) return realIp.trim(); + } + try { + return getConnInfo(c).remote.address ?? "unknown"; + } catch { + return "unknown"; + } +} + +function isSecureRequest(c: Context): boolean { + if (config.secureCookies === "1" || config.secureCookies === "true") return true; + if (config.secureCookies === "0" || config.secureCookies === "false") return false; + if (config.trustProxy) { + const proto = c.req.header("x-forwarded-proto"); + if (proto) return proto.split(",")[0]!.trim() === "https"; + } + return new URL(c.req.url).protocol === "https:"; +} + +/** Security headers for every response. */ +const securityHeaders: MiddlewareHandler = async (c, next) => { + await next(); + const h = c.res.headers; + h.set("X-Content-Type-Options", "nosniff"); + h.set("X-Frame-Options", "DENY"); + h.set("Referrer-Policy", "no-referrer"); + h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()"); + h.set("Cross-Origin-Opener-Policy", "same-origin"); + if (!h.has("Cache-Control")) h.set("Cache-Control", "no-store"); + if (isSecureRequest(c)) h.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); +}; + +/** CSRF: require our custom header on all API calls; reject cross-site fetches. */ +const csrfGuard: MiddlewareHandler = async (c, next) => { + const site = c.req.header("sec-fetch-site"); + if (site && site !== "same-origin" && site !== "none") { + return c.json({ error: "cross_site_request" }, 403); + } + if (c.req.method !== "GET" && c.req.method !== "HEAD") { + if (c.req.header("x-requested-with") !== "ihasmail") { + return c.json({ error: "missing_csrf_header" }, 403); + } + } + await next(); +}; + +const requireSession: MiddlewareHandler = async (c, next) => { + const cookie = getCookie(c, config.cookieName); + const session = sessions.resolve(cookie); + if (!session) { + return c.json({ error: "unauthenticated" }, 401); + } + c.set("session", session); + await next(); +}; + +function setSessionCookie(c: Context, value: string, remember: boolean) { + setCookie(c, config.cookieName, value, { + httpOnly: true, + sameSite: "Lax", + secure: isSecureRequest(c), + path: "/", + ...(remember ? { maxAge: config.sessionRememberTtl } : {}), + }); +} + +function upstreamFailure(c: Context, err: unknown) { + if (err instanceof UpstreamError) { + return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502); + } + const name = (err as Error)?.name ?? ""; + if (name === "TimeoutError" || name === "AbortError") { + return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504); + } + console.error("[ihasmail] upstream failure:", err); + return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502); +} + +export function createApp(): Hono { + const app = new Hono(); + app.use("*", securityHeaders); + + const api = new Hono(); + api.use("*", csrfGuard); + + api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" })); + + api.get("/config", (c) => + c.json({ + appName: config.appName, + imageProxy: config.imageProxy, + maxUploadBytes: config.maxUploadBytes, + }), + ); + + // ---------- Auth ---------- + api.post("/auth/login", async (c) => { + const ip = clientIp(c); + let body: { username?: string; password?: string; totp?: string; remember?: boolean }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "bad_request" }, 400); + } + const username = (body.username ?? "").trim(); + const password = body.password ?? ""; + const totp = (body.totp ?? "").trim(); + if (!username || !password) return c.json({ error: "missing_credentials" }, 400); + if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400); + + const limitKey = `${ip}|${username.toLowerCase()}`; + if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) { + c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey))); + return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429); + } + + // Stalwart accepts TOTP codes appended to the password as "password$123456". + const effectivePassword = totp ? `${password}$${totp}` : password; + const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`; + try { + const upstream = await fetchUpstreamSession(authorization); + loginLimiter.reset(limitKey); + const { cookie, session } = sessions.create({ + username, + password: effectivePassword, + remember: Boolean(body.remember), + userAgent: c.req.header("user-agent") ?? "", + ip, + }); + setSessionCookie(c, cookie, session.remember); + return c.json(localizeSession(upstream, sessionExtras(session))); + } catch (err) { + return upstreamFailure(c, err); + } + }); + + api.get("/auth/session", requireSession, async (c) => { + const session = c.get("session"); + try { + const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1"); + return c.json(localizeSession(upstream, sessionExtras(session))); + } catch (err) { + if (err instanceof UpstreamError && err.status === 401) { + sessions.destroy(session.id); + deleteCookie(c, config.cookieName, { path: "/" }); + } + return upstreamFailure(c, err); + } + }); + + api.post("/auth/logout", async (c) => { + const cookie = getCookie(c, config.cookieName); + const session = sessions.resolve(cookie); + if (session) { + sessions.destroy(session.id); + forgetUpstreamSession(session.id); + } + deleteCookie(c, config.cookieName, { path: "/" }); + return c.json({ ok: true }); + }); + + api.get("/auth/sessions", requireSession, (c) => { + const session = c.get("session"); + return c.json({ current: session.id, sessions: sessions.listForUser(session.username) }); + }); + + api.post("/auth/sessions/revoke-others", requireSession, (c) => { + const session = c.get("session"); + const n = sessions.destroyAllForUser(session.username, session.id); + return c.json({ revoked: n }); + }); + + // ---------- JMAP API proxy ---------- + api.post("/jmap", requireSession, async (c) => { + const session = c.get("session"); + const ct = c.req.header("content-type") ?? ""; + if (!ct.toLowerCase().startsWith("application/json")) { + return c.json({ error: "unsupported_media_type" }, 415); + } + try { + const upstream = await getUpstreamSession(session.id, session.authorization); + const res = await fetch(absoluteUpstream(upstream.apiUrl), { + method: "POST", + headers: { + authorization: session.authorization, + "content-type": "application/json", + accept: "application/json", + }, + body: c.req.raw.body, + duplex: "half", + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401) { + sessions.destroy(session.id); + forgetUpstreamSession(session.id); + deleteCookie(c, config.cookieName, { path: "/" }); + return c.json({ error: "unauthenticated" }, 401); + } + return passthrough(res); + } catch (err) { + return upstreamFailure(c, err); + } + }); + + // ---------- Blob upload ---------- + api.post("/upload/:accountId", requireSession, async (c) => { + const session = c.get("session"); + const accountId = c.req.param("accountId"); + const len = Number(c.req.header("content-length") ?? "0"); + if (len > config.maxUploadBytes) return c.json({ error: "too_large" }, 413); + try { + const upstream = await getUpstreamSession(session.id, session.authorization); + const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId })); + const res = await fetch(url, { + method: "POST", + headers: { + authorization: session.authorization, + "content-type": c.req.header("content-type") ?? "application/octet-stream", + accept: "application/json", + }, + body: c.req.raw.body, + duplex: "half", + signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)), + }); + return passthrough(res); + } catch (err) { + return upstreamFailure(c, err); + } + }); + + // ---------- Blob download ---------- + api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => { + const session = c.get("session"); + const { accountId, blobId, name } = c.req.param(); + const accept = c.req.query("accept") ?? "application/octet-stream"; + const inline = c.req.query("inline") === "1"; + try { + const upstream = await getUpstreamSession(session.id, session.authorization); + const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept })); + const res = await fetch(url, { + headers: { authorization: session.authorization }, + signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)), + }); + if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502); + const headers = new Headers(); + const type = sanitizeContentType(res.headers.get("content-type") ?? accept); + headers.set("Content-Type", type); + const cl = res.headers.get("content-length"); + if (cl) headers.set("Content-Length", cl); + const safeInline = inline && isInlineSafe(type); + headers.set( + "Content-Disposition", + `${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`, + ); + headers.set("X-Content-Type-Options", "nosniff"); + // Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render). + if (!(safeInline && type === "application/pdf")) { + headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"); + } + headers.set("Cache-Control", "private, max-age=3600"); + return new Response(res.body, { status: 200, headers }); + } catch (err) { + return upstreamFailure(c, err); + } + }); + + // ---------- Push (Server-Sent Events) ---------- + api.get("/events", requireSession, async (c) => { + const session = c.get("session"); + const types = c.req.query("types") ?? "*"; + const closeafter = c.req.query("closeafter") ?? "no"; + const ping = c.req.query("ping") ?? "30"; + try { + const upstream = await getUpstreamSession(session.id, session.authorization); + const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping })); + const controller = new AbortController(); + c.req.raw.signal.addEventListener("abort", () => controller.abort()); + const res = await fetch(url, { + headers: { authorization: session.authorization, accept: "text/event-stream" }, + signal: controller.signal, + }); + if (!res.ok || !res.body) return c.json({ error: "upstream_error" }, 502); + const headers = new Headers({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + return new Response(res.body, { status: 200, headers }); + } catch (err) { + return upstreamFailure(c, err); + } + }); + + // ---------- Remote image privacy proxy ---------- + api.get("/image", requireSession, imageProxyHandler); + + api.notFound((c) => c.json({ error: "not_found" }, 404)); + api.onError((err, c) => { + console.error("[ihasmail] api error:", err); + return c.json({ error: "internal_error" }, 500); + }); + + app.route("/api", api); + + // ---------- Static SPA ---------- + app.get("*", staticHandler(config.staticDir)); + return app; +} + +function sessionExtras(session: LiveSession) { + return { + ihasmail: { + appName: config.appName, + imageProxy: config.imageProxy, + maxUploadBytes: config.maxUploadBytes, + sessionId: session.id, + loginName: session.username, + remember: session.remember, + }, + }; +} + +function passthrough(res: Response): Response { + const headers = new Headers(); + res.headers.forEach((v, k) => { + if (!HOP_BY_HOP.has(k.toLowerCase())) headers.set(k, v); + }); + if (!headers.has("content-type")) headers.set("content-type", "application/json"); + headers.set("Cache-Control", "no-store"); + return new Response(res.body, { status: res.status, headers }); +} + +function sanitizeContentType(ct: string): string { + const lower = ct.split(";")[0]!.trim().toLowerCase(); + // Never let the browser render HTML/SVG/XML/JS served from the blob endpoint. + if ( + lower === "text/html" || + lower === "application/xhtml+xml" || + lower === "image/svg+xml" || + lower.includes("javascript") || + lower === "text/xml" || + lower === "application/xml" + ) { + return "application/octet-stream"; + } + if (lower.startsWith("text/")) return `${lower}; charset=utf-8`; + return lower || "application/octet-stream"; +} + +function isInlineSafe(type: string): boolean { + const t = type.split(";")[0]!.trim(); + return ( + (t.startsWith("image/") && t !== "image/svg+xml") || + t.startsWith("video/") || + t.startsWith("audio/") || + t === "application/pdf" || + t === "text/plain" || + t === "text/calendar" || + t === "text/vcard" + ); +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..be3e13f --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,81 @@ +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** Minimal .env loader (no dependency): first match wins, never overrides real env. */ +function loadDotEnv() { + const candidates = [resolve(process.cwd(), ".env"), fileURLToPath(new URL("../../.env", import.meta.url)), fileURLToPath(new URL("../.env", import.meta.url))]; + for (const file of candidates) { + if (!existsSync(file)) continue; + for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { + const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line); + if (!m || line.trim().startsWith("#")) continue; + let v = m[2]!; + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + if (process.env[m[1]!] === undefined) process.env[m[1]!] = v; + } + break; + } +} +loadDotEnv(); + +function env(name: string, fallback?: string): string { + const v = process.env[name]; + if (v === undefined || v === "") { + if (fallback === undefined) throw new Error(`Missing required environment variable ${name}`); + return fallback; + } + return v; +} + +function bool(name: string, fallback: boolean): boolean { + const v = process.env[name]; + if (v === undefined || v === "") return fallback; + return ["1", "true", "yes", "on"].includes(v.toLowerCase()); +} + +function int(name: string, fallback: number): number { + const v = process.env[name]; + if (v === undefined || v === "") return fallback; + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n)) throw new Error(`Invalid integer for ${name}: ${v}`); + return n; +} + +const isProd = process.env.NODE_ENV === "production"; +let appSecret = process.env.APP_SECRET ?? ""; +if (!appSecret || appSecret === "change-me") { + if (isProd) { + throw new Error("APP_SECRET must be set to a strong random value in production"); + } + appSecret = randomBytes(32).toString("base64"); + console.warn( + "[ihasmail] APP_SECRET not set - using an ephemeral secret (persisted sessions will not survive restarts)", + ); +} + +const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+$/, ""); + +export const config = { + isProd, + appName: env("APP_NAME", "ihasmail"), + host: env("HOST", "0.0.0.0"), + port: int("PORT", 8080), + stalwartUrl, + appSecret, + trustProxy: bool("TRUST_PROXY", true), + /** "auto" = Secure when the request arrived over https; "1"/"0" to force. */ + secureCookies: (process.env.SECURE_COOKIES ?? "auto").toLowerCase(), + sessionTtl: int("SESSION_TTL", 12 * 60 * 60), + sessionRememberTtl: int("SESSION_REMEMBER_TTL", 30 * 24 * 60 * 60), + sessionFile: process.env.SESSION_FILE ?? "", + upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000), + maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024), + imageProxy: bool("IMAGE_PROXY", true), + cookieName: env("COOKIE_NAME", "ihm_session"), + staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)), + loginRateLimit: int("LOGIN_RATE_LIMIT", 10), +}; + +export type Config = typeof config; diff --git a/server/src/crypto.ts b/server/src/crypto.ts new file mode 100644 index 0000000..69541f4 Binary files /dev/null and b/server/src/crypto.ts differ diff --git a/server/src/imageproxy.ts b/server/src/imageproxy.ts new file mode 100644 index 0000000..8f684b8 --- /dev/null +++ b/server/src/imageproxy.ts @@ -0,0 +1,121 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import type { Context } from "hono"; +import { config } from "./config.js"; + +const MAX_IMAGE_BYTES = 15 * 1024 * 1024; + +function isPrivateAddress(addr: string): boolean { + const v = isIP(addr); + if (v === 4) { + const [a, b] = addr.split(".").map(Number) as [number, number]; + if (a === 10 || a === 127 || a === 0) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a >= 224) return true; + return false; + } + if (v === 6) { + const lower = addr.toLowerCase(); + if (lower === "::1" || lower === "::") return true; + if (lower.startsWith("fe80") || lower.startsWith("fc") || lower.startsWith("fd")) return true; + if (lower.startsWith("::ffff:")) return isPrivateAddress(lower.slice(7)); + return false; + } + return true; +} + +/** + * Gmail-style remote content proxy: hides the reader's IP address and + * user-agent from tracking pixels, and blocks SSRF to internal networks. + */ +export async function imageProxyHandler(c: Context) { + if (!config.imageProxy) return c.json({ error: "disabled" }, 404); + const raw = c.req.query("url") ?? ""; + let url: URL; + try { + url = new URL(raw); + } catch { + return c.json({ error: "bad_url" }, 400); + } + if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400); + if (url.username || url.password) return c.json({ error: "bad_url" }, 400); + + // Resolve and refuse private targets. + try { + const host = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(host)) { + if (isPrivateAddress(host)) return c.json({ error: "forbidden_target" }, 403); + } else { + const addrs = await lookup(host, { all: true }); + if (!addrs.length || addrs.some((a) => isPrivateAddress(a.address))) { + return c.json({ error: "forbidden_target" }, 403); + } + } + } catch { + return c.json({ error: "dns_failure" }, 502); + } + + let res: Response; + try { + res = await fetch(url, { + redirect: "manual", + headers: { + accept: "image/avif,image/webp,image/*,*/*;q=0.8", + "user-agent": "Mozilla/5.0 (compatible; ihasmail-image-proxy)", + }, + signal: AbortSignal.timeout(15_000), + }); + // Follow a limited number of redirects manually, re-validating each hop. + let hops = 0; + while ([301, 302, 303, 307, 308].includes(res.status) && hops < 3) { + const loc = res.headers.get("location"); + if (!loc) break; + const next = new URL(loc, url); + if (next.protocol !== "http:" && next.protocol !== "https:") return c.json({ error: "bad_redirect" }, 400); + const host = next.hostname.replace(/^\[|\]$/g, ""); + if (isIP(host)) { + if (isPrivateAddress(host)) return c.json({ error: "forbidden_target" }, 403); + } else { + const addrs = await lookup(host, { all: true }); + if (!addrs.length || addrs.some((a) => isPrivateAddress(a.address))) { + return c.json({ error: "forbidden_target" }, 403); + } + } + res = await fetch(next, { + redirect: "manual", + headers: { accept: "image/*", "user-agent": "Mozilla/5.0 (compatible; ihasmail-image-proxy)" }, + signal: AbortSignal.timeout(15_000), + }); + hops++; + } + } catch { + return c.json({ error: "fetch_failed" }, 502); + } + if (!res.ok || !res.body) return c.json({ error: "fetch_failed" }, 502); + const type = (res.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); + if (!type.startsWith("image/") || type === "image/svg+xml") return c.json({ error: "not_image" }, 415); + const len = Number(res.headers.get("content-length") ?? "0"); + if (len > MAX_IMAGE_BYTES) return c.json({ error: "too_large" }, 413); + + // Enforce the size limit while streaming. + let total = 0; + const limiter = new TransformStream({ + transform(chunk, controller) { + total += chunk.byteLength; + if (total > MAX_IMAGE_BYTES) controller.error(new Error("too large")); + else controller.enqueue(chunk); + }, + }); + const headers = new Headers({ + "Content-Type": type, + "Cache-Control": "private, max-age=86400", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "sandbox; default-src 'none'", + "Cross-Origin-Resource-Policy": "same-origin", + }); + if (len) headers.set("Content-Length", String(len)); + return new Response(res.body.pipeThrough(limiter), { status: 200, headers }); +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..b62feef --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,27 @@ +import { serve } from "@hono/node-server"; +import { config } from "./config.js"; +import { createApp, sessions } from "./app.js"; + +async function main() { + await sessions.init(); + const app = createApp(); + const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => { + console.log(`[ihasmail] ${config.appName} listening on http://${info.address}:${info.port}`); + console.log(`[ihasmail] upstream Stalwart: ${config.stalwartUrl}`); + console.log(`[ihasmail] static dir: ${config.staticDir}`); + }); + + const shutdown = async (signal: string) => { + console.log(`[ihasmail] ${signal} received, shutting down`); + server.close(); + await sessions.close(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); +} + +main().catch((err) => { + console.error("[ihasmail] fatal:", err); + process.exit(1); +}); diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts new file mode 100644 index 0000000..1800719 --- /dev/null +++ b/server/src/mock/index.ts @@ -0,0 +1,434 @@ +/** + * A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail + * uses. For local development and demos only: `npm run mock` then point the + * server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo). + */ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; + +const PORT = Number(process.env.MOCK_PORT ?? 8788); +const ACCOUNT = "a1"; +const USER = process.env.MOCK_USER ?? "demo@example.com"; +const PASS = process.env.MOCK_PASS ?? "demo"; + +type Obj = Record; +const state = { n: 1 }; +const nextState = () => String(state.n++); + +/* ---------- data ---------- */ +const mailboxes: Obj[] = [ + mb("inbox", "Inbox", "inbox"), + mb("drafts", "Drafts", "drafts"), + mb("sent", "Sent", "sent"), + mb("junk", "Junk Mail", "junk"), + mb("trash", "Trash", "trash"), + mb("archive", "Archive", "archive"), + mb("work", "Work", null), + mb("work-inv", "Invoices", null, "work"), + mb("news", "Newsletters", null), +]; +function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj { + return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } }; +} + +const blobs = new Map(); +function putBlob(data: Buffer | string, type: string): string { + const id = `b${randomUUID().slice(0, 8)}`; + blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) }); + return id; +} + +const people = [ + ["Ada Lovelace", "ada@example.org"], ["Grace Hopper", "grace@example.org"], ["Linus Torvalds", "linus@kernel.example"], + ["Margaret Hamilton", "margaret@nasa.example"], ["Alan Turing", "alan@bletchley.example"], ["GitHub", "noreply@github.example"], + ["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "digest@newsletter.example"], ["Finance Team", "finance@example.org"], +]; +const subjects = [ + "Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff", + "Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?", + "Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in", +]; +const emails: Obj[] = []; +let counter = 1; +function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) { + const id = `e${counter++}`; + const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); + const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; + const html = `

Hi,

This is a sample HTML message about “${o.subject}”. It was generated by the ihasmail mock server.

  • Keyboard shortcuts (press ?)
  • Conversation view
  • Drag & drop to folders

logo

Cheers,
${o.from[0]}

On Monday, someone wrote:
This is the quoted part of an earlier message. It should be collapsed by default.
`; + const textBlob = putBlob(text, "text/plain"); + const htmlBlob = putBlob(html, "text/html"); + const attachments: Obj[] = []; + if (o.attach) { + attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null }); + attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null }); + } + if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" }); + const e: Obj = { + id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"), + threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true }, + keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) }, + size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received, + messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null, + from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, + subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), + textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], + htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], + attachments, + bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: html, isEncodingProblem: false, isTruncated: false } } : {}) }, + bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: html.length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, + "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, + "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, + }; + emails.push(e); + return e; +} +// Seed +for (let i = 0; i < 45; i++) { + const p = people[i % people.length]!; + const subj = subjects[i % subjects.length]!; + const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 }); + if (i % 4 === 0) { + // thread replies + addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true }); + addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 }); + } +} +addEmail({ from: ["Demo User", USER], to: "ada@example.org", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; +addEmail({ from: ["Spammy", "win@lottery.example"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true }); +// Invitation email +{ + const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:ada@example.org\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; + const e = addEmail({ from: ["Ada Lovelace", "ada@example.org"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true }); + const b = putBlob(ics, "text/calendar"); + (e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }]; + (e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }); + e.hasAttachment = true; +} + +const identities: Obj[] = [ + { id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "
--
Demo User
ihasmail
", mayDelete: false }, + { id: "i2", name: "Demo (alias)", email: "alias@example.com", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true }, +]; +let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }; +const sieveScripts: Obj[] = []; +const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }]; +function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; } +const events: Obj[] = []; +{ + const now = new Date(); + const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; }; + const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }], showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); + events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", email: "ada@example.org", sendTo: { imip: "mailto:ada@example.org" }, roles: { attendee: true }, participationStatus: "needs-action", expectReply: true } }, replyTo: { imip: `mailto:${USER}` } }); + events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null }); + events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" }); +} +const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }]; +const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }]; +const cards: Obj[] = people.slice(0, 6).map((p, i) => { + const [given, surname] = p[0]!.split(" "); + return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined }; +}); +const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" })); +const fileNodes: Obj[] = [ + { id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), role: "documents" }, + { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, + { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr() }, +]; +function fr() { return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; } + +function recount() { + for (const m of mailboxes) { + const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]); + m.totalEmails = inBox.length; + m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length; + const threads = new Set(inBox.map((e) => e.threadId)); + m.totalThreads = threads.size; + m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size; + } +} +recount(); + +/* ---------- helpers ---------- */ +function pick(o: Obj, props?: string[] | null): Obj { + if (!props) return o; + const out: Obj = { id: o.id }; + for (const p of props) if (p in o) out[p] = o[p]; + else if (p.startsWith("header:")) out[p] = null; + return out; +} +function resolveRefs(args: Obj, responses: [string, Obj, string][]): Obj { + const out: Obj = {}; + for (const [k, v] of Object.entries(args)) { + if (k.startsWith("#")) { + const r = v as { resultOf: string; name: string; path: string }; + const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name); + out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : []; + } else out[k] = v; + } + return out; +} +function jsonPointer(obj: unknown, path: string): unknown { + const parts = path.split("/").filter(Boolean); + let cur: unknown = obj; + for (let i = 0; i < parts.length; i++) { + const p = parts[i]!; + if (p === "*") { + const rest = parts.slice(i + 1).join("/"); + const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; }); + return arr; + } + cur = (cur as Obj)?.[p]; + } + return cur; +} +function matchFilter(e: Obj, f: Obj | undefined): boolean { + if (!f) return true; + if (f.operator) { + const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c)); + return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean); + } + const kw = e.keywords as Obj; + if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false; + if (f.hasKeyword && !kw[f.hasKeyword as string]) return false; + if (f.notKeyword && kw[f.notKeyword as string]) return false; + if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false; + const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase(); + for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false; + if (f.before && String(e.receivedAt) >= String(f.before)) return false; + if (f.after && String(e.receivedAt) < String(f.after)) return false; + if (f.minSize && Number(e.size) < Number(f.minSize)) return false; + if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false; + return true; +} +function applyPatch(obj: Obj, patch: Obj) { + for (const [k, v] of Object.entries(patch)) { + if (k.includes("/")) { + const [root, ...rest] = k.split("/"); + const key = rest.join("/"); + const target = (obj[root!] as Obj) ?? {}; + if (v === null) delete target[key]; + else target[key] = v; + obj[root!] = target; + } else obj[k] = v; + } +} + +/* ---------- method handlers ---------- */ +type Handler = (args: Obj) => Obj | [string, Obj][]; +const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra }); + +function genericGet(list: Obj[]) { + return (a: Obj) => { + const ids = a.ids as string[] | null | undefined; + const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list; + return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] }; + }; +} +function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) { + return (a: Obj) => { + const created: Obj = {}; + const updated: Obj = {}; + const destroyed: string[] = []; + const notCreated: Obj = {}; + for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { + const id = `${prefix}${randomUUID().slice(0, 6)}`; + const o = { ...(obj as Obj), id }; + onCreate?.(o); + list.push(o); + created[cid] = { id }; + } + for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) { + const o = list.find((x) => x.id === id); + if (o) { applyPatch(o, patch as Obj); updated[id] = null; } + } + for (const id of (a.destroy as string[]) ?? []) { + const i = list.findIndex((x) => x.id === id); + if (i >= 0) { list.splice(i, 1); destroyed.push(id); } + } + return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) }); + }; +} + +const handlers: Record = { + "Mailbox/get": genericGet(mailboxes), + "Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; }, + "Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }), + "Email/query": (a) => { + let list = emails.filter((e) => matchFilter(e, a.filter as Obj)); + list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt))); + if (a.collapseThreads) { + const seen = new Set(); + list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; }); + } + const pos = Number(a.position ?? 0); + const limit = Number(a.limit ?? 50); + return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit }; + }, + "Email/get": (a) => genericGet(emails)(a), + "Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }), + "Email/set": (a) => { + const r = genericSet(emails, "e", (o) => { + const bv = (o.bodyValues as Record) ?? {}; + const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); }; + const parts: Obj[] = []; + walk(o.bodyStructure as Obj, parts); + o.textBody = parts.filter((p) => p.type === "text/plain"); + o.htmlBody = parts.filter((p) => p.type === "text/html"); + o.attachments = []; + const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); }; + collect(o.bodyStructure as Obj); + o.hasAttachment = (o.attachments as Obj[]).length > 0; + o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`; + o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + o.size = 2000; + o.preview = (bv.text?.value ?? "").slice(0, 100); + o.messageId = [`${o.id}@mock`]; + o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822"); + })(a); + recount(); + return r; + }, + "Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); }, + "Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; }, + "Identity/get": genericGet(identities), + "Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })), + "EmailSubmission/set": (a) => { + const created: Obj = {}; + for (const [cid, sub] of Object.entries((a.create as Obj) ?? {})) { + const emailId = (sub as Obj).emailId as string; + const e = emails.find((x) => x.id === emailId); + if (!e) continue; + created[cid] = { id: `s${randomUUID().slice(0, 6)}`, sendAt: new Date().toISOString(), undoStatus: "final" }; + const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined; + if (patch) applyPatch(e, patch); + } + recount(); + return setResp({ created }); + }, + "VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacation], notFound: [] }), + "VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacation = { ...vacation, ...p }; return setResp({ updated: { singleton: null } }); }, + "Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }), + "SieveScript/get": genericGet(sieveScripts), + "SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; }, + "SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }), + "Calendar/get": genericGet(calendars), + "Calendar/set": genericSet(calendars, "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o })), + "CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }), + "CalendarEvent/get": genericGet(events), + "CalendarEvent/set": genericSet(events, "ev", (o) => Object.assign(o, { uid: o.uid ?? randomUUID() })), + "CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", email: "ada@example.org", sendTo: { imip: "mailto:ada@example.org" }, roles: { owner: true } }, me: { name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { attendee: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, + "ParticipantIdentity/get": genericGet(participantIdentities), + "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), + "Principal/get": genericGet(principals), + "Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }), + "AddressBook/get": genericGet(addressBooks), + "AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })), + "ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }), + "ContactCard/get": genericGet(cards), + "ContactCard/set": genericSet(cards, "cc"), + "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, + "FileNode/query": (a) => { const f = (a.filter as Obj) ?? {}; const list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true)); return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; }, + "FileNode/get": genericGet(fileNodes), + "FileNode/set": genericSet(fileNodes, "f", (o) => Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o })), +}; + +/* ---------- http ---------- */ +function unauthorized(res: ServerResponse) { + res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' }); + res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); +} +function checkAuth(req: IncomingMessage): boolean { + const h = req.headers.authorization ?? ""; + if (!h.startsWith("Basic ")) return false; + const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); + return u === USER && p === PASS; +} +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); }); +} + +const session = () => ({ + capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} }, + accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } }, + primaryAccounts: Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), + username: USER, + apiUrl: `http://127.0.0.1:${PORT}/jmap/`, + downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`, + eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`, + state: String(state.n), +}); + +const sseClients = new Set(); +function broadcast(types: string[]) { + const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`; + for (const c of sseClients) c.write(payload); +} + +createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`); + if (!checkAuth(req)) return unauthorized(res); + if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify(session())); + } + if (url.pathname === "/jmap/" && req.method === "POST") { + const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] }; + const responses: [string, Obj, string][] = []; + const touched = new Set(); + for (const [name, rawArgs, id] of body.methodCalls) { + const h = handlers[name]; + if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; } + try { + const args = resolveRefs(rawArgs, responses); + const r = h(args); + responses.push([name, r as Obj, id]); + if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!); + } catch (err) { + responses.push(["error", { type: "serverFail", description: String(err) }, id]); + } + } + if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); } + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ methodResponses: responses, sessionState: "1" })); + } + if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") { + const data = await readBody(req); + const type = req.headers["content-type"] ?? "application/octet-stream"; + const blobId = putBlob(data, type); + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ accountId: ACCOUNT, blobId, type, size: data.length })); + } + if (url.pathname.startsWith("/jmap/download/")) { + const [, , , , blobId] = url.pathname.split("/"); + const b = blobs.get(blobId ?? ""); + if (!b) { res.writeHead(404); return res.end(); } + res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length }); + return res.end(b.data); + } + if (url.pathname.startsWith("/jmap/eventsource")) { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + res.write(`event: ping\ndata: {}\n\n`); + sseClients.add(res); + const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000); + req.on("close", () => { clearInterval(t); sseClients.delete(res); }); + // Simulate a new message every 90s + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); +}).listen(PORT, "127.0.0.1", () => { + console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`); + console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`); +}); + +// Periodically inject a new inbox email to demo push +setInterval(() => { + const p = people[Math.floor(Math.random() * people.length)]!; + addEmail({ from: [p[0]!, p[1]!], subject: `Live update ${new Date().toLocaleTimeString()}`, daysAgo: 0, mailbox: "inbox", unread: true, html: true }); + recount(); + nextState(); + broadcast(["Email", "Mailbox", "Thread"]); +}, 120_000).unref(); diff --git a/server/src/ratelimit.ts b/server/src/ratelimit.ts new file mode 100644 index 0000000..a419f81 --- /dev/null +++ b/server/src/ratelimit.ts @@ -0,0 +1,45 @@ +/** Simple sliding-window rate limiter keyed by arbitrary string (ip, ip+user). */ +export class RateLimiter { + private hits = new Map(); + + constructor( + private readonly max: number, + private readonly windowMs: number, + ) { + const t = setInterval(() => this.prune(), windowMs); + t.unref(); + } + + /** Returns true if the action is allowed, false if the caller should back off. */ + check(key: string): boolean { + const now = Date.now(); + const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs); + if (arr.length >= this.max) { + this.hits.set(key, arr); + return false; + } + arr.push(now); + this.hits.set(key, arr); + return true; + } + + reset(key: string): void { + this.hits.delete(key); + } + + retryAfterSeconds(key: string): number { + const arr = this.hits.get(key); + if (!arr || !arr.length) return 0; + const oldest = arr[0]!; + return Math.max(1, Math.ceil((this.windowMs - (Date.now() - oldest)) / 1000)); + } + + private prune(): void { + const now = Date.now(); + for (const [k, arr] of this.hits) { + const kept = arr.filter((t) => now - t < this.windowMs); + if (kept.length) this.hits.set(k, kept); + else this.hits.delete(k); + } + } +} diff --git a/server/src/sessions.test.ts b/server/src/sessions.test.ts new file mode 100644 index 0000000..3a0bdd7 --- /dev/null +++ b/server/src/sessions.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { SessionStore } from "./sessions.js"; +import { deriveKey, open, seal, sha256 } from "./crypto.js"; +import { RateLimiter } from "./ratelimit.js"; +import { randomBytes } from "node:crypto"; + +test("seal/open round-trips and rejects wrong key", () => { + const salt = randomBytes(16); + const k1 = deriveKey("cookie-secret", "app-secret", salt); + const k2 = deriveKey("other", "app-secret", salt); + const ct = seal("hello", k1); + assert.equal(open(ct, k1), "hello"); + assert.equal(open(ct, k2), null); + assert.equal(sha256("a"), sha256("a")); +}); + +test("session store creates, resolves, and refuses tampered cookies", () => { + const store = new SessionStore(""); + const { cookie, session } = store.create({ username: "u@example.com", password: "p4ss", remember: false, userAgent: "ua", ip: "127.0.0.1" }); + assert.equal(session.username, "u@example.com"); + const live = store.resolve(cookie); + assert.ok(live); + assert.equal(live!.authorization, `Basic ${Buffer.from("u@example.com:p4ss").toString("base64")}`); + assert.equal(store.resolve(cookie + "x"), null); + assert.equal(store.resolve("nope"), null); + assert.equal(store.listForUser("u@example.com").length, 1); + store.destroy(live!.id); + assert.equal(store.resolve(cookie), null); +}); + +test("persisted session data does not contain the password", () => { + const store = new SessionStore(""); + store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" }); + const json = JSON.stringify(store.listForUser("u")); + assert.ok(!json.includes("super-secret-pw")); +}); + +test("rate limiter blocks after max hits in window", () => { + const rl = new RateLimiter(3, 60_000); + assert.equal(rl.check("k"), true); + assert.equal(rl.check("k"), true); + assert.equal(rl.check("k"), true); + assert.equal(rl.check("k"), false); + assert.ok(rl.retryAfterSeconds("k") > 0); + rl.reset("k"); + assert.equal(rl.check("k"), true); +}); diff --git a/server/src/sessions.ts b/server/src/sessions.ts new file mode 100644 index 0000000..f5c36ae --- /dev/null +++ b/server/src/sessions.ts @@ -0,0 +1,213 @@ +import { mkdir, readFile, writeFile, rename } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomBytes } from "node:crypto"; +import { config } from "./config.js"; +import { deriveKey, open, randomToken, safeEqual, seal, sha256 } from "./crypto.js"; + +export interface StoredSession { + id: string; + /** sha256 of the cookie secret; used to validate presented cookies. */ + secretHash: string; + /** base64 random salt for key derivation */ + salt: string; + /** sealed JSON {username, password} */ + sealedCredentials: string; + username: string; + createdAt: number; + lastSeenAt: number; + expiresAt: number; + remember: boolean; + userAgent: string; + ip: string; +} + +export interface LiveSession { + id: string; + username: string; + /** Basic Authorization header value for upstream calls. */ + authorization: string; + remember: boolean; + createdAt: number; + lastSeenAt: number; + expiresAt: number; + userAgent: string; + ip: string; +} + +const COOKIE_SEP = "."; + +export class SessionStore { + private sessions = new Map(); + private dirty = false; + private saveTimer: NodeJS.Timeout | null = null; + private sweepTimer: NodeJS.Timeout | null = null; + + constructor(private readonly file: string) {} + + async init(): Promise { + if (this.file) { + try { + const raw = await readFile(this.file, "utf8"); + const arr = JSON.parse(raw) as StoredSession[]; + const now = Date.now(); + for (const s of arr) if (s.expiresAt > now) this.sessions.set(s.id, s); + console.log(`[ihasmail] restored ${this.sessions.size} session(s)`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn("[ihasmail] could not read session file:", (err as Error).message); + } + } + } + this.sweepTimer = setInterval(() => this.sweep(), 60_000); + this.sweepTimer.unref(); + } + + async close(): Promise { + if (this.sweepTimer) clearInterval(this.sweepTimer); + if (this.saveTimer) clearTimeout(this.saveTimer); + await this.flush(); + } + + private sweep(): void { + const now = Date.now(); + let removed = 0; + for (const [id, s] of this.sessions) { + if (s.expiresAt <= now) { + this.sessions.delete(id); + removed++; + } + } + if (removed) this.scheduleSave(); + } + + private scheduleSave(): void { + this.dirty = true; + if (!this.file || this.saveTimer) return; + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + void this.flush(); + }, 1000); + this.saveTimer.unref(); + } + + private async flush(): Promise { + if (!this.file || !this.dirty) return; + this.dirty = false; + try { + await mkdir(dirname(this.file), { recursive: true }); + const tmp = `${this.file}.tmp`; + await writeFile(tmp, JSON.stringify([...this.sessions.values()]), { mode: 0o600 }); + await rename(tmp, this.file); + } catch (err) { + console.warn("[ihasmail] could not persist sessions:", (err as Error).message); + } + } + + /** Create a session; returns the cookie value to hand to the client. */ + create(params: { + username: string; + password: string; + remember: boolean; + userAgent: string; + ip: string; + }): { cookie: string; session: LiveSession } { + const id = randomToken(18); + const secret = randomToken(32); + const salt = randomBytes(16); + const key = deriveKey(secret, config.appSecret, salt); + const now = Date.now(); + const ttl = (params.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000; + const stored: StoredSession = { + id, + secretHash: sha256(secret), + salt: salt.toString("base64"), + sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key), + username: params.username, + createdAt: now, + lastSeenAt: now, + expiresAt: now + ttl, + remember: params.remember, + userAgent: params.userAgent.slice(0, 200), + ip: params.ip, + }; + this.sessions.set(id, stored); + this.scheduleSave(); + const cookie = `${id}${COOKIE_SEP}${secret}`; + return { cookie, session: this.toLive(stored, params.username, params.password) }; + } + + /** Resolve a cookie to a live session (with decrypted upstream credentials). */ + resolve(cookie: string | undefined): LiveSession | null { + if (!cookie) return null; + const idx = cookie.indexOf(COOKIE_SEP); + if (idx <= 0) return null; + const id = cookie.slice(0, idx); + const secret = cookie.slice(idx + 1); + const stored = this.sessions.get(id); + if (!stored) return null; + const now = Date.now(); + if (stored.expiresAt <= now) { + this.sessions.delete(id); + this.scheduleSave(); + return null; + } + if (!safeEqual(stored.secretHash, sha256(secret))) return null; + const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64")); + const json = open(stored.sealedCredentials, key); + if (!json) return null; + let creds: { u: string; p: string }; + try { + creds = JSON.parse(json) as { u: string; p: string }; + } catch { + return null; + } + // Sliding expiry: bump every few minutes, not on every request. + if (now - stored.lastSeenAt > 60_000) { + stored.lastSeenAt = now; + const ttl = (stored.remember ? config.sessionRememberTtl : config.sessionTtl) * 1000; + stored.expiresAt = now + ttl; + this.scheduleSave(); + } + return this.toLive(stored, creds.u, creds.p); + } + + destroy(id: string): void { + if (this.sessions.delete(id)) this.scheduleSave(); + } + + destroyAllForUser(username: string, exceptId?: string): number { + let n = 0; + for (const [id, s] of this.sessions) { + if (s.username === username && id !== exceptId) { + this.sessions.delete(id); + n++; + } + } + if (n) this.scheduleSave(); + return n; + } + + listForUser(username: string): Array> { + const out = []; + for (const s of this.sessions.values()) { + if (s.username !== username) continue; + const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s; + out.push(rest); + } + return out; + } + + private toLive(s: StoredSession, username: string, password: string): LiveSession { + return { + id: s.id, + username, + authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`, + remember: s.remember, + createdAt: s.createdAt, + lastSeenAt: s.lastSeenAt, + expiresAt: s.expiresAt, + userAgent: s.userAgent, + ip: s.ip, + }; + } +} diff --git a/server/src/static.ts b/server/src/static.ts new file mode 100644 index 0000000..d994857 --- /dev/null +++ b/server/src/static.ts @@ -0,0 +1,101 @@ +import { createReadStream } from "node:fs"; +import { stat, readFile } from "node:fs/promises"; +import { extname, join, normalize, resolve, sep } from "node:path"; +import { Readable } from "node:stream"; +import type { Context, Handler } from "hono"; + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", +}; + +/** + * Content Security Policy for the app shell. Inline styles are required because + * sanitized HTML email carries style attributes; everything else is strict. + */ +export const APP_CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "media-src 'self' blob:", + "frame-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "worker-src 'self'", + "manifest-src 'self'", +].join("; "); + +export function staticHandler(root: string): Handler { + const absRoot = resolve(root); + let indexCache: { body: string; mtime: number } | null = null; + + async function serveIndex(c: Context) { + try { + const p = join(absRoot, "index.html"); + const st = await stat(p); + if (!indexCache || indexCache.mtime !== st.mtimeMs) { + indexCache = { body: await readFile(p, "utf8"), mtime: st.mtimeMs }; + } + c.header("Content-Type", "text/html; charset=utf-8"); + c.header("Cache-Control", "no-cache"); + c.header("Content-Security-Policy", APP_CSP); + return c.body(indexCache.body); + } catch { + c.header("Content-Type", "text/plain; charset=utf-8"); + return c.body("ihasmail: web build not found. Run `npm run build` first.", 503); + } + } + + return async (c) => { + if (c.req.method !== "GET" && c.req.method !== "HEAD") return c.text("Method Not Allowed", 405); + const urlPath = decodeURIComponent(new URL(c.req.url).pathname); + if (urlPath === "/" || urlPath === "/index.html") return serveIndex(c); + const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, ""); + const filePath = join(absRoot, rel); + if (!filePath.startsWith(absRoot + sep)) return serveIndex(c); + try { + const st = await stat(filePath); + if (!st.isFile()) return serveIndex(c); + const ext = extname(filePath).toLowerCase(); + c.header("Content-Type", MIME[ext] ?? "application/octet-stream"); + c.header("Content-Length", String(st.size)); + if (rel.startsWith("/assets/") || rel.startsWith("assets/")) { + c.header("Cache-Control", "public, max-age=31536000, immutable"); + } else if (ext === ".html") { + c.header("Cache-Control", "no-cache"); + c.header("Content-Security-Policy", APP_CSP); + } else { + c.header("Cache-Control", "public, max-age=3600"); + } + if (c.req.method === "HEAD") return c.body(null); + const stream = Readable.toWeb(createReadStream(filePath)) as ReadableStream; + return c.body(stream); + } catch { + // SPA fallback for client-side routes (no file extension) only. + if (!extname(rel)) return serveIndex(c); + return c.text("Not Found", 404); + } + }; +} diff --git a/server/src/upstream.ts b/server/src/upstream.ts new file mode 100644 index 0000000..87fa0e3 --- /dev/null +++ b/server/src/upstream.ts @@ -0,0 +1,94 @@ +import { config } from "./config.js"; + +export interface UpstreamSession { + capabilities: Record; + accounts: Record; + primaryAccounts: Record; + username: string; + apiUrl: string; + downloadUrl: string; + uploadUrl: string; + eventSourceUrl: string; + state: string; +} + +export class UpstreamError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + } +} + +const sessionCache = new Map(); +const SESSION_CACHE_MS = 5 * 60_000; + +export function wellKnownUrl(): string { + return `${config.stalwartUrl}/.well-known/jmap`; +} + +/** + * Fetch the JMAP session resource from Stalwart using the given Authorization + * header. Throws UpstreamError(401) on bad credentials. + */ +export async function fetchUpstreamSession(authorization: string): Promise { + const res = await fetch(wellKnownUrl(), { + headers: { authorization, accept: "application/json" }, + redirect: "follow", + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) { + throw new UpstreamError("Invalid credentials", 401); + } + if (!res.ok) { + throw new UpstreamError(`Upstream session request failed (${res.status})`, 502); + } + const session = (await res.json()) as UpstreamSession; + if (!session.apiUrl) throw new UpstreamError("Upstream returned an invalid JMAP session", 502); + return session; +} + +export async function getUpstreamSession(sessionId: string, authorization: string, force = false) { + const cached = sessionCache.get(sessionId); + if (!force && cached && Date.now() - cached.fetchedAt < SESSION_CACHE_MS) return cached.session; + const session = await fetchUpstreamSession(authorization); + sessionCache.set(sessionId, { session, fetchedAt: Date.now() }); + return session; +} + +export function forgetUpstreamSession(sessionId: string): void { + sessionCache.delete(sessionId); +} + +/** + * Rewrite the upstream session so the browser talks to our same-origin proxy + * endpoints instead of Stalwart directly (no CORS, no credentials in browser). + */ +export function localizeSession(s: UpstreamSession, extras: Record): Record { + const caps = { ...s.capabilities }; + // We proxy push as Server-Sent Events; hide the upstream websocket endpoint. + delete caps["urn:ietf:params:jmap:websocket"]; + return { + ...s, + capabilities: caps, + apiUrl: "/api/jmap", + downloadUrl: "/api/blob/{accountId}/{blobId}/{name}?accept={type}", + uploadUrl: "/api/upload/{accountId}", + eventSourceUrl: "/api/events?types={types}&closeafter={closeafter}&ping={ping}", + ...extras, + }; +} + +/** Resolve a possibly-relative upstream URL template against STALWART_URL. */ +export function absoluteUpstream(url: string): string { + try { + return new URL(url, config.stalwartUrl).toString(); + } catch { + return url; + } +} + +export function expandTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (_m, k: string) => encodeURIComponent(vars[k] ?? "")); +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..9e44e8c --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tests/test_smoke.py b/tests/test_smoke.py deleted file mode 100644 index c7b9b08..0000000 --- a/tests/test_smoke.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi.testclient import TestClient -from app.main import app - -def test_root_redirect(): - client = TestClient(app) - r = client.get("/", allow_redirects=False) - assert r.status_code in (302, 303) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..abb8739 --- /dev/null +++ b/web/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + ihasmail + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..759e7ed --- /dev/null +++ b/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "@ihasmail/web", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/react-virtual": "^3.13.2", + "dompurify": "^3.2.4", + "lucide-react": "^0.477.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "wouter": "^3.6.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.3", + "vite": "^6.2.0", + "vitest": "^3.0.8" + } +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..c20ae3f Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/img/apple-touch-icon.png b/web/public/img/apple-touch-icon.png new file mode 100644 index 0000000..b9f4415 Binary files /dev/null and b/web/public/img/apple-touch-icon.png differ diff --git a/web/public/img/favicon-64.png b/web/public/img/favicon-64.png new file mode 100644 index 0000000..3c7e0fe Binary files /dev/null and b/web/public/img/favicon-64.png differ diff --git a/web/public/img/icon-192.png b/web/public/img/icon-192.png new file mode 100644 index 0000000..0fb6f7a Binary files /dev/null and b/web/public/img/icon-192.png differ diff --git a/web/public/img/icon-512.png b/web/public/img/icon-512.png new file mode 100644 index 0000000..1957cc9 Binary files /dev/null and b/web/public/img/icon-512.png differ diff --git a/web/public/img/icon-maskable.png b/web/public/img/icon-maskable.png new file mode 100644 index 0000000..80764ba Binary files /dev/null and b/web/public/img/icon-maskable.png differ diff --git a/app/static/img/logo.png b/web/public/img/logo.png similarity index 100% rename from app/static/img/logo.png rename to web/public/img/logo.png diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 0000000..b3a506d --- /dev/null +++ b/web/public/manifest.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "ihasmail", + "short_name": "ihasmail", + "description": "Fast, friendly JMAP webmail for Stalwart", + "start_url": "/mail", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#0f766e", + "icons": [ + { "src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/img/icon-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" } + ], + "shortcuts": [ + { "name": "Compose", "url": "/mail?compose=new", "description": "Write a new message" }, + { "name": "Calendar", "url": "/calendar" }, + { "name": "Contacts", "url": "/contacts" } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..f19aeb6 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,41 @@ +/* ihasmail service worker: app-shell caching for installability & fast loads. + API requests are never cached. */ +const VERSION = "ihasmail-v2"; +const SHELL = ["/", "/manifest.webmanifest", "/img/logo.png", "/img/icon-192.png", "/favicon.ico"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/api/")) return; + + // Hashed build assets: cache-first. + if (url.pathname.startsWith("/assets/")) { + event.respondWith( + caches.match(req).then((hit) => hit || fetch(req).then((res) => { + const copy = res.clone(); + caches.open(VERSION).then((c) => c.put(req, copy)); + return res; + })) + ); + return; + } + + // Navigations & everything else: network-first, fall back to cached shell. + if (req.mode === "navigate") { + event.respondWith(fetch(req).catch(() => caches.match("/"))); + return; + } + event.respondWith(fetch(req).catch(() => caches.match(req))); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..e72ad99 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,137 @@ +import { lazy, Suspense, useEffect } from "react"; +import { Route, Switch, Redirect, useLocation } from "wouter"; +import { useSession } from "@/store/session"; +import { useMail } from "@/store/mail"; +import { useContacts } from "@/store/contacts"; +import { useCalendar } from "@/store/calendar"; +import { useFiles } from "@/store/files"; +import { useSieve } from "@/store/sieve"; +import { push } from "@/jmap/push"; +import { client } from "@/jmap/client"; +import { ToastHost } from "@/ui/toast"; +import { ConfirmHost } from "@/ui/dialog"; +import { Spinner } from "@/ui/misc"; +import { LoginPage } from "@/views/Login"; +import { AppShell } from "@/views/AppShell"; +import { MailView } from "@/views/mail/MailView"; +import { ComposerDock } from "@/views/compose/ComposerDock"; +import { setUnreadBadge } from "@/lib/notify"; +import { useSettings } from "@/store/settings"; + +const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView }))); +const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView }))); +const FilesView = lazy(() => import("@/views/files/FilesView").then((m) => ({ default: m.FilesView }))); +const SettingsView = lazy(() => import("@/views/settings/SettingsView").then((m) => ({ default: m.SettingsView }))); + +export function App() { + const status = useSession((s) => s.status); + const bootstrap = useSession((s) => s.bootstrap); + useEffect(() => { + void bootstrap(); + }, [bootstrap]); + + if (status === "loading") { + return ( +
+ +
+ ); + } + return ( + <> + {status === "anonymous" ? : } + + + + ); +} + +function AuthedApp() { + const accountId = useSession((s) => s.accountId); + const [location] = useLocation(); + + // Initial data + push wiring + useEffect(() => { + if (!accountId) return; + const mail = useMail.getState(); + void mail.loadMailboxes(); + void mail.loadIdentities(); + void mail.loadQuota(); + void useContacts.getState().init(); + void useCalendar.getState().init(); + void useFiles.getState().init(); + void useSieve.getState().init(); + push.start(); + const pending = new Map>(); + let timer: number | null = null; + const unsub = push.subscribe((acct, type) => { + const set = pending.get(acct) ?? new Set(); + set.add(type); + pending.set(acct, set); + if (timer) return; + timer = window.setTimeout(() => { + timer = null; + for (const [a, types] of pending) { + if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types); + if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types); + if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types); + if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types); + if (a === useSieve.getState().accountId) useSieve.getState().applyChanges(types); + } + pending.clear(); + }, 400); + }); + const unsubState = client.onSessionState(() => void useSession.getState().refresh()); + // Poll fallback when push is disconnected (every 2 minutes) + const poll = window.setInterval(() => { + if (!push.connected && document.visibilityState === "visible") { + void useMail.getState().applyChanges(new Set(["Email", "Mailbox"])); + } + }, 120_000); + return () => { + unsub(); + unsubState(); + window.clearInterval(poll); + push.stop(); + }; + }, [accountId]); + + // Unread badge in title/favicon + const inboxUnread = useMail((s) => { + const id = s.roleId("inbox"); + return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0; + }); + const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail"); + useEffect(() => { + void import("@/lib/notify").then((m) => { + m.setBaseTitle(appName); + setUnreadBadge(inboxUnread); + }); + }, [inboxUnread, appName]); + + // Request notification permission lazily when enabled + const notif = useSettings((s) => s.settings.desktopNotifications); + useEffect(() => { + if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission()); + }, [notif]); + + return ( + + }> + + {(p) => } + {(p) => } + {(p) => } + {(p) => } + {(p) => } + {(p) => } + + + + {location === "/" ? : } + + + + + ); +} diff --git a/web/src/jmap/client.ts b/web/src/jmap/client.ts new file mode 100644 index 0000000..ca35623 --- /dev/null +++ b/web/src/jmap/client.ts @@ -0,0 +1,348 @@ +import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types"; + +export const CAP = { + core: "urn:ietf:params:jmap:core", + mail: "urn:ietf:params:jmap:mail", + submission: "urn:ietf:params:jmap:submission", + vacation: "urn:ietf:params:jmap:vacationresponse", + sieve: "urn:ietf:params:jmap:sieve", + contacts: "urn:ietf:params:jmap:contacts", + contactsParse: "urn:ietf:params:jmap:contacts:parse", + calendars: "urn:ietf:params:jmap:calendars", + calendarsParse: "urn:ietf:params:jmap:calendars:parse", + principals: "urn:ietf:params:jmap:principals", + availability: "urn:ietf:params:jmap:principals:availability", + quota: "urn:ietf:params:jmap:quota", + blob: "urn:ietf:params:jmap:blob", + filenode: "urn:ietf:params:jmap:filenode", + websocket: "urn:ietf:params:jmap:websocket", +} as const; + +export class JmapMethodError extends Error { + constructor( + public readonly method: string, + public readonly error: MethodError, + ) { + super(`${method}: ${error.type}${error.description ? ` - ${error.description}` : ""}`); + this.name = "JmapMethodError"; + } + get type() { + return this.error.type; + } +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message?: string, + ) { + super(message ?? `${code} (${status})`); + this.name = "ApiError"; + } +} + +export interface ApiErrorBody { + error?: string; + message?: string; + type?: string; + detail?: string; + title?: string; +} + +interface Pending { + method: string; + args: Record; + using: Set; + resolve: (v: unknown) => void; + reject: (e: unknown) => void; +} + +export type ResultRef = { resultOf: string; name: string; path: string }; + +const HEADERS = { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" }; + +/** Generic fetch against our same-origin API with CSRF header + auth handling. */ +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + ...init, + headers: { ...HEADERS, ...(init.headers as Record | undefined) }, + credentials: "same-origin", + }); + if (res.status === 401 && !path.startsWith("/api/auth/login")) { + client.handleUnauthenticated(); + throw new ApiError(401, "unauthenticated", "Your session has expired. Please sign in again."); + } + if (!res.ok) { + let body: ApiErrorBody = {}; + try { + body = (await res.json()) as ApiErrorBody; + } catch { + /* ignore */ + } + throw new ApiError(res.status, body.error ?? body.type ?? "error", body.message ?? body.detail ?? body.title ?? res.statusText); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export class JmapClient { + session: JmapSession | null = null; + private pending: Pending[] = []; + private flushScheduled = false; + private callCounter = 0; + private unauthHandlers = new Set<() => void>(); + private stateHandlers = new Set<(sessionState: string) => void>(); + + get maxCallsInRequest(): number { + const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined; + return core?.maxCallsInRequest ?? 16; + } + + get maxObjectsInGet(): number { + const core = this.session?.capabilities[CAP.core] as { maxObjectsInGet?: number } | undefined; + return core?.maxObjectsInGet ?? 500; + } + + get maxSizeUpload(): number { + const core = this.session?.capabilities[CAP.core] as { maxSizeUpload?: number } | undefined; + return core?.maxSizeUpload ?? 50_000_000; + } + + hasCapability(cap: string): boolean { + return Boolean(this.session?.capabilities && cap in this.session.capabilities); + } + + accountHasCapability(accountId: Id, cap: string): boolean { + const acc = this.session?.accounts[accountId]; + return Boolean(acc && cap in acc.accountCapabilities); + } + + primaryAccount(cap: string): Id | null { + return this.session?.primaryAccounts[cap] ?? null; + } + + onUnauthenticated(fn: () => void): () => void { + this.unauthHandlers.add(fn); + return () => this.unauthHandlers.delete(fn); + } + + onSessionState(fn: (s: string) => void): () => void { + this.stateHandlers.add(fn); + return () => this.stateHandlers.delete(fn); + } + + handleUnauthenticated(): void { + for (const fn of this.unauthHandlers) fn(); + } + + /** + * Queue a single method call; calls made within the same tick are batched + * into one HTTP request (up to maxCallsInRequest). + */ + call>(method: string, args: Record, using: string[] = []): Promise { + return new Promise((resolve, reject) => { + this.pending.push({ + method, + args, + using: new Set([CAP.core, ...usingFor(method), ...using]), + resolve: resolve as (v: unknown) => void, + reject, + }); + if (!this.flushScheduled) { + this.flushScheduled = true; + queueMicrotask(() => void this.flush()); + } + }); + } + + private async flush(): Promise { + this.flushScheduled = false; + const batch = this.pending; + this.pending = []; + const max = this.maxCallsInRequest; + for (let i = 0; i < batch.length; i += max) { + void this.sendBatch(batch.slice(i, i + max)); + } + } + + private async sendBatch(batch: Pending[]): Promise { + const using = new Set(); + const calls: Invocation[] = batch.map((p, idx) => { + for (const u of p.using) using.add(u); + return [p.method, p.args, `c${this.callCounter++}_${idx}`]; + }); + try { + const res = await this.request(calls, [...using]); + const byId = new Map(); + for (const inv of res.methodResponses) { + const arr = byId.get(inv[2]) ?? []; + arr.push(inv); + byId.set(inv[2], arr); + } + batch.forEach((p, idx) => { + const responses = byId.get(calls[idx]![2]); + const first = responses?.[0]; + if (!first) { + p.reject(new JmapMethodError(p.method, { type: "serverFail", description: "No response for call" })); + return; + } + if (first[0] === "error") p.reject(new JmapMethodError(p.method, first[1] as MethodError)); + else p.resolve(first[1]); + }); + } catch (err) { + for (const p of batch) p.reject(err); + } + } + + /** Low-level request: send invocations verbatim, return raw response. */ + async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record): Promise { + const body: Record = { using, methodCalls }; + if (createdIds) body.createdIds = createdIds; + const res = await apiFetch("/api/jmap", { method: "POST", body: JSON.stringify(body) }); + if (res.sessionState && this.session && res.sessionState !== this.session.state) { + for (const fn of this.stateHandlers) fn(res.sessionState); + } + return res; + } + + /** + * Run a chain of invocations (which may use result references) and return + * responses keyed by call id. Throws if any call errored, unless `allowErrors`. + */ + async chain( + calls: Array<[method: string, args: Record, id: string]>, + opts: { using?: string[]; allowErrors?: boolean } = {}, + ): Promise[]>> { + const using = new Set([CAP.core]); + for (const [m] of calls) for (const u of usingFor(m)) using.add(u); + for (const u of opts.using ?? []) using.add(u); + const res = await this.request(calls, [...using]); + const out = new Map[]>(); + for (const [name, args, id] of res.methodResponses) { + if (name === "error" && !opts.allowErrors) { + const method = calls.find((c) => c[2] === id)?.[0] ?? id; + throw new JmapMethodError(method, args as MethodError); + } + const arr = out.get(id) ?? []; + arr.push(name === "error" ? { __error: args } : args); + out.set(id, arr); + } + return out; + } + + uploadUrl(accountId: Id): string { + return `/api/upload/${encodeURIComponent(accountId)}`; + } + + downloadUrl(accountId: Id, blobId: Id, name: string, type: string, inline = false): string { + const safeName = (name || "attachment").replace(/[/\\?#%]/g, "_"); + const u = `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(blobId)}/${encodeURIComponent(safeName)}?accept=${encodeURIComponent(type || "application/octet-stream")}`; + return inline ? `${u}&inline=1` : u; + } + + /** Upload a blob with progress reporting (XHR because fetch lacks upload progress). */ + upload( + accountId: Id, + data: Blob, + opts: { type?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal } = {}, + ): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", this.uploadUrl(accountId)); + xhr.setRequestHeader("content-type", opts.type || data.type || "application/octet-stream"); + xhr.setRequestHeader("x-requested-with", "ihasmail"); + xhr.responseType = "json"; + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) opts.onProgress?.(e.loaded, e.total); + }; + xhr.onload = () => { + if (xhr.status === 401) { + this.handleUnauthenticated(); + reject(new ApiError(401, "unauthenticated")); + return; + } + if (xhr.status >= 200 && xhr.status < 300 && xhr.response) resolve(xhr.response as UploadResponse); + else reject(new ApiError(xhr.status, (xhr.response as ApiErrorBody)?.error ?? "upload_failed", (xhr.response as ApiErrorBody)?.message ?? "Upload failed")); + }; + xhr.onerror = () => reject(new ApiError(0, "network_error", "Network error during upload")); + xhr.onabort = () => reject(new ApiError(0, "aborted", "Upload cancelled")); + opts.signal?.addEventListener("abort", () => xhr.abort()); + xhr.send(data); + }); + } + + /** Fetch a blob's content as text (via the download proxy). */ + async fetchBlobText(accountId: Id, blobId: Id, type = "text/plain"): Promise { + const res = await fetch(this.downloadUrl(accountId, blobId, "blob.txt", type), { credentials: "same-origin" }); + if (res.status === 401) { + this.handleUnauthenticated(); + throw new ApiError(401, "unauthenticated"); + } + if (!res.ok) throw new ApiError(res.status, "download_failed"); + return await res.text(); + } + + async fetchBlob(accountId: Id, blobId: Id, type = "application/octet-stream"): Promise { + const res = await fetch(this.downloadUrl(accountId, blobId, "blob", type), { credentials: "same-origin" }); + if (res.status === 401) { + this.handleUnauthenticated(); + throw new ApiError(401, "unauthenticated"); + } + if (!res.ok) throw new ApiError(res.status, "download_failed"); + return await res.blob(); + } +} + +/** Map method name prefix → required capability URNs. */ +function usingFor(method: string): string[] { + const type = method.split("/")[0] ?? ""; + switch (type) { + case "Mailbox": + case "Thread": + case "Email": + case "SearchSnippet": + case "Identity": + return [CAP.mail]; + case "EmailSubmission": + return [CAP.mail, CAP.submission]; + case "VacationResponse": + return [CAP.mail, CAP.vacation]; + case "SieveScript": + return [CAP.sieve]; + case "AddressBook": + case "ContactCard": + return [CAP.contacts, CAP.contactsParse]; + case "Calendar": + case "CalendarEvent": + case "ParticipantIdentity": + case "CalendarEventNotification": + return [CAP.calendars, CAP.calendarsParse]; + case "Principal": + return [CAP.principals, CAP.availability]; + case "Quota": + return [CAP.quota]; + case "Blob": + return [CAP.blob]; + case "FileNode": + return [CAP.filenode]; + case "PushSubscription": + return []; + default: + return []; + } +} + +export const client = new JmapClient(); + +/** Build a JMAP result reference argument ("#ids": {...}). */ +export function ref(resultOf: string, name: string, path: string): ResultRef { + return { resultOf, name, path }; +} + +/** Chunk ids for /get calls to respect maxObjectsInGet. */ +export function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} diff --git a/web/src/jmap/push.ts b/web/src/jmap/push.ts new file mode 100644 index 0000000..23fe199 --- /dev/null +++ b/web/src/jmap/push.ts @@ -0,0 +1,104 @@ +import type { Id, StateChange } from "./types"; + +export type PushListener = (accountId: Id, type: string, newState: string) => void; + +/** + * JMAP push over Server-Sent Events (proxied through our server). + * Emits per-type state changes so stores can refresh incrementally. + */ +class PushManager { + private es: EventSource | null = null; + private listeners = new Set(); + private connectionListeners = new Set<(connected: boolean) => void>(); + private backoff = 1000; + private reconnectTimer: number | null = null; + private stopped = true; + private lastStates = new Map(); + connected = false; + + start(): void { + this.stopped = false; + this.connect(); + document.addEventListener("visibilitychange", this.onVisibility); + window.addEventListener("online", this.onOnline); + } + + stop(): void { + this.stopped = true; + document.removeEventListener("visibilitychange", this.onVisibility); + window.removeEventListener("online", this.onOnline); + if (this.reconnectTimer) window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.es?.close(); + this.es = null; + this.setConnected(false); + } + + subscribe(fn: PushListener): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + onConnection(fn: (connected: boolean) => void): () => void { + this.connectionListeners.add(fn); + return () => this.connectionListeners.delete(fn); + } + + private setConnected(v: boolean) { + if (this.connected === v) return; + this.connected = v; + for (const fn of this.connectionListeners) fn(v); + } + + private onVisibility = () => { + if (document.visibilityState === "visible" && !this.es && !this.stopped) this.connect(); + }; + + private onOnline = () => { + if (!this.es && !this.stopped) this.connect(); + }; + + private connect(): void { + if (this.stopped || this.es) return; + const url = `/api/events?types=*&closeafter=no&ping=30`; + const es = new EventSource(url, { withCredentials: true }); + this.es = es; + es.onopen = () => { + this.backoff = 1000; + this.setConnected(true); + }; + es.addEventListener("state", (ev) => { + try { + const data = JSON.parse((ev as MessageEvent).data as string) as StateChange; + if (data["@type"] !== "StateChange") return; + for (const [accountId, types] of Object.entries(data.changed)) { + for (const [type, state] of Object.entries(types)) { + const key = `${accountId}/${type}`; + if (this.lastStates.get(key) === state) continue; + this.lastStates.set(key, state); + for (const fn of this.listeners) fn(accountId, type, state); + } + } + } catch { + /* ignore malformed */ + } + }); + es.addEventListener("ping", () => { + /* keepalive */ + }); + es.onerror = () => { + es.close(); + this.es = null; + this.setConnected(false); + if (this.stopped) return; + const delay = Math.min(this.backoff, 60_000); + this.backoff = Math.min(this.backoff * 2, 60_000); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + }; + } +} + +export const push = new PushManager(); diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts new file mode 100644 index 0000000..474dbd0 --- /dev/null +++ b/web/src/jmap/types.ts @@ -0,0 +1,775 @@ +/* ------------------------------------------------------------------ */ +/* JMAP core (RFC 8620) */ +/* ------------------------------------------------------------------ */ + +export type Id = string; +export type UTCDate = string; // "2024-01-01T10:00:00Z" +export type LocalDate = string; // "2024-01-01T10:00:00" + +export interface Account { + name: string; + isPersonal: boolean; + isReadOnly: boolean; + accountCapabilities: Record; +} + +export interface JmapSession { + capabilities: Record; + accounts: Record; + primaryAccounts: Record; + username: string; + apiUrl: string; + downloadUrl: string; + uploadUrl: string; + eventSourceUrl: string; + state: string; + ihasmail?: { + appName: string; + imageProxy: boolean; + maxUploadBytes: number; + sessionId: string; + loginName: string; + remember: boolean; + }; +} + +export interface CoreCapabilities { + maxSizeUpload: number; + maxConcurrentUpload: number; + maxSizeRequest: number; + maxConcurrentRequests: number; + maxCallsInRequest: number; + maxObjectsInGet: number; + maxObjectsInSet: number; + collationAlgorithms: string[]; +} + +export interface MailCapabilities { + maxMailboxesPerEmail: number | null; + maxMailboxDepth: number | null; + maxSizeMailboxName: number; + maxSizeAttachmentsPerEmail: number; + emailQuerySortOptions: string[]; + mayCreateTopLevelMailbox: boolean; +} + +export type Invocation = [name: string, args: Record, callId: string]; + +export interface JmapResponse { + methodResponses: Invocation[]; + sessionState: string; + createdIds?: Record; +} + +export interface MethodError { + type: string; + description?: string; + [k: string]: unknown; +} + +export interface SetError { + type: string; + description?: string; + properties?: string[]; + [k: string]: unknown; +} + +export interface SetResponse> { + accountId: Id; + oldState: string | null; + newState: string; + created?: Record; + updated?: Record; + destroyed?: Id[]; + notCreated?: Record; + notUpdated?: Record; + notDestroyed?: Record; +} + +export interface GetResponse { + accountId: Id; + state: string; + list: T[]; + notFound: Id[]; +} + +export interface QueryResponse { + accountId: Id; + queryState: string; + canCalculateChanges: boolean; + position: number; + ids: Id[]; + total?: number; + limit?: number; +} + +export interface ChangesResponse { + accountId: Id; + oldState: string; + newState: string; + hasMoreChanges: boolean; + created: Id[]; + updated: Id[]; + destroyed: Id[]; +} + +export interface StateChange { + "@type": "StateChange"; + changed: Record>; +} + +/* ------------------------------------------------------------------ */ +/* Mail (RFC 8621) */ +/* ------------------------------------------------------------------ */ + +export type MailboxRole = + | "inbox" + | "archive" + | "drafts" + | "sent" + | "trash" + | "junk" + | "important" + | "all" + | "flagged" + | "subscribed" + | null; + +export interface MailboxRights { + mayReadItems: boolean; + mayAddItems: boolean; + mayRemoveItems: boolean; + maySetSeen: boolean; + maySetKeywords: boolean; + mayCreateChild: boolean; + mayRename: boolean; + mayDelete: boolean; + maySubmit: boolean; +} + +export interface Mailbox { + id: Id; + name: string; + parentId: Id | null; + role: MailboxRole; + sortOrder: number; + totalEmails: number; + unreadEmails: number; + totalThreads: number; + unreadThreads: number; + myRights: MailboxRights; + isSubscribed: boolean; + shareWith?: Record> | null; +} + +export interface EmailAddress { + name: string | null; + email: string; +} + +export interface EmailAddressGroup { + name: string | null; + addresses: EmailAddress[]; +} + +export interface EmailHeader { + name: string; + value: string; +} + +export interface EmailBodyPart { + partId: string | null; + blobId: Id | null; + size: number; + headers?: EmailHeader[]; + name: string | null; + type: string; + charset: string | null; + disposition: string | null; + cid: string | null; + language?: string[] | null; + location?: string | null; + subParts?: EmailBodyPart[] | null; +} + +export interface EmailBodyValue { + value: string; + isEncodingProblem: boolean; + isTruncated: boolean; +} + +export interface Email { + id: Id; + blobId: Id; + threadId: Id; + mailboxIds: Record; + keywords: Record; + size: number; + receivedAt: UTCDate; + messageId?: string[] | null; + inReplyTo?: string[] | null; + references?: string[] | null; + sender?: EmailAddress[] | null; + from?: EmailAddress[] | null; + to?: EmailAddress[] | null; + cc?: EmailAddress[] | null; + bcc?: EmailAddress[] | null; + replyTo?: EmailAddress[] | null; + subject?: string | null; + sentAt?: string | null; + hasAttachment?: boolean; + preview?: string; + bodyStructure?: EmailBodyPart; + bodyValues?: Record; + textBody?: EmailBodyPart[]; + htmlBody?: EmailBodyPart[]; + attachments?: EmailBodyPart[]; + headers?: EmailHeader[]; + // convenience header fetches + "header:List-Unsubscribe:asText"?: string | null; + "header:List-Unsubscribe-Post:asText"?: string | null; + "header:List-Id:asText"?: string | null; + "header:Disposition-Notification-To:asAddresses"?: EmailAddress[] | null; + "header:X-Priority:asText"?: string | null; + "header:Importance:asText"?: string | null; + "header:Auto-Submitted:asText"?: string | null; + "header:Return-Path:asText"?: string | null; + "header:Authentication-Results:asText"?: string | null; + "header:Received:asText:all"?: string[] | null; + "header:X-Spam-Status:asText"?: string | null; + "header:X-Spam-Result:asText"?: string | null; +} + +export interface Thread { + id: Id; + emailIds: Id[]; +} + +export interface Identity { + id: Id; + name: string; + email: string; + replyTo: EmailAddress[] | null; + bcc: EmailAddress[] | null; + textSignature: string; + htmlSignature: string; + mayDelete: boolean; +} + +export interface EmailSubmission { + id: Id; + identityId: Id; + emailId: Id; + threadId: Id; + envelope: { mailFrom: { email: string; parameters?: Record | null }; rcptTo: { email: string }[] } | null; + sendAt: UTCDate; + undoStatus: "pending" | "final" | "canceled"; + deliveryStatus: Record | null; +} + +export interface VacationResponse { + id: "singleton"; + isEnabled: boolean; + fromDate: UTCDate | null; + toDate: UTCDate | null; + subject: string | null; + textBody: string | null; + htmlBody: string | null; +} + +export interface SearchSnippet { + emailId: Id; + subject: string | null; + preview: string | null; +} + +export interface EmailFilterCondition { + inMailbox?: Id; + inMailboxOtherThan?: Id[]; + before?: UTCDate; + after?: UTCDate; + minSize?: number; + maxSize?: number; + allInThreadHaveKeyword?: string; + someInThreadHaveKeyword?: string; + noneInThreadHaveKeyword?: string; + hasKeyword?: string; + notKeyword?: string; + hasAttachment?: boolean; + text?: string; + from?: string; + to?: string; + cc?: string; + bcc?: string; + subject?: string; + body?: string; + header?: string[]; +} + +export interface FilterOperator { + operator: "AND" | "OR" | "NOT"; + conditions: Array>; +} + +export type EmailFilter = EmailFilterCondition | FilterOperator; + +export interface Comparator { + property: string; + isAscending?: boolean; + collation?: string; + keyword?: string; +} + +/* ------------------------------------------------------------------ */ +/* Quota (RFC 9425) */ +/* ------------------------------------------------------------------ */ + +export interface Quota { + id: Id; + resourceType: "count" | "octets"; + used: number; + hardLimit: number; + scope: "account" | "domain" | "global"; + name: string; + types: string[]; + warnLimit?: number | null; + softLimit?: number | null; + description?: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Sieve (RFC 9661) */ +/* ------------------------------------------------------------------ */ + +export interface SieveScript { + id: Id; + name: string; + blobId: Id; + isActive: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Principals (RFC 9670) */ +/* ------------------------------------------------------------------ */ + +export interface Principal { + id: Id; + type: "individual" | "group" | "resource" | "location" | "other"; + name: string; + description: string | null; + email: string | null; + timeZone: string | null; + capabilities?: Record; + accounts?: Record | null; +} + +export interface BusyPeriod { + utcStart: UTCDate; + utcEnd: UTCDate; + busyStatus: "confirmed" | "tentative" | "unavailable"; + event: JSCalendarEvent | null; +} + +/* ------------------------------------------------------------------ */ +/* Contacts (RFC 9610 / JSContact RFC 9553) */ +/* ------------------------------------------------------------------ */ + +export interface AddressBookRights { + mayRead: boolean; + mayWrite: boolean; + mayShare: boolean; + mayDelete: boolean; +} + +export interface AddressBook { + id: Id; + name: string; + description: string | null; + sortOrder: number; + isDefault: boolean; + isSubscribed: boolean; + shareWith: Record | null; + myRights: AddressBookRights; +} + +export interface JSContactNameComponent { + "@type"?: "NameComponent"; + kind: "title" | "given" | "given2" | "surname" | "surname2" | "credential" | "generation" | "separator"; + value: string; +} + +export interface JSContactName { + "@type"?: "Name"; + components?: JSContactNameComponent[]; + isOrdered?: boolean; + full?: string; + defaultSeparator?: string; + sortAs?: Record; +} + +export interface JSContactEmail { + "@type"?: "EmailAddress"; + address: string; + contexts?: Record; + pref?: number; + label?: string; +} + +export interface JSContactPhone { + "@type"?: "Phone"; + number: string; + features?: Record; + contexts?: Record; + pref?: number; + label?: string; +} + +export interface JSContactAddressComponent { + "@type"?: "AddressComponent"; + kind: string; + value: string; +} + +export interface JSContactAddress { + "@type"?: "Address"; + components?: JSContactAddressComponent[]; + isOrdered?: boolean; + countryCode?: string; + coordinates?: string; + timeZone?: string; + contexts?: Record; + full?: string; + defaultSeparator?: string; + pref?: number; +} + +export interface JSContactOrganization { + "@type"?: "Organization"; + name?: string; + units?: { "@type"?: "OrgUnit"; name: string }[]; + sortAs?: string; + contexts?: Record; +} + +export interface JSContactTitle { + "@type"?: "Title"; + name: string; + kind?: "title" | "role"; + organizationId?: string; +} + +export interface JSContactAnniversary { + "@type"?: "Anniversary"; + kind: "birth" | "death" | "wedding" | string; + date: { "@type"?: "PartialDate" | "Timestamp"; year?: number; month?: number; day?: number; utc?: string }; + place?: JSContactAddress; +} + +export interface JSContactNote { + "@type"?: "Note"; + note: string; + created?: string; + author?: { name?: string; uri?: string }; +} + +export interface JSContactOnlineService { + "@type"?: "OnlineService"; + service?: string; + uri?: string; + user?: string; + contexts?: Record; + pref?: number; + label?: string; +} + +export interface JSContactMedia { + "@type"?: "Media"; + kind: "photo" | "sound" | "logo"; + uri?: string; + blobId?: Id; + mediaType?: string; + contexts?: Record; + pref?: number; + label?: string; +} + +export interface JSContactRelation { + "@type"?: "Relation"; + relation?: Record; +} + +export interface ContactCard { + id: Id; + addressBookIds: Record; + "@type"?: "Card"; + version?: "1.0"; + uid: string; + kind?: "individual" | "group" | "org" | "location" | "device" | "application"; + created?: UTCDate; + updated?: UTCDate; + language?: string; + prodId?: string; + members?: Record; + name?: JSContactName; + nicknames?: Record; pref?: number }>; + organizations?: Record; + titles?: Record; + emails?: Record; + phones?: Record; + addresses?: Record; + onlineServices?: Record; + anniversaries?: Record; + notes?: Record; + keywords?: Record; + media?: Record; + relatedTo?: Record; + links?: Record; + preferredLanguages?: Record }>; + speakToAs?: { "@type"?: "SpeakToAs"; grammaticalGender?: string; pronouns?: Record }; + calendars?: Record; + schedulingAddresses?: Record; + personalInfo?: Record; +} + +/* ------------------------------------------------------------------ */ +/* Calendars (draft-ietf-jmap-calendars / JSCalendar RFC 8984) */ +/* ------------------------------------------------------------------ */ + +export interface CalendarRights { + mayReadFreeBusy: boolean; + mayReadItems: boolean; + mayWriteAll: boolean; + mayWriteOwn: boolean; + mayUpdatePrivate: boolean; + mayRSVP: boolean; + mayShare: boolean; + mayDelete: boolean; +} + +export interface Calendar { + id: Id; + name: string; + description: string | null; + color: string | null; + sortOrder: number; + isSubscribed: boolean; + isVisible: boolean; + isDefault: boolean; + includeInAvailability: "all" | "attending" | "none"; + defaultAlertsWithTime: Record | null; + defaultAlertsWithoutTime: Record | null; + timeZone: string | null; + shareWith: Record | null; + myRights: CalendarRights; +} + +export interface JSCalendarAlert { + "@type"?: "Alert"; + trigger: + | { "@type"?: "OffsetTrigger"; offset: string; relativeTo?: "start" | "end" } + | { "@type"?: "AbsoluteTrigger"; when: UTCDate }; + acknowledged?: UTCDate; + action?: "display" | "email"; + relatedTo?: Record; +} + +export interface JSCalendarNDay { + "@type"?: "NDay"; + day: "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su"; + nthOfPeriod?: number; +} + +export interface JSCalendarRecurrenceRule { + "@type"?: "RecurrenceRule"; + frequency: "yearly" | "monthly" | "weekly" | "daily" | "hourly" | "minutely" | "secondly"; + interval?: number; + rscale?: string; + skip?: string; + firstDayOfWeek?: string; + byDay?: JSCalendarNDay[]; + byMonthDay?: number[]; + byMonth?: string[]; + byYearDay?: number[]; + byWeekNo?: number[]; + byHour?: number[]; + byMinute?: number[]; + bySecond?: number[]; + bySetPosition?: number[]; + count?: number; + until?: LocalDate; +} + +export interface JSCalendarParticipant { + "@type"?: "Participant"; + name?: string; + email?: string; + description?: string; + sendTo?: Record; + kind?: "individual" | "group" | "location" | "resource"; + roles: Record; + locationId?: string; + language?: string; + participationStatus?: "needs-action" | "accepted" | "declined" | "tentative" | "delegated"; + participationComment?: string; + expectReply?: boolean; + scheduleAgent?: "server" | "client" | "none"; + scheduleForceSend?: boolean; + scheduleSequence?: number; + scheduleStatus?: string[]; + scheduleUpdated?: UTCDate; + sentBy?: string; + invitedBy?: string; + delegatedTo?: Record; + delegatedFrom?: Record; + memberOf?: Record; + links?: Record; + progress?: string; + percentComplete?: number; +} + +export interface JSCalendarLocation { + "@type"?: "Location"; + name?: string; + description?: string; + locationTypes?: Record; + relativeTo?: "start" | "end"; + timeZone?: string; + coordinates?: string; + links?: Record; +} + +export interface JSCalendarVirtualLocation { + "@type"?: "VirtualLocation"; + name?: string; + description?: string; + uri: string; + features?: Record; +} + +export interface JSCalendarEvent { + "@type"?: "Event"; + uid: string; + relatedTo?: Record; + prodId?: string; + created?: UTCDate; + updated?: UTCDate; + sequence?: number; + method?: string; + title?: string; + description?: string; + descriptionContentType?: string; + showWithoutTime?: boolean; + locations?: Record; + virtualLocations?: Record; + links?: Record; + locale?: string; + keywords?: Record; + categories?: Record; + color?: string; + recurrenceId?: LocalDate; + recurrenceIdTimeZone?: string; + recurrenceRules?: JSCalendarRecurrenceRule[]; + excludedRecurrenceRules?: JSCalendarRecurrenceRule[]; + recurrenceOverrides?: Record | null>; + excluded?: boolean; + priority?: number; + freeBusyStatus?: "free" | "busy"; + privacy?: "public" | "private" | "secret"; + replyTo?: Record; + sentBy?: string; + participants?: Record; + requestStatus?: string; + useDefaultAlerts?: boolean; + alerts?: Record; + localizations?: Record>; + timeZone?: string | null; + start: LocalDate; + duration?: string; + status?: "confirmed" | "cancelled" | "tentative"; +} + +export interface CalendarEvent extends JSCalendarEvent { + id: Id; + baseEventId?: Id | null; + calendarIds: Record; + isDraft?: boolean; + isOrigin?: boolean; + utcStart?: UTCDate; + utcEnd?: UTCDate; + mayInviteSelf?: boolean; + mayInviteOthers?: boolean; + hideAttendees?: boolean; +} + +export interface ParticipantIdentity { + id: Id; + name: string; + calendarAddress: string; + sendTo: Record; + isDefault: boolean; +} + +export interface CalendarEventNotification { + id: Id; + created: UTCDate; + changedBy: { name: string; email: string | null; principalId: Id | null; calendarAddress?: string | null }; + comment: string | null; + type: "created" | "updated" | "destroyed"; + calendarEventId: Id; + isDraft?: boolean; + event: JSCalendarEvent; + eventPatch?: Record; +} + +/* ------------------------------------------------------------------ */ +/* Files (draft-ietf-jmap-filenode) */ +/* ------------------------------------------------------------------ */ + +export interface FilesRights { + mayRead: boolean; + mayAddChildren: boolean; + mayRename: boolean; + mayDelete: boolean; + mayModifyContent: boolean; + mayShare: boolean; +} + +export interface FileNode { + id: Id; + parentId: Id | null; + nodeType: "file" | "directory" | "symlink"; + blobId: Id | null; + target?: string[] | null; + size: number | null; + name: string; + type: string | null; + created: UTCDate; + modified: UTCDate | null; + accessed?: UTCDate | null; + changed?: UTCDate; + executable?: boolean; + isSubscribed?: boolean; + myRights: FilesRights; + shareWith?: Record | null; + role?: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Blob (RFC 9404) */ +/* ------------------------------------------------------------------ */ + +export interface UploadResponse { + accountId: Id; + blobId: Id; + type: string; + size: number; +} + +export interface BlobGetResponse { + id: Id; + "data:asText"?: string | null; + "data:asBase64"?: string | null; + size?: number; + isEncodingProblem?: boolean; + isTruncated?: boolean; +} diff --git a/web/src/lib/__tests__/address.test.ts b/web/src/lib/__tests__/address.test.ts new file mode 100644 index 0000000..daa48e6 --- /dev/null +++ b/web/src/lib/__tests__/address.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { formatAddress, initials, isValidEmail, parseAddressList } from "../address"; + +describe("address parsing", () => { + it("parses mixed lists", () => { + const list = parseAddressList('Ann Example , bob@example.org; "Smith, John" '); + expect(list).toEqual([ + { name: "Ann Example", email: "ann@example.com" }, + { name: null, email: "bob@example.org" }, + { name: "Smith, John", email: "j@x.io" }, + ]); + }); + it("formats with quoting when needed", () => { + expect(formatAddress({ name: "Smith, John", email: "j@x.io" })).toBe('"Smith, John" '); + expect(formatAddress({ name: null, email: "j@x.io" })).toBe("j@x.io"); + }); + it("validates and initials", () => { + expect(isValidEmail("a@b.co")).toBe(true); + expect(isValidEmail("nope")).toBe(false); + expect(initials({ name: "Grace Hopper", email: "" })).toBe("GH"); + expect(initials({ name: null, email: "linus@kernel.org" })).toBe("LK"); + }); +}); diff --git a/web/src/lib/__tests__/dates.test.ts b/web/src/lib/__tests__/dates.test.ts new file mode 100644 index 0000000..403a6f6 --- /dev/null +++ b/web/src/lib/__tests__/dates.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration, parseDuration, zonedToDate, dateToZonedLocal, monthGrid } from "../dates"; + +describe("dates", () => { + it("parses and formats ISO durations", () => { + expect(parseDuration("PT1H30M")).toBe(5400); + expect(parseDuration("P1DT2H")).toBe(93600); + expect(parseDuration("-PT15M")).toBe(-900); + expect(formatDuration(5400)).toBe("PT1H30M"); + expect(formatDuration(-600)).toBe("-PT10M"); + expect(formatDuration(86400)).toBe("P1D"); + }); + it("converts zoned local times to instants", () => { + const d = zonedToDate("2024-07-01T12:00:00", "America/New_York"); + expect(d.toISOString()).toBe("2024-07-01T16:00:00.000Z"); + expect(dateToZonedLocal(d, "Europe/Berlin")).toBe("2024-07-01T18:00:00"); + }); + it("builds a 42-day month grid starting on week start", () => { + const g = monthGrid(new Date(2024, 1, 15), 1); + expect(g).toHaveLength(42); + expect(g[0]!.getDay()).toBe(1); + }); +}); diff --git a/web/src/lib/__tests__/html.test.ts b/web/src/lib/__tests__/html.test.ts new file mode 100644 index 0000000..3892dcd --- /dev/null +++ b/web/src/lib/__tests__/html.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeEmailHtml, sanitizeEditorHtml } from "../html"; + +describe("sanitizeEmailHtml", () => { + it("removes scripts and event handlers", () => { + const r = sanitizeEmailHtml('
hi
'); + expect(r.html).not.toContain("script"); + expect(r.html).not.toContain("onclick"); + expect(r.html).not.toContain("iframe"); + }); + it("blocks remote images until allowed and maps cid", () => { + const src = '
x
'; + const blocked = sanitizeEmailHtml(src, { cidMap: { "logo@x": "/api/blob/a/b/logo.png" } }); + expect(blocked.remoteCount).toBe(2); + expect(blocked.html).toContain('data-ihm-blocked="1"'); + expect(blocked.html).toContain("/api/blob/a/b/logo.png"); + expect(blocked.html).not.toMatch(/src="https:\/\/t\.example/); + expect(blocked.html).not.toContain("url(https://t.example"); + const allowed = sanitizeEmailHtml(src, { allowRemote: true, proxyRemote: true }); + expect(allowed.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fp.gif"); + }); + it("forces links to open in new tabs", () => { + const r = sanitizeEmailHtml('x'); + expect(r.html).toContain('target="_blank"'); + expect(r.html).toContain("noopener"); + }); + it("strips javascript: urls", () => { + const r = sanitizeEmailHtml('x'); + expect(r.html).not.toContain("javascript:"); + }); + it("editor sanitizer keeps basic formatting", () => { + expect(sanitizeEditorHtml("x")).toBe("x"); + }); +}); diff --git a/web/src/lib/__tests__/search.test.ts b/web/src/lib/__tests__/search.test.ts new file mode 100644 index 0000000..5024d2e --- /dev/null +++ b/web/src/lib/__tests__/search.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { buildFilter, parseQuery } from "../search"; +import type { Mailbox } from "@/jmap/types"; + +const mb = (id: string, name: string, role: Mailbox["role"] = null): Mailbox => + ({ id, name, role, parentId: null, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] }); + +describe("parseQuery", () => { + it("parses gmail-style operators", () => { + const p = parseQuery('from:ada subject:"q3 plan" has:attachment is:unread in:work before:2024-01-02 larger:2M hello world'); + expect(p.from).toBe("ada"); + expect(p.subject).toBe("q3 plan"); + expect(p.hasAttachment).toBe(true); + expect(p.unread).toBe(true); + expect(p.in).toBe("work"); + expect(p.before).toMatch(/^2024-01-0[12]T/); + expect(p.larger).toBe(2 * 1024 * 1024); + expect(p.text).toEqual(["hello", "world"]); + }); + it("handles labels and negation", () => { + const p = parseQuery("label:work -label:done is:starred"); + expect(p.label).toEqual(["work"]); + expect(p.notLabel).toEqual(["done"]); + expect(p.starred).toBe(true); + }); +}); + +describe("buildFilter", () => { + const mailboxes = { inbox: mb("inbox", "Inbox", "inbox"), work: mb("work", "Work") }; + it("builds a simple condition", () => { + const f = buildFilter(parseQuery("invoice"), mailboxes, "inbox"); + expect(f).toEqual({ text: "invoice", inMailbox: "inbox" }); + }); + it("resolves in: to a mailbox by name and ANDs keyword conditions", () => { + const f = buildFilter(parseQuery("in:work is:starred label:foo"), mailboxes, "inbox"); + expect(f).toEqual({ operator: "AND", conditions: [{ inMailbox: "work" }, { hasKeyword: "$flagged" }, { hasKeyword: "foo" }] }); + }); + it("maps is:unread to notKeyword $seen", () => { + const f = buildFilter(parseQuery("is:unread"), mailboxes, null); + expect(f).toEqual({ notKeyword: "$seen" }); + }); +}); diff --git a/web/src/lib/__tests__/sieve.test.ts b/web/src/lib/__tests__/sieve.test.ts new file mode 100644 index 0000000..e2613f2 --- /dev/null +++ b/web/src/lib/__tests__/sieve.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { newRule, rulesToSieve, sieveToRules, testToSieve, sieveString } from "../sieve"; + +describe("sieve codec", () => { + it("escapes strings", () => { + expect(sieveString('a "quoted" \\ value')).toBe('"a \\"quoted\\" \\\\ value"'); + }); + it("generates tests", () => { + expect(testToSieve({ type: "header", header: "subject", op: "contains", value: "hi" })).toBe('header :contains "subject" "hi"'); + expect(testToSieve({ type: "header", header: "x-foo", op: "notexists", value: "" })).toBe('not exists "x-foo"'); + expect(testToSieve({ type: "address", header: "from", part: "domain", op: "is", value: "example.com" })).toBe('address :domain :is "from" "example.com"'); + expect(testToSieve({ type: "size", op: "over", value: 2048 })).toBe("size :over 2048"); + }); + it("round-trips rules through a script", () => { + const rules = [ + newRule({ id: "r1", name: "Newsletters", tests: [{ type: "header", header: "list-id", op: "exists", value: "" }], actions: [{ type: "fileinto", mailbox: "Newsletters" }, { type: "markread" }, { type: "stop" }] }), + newRule({ id: "r2", name: "Big", enabled: false, join: "anyof", tests: [{ type: "size", op: "over", value: 5_000_000 }], actions: [{ type: "addflag", flag: "big" }] }), + ]; + const script = rulesToSieve(rules); + expect(script).toContain('require ["fileinto", "imap4flags"];'); + expect(script).toContain('if exists "list-id"'); + expect(script).toContain('fileinto "Newsletters";'); + expect(script).toContain('addflag "\\\\Seen";'); + expect(script).toContain("# (disabled) Big"); + expect(sieveToRules(script)).toEqual(rules); + }); + it("reports hand-written scripts as raw", () => { + expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull(); + expect(sieveToRules("")).toEqual([]); + }); +}); diff --git a/web/src/lib/__tests__/sieveApply.test.ts b/web/src/lib/__tests__/sieveApply.test.ts new file mode 100644 index 0000000..b0a3e5e --- /dev/null +++ b/web/src/lib/__tests__/sieveApply.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { evaluateRule, evaluateTest } from "../sieveApply"; +import type { Email } from "@/jmap/types"; +import type { SieveRule } from "../sieve"; + +const email = { + id: "e1", blobId: "b", threadId: "t", mailboxIds: { inbox: true }, keywords: {}, size: 5000, receivedAt: "2026-01-01T00:00:00Z", + from: [{ name: "Ada Lovelace", email: "ada@example.org" }], to: [{ name: null, email: "me@x.io" }], subject: "Invoice #42 is ready", preview: "Please find attached", + "header:List-Id:asText": "", +} as unknown as Email; + +describe("sieve client-side evaluation", () => { + it("evaluates header/address/size/body tests", () => { + expect(evaluateTest(email, { type: "header", header: "from", op: "contains", value: "ada@" })).toBe(true); + expect(evaluateTest(email, { type: "header", header: "subject", op: "matches", value: "invoice*ready" })).toBe(true); + expect(evaluateTest(email, { type: "header", header: "subject", op: "regex", value: "^Invoice #\\d+" })).toBe(true); + expect(evaluateTest(email, { type: "header", header: "list-id", op: "exists", value: "" })).toBe(true); + expect(evaluateTest(email, { type: "header", header: "x-none", op: "notexists", value: "" })).toBe(true); + expect(evaluateTest(email, { type: "address", header: "from", part: "domain", op: "is", value: "example.org" })).toBe(true); + expect(evaluateTest(email, { type: "address", header: "from", part: "localpart", op: "is", value: "ada" })).toBe(true); + expect(evaluateTest(email, { type: "size", op: "over", value: 1000 })).toBe(true); + expect(evaluateTest(email, { type: "size", op: "under", value: 1000 })).toBe(false); + expect(evaluateTest(email, { type: "body", op: "contains", value: "attached" }, "Please find attached the file")).toBe(true); + }); + it("combines with allof/anyof", () => { + const base: SieveRule = { id: "r", name: "r", enabled: true, join: "allof", tests: [{ type: "header", header: "from", op: "contains", value: "ada" }, { type: "header", header: "subject", op: "contains", value: "nope" }], actions: [] }; + expect(evaluateRule(email, base)).toBe(false); + expect(evaluateRule(email, { ...base, join: "anyof" })).toBe(true); + expect(evaluateRule(email, { ...base, tests: [{ type: "true" }] })).toBe(true); + }); +}); diff --git a/web/src/lib/__tests__/signatureHtml.test.ts b/web/src/lib/__tests__/signatureHtml.test.ts new file mode 100644 index 0000000..8671260 --- /dev/null +++ b/web/src/lib/__tests__/signatureHtml.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildMarkerSignature, compactHtml, markerOf, SIGNATURE_LIMIT } from "../signatureHtml"; + +describe("signature compaction", () => { + it("strips office cruft and non-essential styles but keeps colours and links", () => { + const src = `

John Ellis

linuxexpert.org
`; + const out = compactHtml(src); + expect(out).not.toContain("mso-"); + expect(out).not.toContain("class="); + expect(out).not.toContain("John Ellis"); + expect(out).toContain('href="https://linuxexpert.org"'); + expect(out).toContain('width="100"'); + expect(out.length).toBeLessThan(src.length / 2); + }); + it("builds marker signatures within the limit", () => { + const big = `
${"x".repeat(1000)}
`; + const m = buildMarkerSignature("blob123", big); + expect(m.htmlSignature.length).toBeLessThanOrEqual(SIGNATURE_LIMIT); + expect(m.textSignature.length).toBeLessThanOrEqual(SIGNATURE_LIMIT); + expect(markerOf(m.htmlSignature)).toEqual({ blobId: "blob123", type: "text/html" }); + expect(markerOf("
plain
")).toBeNull(); + }); +}); diff --git a/web/src/lib/__tests__/text.test.ts b/web/src/lib/__tests__/text.test.ts new file mode 100644 index 0000000..b25408a --- /dev/null +++ b/web/src/lib/__tests__/text.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { htmlToText, quoteText, replySubject, textToHtml } from "../text"; + +describe("text helpers", () => { + it("linkifies and escapes", () => { + const html = textToHtml("see now"); + expect(html).toContain("<"); + expect(html).toContain(' { + expect(textToHtml("> hi\n>> there")).toContain('class="q1"'); + expect(textToHtml("> hi\n>> there")).toContain('class="q2"'); + }); + it("converts html to text", () => { + const t = htmlToText("

Hello world

  • one
  • two
q
link"); + expect(t).toContain("Hello world"); + expect(t).toContain("- one"); + expect(t).toContain("> q"); + expect(t).toContain("link "); + }); + it("quotes and subjects", () => { + expect(quoteText("a\n> b")).toBe("> a\n>> b"); + expect(replySubject("Re: Hi", "Re")).toBe("Re: Hi"); + expect(replySubject("Fwd: Hi", "Re")).toBe("Re: Hi"); + expect(replySubject("Hi", "Fwd")).toBe("Fwd: Hi"); + }); +}); diff --git a/web/src/lib/address.ts b/web/src/lib/address.ts new file mode 100644 index 0000000..e7475f6 --- /dev/null +++ b/web/src/lib/address.ts @@ -0,0 +1,116 @@ +import type { EmailAddress } from "@/jmap/types"; + +const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/; + +export function isValidEmail(s: string): boolean { + return EMAIL_RE.test(s.trim()); +} + +/** + * Parse a free-form recipient string ("Ann , bob@y.org; \"C, D\" ") + * into a list of EmailAddress. Lenient by design. + */ +export function parseAddressList(input: string): EmailAddress[] { + const out: EmailAddress[] = []; + let buf = ""; + let inQuote = false; + let inAngle = false; + const flush = () => { + const a = parseOne(buf); + if (a) out.push(a); + buf = ""; + }; + for (const ch of input) { + if (ch === '"' && !inAngle) inQuote = !inQuote; + if (ch === "<" && !inQuote) inAngle = true; + if (ch === ">" && !inQuote) inAngle = false; + if ((ch === "," || ch === ";" || ch === "\n") && !inQuote && !inAngle) { + flush(); + continue; + } + buf += ch; + } + flush(); + return out; +} + +export function parseOne(raw: string): EmailAddress | null { + const s = raw.trim(); + if (!s) return null; + const m = /^(.*?)\s*<([^<>]+)>\s*$/.exec(s); + if (m) { + let name = m[1]!.trim(); + if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, "$1"); + return { name: name || null, email: m[2]!.trim() }; + } + return { name: null, email: s.replace(/^<|>$/g, "") }; +} + +export function formatAddress(a: EmailAddress | null | undefined): string { + if (!a) return ""; + if (!a.name) return a.email; + const needsQuote = /[,;<>"()\\]/.test(a.name); + const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name; + return `${name} <${a.email}>`; +} + +export function formatAddressList(list: EmailAddress[] | null | undefined): string { + return (list ?? []).map(formatAddress).join(", "); +} + +export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string { + if (!a) return fallback; + if (a.name?.trim()) return a.name.trim(); + return a.email || fallback; +} + +export function shortName(a: EmailAddress | null | undefined): string { + const n = displayName(a, ""); + if (!n) return ""; + if (n.includes("@")) return n.split("@")[0]!; + return n.split(/\s+/)[0]!; +} + +export function initials(a: EmailAddress | { name?: string | null; email?: string } | string | null | undefined): string { + const name = typeof a === "string" ? a : a?.name || a?.email || ""; + const parts = name + .replace(/[<>"]/g, "") + .split(/[\s._@-]+/) + .filter(Boolean); + if (!parts.length) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); +} + +const PALETTE = [ + "#0f766e", "#b45309", "#7c3aed", "#be185d", "#1d4ed8", "#047857", + "#c2410c", "#4338ca", "#a21caf", "#0e7490", "#b91c1c", "#15803d", +]; + +export function avatarColor(seed: string | null | undefined): string { + const s = (seed ?? "").toLowerCase(); + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; + return PALETTE[h % PALETTE.length]!; +} + +export function sameAddress(a: string | null | undefined, b: string | null | undefined): boolean { + return (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase(); +} + +export function uniqueAddresses(list: EmailAddress[]): EmailAddress[] { + const seen = new Set(); + const out: EmailAddress[] = []; + for (const a of list) { + const k = a.email.trim().toLowerCase(); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(a); + } + return out; +} + +export function domainOf(email: string): string { + const i = email.lastIndexOf("@"); + return i >= 0 ? email.slice(i + 1).toLowerCase() : ""; +} diff --git a/web/src/lib/contacts.ts b/web/src/lib/contacts.ts new file mode 100644 index 0000000..f369cfc --- /dev/null +++ b/web/src/lib/contacts.ts @@ -0,0 +1,149 @@ +import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types"; + +/** Best display name for a card. */ +export function contactDisplayName(c: ContactCard): string { + const n = c.name; + if (n?.full?.trim()) return n.full.trim(); + const comps = n?.components ?? []; + const ordered = comps.filter((x) => ["given", "given2", "surname", "surname2"].includes(x.kind)); + if (ordered.length) { + // Prefer given + surname order regardless of isOrdered for display. + const given = comps.filter((x) => x.kind === "given" || x.kind === "given2").map((x) => x.value).join(" "); + const sur = comps.filter((x) => x.kind === "surname" || x.kind === "surname2").map((x) => x.value).join(" "); + const s = `${given} ${sur}`.trim(); + if (s) return s; + } + if (c.kind === "group" || c.kind === "org") { + const org = Object.values(c.organizations ?? {})[0]?.name; + if (org) return org; + } + const nick = Object.values(c.nicknames ?? {})[0]?.name; + if (nick) return nick; + const org = Object.values(c.organizations ?? {})[0]?.name; + if (org) return org; + const email = primaryEmail(c); + if (email) return email; + return "(no name)"; +} + +export function nameParts(c: ContactCard): { given: string; surname: string; prefix: string; suffix: string; middle: string } { + const comps = c.name?.components ?? []; + const pick = (k: string) => comps.filter((x) => x.kind === k).map((x) => x.value).join(" "); + return { given: pick("given"), middle: pick("given2"), surname: pick("surname"), prefix: pick("title"), suffix: pick("credential") || pick("generation") }; +} + +export function buildName(parts: { given?: string; middle?: string; surname?: string; prefix?: string; suffix?: string }): JSContactName | undefined { + const components: JSContactName["components"] = []; + if (parts.prefix?.trim()) components.push({ "@type": "NameComponent", kind: "title", value: parts.prefix.trim() }); + if (parts.given?.trim()) components.push({ "@type": "NameComponent", kind: "given", value: parts.given.trim() }); + if (parts.middle?.trim()) components.push({ "@type": "NameComponent", kind: "given2", value: parts.middle.trim() }); + if (parts.surname?.trim()) components.push({ "@type": "NameComponent", kind: "surname", value: parts.surname.trim() }); + if (parts.suffix?.trim()) components.push({ "@type": "NameComponent", kind: "credential", value: parts.suffix.trim() }); + if (!components.length) return undefined; + const full = [parts.prefix, parts.given, parts.middle, parts.surname, parts.suffix].map((s) => s?.trim()).filter(Boolean).join(" "); + return { "@type": "Name", components, isOrdered: true, full }; +} + +export function primaryEmail(c: ContactCard): string | null { + const emails = Object.values(c.emails ?? {}); + if (!emails.length) return null; + const sorted = [...emails].sort((a, b) => (a.pref ?? 100) - (b.pref ?? 100)); + return sorted[0]!.address; +} + +export function contactEmails(c: ContactCard): EmailAddress[] { + const name = contactDisplayName(c); + return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address })); +} + +export function contactPhoto(c: ContactCard, accountId: string): string | null { + const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo"); + if (!m) return null; + if (m.uri) return m.uri.startsWith("data:") ? m.uri : null; + if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`; + return null; +} + +export function sortKey(c: ContactCard, by: "surname" | "given" = "given"): string { + const p = nameParts(c); + const k = by === "surname" ? `${p.surname} ${p.given}` : `${p.given} ${p.surname}`; + return (k.trim() || contactDisplayName(c)).toLowerCase(); +} + +export function formatAddressLines(a: { components?: Array<{ kind: string; value: string }>; full?: string }): string[] { + if (a.full) return a.full.split(/\n/); + const get = (k: string) => + (a.components ?? []) + .filter((c) => c.kind === k) + .map((c) => c.value) + .join(" "); + const lines: string[] = []; + const street = [get("number"), get("name"), get("apartment"), get("building"), get("floor"), get("room")].filter(Boolean).join(" "); + const pobox = get("postOfficeBox"); + if (pobox) lines.push(pobox); + if (street) lines.push(street); + const city = [get("locality"), get("region")].filter(Boolean).join(", "); + const cityLine = [city, get("postcode")].filter(Boolean).join(" "); + if (cityLine) lines.push(cityLine); + if (get("country")) lines.push(get("country")); + return lines; +} + +/** Generate a vCard 4.0 for export. */ +export function toVCard(c: ContactCard): string { + const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); + const lines = ["BEGIN:VCARD", "VERSION:4.0"]; + lines.push(`UID:${c.uid}`); + if (c.kind && c.kind !== "individual") lines.push(`KIND:${c.kind}`); + lines.push(`FN:${esc(contactDisplayName(c))}`); + const p = nameParts(c); + if (p.given || p.surname) lines.push(`N:${esc(p.surname)};${esc(p.given)};${esc(p.middle)};${esc(p.prefix)};${esc(p.suffix)}`); + for (const n of Object.values(c.nicknames ?? {})) lines.push(`NICKNAME:${esc(n.name)}`); + for (const e of Object.values(c.emails ?? {})) { + const types = Object.keys(e.contexts ?? {}).join(","); + lines.push(`EMAIL${types ? `;TYPE=${types}` : ""}${e.pref ? `;PREF=${e.pref}` : ""}:${e.address}`); + } + for (const ph of Object.values(c.phones ?? {})) { + const types = [...Object.keys(ph.contexts ?? {}), ...Object.keys(ph.features ?? {})].join(","); + lines.push(`TEL${types ? `;TYPE=${types}` : ""}${ph.pref ? `;PREF=${ph.pref}` : ""}:${ph.number}`); + } + for (const a of Object.values(c.addresses ?? {})) { + const get = (k: string) => + (a.components ?? []) + .filter((x) => x.kind === k) + .map((x) => x.value) + .join(" "); + const street = [get("number"), get("name"), get("apartment")].filter(Boolean).join(" "); + const types = Object.keys(a.contexts ?? {}).join(","); + lines.push(`ADR${types ? `;TYPE=${types}` : ""}:${esc(get("postOfficeBox"))};;${esc(street)};${esc(get("locality"))};${esc(get("region"))};${esc(get("postcode"))};${esc(get("country"))}`); + } + for (const o of Object.values(c.organizations ?? {})) lines.push(`ORG:${esc(o.name ?? "")}${(o.units ?? []).map((u) => `;${esc(u.name)}`).join("")}`); + for (const t of Object.values(c.titles ?? {})) lines.push(`${t.kind === "role" ? "ROLE" : "TITLE"}:${esc(t.name)}`); + for (const an of Object.values(c.anniversaries ?? {})) { + const d = an.date; + const v = d.utc ? d.utc.slice(0, 10).replace(/-/g, "") : `${d.year ?? "--"}${String(d.month ?? 0).padStart(2, "0")}${String(d.day ?? 0).padStart(2, "0")}`; + if (an.kind === "birth") lines.push(`BDAY:${v}`); + else if (an.kind === "wedding") lines.push(`ANNIVERSARY:${v}`); + } + for (const n of Object.values(c.notes ?? {})) lines.push(`NOTE:${esc(n.note)}`); + for (const l of Object.values(c.links ?? {})) lines.push(`URL:${l.uri}`); + for (const s of Object.values(c.onlineServices ?? {})) if (s.uri) lines.push(`IMPP:${s.uri}`); + if (c.members) for (const m of Object.keys(c.members)) lines.push(`MEMBER:${m}`); + lines.push("END:VCARD"); + return lines.map(fold).join("\r\n") + "\r\n"; +} + +function fold(line: string): string { + if (line.length <= 75) return line; + const out: string[] = []; + let i = 0; + while (i < line.length) { + out.push((i ? " " : "") + line.slice(i, i + 74)); + i += 74; + } + return out.join("\r\n"); +} + +export function newKey(prefix = "k"): string { + return `${prefix}${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/web/src/lib/dates.ts b/web/src/lib/dates.ts new file mode 100644 index 0000000..5b772a6 --- /dev/null +++ b/web/src/lib/dates.ts @@ -0,0 +1,250 @@ +export const DAY_MS = 86_400_000; + +export function startOfDay(d: Date): Date { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +export function endOfDay(d: Date): Date { + const x = new Date(d); + x.setHours(23, 59, 59, 999); + return x; +} + +export function addDays(d: Date, n: number): Date { + const x = new Date(d); + x.setDate(x.getDate() + n); + return x; +} + +export function addMonths(d: Date, n: number): Date { + const x = new Date(d); + const day = x.getDate(); + x.setDate(1); + x.setMonth(x.getMonth() + n); + const dim = daysInMonth(x.getFullYear(), x.getMonth()); + x.setDate(Math.min(day, dim)); + return x; +} + +export function addMinutes(d: Date, n: number): Date { + return new Date(d.getTime() + n * 60_000); +} + +export function daysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +export function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +export function endOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999); +} + +/** weekStart: 0 = Sunday, 1 = Monday */ +export function startOfWeek(d: Date, weekStart = 1): Date { + const x = startOfDay(d); + const diff = (x.getDay() - weekStart + 7) % 7; + return addDays(x, -diff); +} + +export function isSameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +export function isToday(d: Date): boolean { + return isSameDay(d, new Date()); +} + +/** 6x7 grid of dates covering the month view. */ +export function monthGrid(anchor: Date, weekStart = 1): Date[] { + const first = startOfWeek(startOfMonth(anchor), weekStart); + const out: Date[] = []; + for (let i = 0; i < 42; i++) out.push(addDays(first, i)); + return out; +} + +export function weekDays(anchor: Date, weekStart = 1, count = 7): Date[] { + const first = startOfWeek(anchor, weekStart); + const out: Date[] = []; + for (let i = 0; i < count; i++) out.push(addDays(first, i)); + return out; +} + +function pad(n: number, w = 2): string { + return String(n).padStart(w, "0"); +} + +/** Format a Date's wall-clock (browser local) as JSCalendar LocalDateTime. */ +export function toLocalDateTime(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function toLocalDateOnly(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Date → "YYYY-MM-DDTHH:MM:SSZ" (JMAP UTCDate, no millis). */ +export function toUTCDate(d: Date): string { + return d.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export function parseLocalDateTime(s: string): { y: number; mo: number; d: number; h: number; mi: number; se: number } | null { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s); + if (!m) return null; + return { y: +m[1]!, mo: +m[2]! - 1, d: +m[3]!, h: +(m[4] ?? 0), mi: +(m[5] ?? 0), se: +(m[6] ?? 0) }; +} + +const dtfCache = new Map(); +function dtf(tz: string): Intl.DateTimeFormat | null { + let f = dtfCache.get(tz); + if (f) return f; + try { + f = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + dtfCache.set(tz, f); + return f; + } catch { + return null; + } +} + +/** Offset (ms) of timezone `tz` at instant `date`. */ +export function tzOffsetMs(date: Date, tz: string): number { + const f = dtf(tz); + if (!f) return -date.getTimezoneOffset() * 60_000; + const parts = f.formatToParts(date); + const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0"); + const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second")); + return asUTC - Math.floor(date.getTime() / 1000) * 1000; +} + +/** Interpret a JSCalendar LocalDateTime in timezone `tz` (or browser local if null) as an instant. */ +export function zonedToDate(local: string, tz: string | null | undefined): Date { + const p = parseLocalDateTime(local); + if (!p) return new Date(NaN); + if (!tz) { + return new Date(p.y, p.mo, p.d, p.h, p.mi, p.se); + } + const asUTC = Date.UTC(p.y, p.mo, p.d, p.h, p.mi, p.se); + // Two-pass offset resolution handles DST edges reasonably. + let off = tzOffsetMs(new Date(asUTC), tz); + off = tzOffsetMs(new Date(asUTC - off), tz); + return new Date(asUTC - off); +} + +/** Format an instant as LocalDateTime in timezone `tz` (browser local if null). */ +export function dateToZonedLocal(d: Date, tz: string | null | undefined): string { + if (!tz) return toLocalDateTime(d); + const f = dtf(tz); + if (!f) return toLocalDateTime(d); + const parts = f.formatToParts(d); + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00"; + return `${get("year")}-${get("month")}-${get("day")}T${String(Number(get("hour")) % 24).padStart(2, "0")}:${get("minute")}:${get("second")}`; +} + +/** Parse ISO 8601 duration (e.g. "P1DT2H30M") into seconds. */ +export function parseDuration(dur: string | null | undefined): number { + if (!dur) return 0; + const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(dur); + if (!m) return 0; + const sign = m[1] === "-" ? -1 : 1; + const w = Number(m[2] ?? 0), d = Number(m[3] ?? 0), h = Number(m[4] ?? 0), mi = Number(m[5] ?? 0), s = Number(m[6] ?? 0); + return sign * (w * 7 * 86400 + d * 86400 + h * 3600 + mi * 60 + s); +} + +export function formatDuration(seconds: number): string { + const neg = seconds < 0; + let s = Math.abs(Math.round(seconds)); + const d = Math.floor(s / 86400); + s -= d * 86400; + const h = Math.floor(s / 3600); + s -= h * 3600; + const m = Math.floor(s / 60); + s -= m * 60; + let out = "P"; + if (d) out += `${d}D`; + if (h || m || s) { + out += "T"; + if (h) out += `${h}H`; + if (m) out += `${m}M`; + if (s) out += `${s}S`; + } + if (out === "P") out = "PT0S"; + return (neg ? "-" : "") + out; +} + +export function humanDuration(seconds: number): string { + const abs = Math.abs(seconds); + if (abs === 0) return "at time of event"; + const parts: string[] = []; + const d = Math.floor(abs / 86400); + const h = Math.floor((abs % 86400) / 3600); + const m = Math.floor((abs % 3600) / 60); + if (d) parts.push(`${d} day${d === 1 ? "" : "s"}`); + if (h) parts.push(`${h} hour${h === 1 ? "" : "s"}`); + if (m) parts.push(`${m} minute${m === 1 ? "" : "s"}`); + return parts.join(" ") || `${abs} seconds`; +} + +export const browserTimeZone = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +})(); + +export function listTimeZones(): string[] { + try { + const sv = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf; + if (sv) return sv("timeZone"); + } catch { + /* ignore */ + } + return ["UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Tokyo", "Asia/Kolkata", "Australia/Sydney"]; +} + +export function formatTimeRange(start: Date, end: Date, allDay: boolean): string { + if (allDay) { + const lastDay = new Date(end.getTime() - 1); + if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); + return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`; + } + const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + if (isSameDay(start, end)) { + return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} – ${t(end)}`; + } + return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} – ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`; +} + +/** For */ +export function toInputDateTime(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function fromInputDateTime(s: string): Date { + const p = parseLocalDateTime(s); + if (!p) return new Date(NaN); + return new Date(p.y, p.mo, p.d, p.h, p.mi, 0); +} + +export function roundToNext(d: Date, minutes: number): Date { + const x = new Date(d); + x.setSeconds(0, 0); + const m = x.getMinutes(); + const r = Math.ceil(m / minutes) * minutes; + x.setMinutes(r); + return x; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..f97a2b2 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,108 @@ +const rtf = typeof Intl !== "undefined" && "RelativeTimeFormat" in Intl ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null; + +export function formatSize(bytes: number | null | undefined): string { + if (bytes == null || !Number.isFinite(bytes)) return ""; + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = bytes / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; +} + +export function isSameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +/** Gmail-style compact date for list views. */ +export function formatListDate(iso: string | null | undefined, now = new Date()): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + if (isSameDay(d, now)) return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" */ +export function formatFullDate(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function formatRelative(iso: string | null | undefined, now = new Date()): string { + if (!iso) return ""; + const d = new Date(iso); + const diff = (d.getTime() - now.getTime()) / 1000; + const abs = Math.abs(diff); + if (!rtf) return formatListDate(iso, now); + if (abs < 60) return rtf.format(Math.round(diff), "second"); + if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute"); + if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour"); + if (abs < 86400 * 7) return rtf.format(Math.round(diff / 86400), "day"); + return formatListDate(iso, now); +} + +export function formatDateShort(d: Date): string { + return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); +} + +export function formatTime(d: Date): string { + return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +export function formatMonthYear(d: Date): string { + return d.toLocaleDateString(undefined, { month: "long", year: "numeric" }); +} + +export function plural(n: number, one: string, many = `${one}s`): string { + return `${n} ${n === 1 ? one : many}`; +} + +export function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +export function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} + +export function uid(prefix = "u"): string { + return `${prefix}${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; +} + +export function debounce void>(fn: T, ms: number): T & { cancel(): void } { + let t: number | null = null; + const wrapped = ((...args: Parameters) => { + if (t) window.clearTimeout(t); + t = window.setTimeout(() => { + t = null; + fn(...args); + }, ms); + }) as T & { cancel(): void }; + wrapped.cancel = () => { + if (t) window.clearTimeout(t); + t = null; + }; + return wrapped; +} + +export function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export function cx(...parts: Array): string { + return parts.filter(Boolean).join(" "); +} diff --git a/web/src/lib/html.ts b/web/src/lib/html.ts new file mode 100644 index 0000000..dc0a4b7 --- /dev/null +++ b/web/src/lib/html.ts @@ -0,0 +1,169 @@ +import DOMPurify from "dompurify"; + +export interface SanitizeOptions { + /** Map of Content-ID (without angle brackets) → URL for inline images. */ + cidMap?: Record; + /** Whether remote content (http/https images, css urls) may load. */ + allowRemote?: boolean; + /** Route remote images through the privacy proxy. */ + proxyRemote?: boolean; +} + +export interface SanitizeResult { + html: string; + remoteCount: number; + bodyStyle: string; +} + +const REMOTE_URL_RE = /^(https?:)?\/\//i; +const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi; + +let hooked = false; +function ensureHooks() { + if (hooked) return; + hooked = true; + DOMPurify.addHook("uponSanitizeElement", (node, data) => { + // Strip `; + // Collapse quoted content + const container = root.querySelector(".ihm-email-root") as HTMLElement | null; + let found = false; + if (container) { + let q: Element | null = null; + for (const sel of QUOTE_SELECTORS) { + q = container.querySelector(sel); + if (q) break; + } + if (!q) { + // Heuristic: a blockquote preceded by text ending in "wrote:" + const bqs = Array.from(container.querySelectorAll("blockquote")); + for (const bq of bqs) { + const prev = bq.previousElementSibling; + if (prev && /wrote:\s*$|Original Message|Von:|De :|From:/i.test(prev.textContent ?? "")) { + q = prev; + break; + } + } + if (!q && bqs.length === 1 && (bqs[0]!.textContent?.length ?? 0) > 200) q = bqs[0]!; + } + if (q && q.parentElement) { + // Move q and subsequent siblings into a hidden wrapper (only if q isn't the whole body) + const parent = q.parentElement; + const textBefore = (container.textContent ?? "").indexOf((q.textContent ?? "").slice(0, 40)); + if (textBefore > 0 || q.previousElementSibling) { + const wrap = root.ownerDocument.createElement("div"); + wrap.className = "ihm-quoted"; + wrap.hidden = true; + const nodes: ChildNode[] = []; + let n: ChildNode | null = q.classList.contains("moz-cite-prefix") ? q : q; + while (n) { + nodes.push(n); + n = n.nextSibling; + } + parent.insertBefore(wrap, q); + for (const node of nodes) wrap.appendChild(node); + found = true; + } + } + } + setHasQuote(found); + setQuoteOpen(false); + root.addEventListener("click", onClick); + return () => root.removeEventListener("click", onClick); + }, [html, bodyStyle, onClick]); + + useEffect(() => { + const root = hostRef.current?.shadowRoot; + const q = root?.querySelector(".ihm-quoted"); + if (q) q.hidden = !quoteOpen; + }, [quoteOpen]); + + return ( + <> +
+ {hasQuote && ( + + )} + + ); +} + +function TextBody({ text }: { text: string }) { + const hostRef = useRef(null); + const [quoteOpen, setQuoteOpen] = useState(false); + const openCompose = useCompose((s) => s.open); + const { main, quoted } = useMemo(() => { + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + const idx = findQuoteStart(lines); + if (idx > 2) return { main: lines.slice(0, idx).join("\n"), quoted: lines.slice(idx).join("\n") }; + return { main: text, quoted: "" }; + }, [text]); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const root = host.shadowRoot ?? host.attachShadow({ mode: "open" }); + root.innerHTML = `
${textToHtml(main)}${quoted ? `
\n${textToHtml(quoted)}
` : ""}
`; + const onClick = (ev: Event) => { + const a = (ev.target as HTMLElement).closest("a"); + if (a && a.getAttribute("href")?.startsWith("mailto:")) { + ev.preventDefault(); + openCompose({ to: [{ name: null, email: a.getAttribute("href")!.slice(7) }] }); + } + }; + root.addEventListener("click", onClick); + return () => root.removeEventListener("click", onClick); + }, [main, quoted, quoteOpen, openCompose]); + + return ( + <> +
+ {quoted && ( + + )} + + ); +} + +/* ---------- Attachments ---------- */ + +export function attachmentIcon(type: string, name?: string | null) { + const t = type.toLowerCase(); + const n = (name ?? "").toLowerCase(); + if (t.startsWith("image/")) return ; + if (t.startsWith("video/")) return ; + if (t.startsWith("audio/")) return ; + if (t === "application/pdf") return ; + if (/zip|tar|gzip|7z|rar|compressed/.test(t) || /\.(zip|tgz|gz|7z|rar)$/.test(n)) return ; + if (/spreadsheet|excel|csv/.test(t) || /\.(xlsx?|csv)$/.test(n)) return ; + if (t === "text/calendar") return ; + if (t.includes("vcard")) return ; + if (t.startsWith("text/") || /word|document/.test(t)) return ; + return ; +} + +function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) { + const [preview, setPreview] = useState(null); + const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain"; + return ( + <> +
+ {attachments.map((a, i) => { + const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#"; + const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#"; + return ( + { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}> + {a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? : attachmentIcon(a.type, a.name)} + + {a.name ?? "(unnamed)"} + {formatSize(a.size)} + + + {viewable(a) && } + + + + ); + })} + {attachments.length > 1 && ( + + )} +
+ setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && Download}> + {preview?.type.startsWith("image/") && {preview.name} + {preview?.type === "application/pdf" &&