ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
server/data
|
||||
@@ -0,0 +1,35 @@
|
||||
# ---- ihasmail server configuration ----
|
||||
|
||||
# Base URL of your Stalwart server (scheme + host, no path). ihasmail discovers
|
||||
# the JMAP session at <STALWART_URL>/.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
|
||||
@@ -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 .
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
server/data/
|
||||
.vite/
|
||||
coverage/
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -1,60 +1,142 @@
|
||||
<p align="center">
|
||||
<img src="web/public/img/logo.png" alt="ihasmail" width="180">
|
||||
</p>
|
||||
|
||||
# ihasmail
|
||||
|
||||

|
||||
**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 & conversation view (light)**  |
|
||||
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text  | **Calendar (month view)**  |
|
||||
| **Contacts**  | **Sieve filter builder** — also reachable from a message's right-click menu  |
|
||||
| **Sign-in**  | **Mobile layout** <img src="docs/screenshots/mobile.jpg" alt="Mobile" width="300"> |
|
||||
|
||||
## 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 ([email protected] / 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).
|
||||
|
||||
@@ -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"))
|
||||
@@ -1,41 +0,0 @@
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import httpx
|
||||
from urllib.parse import urljoin
|
||||
from . import config
|
||||
|
||||
DAV_PROPFIND = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d: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
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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})
|
||||
@@ -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)})
|
||||
@@ -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})
|
||||
@@ -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;}
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ title or "ihasmail" }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://unpkg.com;">
|
||||
<link rel="icon" href="/static/img/logo.png">
|
||||
<link rel="preconnect" href="https://unpkg.com">
|
||||
<script defer src="https://unpkg.com/[email protected]"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<a href="/" class="brand-link"><img src="/static/img/logo.png" alt="ihasmail" class="logo"> <strong>ihasmail</strong></a>
|
||||
</div>
|
||||
<nav>
|
||||
{% if user %}
|
||||
<span class="muted">Signed in as {{ user.get("username") }}</span>
|
||||
<a class="btn" href="/mail">Inbox</a>
|
||||
<a class="btn" href="/compose">Compose</a>
|
||||
<a class="btn" href="/calendar">Calendar</a>
|
||||
<a class="btn" href="/contacts">Contacts</a>
|
||||
<a class="btn" href="/webdav">WebDAV</a>
|
||||
<a class="btn" href="/logout">Logout</a>
|
||||
{% else %}
|
||||
<a class="btn" href="/login">Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="muted">ihasmail • JMAP • Sieve • DAV • FastAPI • reverse-proxy ready</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Calendar (JMAP & CalDAV)</h1>
|
||||
<p class="muted">Listing upcoming events via JMAP. CalDAV endpoints available for DAV clients.</p>
|
||||
<table>
|
||||
<tr><th>When</th><th>Summary</th><th>Where</th></tr>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ e.start }} – {{ e.end }}</td>
|
||||
<td>{{ e.title }}</td>
|
||||
<td>{{ e.loc or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Compose</h1>
|
||||
<form method="post" action="/compose">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row"><label>To</label><input name="to" required></div>
|
||||
<div class="row"><label>Subject</label><input name="subject"></div>
|
||||
<div class="row"><label>Body</label><textarea name="body" rows="14"></textarea></div>
|
||||
<button class="btn">Send</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Contacts (Directory via JMAP)</h1>
|
||||
<div class="toolbar">
|
||||
<form>
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search name/email…">
|
||||
</form>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Email</th><th>Org</th></tr>
|
||||
{% for c in contacts %}
|
||||
<tr>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>{{ c.org or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="center"><div class="card" style="min-width:320px; max-width:420px;">
|
||||
<div style="text-align:center;margin-bottom:8px"><img class="logo-lg" src="/static/img/logo.png" alt="ihasmail"></div>
|
||||
<h2 style="text-align:center;margin-top:0">Sign in</h2>
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="row">
|
||||
<label>Username</label>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>JMAP Base</label>
|
||||
<input name="jmap_base" value="{{ jmap_base }}">
|
||||
</div>
|
||||
<button class="btn" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<p class="muted">Credentials are sent to your JMAP server to obtain a session/auth token; they are not stored on the server.</p>
|
||||
{% endblock %}
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Inbox</h1>
|
||||
<div class="toolbar">
|
||||
<form method="get" action="/mail">
|
||||
<select name="mailbox" onchange="this.form.submit()">
|
||||
{% for b in mailboxes %}
|
||||
<option value="{{ b.id }}" {% if b.id == selected %}selected{% endif %}>{{ b.name }}{% if b.unread %} ({{ b.unread }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input name="q" placeholder="Search (from, subject, text…)" value="{{ q or '' }}">
|
||||
</form>
|
||||
<a class="btn" href="/compose">Compose</a>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th class="nowrap">When</th><th>From</th><th>Subject</th><th class="right">Size</th></tr>
|
||||
{% for m in messages %}
|
||||
<tr class="msg" onclick="location.href='/mail/{{ m.id }}'">
|
||||
<td class="nowrap">{{ m.when }}</td>
|
||||
<td>{{ m.from }}</td>
|
||||
<td>{{ m.subject }}</td>
|
||||
<td class="right">{{ m.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,25 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ msg.subject or "(no subject)" }}</h1>
|
||||
<p><span class="pill">From</span> {{ msg.from }} <span class="pill">To</span> {{ msg.to|join(", ") }}</p>
|
||||
<p class="muted">{{ msg.when }}</p>
|
||||
{% if msg.htmlBody %}
|
||||
<div class="panel">{{ (msg.htmlBody | safe) }}</div>
|
||||
{% elif msg.textBody %}
|
||||
<div class="panel">{{ msg.textBody }}</div>
|
||||
{% else %}
|
||||
<div class="panel muted">(no body)</div>
|
||||
{% endif %}
|
||||
<div class="toolbar">
|
||||
<a class="btn" href="/compose?reply={{ msg.id }}">Reply</a>
|
||||
<a class="btn" href="/compose?forward={{ msg.id }}">Forward</a>
|
||||
</div>
|
||||
{% if msg.attachments %}
|
||||
<h3>Attachments</h3>
|
||||
<ul>
|
||||
{% for a in msg.attachments %}
|
||||
<li>{{ a.name }} ({{ a.type }}, {{ a.size }} bytes)</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>WebDAV</h1>
|
||||
<p class="muted">Browsing {{ base }}</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Type</th><th class="right">Size</th></tr>
|
||||
{% for i in items %}
|
||||
<tr>
|
||||
<td>{{ i.name }}</td>
|
||||
<td>{{ i.type }}</td>
|
||||
<td class="right">{% if i.size is not none %}{{ i.size }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -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 ""
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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 -}}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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: {}
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = "[email protected]"}]
|
||||
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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Env> = 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<Env> {
|
||||
const app = new Hono<Env>();
|
||||
app.use("*", securityHeaders);
|
||||
|
||||
const api = new Hono<Env>();
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Uint8Array, Uint8Array>({
|
||||
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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 ?? "[email protected]";
|
||||
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
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<string, { type: string; data: Buffer }>();
|
||||
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", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||||
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||||
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||||
];
|
||||
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 = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||||
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") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : 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: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||
addEmail({ from: ["Spammy", "[email protected]"], 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:[email protected]\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", "[email protected]"], 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: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
|
||||
{ id: "i2", name: "Demo (alias)", email: "[email protected]", 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: "[email protected]", sendTo: { imip: "mailto:[email protected]" }, 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<string, Handler> = {
|
||||
"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<string>();
|
||||
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<string, { value: string }>) ?? {};
|
||||
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: "[email protected]", sendTo: { imip: "mailto:[email protected]" }, 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<Buffer> {
|
||||
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<ServerResponse>();
|
||||
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<string>();
|
||||
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();
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Simple sliding-window rate limiter keyed by arbitrary string (ip, ip+user). */
|
||||
export class RateLimiter {
|
||||
private hits = new Map<string, number[]>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "[email protected]", password: "p4ss", remember: false, userAgent: "ua", ip: "127.0.0.1" });
|
||||
assert.equal(session.username, "[email protected]");
|
||||
const live = store.resolve(cookie);
|
||||
assert.ok(live);
|
||||
assert.equal(live!.authorization, `Basic ${Buffer.from("[email protected]:p4ss").toString("base64")}`);
|
||||
assert.equal(store.resolve(cookie + "x"), null);
|
||||
assert.equal(store.resolve("nope"), null);
|
||||
assert.equal(store.listForUser("[email protected]").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);
|
||||
});
|
||||
@@ -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<string, StoredSession>();
|
||||
private dirty = false;
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
private sweepTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly file: string) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Omit<StoredSession, "secretHash" | "salt" | "sealedCredentials">> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
".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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { config } from "./config.js";
|
||||
|
||||
export interface UpstreamSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
accounts: Record<string, unknown>;
|
||||
primaryAccounts: Record<string, string>;
|
||||
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<string, { session: UpstreamSession; fetchedAt: number }>();
|
||||
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<UpstreamSession> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_m, k: string) => encodeURIComponent(vars[k] ?? ""));
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/img/favicon-64.png" />
|
||||
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>ihasmail</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="center" style={{ height: "100%" }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{status === "anonymous" ? <LoginPage /> : <AuthedApp />}
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, Set<string>>();
|
||||
let timer: number | null = null;
|
||||
const unsub = push.subscribe((acct, type) => {
|
||||
const set = pending.get(acct) ?? new Set<string>();
|
||||
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 (
|
||||
<AppShell>
|
||||
<Suspense fallback={<Spinner size="lg" />}>
|
||||
<Switch>
|
||||
<Route path="/mail/:mailboxId?/:threadId?">{(p) => <MailView mailboxId={p.mailboxId} threadId={p.threadId} />}</Route>
|
||||
<Route path="/search/:threadId?">{(p) => <MailView search threadId={p.threadId} />}</Route>
|
||||
<Route path="/contacts/:id?">{(p) => <ContactsView id={p.id} />}</Route>
|
||||
<Route path="/calendar/:view?/:date?">{(p) => <CalendarView view={p.view} date={p.date} />}</Route>
|
||||
<Route path="/files/:nodeId?">{(p) => <FilesView nodeId={p.nodeId} />}</Route>
|
||||
<Route path="/settings/:section?">{(p) => <SettingsView section={p.section} />}</Route>
|
||||
<Route path="/login">
|
||||
<Redirect to="/mail" />
|
||||
</Route>
|
||||
<Route>{location === "/" ? <Redirect to="/mail" /> : <Redirect to="/mail" />}</Route>
|
||||
</Switch>
|
||||
</Suspense>
|
||||
<ComposerDock />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
using: Set<string>;
|
||||
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<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...HEADERS, ...(init.headers as Record<string, string> | 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<T = Record<string, unknown>>(method: string, args: Record<string, unknown>, using: string[] = []): Promise<T> {
|
||||
return new Promise<T>((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<void> {
|
||||
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<void> {
|
||||
const using = new Set<string>();
|
||||
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<string, Invocation[]>();
|
||||
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<string, Id>): Promise<JmapResponse> {
|
||||
const body: Record<string, unknown> = { using, methodCalls };
|
||||
if (createdIds) body.createdIds = createdIds;
|
||||
const res = await apiFetch<JmapResponse>("/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<string, unknown>, id: string]>,
|
||||
opts: { using?: string[]; allowErrors?: boolean } = {},
|
||||
): Promise<Map<string, Record<string, unknown>[]>> {
|
||||
const using = new Set<string>([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<string, Record<string, unknown>[]>();
|
||||
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<UploadResponse> {
|
||||
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<string> {
|
||||
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<Blob> {
|
||||
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<T>(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;
|
||||
}
|
||||
@@ -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<PushListener>();
|
||||
private connectionListeners = new Set<(connected: boolean) => void>();
|
||||
private backoff = 1000;
|
||||
private reconnectTimer: number | null = null;
|
||||
private stopped = true;
|
||||
private lastStates = new Map<string, string>();
|
||||
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();
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JmapSession {
|
||||
capabilities: Record<string, unknown>;
|
||||
accounts: Record<Id, Account>;
|
||||
primaryAccounts: Record<string, Id>;
|
||||
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<string, unknown>, callId: string];
|
||||
|
||||
export interface JmapResponse {
|
||||
methodResponses: Invocation[];
|
||||
sessionState: string;
|
||||
createdIds?: Record<string, Id>;
|
||||
}
|
||||
|
||||
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<T = Record<string, unknown>> {
|
||||
accountId: Id;
|
||||
oldState: string | null;
|
||||
newState: string;
|
||||
created?: Record<string, T>;
|
||||
updated?: Record<string, T | null>;
|
||||
destroyed?: Id[];
|
||||
notCreated?: Record<string, SetError>;
|
||||
notUpdated?: Record<string, SetError>;
|
||||
notDestroyed?: Record<string, SetError>;
|
||||
}
|
||||
|
||||
export interface GetResponse<T> {
|
||||
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<Id, Record<string, string>>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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<Id, Partial<MailboxRights>> | 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<Id, boolean>;
|
||||
keywords: Record<string, boolean>;
|
||||
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<string, EmailBodyValue>;
|
||||
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<string, unknown> | null }; rcptTo: { email: string }[] } | null;
|
||||
sendAt: UTCDate;
|
||||
undoStatus: "pending" | "final" | "canceled";
|
||||
deliveryStatus: Record<string, { smtpReply: string; delivered: string; displayed: string }> | 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<T> {
|
||||
operator: "AND" | "OR" | "NOT";
|
||||
conditions: Array<T | FilterOperator<T>>;
|
||||
}
|
||||
|
||||
export type EmailFilter = EmailFilterCondition | FilterOperator<EmailFilterCondition>;
|
||||
|
||||
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<string, unknown>;
|
||||
accounts?: Record<Id, Account> | 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<Id, AddressBookRights> | 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<string, string>;
|
||||
}
|
||||
|
||||
export interface JSContactEmail {
|
||||
"@type"?: "EmailAddress";
|
||||
address: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactPhone {
|
||||
"@type"?: "Phone";
|
||||
number: string;
|
||||
features?: Record<string, boolean>;
|
||||
contexts?: Record<string, boolean>;
|
||||
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<string, boolean>;
|
||||
full?: string;
|
||||
defaultSeparator?: string;
|
||||
pref?: number;
|
||||
}
|
||||
|
||||
export interface JSContactOrganization {
|
||||
"@type"?: "Organization";
|
||||
name?: string;
|
||||
units?: { "@type"?: "OrgUnit"; name: string }[];
|
||||
sortAs?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
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<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactMedia {
|
||||
"@type"?: "Media";
|
||||
kind: "photo" | "sound" | "logo";
|
||||
uri?: string;
|
||||
blobId?: Id;
|
||||
mediaType?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
pref?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface JSContactRelation {
|
||||
"@type"?: "Relation";
|
||||
relation?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ContactCard {
|
||||
id: Id;
|
||||
addressBookIds: Record<Id, boolean>;
|
||||
"@type"?: "Card";
|
||||
version?: "1.0";
|
||||
uid: string;
|
||||
kind?: "individual" | "group" | "org" | "location" | "device" | "application";
|
||||
created?: UTCDate;
|
||||
updated?: UTCDate;
|
||||
language?: string;
|
||||
prodId?: string;
|
||||
members?: Record<string, boolean>;
|
||||
name?: JSContactName;
|
||||
nicknames?: Record<string, { "@type"?: "Nickname"; name: string; contexts?: Record<string, boolean>; pref?: number }>;
|
||||
organizations?: Record<string, JSContactOrganization>;
|
||||
titles?: Record<string, JSContactTitle>;
|
||||
emails?: Record<string, JSContactEmail>;
|
||||
phones?: Record<string, JSContactPhone>;
|
||||
addresses?: Record<string, JSContactAddress>;
|
||||
onlineServices?: Record<string, JSContactOnlineService>;
|
||||
anniversaries?: Record<string, JSContactAnniversary>;
|
||||
notes?: Record<string, JSContactNote>;
|
||||
keywords?: Record<string, boolean>;
|
||||
media?: Record<string, JSContactMedia>;
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
links?: Record<string, { "@type"?: "Link"; uri: string; kind?: string; label?: string }>;
|
||||
preferredLanguages?: Record<string, { "@type"?: "LanguagePref"; language: string; pref?: number; contexts?: Record<string, boolean> }>;
|
||||
speakToAs?: { "@type"?: "SpeakToAs"; grammaticalGender?: string; pronouns?: Record<string, { pronouns: string }> };
|
||||
calendars?: Record<string, { "@type"?: "Calendar"; kind?: string; uri: string }>;
|
||||
schedulingAddresses?: Record<string, { "@type"?: "SchedulingAddress"; uri: string }>;
|
||||
personalInfo?: Record<string, { "@type"?: "PersonalInfo"; kind: string; value: string; level?: string }>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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<string, JSCalendarAlert> | null;
|
||||
defaultAlertsWithoutTime: Record<string, JSCalendarAlert> | null;
|
||||
timeZone: string | null;
|
||||
shareWith: Record<Id, CalendarRights> | 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<string, JSContactRelation>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
kind?: "individual" | "group" | "location" | "resource";
|
||||
roles: Record<string, boolean>;
|
||||
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<string, boolean>;
|
||||
delegatedFrom?: Record<string, boolean>;
|
||||
memberOf?: Record<string, boolean>;
|
||||
links?: Record<string, unknown>;
|
||||
progress?: string;
|
||||
percentComplete?: number;
|
||||
}
|
||||
|
||||
export interface JSCalendarLocation {
|
||||
"@type"?: "Location";
|
||||
name?: string;
|
||||
description?: string;
|
||||
locationTypes?: Record<string, boolean>;
|
||||
relativeTo?: "start" | "end";
|
||||
timeZone?: string;
|
||||
coordinates?: string;
|
||||
links?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JSCalendarVirtualLocation {
|
||||
"@type"?: "VirtualLocation";
|
||||
name?: string;
|
||||
description?: string;
|
||||
uri: string;
|
||||
features?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface JSCalendarEvent {
|
||||
"@type"?: "Event";
|
||||
uid: string;
|
||||
relatedTo?: Record<string, JSContactRelation>;
|
||||
prodId?: string;
|
||||
created?: UTCDate;
|
||||
updated?: UTCDate;
|
||||
sequence?: number;
|
||||
method?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
descriptionContentType?: string;
|
||||
showWithoutTime?: boolean;
|
||||
locations?: Record<string, JSCalendarLocation>;
|
||||
virtualLocations?: Record<string, JSCalendarVirtualLocation>;
|
||||
links?: Record<string, { "@type"?: "Link"; href: string; contentType?: string; size?: number; rel?: string; display?: string; title?: string }>;
|
||||
locale?: string;
|
||||
keywords?: Record<string, boolean>;
|
||||
categories?: Record<string, boolean>;
|
||||
color?: string;
|
||||
recurrenceId?: LocalDate;
|
||||
recurrenceIdTimeZone?: string;
|
||||
recurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
excludedRecurrenceRules?: JSCalendarRecurrenceRule[];
|
||||
recurrenceOverrides?: Record<LocalDate, Record<string, unknown> | null>;
|
||||
excluded?: boolean;
|
||||
priority?: number;
|
||||
freeBusyStatus?: "free" | "busy";
|
||||
privacy?: "public" | "private" | "secret";
|
||||
replyTo?: Record<string, string>;
|
||||
sentBy?: string;
|
||||
participants?: Record<string, JSCalendarParticipant>;
|
||||
requestStatus?: string;
|
||||
useDefaultAlerts?: boolean;
|
||||
alerts?: Record<string, JSCalendarAlert>;
|
||||
localizations?: Record<string, Record<string, unknown>>;
|
||||
timeZone?: string | null;
|
||||
start: LocalDate;
|
||||
duration?: string;
|
||||
status?: "confirmed" | "cancelled" | "tentative";
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends JSCalendarEvent {
|
||||
id: Id;
|
||||
baseEventId?: Id | null;
|
||||
calendarIds: Record<Id, boolean>;
|
||||
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<string, string>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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<Id, FilesRights> | 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;
|
||||
}
|
||||
@@ -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 <[email protected]>, [email protected]; "Smith, John" <[email protected]>');
|
||||
expect(list).toEqual([
|
||||
{ name: "Ann Example", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Smith, John", email: "[email protected]" },
|
||||
]);
|
||||
});
|
||||
it("formats with quoting when needed", () => {
|
||||
expect(formatAddress({ name: "Smith, John", email: "[email protected]" })).toBe('"Smith, John" <[email protected]>');
|
||||
expect(formatAddress({ name: null, email: "[email protected]" })).toBe("[email protected]");
|
||||
});
|
||||
it("validates and initials", () => {
|
||||
expect(isValidEmail("[email protected]")).toBe(true);
|
||||
expect(isValidEmail("nope")).toBe(false);
|
||||
expect(initials({ name: "Grace Hopper", email: "" })).toBe("GH");
|
||||
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('<div onclick="x()">hi<script>alert(1)</script><iframe src="https://evil"></iframe></div>');
|
||||
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 = '<img src="https://t.example/p.gif"><img src="cid:logo@x"><div style="background:url(https://t.example/b.png)">x</div>';
|
||||
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('<a href="https://x.io">x</a>');
|
||||
expect(r.html).toContain('target="_blank"');
|
||||
expect(r.html).toContain("noopener");
|
||||
});
|
||||
it("strips javascript: urls", () => {
|
||||
const r = sanitizeEmailHtml('<a href="javascript:alert(1)">x</a>');
|
||||
expect(r.html).not.toContain("javascript:");
|
||||
});
|
||||
it("editor sanitizer keeps basic formatting", () => {
|
||||
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: "[email protected]" }], to: [{ name: null, email: "[email protected]" }], subject: "Invoice #42 is ready", preview: "Please find attached",
|
||||
"header:List-Id:asText": "<dev.lists.example.org>",
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
@@ -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 = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Ellis</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
|
||||
const out = compactHtml(src);
|
||||
expect(out).not.toContain("mso-");
|
||||
expect(out).not.toContain("class=");
|
||||
expect(out).not.toContain("<xml");
|
||||
expect(out).not.toContain("o:p");
|
||||
expect(out).toContain("color:#1F4E79");
|
||||
expect(out).toContain("<b>John Ellis</b>");
|
||||
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 = `<div>${"<b>x</b>".repeat(1000)}</div>`;
|
||||
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("<div>plain</div>")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 <https://x.io/a?b=1> now");
|
||||
expect(html).toContain("<");
|
||||
expect(html).toContain('<a href="https://x.io/a?b=1"');
|
||||
});
|
||||
it("colors quote levels", () => {
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q1"');
|
||||
expect(textToHtml("> hi\n>> there")).toContain('class="q2"');
|
||||
});
|
||||
it("converts html to text", () => {
|
||||
const t = htmlToText("<p>Hello <b>world</b></p><ul><li>one</li><li>two</li></ul><blockquote>q</blockquote><a href='https://a.b'>link</a>");
|
||||
expect(t).toContain("Hello world");
|
||||
expect(t).toContain("- one");
|
||||
expect(t).toContain("> q");
|
||||
expect(t).toContain("link <https://a.b>");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 <[email protected]>, [email protected]; \"C, D\" <c@z>")
|
||||
* 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<string>();
|
||||
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() : "";
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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<string, Intl.DateTimeFormat>();
|
||||
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 <input type="datetime-local"> */
|
||||
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;
|
||||
}
|
||||
@@ -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<T extends (...args: never[]) => void>(fn: T, ms: number): T & { cancel(): void } {
|
||||
let t: number | null = null;
|
||||
const wrapped = ((...args: Parameters<T>) => {
|
||||
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<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Map of Content-ID (without angle brackets) → URL for inline images. */
|
||||
cidMap?: Record<string, string>;
|
||||
/** 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 <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
|
||||
if (data.tagName === "style" && node.textContent) {
|
||||
// Remove @import and remote url() references; they're handled later in processRemote().
|
||||
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
|
||||
}
|
||||
});
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
// Forms are forbidden but be safe about formaction-like attributes on anything.
|
||||
for (const attr of ["formaction", "action", "ping", "xlink:href"]) {
|
||||
if (node.hasAttribute(attr)) node.removeAttribute(attr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function proxiedImageUrl(url: string): string {
|
||||
return `/api/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
|
||||
ensureHooks();
|
||||
let bodyStyle = "";
|
||||
const bodyMatch = /<body([^>]*)>/i.exec(input);
|
||||
if (bodyMatch) {
|
||||
const attrs = bodyMatch[1]!;
|
||||
const bg = /bgcolor\s*=\s*["']?([#\w()%,.\s-]+)["']?/i.exec(attrs)?.[1];
|
||||
const style = /style\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? /style\s*=\s*'([^']*)'/i.exec(attrs)?.[1];
|
||||
if (bg) bodyStyle += `background-color:${bg.trim()};`;
|
||||
if (style) bodyStyle += style;
|
||||
}
|
||||
|
||||
const clean = DOMPurify.sanitize(input, {
|
||||
WHOLE_DOCUMENT: false,
|
||||
RETURN_DOM: true,
|
||||
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "slot", "dialog", "noscript"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "autoplay", "contenteditable", "draggable", "tabindex"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ["style", "center", "font", "marquee"],
|
||||
ADD_ATTR: ["bgcolor", "background", "valign", "align", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size", "target"],
|
||||
}) as unknown as HTMLElement;
|
||||
|
||||
let remoteCount = 0;
|
||||
const cidMap = opts.cidMap ?? {};
|
||||
const allow = Boolean(opts.allowRemote);
|
||||
const proxy = Boolean(opts.proxyRemote);
|
||||
|
||||
const remote = (url: string): string => {
|
||||
remoteCount++;
|
||||
if (!allow) return "";
|
||||
return proxy ? proxiedImageUrl(url) : url;
|
||||
};
|
||||
|
||||
const rewriteUrl = (raw: string): { url: string; keep: boolean } => {
|
||||
const url = raw.trim();
|
||||
if (/^cid:/i.test(url)) {
|
||||
const cid = url.slice(4).replace(/^<|>$/g, "");
|
||||
const mapped = cidMap[cid] ?? cidMap[cid.toLowerCase()];
|
||||
return mapped ? { url: mapped, keep: true } : { url: "", keep: false };
|
||||
}
|
||||
if (/^data:image\//i.test(url)) return { url, keep: true };
|
||||
if (REMOTE_URL_RE.test(url)) {
|
||||
const abs = url.startsWith("//") ? `https:${url}` : url;
|
||||
const u = remote(abs);
|
||||
return { url: u, keep: Boolean(u) };
|
||||
}
|
||||
// Relative or unknown scheme -> drop.
|
||||
return { url: "", keep: false };
|
||||
};
|
||||
|
||||
// Image-bearing attributes
|
||||
const els = clean.querySelectorAll<HTMLElement>("[src],[background],[poster],[srcset]");
|
||||
els.forEach((el) => {
|
||||
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
|
||||
for (const attr of ["src", "background", "poster"]) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (v == null) continue;
|
||||
const r = rewriteUrl(v);
|
||||
if (r.keep) el.setAttribute(attr, r.url);
|
||||
else {
|
||||
el.removeAttribute(attr);
|
||||
if (attr === "src" && el.tagName === "IMG") {
|
||||
el.setAttribute("data-ihm-blocked", "1");
|
||||
if (REMOTE_URL_RE.test(v)) el.setAttribute("data-ihm-remote", v.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// CSS url() in style attributes and <style> blocks
|
||||
const rewriteCss = (css: string): string =>
|
||||
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
|
||||
const r = rewriteUrl(u);
|
||||
return r.keep ? `url(${q}${r.url}${q})` : "none";
|
||||
});
|
||||
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
||||
const s = el.getAttribute("style");
|
||||
if (s && /url\(/i.test(s)) el.setAttribute("style", rewriteCss(s));
|
||||
});
|
||||
clean.querySelectorAll("style").forEach((st) => {
|
||||
if (st.textContent && /url\(|@import/i.test(st.textContent)) {
|
||||
st.textContent = rewriteCss(st.textContent.replace(/@import[^;]+;?/gi, ""));
|
||||
}
|
||||
});
|
||||
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle);
|
||||
|
||||
return { html: clean.innerHTML, remoteCount, bodyStyle };
|
||||
}
|
||||
|
||||
/** Minimal sanitizer for signatures / composer HTML (no remote blocking, keeps images). */
|
||||
export function sanitizeEditorHtml(input: string): string {
|
||||
ensureHooks();
|
||||
return DOMPurify.sanitize(input, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "meta", "link", "base", "svg", "math"],
|
||||
FORBID_ATTR: ["srcdoc", "formaction", "ping", "onerror", "onload"],
|
||||
ADD_ATTR: ["target", "bgcolor", "align", "valign", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size"],
|
||||
}) as string;
|
||||
}
|
||||
|
||||
/** Base CSS injected into the shadow root that hosts HTML email. */
|
||||
export const EMAIL_BASE_CSS = `
|
||||
:host { display:block; color-scheme: light; }
|
||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
|
||||
.ihm-email-root img { max-width:100%; height:auto; }
|
||||
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
||||
.ihm-email-root table { max-width:100%; }
|
||||
.ihm-email-root pre { white-space:pre-wrap; }
|
||||
.ihm-email-root blockquote { margin:0 0 0 .8ex; border-left:2px solid #cbd5e1; padding-left:1ex; color:#475569; }
|
||||
.ihm-email-root a { color:#0f766e; }
|
||||
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
|
||||
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
|
||||
`;
|
||||
|
||||
export const TEXT_EMAIL_CSS = `
|
||||
:host { display:block; }
|
||||
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
|
||||
.ihm-text-root a { color: var(--link, #0f766e); }
|
||||
.ihm-text-root .q1 { color: var(--q1,#2563eb); } .ihm-text-root .q2 { color: var(--q2,#16a34a); } .ihm-text-root .q3 { color: var(--q3,#9333ea); }
|
||||
`;
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Gmail-style keyboard shortcut manager with two-key sequences ("g i").
|
||||
* Handlers are registered in scopes; the most recently pushed scope wins.
|
||||
*/
|
||||
export type KeyHandler = (e: KeyboardEvent) => void | boolean;
|
||||
|
||||
interface Binding {
|
||||
keys: string; // e.g. "j", "shift+i", "g i", "mod+enter"
|
||||
handler: KeyHandler;
|
||||
description: string;
|
||||
group: string;
|
||||
allowInInput?: boolean;
|
||||
}
|
||||
|
||||
interface Scope {
|
||||
name: string;
|
||||
bindings: Binding[];
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
private scopes: Scope[] = [];
|
||||
private pendingPrefix: string | null = null;
|
||||
private prefixTimer: number | null = null;
|
||||
enabled = true;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") window.addEventListener("keydown", this.onKeyDown, true);
|
||||
}
|
||||
|
||||
pushScope(name: string, bindings: Binding[]): () => void {
|
||||
const scope = { name, bindings };
|
||||
this.scopes.push(scope);
|
||||
return () => {
|
||||
this.scopes = this.scopes.filter((s) => s !== scope);
|
||||
};
|
||||
}
|
||||
|
||||
/** All bindings with descriptions, for the help overlay. */
|
||||
list(): Array<{ group: string; keys: string; description: string }> {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ group: string; keys: string; description: string }> = [];
|
||||
for (const s of [...this.scopes].reverse()) {
|
||||
for (const b of s.bindings) {
|
||||
if (!b.description || seen.has(b.keys)) continue;
|
||||
seen.add(b.keys);
|
||||
out.push({ group: b.group, keys: b.keys, description: b.description });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.enabled) return;
|
||||
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
|
||||
if (document.querySelector(".dialog-backdrop, .popover")) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const inInput =
|
||||
!!target &&
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);
|
||||
const combo = comboOf(e);
|
||||
if (!combo) return;
|
||||
|
||||
// Try sequence completion first.
|
||||
const candidates: Binding[] = [];
|
||||
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
||||
for (const b of this.scopes[i]!.bindings) candidates.push(b);
|
||||
}
|
||||
if (this.pendingPrefix) {
|
||||
const seq = `${this.pendingPrefix} ${combo}`;
|
||||
const b = candidates.find((x) => x.keys === seq && (!inInput || x.allowInInput));
|
||||
this.clearPrefix();
|
||||
if (b) {
|
||||
const r = b.handler(e);
|
||||
if (r !== false) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Is this combo the first half of any sequence?
|
||||
if (!inInput && candidates.some((x) => x.keys.startsWith(`${combo} `))) {
|
||||
this.pendingPrefix = combo;
|
||||
this.prefixTimer = window.setTimeout(() => this.clearPrefix(), 1200);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const b = candidates.find((x) => x.keys === combo && (!inInput || x.allowInInput));
|
||||
if (b) {
|
||||
const r = b.handler(e);
|
||||
if (r !== false) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private clearPrefix() {
|
||||
this.pendingPrefix = null;
|
||||
if (this.prefixTimer) window.clearTimeout(this.prefixTimer);
|
||||
this.prefixTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export function comboOf(e: KeyboardEvent): string | null {
|
||||
const key = e.key;
|
||||
if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") return null;
|
||||
const parts: string[] = [];
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (mod) parts.push("mod");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey && key.length > 1) parts.push("shift");
|
||||
let k = key;
|
||||
if (k === " ") k = "space";
|
||||
else if (k === "Escape") k = "esc";
|
||||
else if (k.length === 1) {
|
||||
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
|
||||
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
|
||||
} else k = k.toLowerCase();
|
||||
parts.push(k);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function formatKeys(keys: string): string {
|
||||
return keys
|
||||
.split(" ")
|
||||
.map((k) =>
|
||||
k
|
||||
.split("+")
|
||||
.map((p) => (p === "mod" ? (isMac ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "alt" ? (isMac ? "⌥" : "Alt") : p === "enter" ? "↵" : p === "esc" ? "Esc" : p === "space" ? "Space" : p === "arrowup" ? "↑" : p === "arrowdown" ? "↓" : p === "arrowleft" ? "←" : p === "arrowright" ? "→" : p.length === 1 ? p : p[0]!.toUpperCase() + p.slice(1)))
|
||||
.join(isMac ? "" : "+"),
|
||||
)
|
||||
.join(" then ");
|
||||
}
|
||||
|
||||
export const keyboard = new Keyboard();
|
||||
@@ -0,0 +1,95 @@
|
||||
let baseTitle = "ihasmail";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
let baseFavicon: HTMLImageElement | null = null;
|
||||
|
||||
export function setBaseTitle(t: string) {
|
||||
baseTitle = t;
|
||||
}
|
||||
|
||||
/** Update document title and favicon badge with unread count. */
|
||||
export function setUnreadBadge(count: number): void {
|
||||
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
|
||||
try {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
|
||||
if (!link) return;
|
||||
if (!baseFavicon) {
|
||||
baseFavicon = new Image();
|
||||
baseFavicon.src = "/img/favicon-64.png";
|
||||
baseFavicon.onload = () => setUnreadBadge(count);
|
||||
return;
|
||||
}
|
||||
if (!baseFavicon.complete) return;
|
||||
if (count <= 0) {
|
||||
link.href = "/img/favicon-64.png";
|
||||
return;
|
||||
}
|
||||
faviconCanvas ??= document.createElement("canvas");
|
||||
const c = faviconCanvas;
|
||||
c.width = 64;
|
||||
c.height = 64;
|
||||
const ctx = c.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, 64, 64);
|
||||
ctx.drawImage(baseFavicon, 0, 0, 64, 64);
|
||||
ctx.fillStyle = "#dc2626";
|
||||
ctx.beginPath();
|
||||
ctx.arc(46, 18, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.font = "bold 22px system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(count > 99 ? "99" : String(count), 46, 19);
|
||||
link.href = c.toDataURL("image/png");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (!("Notification" in window)) return "denied";
|
||||
if (Notification.permission !== "default") return Notification.permission;
|
||||
try {
|
||||
return await Notification.requestPermission();
|
||||
} catch {
|
||||
return "denied";
|
||||
}
|
||||
}
|
||||
|
||||
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") return;
|
||||
if (document.visibilityState === "visible" && document.hasFocus()) return;
|
||||
try {
|
||||
const n = new Notification(title, { icon: "/img/icon-192.png", badge: "/img/favicon-64.png", ...opts });
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
opts.onClick?.();
|
||||
n.close();
|
||||
};
|
||||
setTimeout(() => n.close(), 8000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
/** Short, soft "ding" using WebAudio (no asset needed). */
|
||||
export function playNewMailSound(): void {
|
||||
try {
|
||||
audioCtx ??= new AudioContext();
|
||||
const ctx = audioCtx;
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.type = "sine";
|
||||
o.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
o.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.08);
|
||||
g.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4);
|
||||
o.connect(g).connect(ctx.destination);
|
||||
o.start();
|
||||
o.stop(ctx.currentTime + 0.45);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
|
||||
|
||||
export const WEEKDAYS: Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> = [
|
||||
{ key: "mo", label: "Monday", short: "M" },
|
||||
{ key: "tu", label: "Tuesday", short: "T" },
|
||||
{ key: "we", label: "Wednesday", short: "W" },
|
||||
{ key: "th", label: "Thursday", short: "T" },
|
||||
{ key: "fr", label: "Friday", short: "F" },
|
||||
{ key: "sa", label: "Saturday", short: "S" },
|
||||
{ key: "su", label: "Sunday", short: "S" },
|
||||
];
|
||||
|
||||
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
|
||||
|
||||
export function presetFor(rule: JSCalendarRecurrenceRule | undefined): RecurrencePreset {
|
||||
if (!rule) return "none";
|
||||
const simple = !rule.count && !rule.until && (rule.interval ?? 1) === 1;
|
||||
if (rule.frequency === "daily" && simple && !rule.byDay) return "daily";
|
||||
if (rule.frequency === "weekly" && simple) {
|
||||
if (!rule.byDay) return "weekly";
|
||||
const days = rule.byDay.map((d) => d.day).sort().join(",");
|
||||
if (days === ["mo", "tu", "we", "th", "fr"].sort().join(",")) return "weekdays";
|
||||
if (rule.byDay.length === 1) return "weekly";
|
||||
}
|
||||
if (rule.frequency === "monthly" && simple && !rule.byDay && (!rule.byMonthDay || rule.byMonthDay.length === 1)) return "monthly";
|
||||
if (rule.frequency === "yearly" && simple && !rule.byDay && !rule.byMonth) return "yearly";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
|
||||
const dow = WEEKDAYS[(start.getDay() + 6) % 7]!.key;
|
||||
switch (preset) {
|
||||
case "daily":
|
||||
return { "@type": "RecurrenceRule", frequency: "daily" };
|
||||
case "weekly":
|
||||
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: dow }] };
|
||||
case "weekdays":
|
||||
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: ["mo", "tu", "we", "th", "fr"].map((d) => ({ "@type": "NDay" as const, day: d as JSCalendarNDay["day"] })) };
|
||||
case "monthly":
|
||||
return { "@type": "RecurrenceRule", frequency: "monthly", byMonthDay: [start.getDate()] };
|
||||
case "yearly":
|
||||
return { "@type": "RecurrenceRule", frequency: "yearly" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
|
||||
if (!rule) return "Does not repeat";
|
||||
const n = rule.interval ?? 1;
|
||||
let base: string;
|
||||
switch (rule.frequency) {
|
||||
case "daily":
|
||||
base = n === 1 ? "Daily" : `Every ${n} days`;
|
||||
break;
|
||||
case "weekly": {
|
||||
base = n === 1 ? "Weekly" : `Every ${n} weeks`;
|
||||
if (rule.byDay?.length) {
|
||||
const names = rule.byDay.map((d) => WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day);
|
||||
const set = rule.byDay.map((d) => d.day).sort().join(",");
|
||||
if (set === ["mo", "tu", "we", "th", "fr"].sort().join(",") && n === 1) base = "Every weekday";
|
||||
else base += ` on ${names.join(", ")}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "monthly": {
|
||||
base = n === 1 ? "Monthly" : `Every ${n} months`;
|
||||
if (rule.byMonthDay?.length) base += ` on day ${rule.byMonthDay.join(", ")}`;
|
||||
else if (rule.byDay?.length) {
|
||||
const d = rule.byDay[0]!;
|
||||
const ord = d.nthOfPeriod ? ordinal(d.nthOfPeriod) + " " : "";
|
||||
base += ` on the ${ord}${WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "yearly":
|
||||
base = n === 1 ? "Yearly" : `Every ${n} years`;
|
||||
break;
|
||||
default:
|
||||
base = `Every ${n} ${rule.frequency}`;
|
||||
}
|
||||
if (rule.count) base += `, ${rule.count} times`;
|
||||
if (rule.until) base += `, until ${rule.until.slice(0, 10)}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
if (n === -1) return "last";
|
||||
const s = ["th", "st", "nd", "rd"];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { EmailFilter, EmailFilterCondition, Mailbox } from "@/jmap/types";
|
||||
|
||||
export interface ParsedQuery {
|
||||
text: string[];
|
||||
from?: string;
|
||||
to?: string;
|
||||
cc?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
hasAttachment?: boolean;
|
||||
unread?: boolean;
|
||||
read?: boolean;
|
||||
starred?: boolean;
|
||||
in?: string;
|
||||
label?: string[];
|
||||
before?: string;
|
||||
after?: string;
|
||||
larger?: number;
|
||||
smaller?: number;
|
||||
notLabel?: string[];
|
||||
}
|
||||
|
||||
const SIZE_RE = /^(\d+(?:\.\d+)?)\s*([kmg]?b?)$/i;
|
||||
function parseSize(s: string): number | undefined {
|
||||
const m = SIZE_RE.exec(s.trim());
|
||||
if (!m) return undefined;
|
||||
const n = Number(m[1]);
|
||||
const unit = (m[2] ?? "").toLowerCase();
|
||||
const mult = unit.startsWith("k") ? 1024 : unit.startsWith("m") ? 1024 ** 2 : unit.startsWith("g") ? 1024 ** 3 : 1;
|
||||
return Math.round(n * mult);
|
||||
}
|
||||
|
||||
function parseDate(s: string, endOfDay = false): string | undefined {
|
||||
const t = s.trim();
|
||||
let d: Date | null = null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t) || /^\d{4}\/\d{2}\/\d{2}$/.test(t)) {
|
||||
const [y, m, dd] = t.split(/[-/]/).map(Number) as [number, number, number];
|
||||
d = new Date(y, m - 1, dd);
|
||||
} else if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(t)) {
|
||||
const [m, dd, y] = t.split("/").map(Number) as [number, number, number];
|
||||
d = new Date(y, m - 1, dd);
|
||||
} else {
|
||||
const rel = /^(\d+)([dwmy])$/.exec(t);
|
||||
if (rel) {
|
||||
d = new Date();
|
||||
const n = Number(rel[1]);
|
||||
if (rel[2] === "d") d.setDate(d.getDate() - n);
|
||||
if (rel[2] === "w") d.setDate(d.getDate() - n * 7);
|
||||
if (rel[2] === "m") d.setMonth(d.getMonth() - n);
|
||||
if (rel[2] === "y") d.setFullYear(d.getFullYear() - n);
|
||||
} else {
|
||||
const p = new Date(t);
|
||||
if (!Number.isNaN(p.getTime())) d = p;
|
||||
}
|
||||
}
|
||||
if (!d || Number.isNaN(d.getTime())) return undefined;
|
||||
if (endOfDay) d.setHours(23, 59, 59, 999);
|
||||
else d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
/** Tokenize respecting quotes. */
|
||||
function tokenize(q: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re = /(\S+?:"[^"]*"|"[^"]*"|\S+)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(q))) out.push(m[1]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseQuery(q: string): ParsedQuery {
|
||||
const p: ParsedQuery = { text: [] };
|
||||
for (const tok of tokenize(q)) {
|
||||
const idx = tok.indexOf(":");
|
||||
const key = idx > 0 ? tok.slice(0, idx).toLowerCase() : "";
|
||||
let val = idx > 0 ? tok.slice(idx + 1) : tok;
|
||||
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
||||
const neg = key.startsWith("-");
|
||||
const k = neg ? key.slice(1) : key;
|
||||
switch (k) {
|
||||
case "from":
|
||||
p.from = val;
|
||||
break;
|
||||
case "to":
|
||||
p.to = val;
|
||||
break;
|
||||
case "cc":
|
||||
p.cc = val;
|
||||
break;
|
||||
case "subject":
|
||||
p.subject = val;
|
||||
break;
|
||||
case "body":
|
||||
p.body = val;
|
||||
break;
|
||||
case "has":
|
||||
if (val === "attachment") p.hasAttachment = true;
|
||||
if (val === "star" || val === "flag") p.starred = true;
|
||||
break;
|
||||
case "is":
|
||||
if (val === "unread") p.unread = true;
|
||||
if (val === "read") p.read = true;
|
||||
if (val === "starred" || val === "flagged") p.starred = true;
|
||||
break;
|
||||
case "in":
|
||||
case "folder":
|
||||
p.in = val;
|
||||
break;
|
||||
case "label":
|
||||
case "keyword":
|
||||
if (neg) (p.notLabel ??= []).push(val);
|
||||
else (p.label ??= []).push(val);
|
||||
break;
|
||||
case "before":
|
||||
p.before = parseDate(val);
|
||||
break;
|
||||
case "after":
|
||||
case "since":
|
||||
p.after = parseDate(val);
|
||||
break;
|
||||
case "newer":
|
||||
case "newer_than":
|
||||
p.after = parseDate(val);
|
||||
break;
|
||||
case "older":
|
||||
case "older_than":
|
||||
p.before = parseDate(val);
|
||||
break;
|
||||
case "larger":
|
||||
case "size":
|
||||
p.larger = parseSize(val);
|
||||
break;
|
||||
case "smaller":
|
||||
p.smaller = parseSize(val);
|
||||
break;
|
||||
default:
|
||||
p.text.push(val);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function buildFilter(p: ParsedQuery, mailboxes: Record<string, Mailbox>, currentMailbox?: string | null): EmailFilter {
|
||||
const conds: EmailFilterCondition[] = [];
|
||||
const c: EmailFilterCondition = {};
|
||||
if (p.text.length) c.text = p.text.join(" ");
|
||||
if (p.from) c.from = p.from;
|
||||
if (p.to) c.to = p.to;
|
||||
if (p.cc) c.cc = p.cc;
|
||||
if (p.subject) c.subject = p.subject;
|
||||
if (p.body) c.body = p.body;
|
||||
if (p.hasAttachment) c.hasAttachment = true;
|
||||
if (p.unread) c.notKeyword = "$seen";
|
||||
if (p.read) c.hasKeyword = "$seen";
|
||||
if (p.before) c.before = p.before;
|
||||
if (p.after) c.after = p.after;
|
||||
if (p.larger != null) c.minSize = p.larger;
|
||||
if (p.smaller != null) c.maxSize = p.smaller;
|
||||
if (p.in) {
|
||||
const mb = resolveMailbox(p.in, mailboxes);
|
||||
if (mb) c.inMailbox = mb.id;
|
||||
} else if (currentMailbox) {
|
||||
c.inMailbox = currentMailbox;
|
||||
}
|
||||
conds.push(c);
|
||||
if (p.starred) conds.push({ hasKeyword: "$flagged" });
|
||||
for (const l of p.label ?? []) conds.push({ hasKeyword: l.startsWith("$") ? l : l });
|
||||
for (const l of p.notLabel ?? []) conds.push({ notKeyword: l });
|
||||
if (conds.length === 1) return conds[0]!;
|
||||
return { operator: "AND", conditions: conds };
|
||||
}
|
||||
|
||||
export function resolveMailbox(name: string, mailboxes: Record<string, Mailbox>): Mailbox | undefined {
|
||||
const n = name.toLowerCase();
|
||||
const list = Object.values(mailboxes);
|
||||
const byRole = list.find((m) => m.role === n || (n === "spam" && m.role === "junk") || (n === "starred" && m.role === "flagged") || (n === "anywhere" && m.role === "all"));
|
||||
if (byRole) return byRole;
|
||||
if (n === "anywhere" || n === "all") return undefined;
|
||||
return list.find((m) => m.name.toLowerCase() === n) ?? list.find((m) => m.name.toLowerCase().includes(n));
|
||||
}
|
||||
|
||||
export function describeFilter(p: ParsedQuery): string {
|
||||
const parts: string[] = [];
|
||||
if (p.text.length) parts.push(`"${p.text.join(" ")}"`);
|
||||
if (p.from) parts.push(`from ${p.from}`);
|
||||
if (p.to) parts.push(`to ${p.to}`);
|
||||
if (p.subject) parts.push(`subject ${p.subject}`);
|
||||
if (p.hasAttachment) parts.push("has attachment");
|
||||
if (p.unread) parts.push("unread");
|
||||
if (p.starred) parts.push("starred");
|
||||
if (p.in) parts.push(`in ${p.in}`);
|
||||
return parts.join(", ") || "all mail";
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Visual filter rules <-> Sieve script codec.
|
||||
*
|
||||
* Rules are persisted inside the Sieve script itself as JSON comments
|
||||
* (`# rule:{...}`) so the UI can round-trip them losslessly; the generated
|
||||
* Sieve below each comment is what the server actually runs.
|
||||
*/
|
||||
|
||||
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
|
||||
|
||||
export type SieveTest =
|
||||
| { type: "header"; header: string; op: HeaderOp; value: string }
|
||||
| { type: "address"; header: string; part: "all" | "localpart" | "domain"; op: HeaderOp; value: string }
|
||||
| { type: "size"; op: "over" | "under"; value: number }
|
||||
| { type: "body"; op: "contains" | "notcontains"; value: string }
|
||||
| { type: "true" };
|
||||
|
||||
export type SieveAction =
|
||||
| { type: "fileinto"; mailbox: string; mailboxId?: string; copy?: boolean }
|
||||
| { type: "redirect"; address: string; copy?: boolean }
|
||||
| { type: "discard" }
|
||||
| { type: "keep" }
|
||||
| { type: "reject"; reason: string }
|
||||
| { type: "addflag"; flag: string }
|
||||
| { type: "setflag"; flag: string }
|
||||
| { type: "removeflag"; flag: string }
|
||||
| { type: "markread" }
|
||||
| { type: "flag" }
|
||||
| { type: "stop" };
|
||||
|
||||
export interface SieveRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
join: "allof" | "anyof";
|
||||
tests: SieveTest[];
|
||||
actions: SieveAction[];
|
||||
}
|
||||
|
||||
export const HEADER_CHOICES = [
|
||||
{ value: "from", label: "From" },
|
||||
{ value: "to", label: "To" },
|
||||
{ value: "cc", label: "Cc" },
|
||||
{ value: "subject", label: "Subject" },
|
||||
{ value: "list-id", label: "List-Id" },
|
||||
{ value: "reply-to", label: "Reply-To" },
|
||||
{ value: "x-spam-status", label: "X-Spam-Status" },
|
||||
{ value: "__custom__", label: "Other header…" },
|
||||
];
|
||||
|
||||
export const HEADER_OPS: Array<{ value: HeaderOp; label: string }> = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "notcontains", label: "does not contain" },
|
||||
{ value: "is", label: "is" },
|
||||
{ value: "notis", label: "is not" },
|
||||
{ value: "matches", label: "matches (wildcards * ?)" },
|
||||
{ value: "notmatches", label: "does not match" },
|
||||
{ value: "regex", label: "matches regex" },
|
||||
{ value: "notregex", label: "does not match regex" },
|
||||
{ value: "exists", label: "exists" },
|
||||
{ value: "notexists", label: "does not exist" },
|
||||
];
|
||||
|
||||
export function sieveString(s: string): string {
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ")}"`;
|
||||
}
|
||||
|
||||
function opToSieve(op: HeaderOp): { neg: boolean; match: string } {
|
||||
const neg = op.startsWith("not");
|
||||
const base = neg ? op.slice(3) : op;
|
||||
return { neg, match: base === "regex" ? ":regex" : base === "matches" ? ":matches" : base === "is" ? ":is" : base === "exists" ? "exists" : ":contains" };
|
||||
}
|
||||
|
||||
export function testToSieve(t: SieveTest): string {
|
||||
switch (t.type) {
|
||||
case "true":
|
||||
return "true";
|
||||
case "header": {
|
||||
const { neg, match } = opToSieve(t.op);
|
||||
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `header ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
|
||||
return neg ? `not ${inner}` : inner;
|
||||
}
|
||||
case "address": {
|
||||
const { neg, match } = opToSieve(t.op);
|
||||
const part = t.part === "all" ? ":all" : t.part === "localpart" ? ":localpart" : ":domain";
|
||||
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `address ${part} ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
|
||||
return neg ? `not ${inner}` : inner;
|
||||
}
|
||||
case "size":
|
||||
return `size :${t.op} ${Math.max(0, Math.round(t.value))}`;
|
||||
case "body": {
|
||||
const inner = `body :text :contains ${sieveString(t.value)}`;
|
||||
return t.op === "notcontains" ? `not ${inner}` : inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function actionToSieve(a: SieveAction): string[] {
|
||||
switch (a.type) {
|
||||
case "fileinto":
|
||||
return [`fileinto${a.copy ? " :copy" : ""} ${sieveString(a.mailbox)};`];
|
||||
case "redirect":
|
||||
return [`redirect${a.copy ? " :copy" : ""} ${sieveString(a.address)};`];
|
||||
case "discard":
|
||||
return ["discard;"];
|
||||
case "keep":
|
||||
return ["keep;"];
|
||||
case "reject":
|
||||
return [`reject ${sieveString(a.reason || "Message rejected")};`];
|
||||
case "addflag":
|
||||
return [`addflag ${sieveString(a.flag)};`];
|
||||
case "setflag":
|
||||
return [`setflag ${sieveString(a.flag)};`];
|
||||
case "removeflag":
|
||||
return [`removeflag ${sieveString(a.flag)};`];
|
||||
case "markread":
|
||||
return ['addflag "\\\\Seen";'];
|
||||
case "flag":
|
||||
return ['addflag "\\\\Flagged";'];
|
||||
case "stop":
|
||||
return ["stop;"];
|
||||
}
|
||||
}
|
||||
|
||||
export function requiredExtensions(rules: SieveRule[]): string[] {
|
||||
const req = new Set<string>();
|
||||
for (const r of rules) {
|
||||
for (const t of r.tests) {
|
||||
if (t.type === "body") req.add("body");
|
||||
if ((t.type === "header" || t.type === "address") && (t.op === "regex" || t.op === "notregex")) req.add("regex");
|
||||
if (t.type === "address") req.add("envelope");
|
||||
}
|
||||
for (const a of r.actions) {
|
||||
if (a.type === "fileinto") {
|
||||
req.add("fileinto");
|
||||
if (a.copy) req.add("copy");
|
||||
}
|
||||
if (a.type === "redirect" && a.copy) req.add("copy");
|
||||
if (a.type === "reject") req.add("reject");
|
||||
if (["addflag", "setflag", "removeflag", "markread", "flag"].includes(a.type)) req.add("imap4flags");
|
||||
}
|
||||
}
|
||||
req.delete("envelope");
|
||||
return [...req].sort();
|
||||
}
|
||||
|
||||
export const SCRIPT_HEADER = "# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments";
|
||||
|
||||
export function rulesToSieve(rules: SieveRule[]): string {
|
||||
const ext = requiredExtensions(rules);
|
||||
const lines: string[] = [SCRIPT_HEADER];
|
||||
if (ext.length) lines.push(`require [${ext.map(sieveString).join(", ")}];`);
|
||||
lines.push("");
|
||||
for (const r of rules) {
|
||||
lines.push(`# rule:${JSON.stringify(r)}`);
|
||||
if (!r.enabled) {
|
||||
lines.push(`# (disabled) ${r.name}`);
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
const tests = r.tests.filter((t) => t.type !== "true");
|
||||
let cond: string;
|
||||
if (!tests.length) cond = "true";
|
||||
else if (tests.length === 1) cond = testToSieve(tests[0]!);
|
||||
else cond = `${r.join} (${tests.map(testToSieve).join(", ")})`;
|
||||
const body = r.actions.flatMap(actionToSieve).map((l) => ` ${l}`);
|
||||
if (!body.length) body.push(" keep;");
|
||||
lines.push(`if ${cond}`);
|
||||
lines.push("{");
|
||||
lines.push(...body);
|
||||
lines.push("}");
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
|
||||
export function sieveToRules(script: string): SieveRule[] | null {
|
||||
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
|
||||
const out: SieveRule[] = [];
|
||||
for (const line of script.split(/\r?\n/)) {
|
||||
if (!line.startsWith("# rule:")) continue;
|
||||
try {
|
||||
const r = JSON.parse(line.slice(7)) as SieveRule;
|
||||
if (r && typeof r === "object" && Array.isArray(r.tests) && Array.isArray(r.actions)) out.push(r);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newRule(partial: Partial<SieveRule> = {}): SieveRule {
|
||||
return {
|
||||
id: `r${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: "New filter",
|
||||
enabled: true,
|
||||
join: "allof",
|
||||
tests: [{ type: "header", header: "from", op: "contains", value: "" }],
|
||||
actions: [{ type: "fileinto", mailbox: "INBOX" }],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeRule(r: SieveRule): string {
|
||||
const tests = r.tests
|
||||
.map((t) => {
|
||||
switch (t.type) {
|
||||
case "header":
|
||||
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
|
||||
case "address":
|
||||
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
|
||||
case "size":
|
||||
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
|
||||
case "body":
|
||||
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
|
||||
case "true":
|
||||
return "always";
|
||||
}
|
||||
})
|
||||
.join(r.join === "allof" ? " and " : " or ");
|
||||
const actions = r.actions
|
||||
.map((a) => {
|
||||
switch (a.type) {
|
||||
case "fileinto":
|
||||
return `move to ${a.mailbox}`;
|
||||
case "redirect":
|
||||
return `forward to ${a.address}`;
|
||||
case "discard":
|
||||
return "delete";
|
||||
case "keep":
|
||||
return "keep";
|
||||
case "reject":
|
||||
return "reject";
|
||||
case "markread":
|
||||
return "mark read";
|
||||
case "flag":
|
||||
return "star";
|
||||
case "addflag":
|
||||
case "setflag":
|
||||
return `add ${a.flag}`;
|
||||
case "removeflag":
|
||||
return `remove ${a.flag}`;
|
||||
case "stop":
|
||||
return "stop";
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
return `${tests || "always"} → ${actions}`;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Client-side evaluation of a visual Sieve rule against existing messages, so a
|
||||
* newly created filter can be applied retroactively to a folder (the server only
|
||||
* runs Sieve on delivery).
|
||||
*/
|
||||
import { client, chunk } from "@/jmap/client";
|
||||
import type { Email, GetResponse, Id, QueryResponse } from "@/jmap/types";
|
||||
import { LIST_PROPS, useMail } from "@/store/mail";
|
||||
import type { SieveRule, SieveTest } from "./sieve";
|
||||
import { domainOf } from "./address";
|
||||
|
||||
function headerValues(e: Email, header: string): string[] {
|
||||
const h = header.toLowerCase();
|
||||
const addr = (list?: { name: string | null; email: string }[] | null) => (list ?? []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email));
|
||||
switch (h) {
|
||||
case "from":
|
||||
return addr(e.from);
|
||||
case "to":
|
||||
return addr(e.to);
|
||||
case "cc":
|
||||
return addr(e.cc);
|
||||
case "bcc":
|
||||
return addr(e.bcc);
|
||||
case "reply-to":
|
||||
return addr(e.replyTo);
|
||||
case "sender":
|
||||
return addr(e.sender);
|
||||
case "subject":
|
||||
return e.subject ? [e.subject] : [];
|
||||
case "message-id":
|
||||
return e.messageId ?? [];
|
||||
default: {
|
||||
const rec = e as unknown as Record<string, unknown>;
|
||||
const key = Object.keys(rec).find((k) => k.toLowerCase().startsWith(`header:${h}:`));
|
||||
const v = key ? rec[key] : undefined;
|
||||
return typeof v === "string" ? [v] : Array.isArray(v) ? (v as string[]) : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addressValues(e: Email, header: string, part: "all" | "localpart" | "domain"): string[] {
|
||||
const h = header.toLowerCase();
|
||||
const list = h === "from" ? e.from : h === "to" ? e.to : h === "cc" ? e.cc : h === "bcc" ? e.bcc : h === "reply-to" ? e.replyTo : h === "sender" ? e.sender : null;
|
||||
return (list ?? []).map((a) => (part === "domain" ? domainOf(a.email) : part === "localpart" ? a.email.split("@")[0] ?? "" : a.email));
|
||||
}
|
||||
|
||||
function wildcardToRegex(pattern: string): RegExp {
|
||||
const esc = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
||||
return new RegExp(`^${esc}$`, "i");
|
||||
}
|
||||
|
||||
function matchOp(values: string[], op: string, value: string): boolean {
|
||||
const neg = op.startsWith("not");
|
||||
const base = neg ? op.slice(3) : op;
|
||||
const v = value.toLowerCase();
|
||||
let r: boolean;
|
||||
switch (base) {
|
||||
case "exists":
|
||||
r = values.length > 0;
|
||||
break;
|
||||
case "is":
|
||||
r = values.some((x) => x.toLowerCase() === v);
|
||||
break;
|
||||
case "matches":
|
||||
r = values.some((x) => wildcardToRegex(value).test(x));
|
||||
break;
|
||||
case "regex": {
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(value, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
r = re ? values.some((x) => re!.test(x)) : false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
r = values.some((x) => x.toLowerCase().includes(v));
|
||||
}
|
||||
return neg ? !r : r;
|
||||
}
|
||||
|
||||
export function evaluateTest(e: Email, t: SieveTest, bodyText?: string): boolean {
|
||||
switch (t.type) {
|
||||
case "true":
|
||||
return true;
|
||||
case "header":
|
||||
return matchOp(headerValues(e, t.header), t.op, t.value);
|
||||
case "address":
|
||||
return matchOp(addressValues(e, t.header, t.part), t.op, t.value);
|
||||
case "size":
|
||||
return t.op === "over" ? e.size > t.value : e.size < t.value;
|
||||
case "body": {
|
||||
const has = (bodyText ?? e.preview ?? "").toLowerCase().includes(t.value.toLowerCase());
|
||||
return t.op === "contains" ? has : !has;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateRule(e: Email, rule: SieveRule, bodyText?: string): boolean {
|
||||
const tests = rule.tests.filter((t) => t.type !== "true");
|
||||
if (!tests.length) return true;
|
||||
return rule.join === "anyof" ? tests.some((t) => evaluateTest(e, t, bodyText)) : tests.every((t) => evaluateTest(e, t, bodyText));
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
scanned: number;
|
||||
matched: number;
|
||||
skippedActions: string[];
|
||||
}
|
||||
|
||||
/** Apply a rule's actions to all matching messages currently in `mailboxId`. */
|
||||
export async function applyRuleToMailbox(rule: SieveRule, mailboxId: Id, onProgress?: (scanned: number, total: number) => void): Promise<ApplyResult> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId;
|
||||
if (!accountId) throw new Error("Not signed in");
|
||||
const customHeaders = rule.tests.filter((t): t is Extract<SieveTest, { type: "header" }> => t.type === "header").map((t) => t.header).filter((h) => !["from", "to", "cc", "bcc", "reply-to", "sender", "subject", "message-id"].includes(h.toLowerCase()));
|
||||
const needsBody = rule.tests.some((t) => t.type === "body");
|
||||
const props = [...LIST_PROPS, "sender", "cc", "bcc", "replyTo", "messageId", ...customHeaders.map((h) => `header:${h}:asText`), ...(needsBody ? ["textBody", "bodyValues"] : [])];
|
||||
|
||||
// Gather all ids in the folder
|
||||
const ids: Id[] = [];
|
||||
let position = 0;
|
||||
let total = 0;
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, sort: [{ property: "receivedAt", isAscending: false }], position, limit: 500, calculateTotal: true });
|
||||
ids.push(...q.ids);
|
||||
total = q.total ?? ids.length;
|
||||
position += q.ids.length;
|
||||
if (!q.ids.length || position >= total) break;
|
||||
}
|
||||
|
||||
const matched: Email[] = [];
|
||||
let scanned = 0;
|
||||
for (const part of chunk(ids, 200)) {
|
||||
const res = await client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: props, ...(needsBody ? { fetchTextBodyValues: true, maxBodyValueBytes: 64 * 1024 } : {}) });
|
||||
for (const e of res.list) {
|
||||
const body = needsBody ? (e.textBody?.[0]?.partId ? e.bodyValues?.[e.textBody[0].partId]?.value : undefined) : undefined;
|
||||
if (evaluateRule(e, rule, body)) matched.push(e);
|
||||
}
|
||||
scanned += part.length;
|
||||
onProgress?.(scanned, ids.length);
|
||||
}
|
||||
|
||||
const skippedActions: string[] = [];
|
||||
if (matched.length) {
|
||||
const mids = matched.map((e) => e.id);
|
||||
const byPath = new Map<string, Id>();
|
||||
for (const m of Object.values(mail.mailboxes)) byPath.set(mail.mailboxPath(m.id).toLowerCase(), m.id);
|
||||
const inboxId = mail.roleId("inbox");
|
||||
for (const a of rule.actions) {
|
||||
switch (a.type) {
|
||||
case "fileinto": {
|
||||
const target = (a.mailboxId && mail.mailboxes[a.mailboxId]?.id) || byPath.get(a.mailbox.toLowerCase()) || (a.mailbox.toLowerCase() === "inbox" ? inboxId : null) || Object.values(mail.mailboxes).find((m) => m.name.toLowerCase() === a.mailbox.toLowerCase())?.id;
|
||||
if (!target) {
|
||||
skippedActions.push(`move to “${a.mailbox}” (folder not found)`);
|
||||
break;
|
||||
}
|
||||
if (target === mailboxId) break;
|
||||
if (a.copy) await mail.addToMailbox(mids, target, true);
|
||||
else await mail.move(mids, target, { silent: true });
|
||||
break;
|
||||
}
|
||||
case "markread":
|
||||
await mail.setKeyword(mids, "$seen", true);
|
||||
break;
|
||||
case "flag":
|
||||
await mail.setKeyword(mids, "$flagged", true);
|
||||
break;
|
||||
case "addflag":
|
||||
case "setflag":
|
||||
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), true);
|
||||
break;
|
||||
case "removeflag":
|
||||
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), false);
|
||||
break;
|
||||
case "discard":
|
||||
await mail.trash(mids);
|
||||
break;
|
||||
case "redirect":
|
||||
skippedActions.push(`forward to ${a.address} (cannot resend existing mail)`);
|
||||
break;
|
||||
case "reject":
|
||||
skippedActions.push("reject (cannot bounce existing mail)");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
void mail.refreshList();
|
||||
void mail.loadMailboxes();
|
||||
}
|
||||
return { scanned: ids.length, matched: matched.length, skippedActions };
|
||||
}
|
||||
|
||||
function normalizeFlag(flag: string): string {
|
||||
const f = flag.trim();
|
||||
if (/^\\\\?seen$/i.test(f)) return "$seen";
|
||||
if (/^\\\\?flagged$/i.test(f)) return "$flagged";
|
||||
if (/^\\\\?answered$/i.test(f)) return "$answered";
|
||||
if (/^\\\\?draft$/i.test(f)) return "$draft";
|
||||
return f.replace(/^\\+/, "");
|
||||
}
|
||||
|
||||
/** Seed a rule from a message (used by "Filter messages like this"). */
|
||||
export function ruleFromEmail(e: Email, currentMailboxId: Id | null): SieveRule {
|
||||
const mail = useMail.getState();
|
||||
const from = e.from?.[0]?.email ?? "";
|
||||
const listId = e["header:List-Id:asText"];
|
||||
const tests: SieveTest[] = listId ? [{ type: "header", header: "list-id", op: "contains", value: listId.replace(/^.*<|>.*$/g, "") }] : [{ type: "header", header: "from", op: "contains", value: from }];
|
||||
const target = Object.values(mail.mailboxes).find((m) => !m.role && m.id !== currentMailboxId) ?? Object.values(mail.mailboxes).find((m) => m.role === "archive");
|
||||
const name = listId ? `List: ${listId.replace(/^.*<|>.*$/g, "")}` : `From ${from}`;
|
||||
return {
|
||||
id: `r${Math.random().toString(36).slice(2, 9)}`,
|
||||
name,
|
||||
enabled: true,
|
||||
join: "allof",
|
||||
tests,
|
||||
actions: [{ type: "fileinto", mailbox: target ? mail.mailboxPath(target.id) : "INBOX", mailboxId: target?.id }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Helpers to fit rich signatures into Stalwart's 2 KB identity signature limit:
|
||||
* - compactHtml(): strips Office/Gmail cruft and non-essential inline styles
|
||||
* - marker signatures: when still too big, the full HTML lives in Files and the
|
||||
* identity only stores `<!--ihasmail:sig=<blobId>-->` + a plain-text fallback.
|
||||
*/
|
||||
import { escapeHtml, htmlToText } from "./text";
|
||||
|
||||
export const SIGNATURE_LIMIT = 2047;
|
||||
|
||||
const KEEP_STYLES = new Set(["color", "background-color", "font-weight", "font-style", "text-decoration", "font-size", "font-family", "text-align", "vertical-align", "width", "height", "max-width", "border", "border-left", "padding-left", "margin"]);
|
||||
const KEEP_ATTRS = new Set(["href", "src", "alt", "width", "height", "target", "style", "title", "colspan", "rowspan", "cellpadding", "cellspacing", "border", "align", "valign"]);
|
||||
const DROP_TAGS = new Set(["META", "STYLE", "SCRIPT", "LINK", "TITLE", "HEAD", "O:P", "XML", "NOSCRIPT", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON"]);
|
||||
|
||||
export function compactHtml(input: string): string {
|
||||
const doc = new DOMParser().parseFromString(`<div id="r">${input}</div>`, "text/html");
|
||||
const root = doc.getElementById("r")!;
|
||||
// Remove comments and junk elements
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
|
||||
const comments: Node[] = [];
|
||||
while (walker.nextNode()) comments.push(walker.currentNode);
|
||||
comments.forEach((c) => c.parentNode?.removeChild(c));
|
||||
Array.from(root.querySelectorAll("*"))
|
||||
.filter((el) => DROP_TAGS.has(el.tagName.toUpperCase()) || el.tagName.includes(":"))
|
||||
.forEach((n) => n.remove());
|
||||
// Clean attributes and styles
|
||||
root.querySelectorAll("*").forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (!KEEP_ATTRS.has(attr.name.toLowerCase())) el.removeAttribute(attr.name);
|
||||
}
|
||||
const style = el.getAttribute("style");
|
||||
if (style) {
|
||||
const kept = style
|
||||
.split(";")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.map((d) => {
|
||||
const i = d.indexOf(":");
|
||||
if (i < 0) return null;
|
||||
const k = d.slice(0, i).trim().toLowerCase();
|
||||
let v = d.slice(i + 1).trim();
|
||||
if (!KEEP_STYLES.has(k) || v.startsWith("mso-") || /^(inherit|initial|unset)$/i.test(v)) return null;
|
||||
if (k === "font-family") v = v.split(",")[0]!.trim();
|
||||
if (k === "color" && /^(windowtext|black|#000000|#000|rgb\(0,\s*0,\s*0\))$/i.test(v)) return null;
|
||||
if (k === "background-color" && /^(transparent|white|#fff(fff)?|rgb\(255,\s*255,\s*255\))$/i.test(v)) return null;
|
||||
return `${k}:${v}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
if (kept) el.setAttribute("style", kept);
|
||||
else el.removeAttribute("style");
|
||||
}
|
||||
if (el.tagName === "A" && el.getAttribute("target")) el.removeAttribute("target");
|
||||
});
|
||||
// Unwrap meaningless spans/fonts and empty blocks (repeat until stable)
|
||||
let changed = true;
|
||||
let guard = 0;
|
||||
while (changed && guard++ < 10) {
|
||||
changed = false;
|
||||
root.querySelectorAll("span,font,div,p,b,strong,i,em,u").forEach((el) => {
|
||||
if (!el.parentNode) return;
|
||||
const hasContent = (el.textContent ?? "").trim() !== "" || el.querySelector("img,br,hr,table");
|
||||
if (!hasContent && el.tagName !== "BR") {
|
||||
el.remove();
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
if ((el.tagName === "SPAN" || el.tagName === "FONT") && el.attributes.length === 0) {
|
||||
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
|
||||
el.remove();
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
// div/p containing only another single div/p: flatten
|
||||
if ((el.tagName === "DIV" || el.tagName === "P") && el.attributes.length === 0 && el.childNodes.length === 1 && el.firstElementChild && (el.firstElementChild.tagName === "DIV" || el.firstElementChild.tagName === "P")) {
|
||||
el.replaceWith(el.firstElementChild);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return root.innerHTML
|
||||
.replace(/\s*\n\s*/g, " ")
|
||||
.replace(/>\s+</g, "><")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const MARKER_RE = /<!--ihasmail:sig=([A-Za-z0-9_-]+)(?::([\w/+.-]+))?-->/;
|
||||
|
||||
export function markerOf(htmlSignature: string | null | undefined): { blobId: string; type: string } | null {
|
||||
const m = htmlSignature ? MARKER_RE.exec(htmlSignature) : null;
|
||||
return m ? { blobId: m[1]!, type: m[2] ?? "text/html" } : null;
|
||||
}
|
||||
|
||||
/** Build the short identity signature that points at a stored full signature. */
|
||||
export function buildMarkerSignature(blobId: string, fullHtml: string): { htmlSignature: string; textSignature: string } {
|
||||
const text = htmlToText(fullHtml);
|
||||
const marker = `<!--ihasmail:sig=${blobId}:text/html-->`;
|
||||
const budget = SIGNATURE_LIMIT - marker.length - 11; // <div></div>
|
||||
let fallback = escapeHtml(text).replace(/\n/g, "<br>");
|
||||
if (fallback.length > budget) fallback = `${fallback.slice(0, Math.max(0, budget - 1))}…`;
|
||||
return { htmlSignature: `${marker}<div>${fallback}</div>`, textSignature: text.length > SIGNATURE_LIMIT ? `${text.slice(0, SIGNATURE_LIMIT - 1)}…` : text };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Signature images: Stalwart caps identity signatures at 2 KB, so pictures can't
|
||||
* be embedded as data: URLs. Instead we store them in JMAP Files (persistent
|
||||
* blobs) under an "ihasmail" folder and reference them by blob URL; the composer
|
||||
* turns such references into inline cid: parts when sending.
|
||||
*/
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "@/store/session";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
const FOLDER = "ihasmail";
|
||||
|
||||
async function ensureFolder(accountId: string): Promise<string> {
|
||||
let list: FileNode[] = [];
|
||||
try {
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
|
||||
]);
|
||||
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
||||
} catch {
|
||||
// Older servers: no filter support — scan everything.
|
||||
const res = await client.chain([
|
||||
["FileNode/query", { accountId, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
|
||||
]);
|
||||
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
|
||||
}
|
||||
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
|
||||
if (existing) return existing.id;
|
||||
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } });
|
||||
const err = set.notCreated?.d;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
return set.created!.d!.id;
|
||||
}
|
||||
|
||||
/** Upload an image for use in a signature; returns a same-origin blob URL. */
|
||||
export async function uploadSignatureImage(file: File): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
if (!accountId || !client.hasCapability(CAP.filenode)) {
|
||||
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
|
||||
throw new Error("filenode unavailable");
|
||||
}
|
||||
if (file.size > 512 * 1024) {
|
||||
toast.error("Please use an image under 512 KB for signatures.");
|
||||
throw new Error("too large");
|
||||
}
|
||||
try {
|
||||
const type = file.type || "image/png";
|
||||
const up = await client.upload(accountId, file, { type });
|
||||
const folderId = await ensureFolder(accountId);
|
||||
const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`;
|
||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } });
|
||||
const err = res.notCreated?.f;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const created = res.created?.f as Partial<FileNode> | undefined;
|
||||
// Prefer the node's (persistent) blobId if the server returned one.
|
||||
const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
|
||||
return client.downloadUrl(accountId, blobId, name, type, true);
|
||||
} catch (err) {
|
||||
toast.error(`Could not store image: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
|
||||
export async function storeSignatureHtml(html: string): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
|
||||
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
|
||||
const folderId = await ensureFolder(accountId);
|
||||
const name = `signature-${Date.now()}.html`;
|
||||
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } });
|
||||
const err = res.notCreated?.f;
|
||||
if (err) throw new Error(err.description ?? err.type);
|
||||
const created = res.created?.f as Partial<FileNode> | undefined;
|
||||
return created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
|
||||
}
|
||||
|
||||
/** Replace data: URL images (pasted pictures) in signature HTML with stored blob URLs. */
|
||||
export async function externalizeDataImages(html: string): Promise<string> {
|
||||
if (!html.includes("data:image/")) return html;
|
||||
const doc = new DOMParser().parseFromString(`<div id="r">${html}</div>`, "text/html");
|
||||
const root = doc.getElementById("r")!;
|
||||
const imgs = Array.from(root.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
|
||||
for (const img of imgs) {
|
||||
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(img.getAttribute("src")!);
|
||||
if (!m) {
|
||||
img.remove();
|
||||
continue;
|
||||
}
|
||||
const bin = atob(m[2]!);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const file = new File([bytes], `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, { type: m[1]! });
|
||||
img.setAttribute("src", await uploadSignatureImage(file));
|
||||
}
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
/** Load the full HTML of a marker signature. */
|
||||
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
|
||||
if (!accountId) throw new Error("no account");
|
||||
return client.fetchBlobText(accountId, blobId, type);
|
||||
}
|
||||
|
||||
async function nodeBlobId(accountId: string, id?: string): Promise<string | undefined> {
|
||||
if (!id) return undefined;
|
||||
try {
|
||||
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
|
||||
return res.list[0]?.blobId ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type { QueryResponse };
|
||||