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.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit c17887e48e
162 changed files with 20398 additions and 1072 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
**/node_modules
**/dist
.git
.env
server/data
+35
View File
@@ -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
+20
View File
@@ -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 .
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
.DS_Store
server/data/
.vite/
coverage/
+9
View File
@@ -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
}
}
+27 -14
View File
@@ -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"]
-13
View File
@@ -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
+122 -40
View File
@@ -1,60 +1,142 @@
<p align="center">
<img src="web/public/img/logo.png" alt="ihasmail" width="180">
</p>
# ihasmail
![ihasmail logo](app/static/img/logo.png)
**A fast, friendly, Gmail-class webmail for [Stalwart Mail Server](https://stalw.art) — built on JMAP, from the ground up.**
A polished, FastAPI + HTMX/Jinja webmail for Stalwart, with JMAP mail/contacts/calendar, Sieve UI, DAV browsing, and reverse-proxy friendly deploy.
ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters and every other modern feature Stalwart exposes, in a responsive single-page app that works equally well on a desktop monitor and a phone. It talks only JMAP (plus Stalwart's blob/upload/EventSource endpoints) — no IMAP, no SMTP, no database.
A production-leaning, **FastAPI** + **HTMX/Jinja** webmail for [Stalwart Mail Server](https://stalw.art/), using **JMAP** for mail, contacts, and calendar, plus simple **WebDAV/CalDAV** helpers. Authenticates with the user's Stalwart mailbox (like Roundcube). Designed to run behind a reverse proxy.
> Status: 2.0 rewrite, in QA against a live Stalwart 1.0 server. The previous FastAPI/HTMX prototype has been removed entirely (only the logo survived).
## Screenshots
*All screenshots are taken against the built-in mock server (`npm run dev:mock`) with sample data — no real mailbox involved.*
| | |
| --- | --- |
| **Inbox & conversation view (dark)** ![Inbox, dark theme](docs/screenshots/inbox-dark.jpg) | **Inbox & conversation view (light)** ![Inbox, light theme](docs/screenshots/inbox-light.jpg) |
| **Reply composer** — identities, Reply-To, rich text, signature, quoted text ![Composer](docs/screenshots/compose.jpg) | **Calendar (month view)** ![Calendar](docs/screenshots/calendar.jpg) |
| **Contacts** ![Contacts](docs/screenshots/contacts.jpg) | **Sieve filter builder** — also reachable from a message's right-click menu ![Filters](docs/screenshots/filters.jpg) |
| **Sign-in** ![Login](docs/screenshots/login.jpg) | **Mobile layout** <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
- AZ 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).
View File
-9
View File
@@ -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"))
-41
View File
@@ -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
-31
View File
@@ -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()
-34
View File
@@ -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}
View File
-45
View File
@@ -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)
-39
View File
@@ -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})
-35
View File
@@ -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})
-316
View File
@@ -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)})
-21
View File
@@ -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})
-30
View File
@@ -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;}
-37
View File
@@ -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>
-15
View File
@@ -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 %}
-11
View File
@@ -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 %}
-19
View File
@@ -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 %}
-24
View File
@@ -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 %}
-26
View File
@@ -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 %}
-25
View File
@@ -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 %}
-15
View File
@@ -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 %}
-19
View File
@@ -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 ""
-6
View File
@@ -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"
-7
View File
@@ -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).
-20
View File
@@ -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 -}}
-60
View File
@@ -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 }}
-30
View File
@@ -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 }}
-15
View File
@@ -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 }}
-32
View File
@@ -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: {}
+12 -6
View File
@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+19
View File
@@ -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;
}
}
+3845
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
-30
View File
@@ -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"
+24
View File
@@ -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"
}
}
+37
View File
@@ -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);
});
+404
View File
@@ -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"
);
}
+81
View File
@@ -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;
Binary file not shown.
+121
View File
@@ -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 });
}
+27
View File
@@ -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);
});
+434
View File
@@ -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 &amp; 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();
+45
View File
@@ -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);
}
}
}
+48
View File
@@ -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);
});
+213
View File
@@ -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,
};
}
}
+101
View File
@@ -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);
}
};
}
+94
View File
@@ -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] ?? ""));
}
+21
View File
@@ -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"]
}
-7
View File
@@ -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)
+23
View File
@@ -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>
+31
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

+21
View File
@@ -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" }
]
}
+41
View File
@@ -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)));
});
+137
View File
@@ -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>
);
}
+348
View File
@@ -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;
}
+104
View File
@@ -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();
+775
View File
@@ -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;
}
+23
View File
@@ -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");
});
});
+23
View File
@@ -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);
});
});
+34
View File
@@ -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>");
});
});
+42
View File
@@ -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" });
});
});
+31
View File
@@ -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([]);
});
});
+31
View File
@@ -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();
});
});
+27
View File
@@ -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("&lt;");
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");
});
});
+116
View File
@@ -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() : "";
}
+149
View File
@@ -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)}`;
}
+250
View File
@@ -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;
}
+108
View File
@@ -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(" ");
}
+169
View File
@@ -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); }
`;
+138
View File
@@ -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();
+95
View File
@@ -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 */
}
}
+92
View File
@@ -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]!);
}
+193
View File
@@ -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";
}
+250
View File
@@ -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}`;
}
+221
View File
@@ -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 }],
};
}
+103
View File
@@ -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(/&nbsp;/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 };
}
+119
View File
@@ -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 };

Some files were not shown because too many files have changed in this diff Show More