diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c5a5ba3..20fc27d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,38 +10,77 @@ jobs: deploy: runs-on: ubuntu-latest permissions: - contents: write + contents: read name: Deploy Auth Inbox Worker steps: - # 步骤 1: 检出代码 - name: Checkout Repository uses: actions/checkout@v4 - # 步骤 2: 设置 Node.js 环境 - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' # 根据您的项目需求选择 Node.js 版本 + node-version: '20' - # 步骤 3: 安装依赖项 - - name: Install Dependencies - run: npm install + - name: Enable Corepack + run: corepack enable - - name: Install Web Dependencies - run: npm --prefix web install + - name: Install Dependencies + run: corepack pnpm install --frozen-lockfile - name: Build Web App - run: npm run build:web + run: corepack pnpm run build:web - # 步骤 4: 部署 Worker - - name: Deploy Backend for ${{ github.ref_name }} + - name: Prepare Wrangler Config + run: | + cp wrangler.toml.example.clear wrangler.toml + + if [ -n "${{ vars.CF_WORKER_NAME }}" ]; then + sed -i "s/^name = .*/name = \"${{ vars.CF_WORKER_NAME }}\"/" wrangler.toml + fi + + if [ -n "${{ vars.CF_D1_DATABASE_NAME }}" ]; then + sed -i "s/^database_name = .*/database_name = \"${{ vars.CF_D1_DATABASE_NAME }}\"/" wrangler.toml + fi + + if [ -n "${{ vars.CF_D1_DATABASE_ID }}" ]; then + sed -i "s/^database_id = .*/database_id = \"${{ vars.CF_D1_DATABASE_ID }}\"/" wrangler.toml + fi + + - name: Validate Wrangler Config run: | + if grep -q '\*\*\*\*' wrangler.toml; then + echo "wrangler.toml still has placeholder values. Set repository vars CF_D1_DATABASE_ID (and optionally CF_WORKER_NAME/CF_D1_DATABASE_NAME)." >&2 + exit 1 + fi - # Write secrets.TOML to wrangler.toml file - echo '${{ secrets.TOML }}' > wrangler.toml + - name: Run ASSETS Smoke QA (local worker) + run: corepack pnpm run qa:assets-smoke + env: + QA_WRANGLER_CONFIG: wrangler.toml + QA_ADMIN_ID: admin + QA_ADMIN_PASSWORD: qa-password + + - name: Deploy Backend for ${{ github.ref_name }} + run: corepack pnpm exec wrangler deploy --config wrangler.toml + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Sync Sensitive Worker Secrets (Optional) + run: | + put_secret () { + local key="$1" + local value="$2" + if [ -n "$value" ]; then + printf '%s' "$value" | corepack pnpm exec wrangler secret put "$key" --config wrangler.toml + fi + } - # Deploy the worker using Wrangler - npx wrangler deploy + put_secret "FrontEndAdminPassword" "${{ secrets.FRONTEND_ADMIN_PASSWORD }}" + put_secret "SESSION_SIGNING_KEY" "${{ secrets.SESSION_SIGNING_KEY }}" + put_secret "AI_API_KEY" "${{ secrets.AI_API_KEY }}" + put_secret "AI_FALLBACK_API_KEY" "${{ secrets.AI_FALLBACK_API_KEY }}" + put_secret "barkTokens" "${{ secrets.BARK_TOKENS }}" env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 8ba2d0a..3b4e1ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,25 +12,25 @@ ```bash # Install -pnpm install # root (Worker) deps +corepack pnpm install # root (Worker) deps # Dev -pnpm run dev # wrangler dev — local Worker on :8787 -pnpm run dev:remote # wrangler dev --remote — use live D1 -pnpm -C web run dev # Vite dev server on :5173 (proxies /api → :8787) +corepack pnpm run dev # wrangler dev — local Worker on :8787 +corepack pnpm run dev:web # Vite dev server on :5173 (proxies /api → :8787) # Build & deploy -pnpm run build:web # vite build → web/dist -pnpm run deploy # build:web + wrangler deploy +corepack pnpm run build:web # vite build → web/dist +corepack pnpm run deploy # build:web + wrangler deploy # Misc -pnpm run test # vitest with @cloudflare/vitest-pool-workers -pnpm run cf-typegen # regenerate Worker type bindings +corepack pnpm run test # run vitest suite +corepack pnpm run qa:assets-smoke # local smoke test for React assets route +corepack pnpm run cf-typegen # regenerate Worker type bindings ``` Local setup: ```bash cp wrangler.toml.example wrangler.toml # fill in database_id + secrets -pnpm wrangler d1 execute inbox-d1 --local --file=db/schema.sql -pnpm run build:web -pnpm run dev +corepack pnpm exec wrangler d1 execute inbox-d1 --local --file=db/schema.sql +corepack pnpm run build:web +corepack pnpm run dev ``` ## Architecture Constraints diff --git a/CLAUDE.md b/CLAUDE.md index 902a8b4..adcc056 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,10 @@ Package manager: **pnpm**. Never use npm commands. ## Key Commands ```bash -pnpm run dev # Worker backend :8787 -pnpm -C web run dev # React frontend :5173 (proxy /api → :8787) -pnpm run dev:remote # backend with live D1 -pnpm run deploy # build:web + wrangler deploy -pnpm run test +corepack pnpm run dev # Worker backend :8787 +corepack pnpm run dev:web # React frontend :5173 (proxy /api → :8787) +corepack pnpm run deploy # build:web + wrangler deploy +corepack pnpm run test ``` ## Architecture Boundaries — Never Violate diff --git a/README.md b/README.md index 8d91298..74362fa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [English](https://github.com/TooonyChen/AuthInbox/blob/main/README.md) | [简体中文](https://github.com/TooonyChen/AuthInbox/blob/main/README_CN.md) -**Auth Inbox** is an open-source, self-hosted email verification code platform built on [Cloudflare](https://cloudflare.com/)'s free serverless services. It automatically processes incoming emails, filters out promotional mail before hitting the AI, extracts verification codes or links, and stores them in a database. A modern React dashboard lets administrators review extracted codes, inspect raw emails, and render HTML email previews — all protected by Basic Auth. +**Auth Inbox** is an open-source, self-hosted email verification code platform built on [Cloudflare](https://cloudflare.com/)'s free serverless services. It automatically processes incoming emails, filters out promotional mail before hitting the AI, extracts verification codes or links, and stores them in a database. A modern React dashboard lets administrators review extracted codes, inspect raw emails, and render HTML email previews — protected with Basic Auth, session login, or both. Don't want ads and spam in your main inbox? Need a bunch of alternative addresses for signups? Try this **secure**, **serverless**, **lightweight** service! @@ -36,6 +36,7 @@ flowchart LR - **Promotional Filter**: Detects and skips bulk/marketing emails via headers (`List-Unsubscribe`, `Precedence: bulk`, etc.) before calling the AI — saves tokens. - **AI Code Extraction**: Uses Google Gemini (with OpenAI as fallback) to extract verification codes, links, and organization names. - **Modern Dashboard**: React 18 + shadcn/ui interface with mail list, detail panel, and three tabs — Extracted, Raw Email, Rendered HTML preview. +- **Gmail-like Admin UI**: Inbox categories, search operators, keyboard shortcuts, configurable reading pane (`none/right/bottom`), and density modes (`default/comfortable/compact`). - **Safe HTML Preview**: Email HTML is sanitized with DOMPurify and rendered in a sandboxed iframe. - **One-click Copy**: Verification codes and links have copy buttons with toast confirmation. - **Real-Time Notifications**: Optionally sends Bark push notifications when new codes arrive. @@ -83,11 +84,18 @@ flowchart LR In your forked repository, go to `Settings` → `Secrets and variables` → `Actions` and add: - `CLOUDFLARE_ACCOUNT_ID` - `CLOUDFLARE_API_TOKEN` - - `TOML` — use the [comment-free template](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear) to avoid parse errors. + - `FRONTEND_ADMIN_PASSWORD` + - `SESSION_SIGNING_KEY` *(required for `AUTH_MODE=session` or `AUTH_MODE=both`)* + - `AI_API_KEY` + - *(Optional)* `AI_FALLBACK_API_KEY` + - *(Optional, Bark)* `BARK_TOKENS` - Then go to `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`. + Add these repository **Variables**: + - `CF_D1_DATABASE_ID` (required) + - *(Optional)* `CF_WORKER_NAME` + - *(Optional)* `CF_D1_DATABASE_NAME` - After success, **delete the workflow logs** to avoid leaking your config. + Then go to `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`. 3. Jump to [Set Email Forwarding](#3-set-email-forwarding-). @@ -100,14 +108,16 @@ flowchart LR ```bash git clone https://github.com/TooonyChen/AuthInbox.git cd AuthInbox - pnpm install + corepack pnpm install ``` 2. **Create D1 database** ```bash - pnpm wrangler d1 create inbox-d1 - pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql + corepack pnpm exec wrangler d1 create inbox-d1 + corepack pnpm exec wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql + corepack pnpm exec wrangler d1 execute inbox-d1 --remote --file=./db/migrations/001_gmail_ui.sql + corepack pnpm exec wrangler d1 execute inbox-d1 --remote --file=./db/migrations/002_auth_sessions.sql ``` Copy the `database_id` from the output. @@ -118,26 +128,42 @@ flowchart LR cp wrangler.toml.example wrangler.toml ``` - Edit `wrangler.toml` — at minimum fill in: + Edit `wrangler.toml` — at minimum fill in non-sensitive values: ```toml - [vars] - FrontEndAdminID = "your-username" - FrontEndAdminPassword = "your-password" - UseBark = "false" + [vars] + FrontEndAdminID = "your-username" + AUTH_MODE = "session" # basic | session | both + UseBark = "false" - # AI provider — choose any compatible service - AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" - AI_API_KEY = "your-api-key" - AI_API_FORMAT = "openai" - AI_MODEL = "gemini-2.0-flash" + # AI provider — choose any compatible service + AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" + AI_API_FORMAT = "openai" + AI_MODEL = "gemini-2.0-flash" [[d1_databases]] binding = "DB" database_name = "inbox-d1" - database_id = "" + database_id = "" + ``` + + Set secrets (never store these in `wrangler.toml`): + + ```bash + corepack pnpm exec wrangler secret put FrontEndAdminPassword + corepack pnpm exec wrangler secret put SESSION_SIGNING_KEY + corepack pnpm exec wrangler secret put AI_API_KEY + + # Optional (fallback provider and Bark) + corepack pnpm exec wrangler secret put AI_FALLBACK_API_KEY + corepack pnpm exec wrangler secret put barkTokens ``` + Auth mode options: + - `AUTH_MODE = "session"`: modern in-app `/login` form (recommended) + - `AUTH_MODE = "basic"`: browser-native Basic Auth prompt only + - `AUTH_MODE = "both"`: accepts session cookie and Basic Auth + **`AI_API_FORMAT`** options: | Value | Endpoint | Compatible providers | @@ -158,21 +184,26 @@ flowchart LR **Optional fallback provider** (triggered if primary fails after 3 retries): ```toml # AI_FALLBACK_BASE_URL = "https://api.openai.com" - # AI_FALLBACK_API_KEY = "your-fallback-key" # AI_FALLBACK_API_FORMAT = "openai" # AI_FALLBACK_MODEL = "gpt-4o-mini" ``` - Optional Bark vars: `barkTokens`, `barkUrl`. + Optional Bark vars: `barkUrl` (`barkTokens` should be configured as a secret). 4. **Build and deploy** ```bash - pnpm run deploy + corepack pnpm run deploy ``` Output: `https://auth-inbox..workers.dev` + Optional local ASSETS smoke QA: + + ```bash + corepack pnpm run qa:assets-smoke + ``` + --- ### 3. Set Email Forwarding ✉️ @@ -189,6 +220,13 @@ Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Websites` → `< Visit your Worker URL, log in with the credentials you set, and start receiving verification emails. +### 5. Security Hardening for Cloudflare Free 🔐 + +1. Enable **Cloudflare Access** on your `workers.dev` (or custom domain) route, and keep app auth enabled (`AUTH_MODE = "session"` or `AUTH_MODE = "both"`). +2. Enable Cloudflare **Managed WAF ruleset** in your zone. +3. Add a **Rate Limiting** rule for `/api/*` (for example, protect against brute-force and scraping bursts). +4. Keep `workers.dev` disabled in production if you only use custom domain routes. + --- ## License 📜 diff --git a/db/migrations/001_gmail_ui.sql b/db/migrations/001_gmail_ui.sql new file mode 100644 index 0000000..5b3c8c6 --- /dev/null +++ b/db/migrations/001_gmail_ui.sql @@ -0,0 +1,32 @@ +-- Gmail-like UI state and user settings for AuthInbox +CREATE TABLE IF NOT EXISTS mail_states ( + raw_id INTEGER PRIMARY KEY, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, + is_important INTEGER NOT NULL DEFAULT 0, + is_muted INTEGER NOT NULL DEFAULT 0, + category TEXT, + labels_json TEXT, + snoozed_until DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (raw_id) REFERENCES raw_mails(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ui_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + density TEXT NOT NULL DEFAULT 'default', + reading_pane TEXT NOT NULL DEFAULT 'right', + theme TEXT NOT NULL DEFAULT 'dark', + shortcuts_enabled INTEGER NOT NULL DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO ui_settings (id, density, reading_pane, theme, shortcuts_enabled) +VALUES (1, 'default', 'right', 'dark', 1); + +CREATE INDEX IF NOT EXISTS idx_mail_states_archived ON mail_states (is_archived, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_mail_states_deleted ON mail_states (is_deleted, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_mail_states_read ON mail_states (is_read, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_mail_states_starred ON mail_states (is_starred, updated_at DESC); diff --git a/db/migrations/002_auth_sessions.sql b/db/migrations/002_auth_sessions.sql new file mode 100644 index 0000000..09ec8bd --- /dev/null +++ b/db/migrations/002_auth_sessions.sql @@ -0,0 +1,22 @@ +-- Session auth and login throttling tables for AuthInbox +CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + csrf_token TEXT NOT NULL, + ip_hash TEXT, + user_agent_hash TEXT, + expires_at DATETIME NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS auth_login_attempts ( + ip_key TEXT PRIMARY KEY, + attempt_count INTEGER NOT NULL DEFAULT 0, + blocked_until DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_revoked ON auth_sessions (revoked, expires_at); diff --git a/docs/cloudflare-security-checklist.md b/docs/cloudflare-security-checklist.md new file mode 100644 index 0000000..21d64a1 --- /dev/null +++ b/docs/cloudflare-security-checklist.md @@ -0,0 +1,40 @@ +# Cloudflare Free Security Checklist + +This project keeps Basic Auth in the Worker and expects Cloudflare edge controls as the second layer. + +## 1) Access on Worker Route + +1. Open Cloudflare Dashboard → `Workers & Pages` → your Worker. +2. In `Settings` → `Domains & Routes`, enable Cloudflare Access on `workers.dev` (or your custom domain route). +3. Add an Access policy that allows only your emails/identity provider group. + +## 2) WAF Managed Rules + +1. Open Dashboard → your zone → `Security` → `WAF`. +2. Ensure Cloudflare Managed Ruleset is deployed. +3. Keep default managed protections enabled unless you need a specific exclusion. + +## 3) Rate Limiting for API + +1. Open Dashboard → your zone → `Security` → `WAF` → `Rate limiting rules`. +2. Create a rule for path `/api/*`. +3. Suggested baseline: + - Action: `Managed Challenge` or `Block` + - Scope: per IP + - Threshold: `60 requests / 1 minute` +4. Keep login-protected UI and API behind both Access and Basic Auth. + +## 4) Secrets Hygiene + +Use Worker secrets for sensitive values: + +- `FrontEndAdminPassword` +- `AI_API_KEY` +- `AI_FALLBACK_API_KEY` (if fallback enabled) +- `barkTokens` (if Bark enabled) + +Set with: + +```bash +corepack pnpm exec wrangler secret put +``` diff --git a/package.json b/package.json index 4ebadce..fc4d462 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "version": "0.0.0", "private": true, "scripts": { - "build:web": "pnpm -C web run build", - "dev:web": "pnpm -C web run dev", - "deploy": "pnpm run build:web && wrangler deploy", + "build:web": "corepack pnpm -C web run build", + "dev:web": "corepack pnpm -C web run dev", + "deploy": "corepack pnpm run build:web && wrangler deploy", "dev": "wrangler dev", "start": "wrangler dev", - "test": "vitest", + "test": "vitest run", + "qa:assets-smoke": "node scripts/qa_assets_smoke.mjs", "cf-typegen": "wrangler types" }, "devDependencies": { diff --git a/scripts/qa_assets_smoke.mjs b/scripts/qa_assets_smoke.mjs new file mode 100644 index 0000000..232dc47 --- /dev/null +++ b/scripts/qa_assets_smoke.mjs @@ -0,0 +1,133 @@ +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +const configPath = process.env.QA_WRANGLER_CONFIG ?? "wrangler.toml"; +const port = Number(process.env.QA_PORT ?? "8787"); +const adminId = process.env.QA_ADMIN_ID ?? "admin"; +const adminPassword = process.env.QA_ADMIN_PASSWORD ?? "qa-password"; +const devVarsPath = ".dev.vars"; +const workerUrl = `http://127.0.0.1:${port}/`; +const apiUrl = `http://127.0.0.1:${port}/api/_qa_probe`; + +const previousDevVars = existsSync(devVarsPath) ? readFileSync(devVarsPath, "utf8") : null; +const tempDevVars = `FrontEndAdminPassword=${adminPassword}\nAI_API_KEY=qa-smoke-key\n`; +writeFileSync(devVarsPath, tempDevVars, "utf8"); + +const wranglerArgs = ["pnpm", "exec", "wrangler", "dev", "--config", configPath, "--local", "--port", String(port)]; +const child = process.platform === "win32" + ? spawn("cmd.exe", ["/d", "/s", "/c", `corepack ${wranglerArgs.join(" ")}`], { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }) + : spawn("corepack", wranglerArgs, { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + +const stdoutLines = []; +const stderrLines = []; +const maxLogLines = 200; + +function pushLog(target, chunk) { + const lines = chunk.toString().split(/\r?\n/).filter(Boolean); + for (const line of lines) { + target.push(line); + if (target.length > maxLogLines) target.shift(); + } +} + +child.stdout.on("data", (chunk) => pushLog(stdoutLines, chunk)); +child.stderr.on("data", (chunk) => pushLog(stderrLines, chunk)); + +let readyResponse = null; +let lastError = null; + +try { + const auth = `Basic ${Buffer.from(`${adminId}:${adminPassword}`, "utf8").toString("base64")}`; + + for (let attempt = 1; attempt <= 45; attempt++) { + await delay(1000); + try { + const response = await fetch(workerUrl, { + signal: AbortSignal.timeout(3000), + headers: { Authorization: auth }, + }); + readyResponse = response; + if (response.status >= 200 && response.status < 500) { + break; + } + } catch (error) { + lastError = error; + } + } + + if (!readyResponse) { + throw new Error(`Smoke test could not reach ${workerUrl}. Last error: ${String(lastError ?? "none")}`); + } + + const rootBody = await readyResponse.text(); + const apiProbe = await fetch(apiUrl, { + signal: AbortSignal.timeout(3000), + headers: { Authorization: auth, Accept: "application/json" }, + }); + + const checks = { + status_200: readyResponse.status === 200, + has_react_root: rootBody.includes('
'), + api_probe_protected_or_not_found: apiProbe.status === 401 || apiProbe.status === 404, + api_has_nosniff: apiProbe.headers.get("x-content-type-options") === "nosniff", + api_has_referrer_policy: apiProbe.headers.get("referrer-policy") === "no-referrer", + }; + + const failedChecks = Object.entries(checks).filter(([, value]) => !value); + if (failedChecks.length > 0) { + throw new Error( + `Assets smoke checks failed: ${failedChecks.map(([name]) => name).join(", ")} (api status=${apiProbe.status})` + ); + } + + console.log( + JSON.stringify( + { + ok: true, + url: workerUrl, + checks, + }, + null, + 2 + ) + ); +} catch (error) { + console.error("ASSETS smoke test failed."); + console.error(String(error)); + if (stdoutLines.length > 0) { + console.error("Recent wrangler stdout:"); + for (const line of stdoutLines.slice(-30)) console.error(line); + } + if (stderrLines.length > 0) { + console.error("Recent wrangler stderr:"); + for (const line of stderrLines.slice(-30)) console.error(line); + } + process.exitCode = 1; +} finally { + if (child.pid) { + if (process.platform === "win32") { + try { + execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } catch { + // Ignore cleanup failures in smoke mode. + } + } else if (!child.killed) { + child.kill("SIGTERM"); + await delay(1500); + if (!child.killed) child.kill("SIGKILL"); + } + } + + if (previousDevVars === null) { + rmSync(devVarsPath, { force: true }); + } else { + writeFileSync(devVarsPath, previousDevVars, "utf8"); + } +} diff --git a/src/html.d.ts b/src/html.d.ts new file mode 100644 index 0000000..3a031d8 --- /dev/null +++ b/src/html.d.ts @@ -0,0 +1,4 @@ +declare module "*.html" { + const content: string; + export default content; +} diff --git a/src/index.ts b/src/index.ts index 0932d6e..3a1d64d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,27 @@ import { RPCEmailMessage } from "./rpcEmail"; import indexHtml from "./index.html"; type ApiFormat = "openai" | "responses" | "anthropic"; +type AuthMode = "basic" | "session" | "both"; +type DensityMode = "default" | "comfortable" | "compact"; +type ReadingPaneMode = "none" | "right" | "bottom"; +type ThemeMode = "dark" | "light" | "system"; + +type ThreadAction = + | "read" + | "unread" + | "star" + | "unstar" + | "archive" + | "unarchive" + | "delete" + | "restore" + | "important" + | "not-important" + | "snooze" + | "label-add" + | "label-remove"; + +type MailCategory = "primary" | "social" | "promotions" | "updates" | "forums"; interface ProviderConfig { baseUrl: string; @@ -22,6 +43,8 @@ interface ProviderConfig { interface Env { DB: D1Database; ASSETS?: Fetcher; + AUTH_MODE?: AuthMode; + SESSION_SIGNING_KEY?: string; FrontEndAdminID: string; FrontEndAdminPassword: string; UseBark: string; @@ -39,22 +62,377 @@ interface Env { AI_FALLBACK_MODEL?: string; } +interface AuthSessionRow { + session_id: string; + username: string; + csrf_token: string; + ip_hash: string | null; + user_agent_hash: string | null; + expires_at: string; + revoked: number; +} + +interface AuthAttemptRow { + ip_key: string; + attempt_count: number; + blocked_until: string | null; +} + +interface UiSettingsRow { + density: DensityMode; + reading_pane: ReadingPaneMode; + theme: ThemeMode; + shortcuts_enabled: number; +} + +interface ThreadQueryResultRow { + rawId: number; + messageId: string | null; + fromAddr: string | null; + toAddr: string | null; + subject: string | null; + raw: string | null; + createdAt: string | null; + fromOrg: string | null; + topic: string | null; + code: string | null; + isRead: number | null; + isStarred: number | null; + isArchived: number | null; + isDeleted: number | null; + isImportant: number | null; + isMuted: number | null; + category: string | null; + labelsJson: string | null; + snoozedUntil: string | null; +} + +interface SearchTokens { + from: string[]; + to: string[]; + subject: string[]; + text: string[]; + categories: MailCategory[]; + inMailbox: Array<"inbox" | "archive" | "trash" | "anywhere" | "snoozed">; + isFlags: Array<"read" | "unread" | "starred" | "important" | "muted">; + hasFlags: Array<"attachment">; +} + +interface AuthContext { + method: "basic" | "session"; + username: string; + sessionId?: string; + csrfToken?: string; +} + +const NO_STORE_HEADERS: Record = { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + Pragma: "no-cache", +}; + +const HARDENING_HEADERS: Record = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + "Content-Security-Policy": + "default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", +}; + +const MAX_PROMPT_BODY_LENGTH = 8000; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const SESSION_TTL_SECONDS = 60 * 60 * 12; +const SESSION_COOKIE_NAME = "__Host-authinbox_session"; +const CSRF_COOKIE_NAME = "__Host-authinbox_csrf"; +const CSRF_HEADER_NAME = "x-csrf-token"; +const MAX_LOGIN_ATTEMPTS = 5; +const LOGIN_BLOCK_MINUTES = 15; +const CATEGORY_VALUES: MailCategory[] = ["primary", "social", "promotions", "updates", "forums"]; +const DENSITY_VALUES: DensityMode[] = ["default", "comfortable", "compact"]; +const READING_PANE_VALUES: ReadingPaneMode[] = ["none", "right", "bottom"]; +const THEME_VALUES: ThemeMode[] = ["dark", "light", "system"]; +const SQL_CATEGORY_EXPRESSION = ` +COALESCE( + ms.category, + CASE + WHEN LOWER(COALESCE(r.from_addr, '')) LIKE '%facebook%' + OR LOWER(COALESCE(r.from_addr, '')) LIKE '%twitter%' + OR LOWER(COALESCE(r.from_addr, '')) LIKE '%linkedin%' + OR LOWER(COALESCE(r.from_addr, '')) LIKE '%instagram%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%mentioned you%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%new follower%' + THEN 'social' + WHEN LOWER(COALESCE(r.from_addr, '')) LIKE '%forum%' + OR LOWER(COALESCE(r.from_addr, '')) LIKE '%community%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%forum%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%thread%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%discussion%' + THEN 'forums' + WHEN LOWER(COALESCE(r.subject, '')) LIKE '%invoice%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%receipt%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%order%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%shipment%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%statement%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%payment%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%security%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%alert%' + THEN 'updates' + WHEN LOWER(COALESCE(r.from_addr, '')) LIKE '%newsletter%' + OR LOWER(COALESCE(r.from_addr, '')) LIKE '%promo%' + OR LOWER(COALESCE(r.from_addr, '')) LIKE '%marketing%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%sale%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%discount%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%offer%' + OR LOWER(COALESCE(r.subject, '')) LIKE '%coupon%' + THEN 'promotions' + ELSE 'primary' + END +) +`; + +function applyHardeningHeaders( + headers: HeadersInit, + options: { noStore?: boolean } = {} +): Headers { + const merged = new Headers(headers); + for (const [key, value] of Object.entries(HARDENING_HEADERS)) { + merged.set(key, value); + } + if (options.noStore ?? true) { + for (const [key, value] of Object.entries(NO_STORE_HEADERS)) { + merged.set(key, value); + } + } + return merged; +} + +function toSecureResponse( + body: BodyInit | null, + init: ResponseInit = {}, + options: { noStore?: boolean } = {} +): Response { + return new Response(body, { + ...init, + headers: applyHardeningHeaders(init.headers ?? {}, options), + }); +} + +function unauthorizedResponse(): Response { + return toSecureResponse("Unauthorized", { + status: 401, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "WWW-Authenticate": "Basic realm=\"User Visible Realm\"", + }, + }); +} + +function unauthorizedJsonResponse(): Response { + return jsonResponse({ error: "Unauthorized" }, 401); +} + +function redirectResponse(location: string, status = 302): Response { + return toSecureResponse(null, { + status, + headers: { + Location: location, + }, + }); +} + +export function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function sanitizeHttpUrl(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +function fixedTimeEquals(left: string, right: string): boolean { + const leftBytes = new TextEncoder().encode(left); + const rightBytes = new TextEncoder().encode(right); + const maxLength = Math.max(leftBytes.length, rightBytes.length); + let diff = leftBytes.length ^ rightBytes.length; + + for (let index = 0; index < maxLength; index++) { + diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0); + } + + return diff === 0; +} + +export function parseBasicAuthCredentials( + authHeader: string | null +): { username: string; password: string } | null { + if (!authHeader || !authHeader.startsWith("Basic ")) { + return null; + } + + const encodedCredentials = authHeader.substring("Basic ".length).trim(); + if (!encodedCredentials) { + return null; + } + + let decodedCredentials: string; + try { + decodedCredentials = atob(encodedCredentials); + } catch { + return null; + } + + const separatorIndex = decodedCredentials.indexOf(":"); + if (separatorIndex < 0) { + return null; + } + + return { + username: decodedCredentials.slice(0, separatorIndex), + password: decodedCredentials.slice(separatorIndex + 1), + }; +} + +export function isAuthorizedBasicAuth( + authHeader: string | null, + expectedUsername: string, + expectedPassword: string +): boolean { + const credentials = parseBasicAuthCredentials(authHeader); + if (!credentials) { + return false; + } + + return ( + fixedTimeEquals(credentials.username, expectedUsername) + && fixedTimeEquals(credentials.password, expectedPassword) + ); +} + +function normalisePromptText(value: string): string { + return value.replace(/\r/g, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s+/g, " ").trim(); +} + +export function buildAiPrompt(params: { + from: string; + to: string; + subject: string; + textBody: string; +}): string { + const emailBody = normalisePromptText(params.textBody).slice(0, MAX_PROMPT_BODY_LENGTH); + const subject = normalisePromptText(params.subject).slice(0, 300); + + return ` +Email metadata: +- From: ${params.from} +- To: ${params.to} +- Subject: ${subject || "(no subject)"} + +Email text content: +${emailBody || "(empty body)"} + +Please extract: +1. Verification code / password / magic link. +2. Sender organization name. +3. A short topic summary. + +Return strict JSON: +{ + "title": "Sender organization", + "code": "verification code, link, or password", + "topic": "short summary", + "codeExist": 1 +} + +If both code and link exist: +"code": "code, link" + +If there is no verification code/password/clickable link: +{ + "codeExist": 0 +} +`; +} + +export function summarizeExtractionForLog(payload: Record): string { + const codeExistValue = payload.codeExist; + const codeExist = + typeof codeExistValue === "number" || typeof codeExistValue === "string" + ? String(codeExistValue) + : "unknown"; + return `codeExist=${codeExist}`; +} + +interface LegacyMailRow { + from_org: string | null; + to_addr: string | null; + topic: string | null; + code: string | null; + created_at: string | null; +} + +export function buildLegacyCodeCell(codeValue: string | null, topicValue: string | null): string { + const codeText = (codeValue ?? "").trim(); + const topic = escapeHtml((topicValue ?? "").trim() || "Open Link"); + + if (!codeText) return "-"; + + const commaParts = codeText.split(","); + if (commaParts.length > 1) { + const codePart = escapeHtml(commaParts[0]?.trim() ?? ""); + const linkPart = commaParts.slice(1).join(",").trim(); + const safeLink = sanitizeHttpUrl(linkPart); + if (safeLink) { + return `${codePart}
${topic}`; + } + return codePart || "-"; + } + + const standaloneLink = sanitizeHttpUrl(codeText); + if (standaloneLink) { + return `${topic}`; + } + + return escapeHtml(codeText); +} + +function buildLegacyTableRow(row: LegacyMailRow): string { + return ` + ${escapeHtml(String(row.from_org ?? "-"))} + ${escapeHtml(String(row.to_addr ?? "-"))} + ${escapeHtml(String(row.topic ?? "-"))} + ${buildLegacyCodeCell(row.code, row.topic)} + ${escapeHtml(String(row.created_at ?? "-"))} + `; +} + // Normalize model output into JSON text // 将模型输出规范化为可解析的 JSON 文本 function extractJsonFromText(rawText: string): Record | null { let candidate = rawText.trim(); const jsonMatch = candidate.match(/```json\s*([\s\S]*?)\s*```/); if (jsonMatch && jsonMatch[1]) { candidate = jsonMatch[1].trim(); - console.log(`Extracted JSON Text: "${candidate}"`); + console.log(`Extracted JSON payload, length=${candidate.length}`); } else { - console.log(`Assuming entire text is JSON: "${candidate}"`); + console.log(`Attempting to parse provider output as JSON, length=${candidate.length}`); } try { return JSON.parse(candidate); } catch (parseError) { console.error("JSON parsing error:", parseError); - console.log(`Problematic JSON Text: "${candidate}"`); + console.log(`Invalid JSON payload length=${candidate.length}`); return null; } } @@ -152,7 +530,7 @@ async function callProvider(config: ProviderConfig, prompt: string): Promise String(entry).trim()) + .filter(Boolean) + .slice(0, 50); + } catch { + return []; + } +} + +function stringifyLabels(labels: string[]): string { + return JSON.stringify(Array.from(new Set(labels.map((label) => label.trim()).filter(Boolean))).slice(0, 50)); +} + +export function inferCategoryFromFields(fromAddr: string | null, subject: string | null): MailCategory { + const from = (fromAddr ?? "").toLowerCase(); + const normalizedSubject = (subject ?? "").toLowerCase(); + + if ( + from.includes("facebook") + || from.includes("twitter") + || from.includes("linkedin") + || from.includes("instagram") + || from.includes("tiktok") + || from.includes("discord") + || normalizedSubject.includes("new follower") + || normalizedSubject.includes("mentioned you") + ) { + return "social"; + } + + if ( + from.includes("forum") + || from.includes("community") + || from.includes("groups") + || from.includes("discourse") + || normalizedSubject.includes("thread") + || normalizedSubject.includes("discussion") + || normalizedSubject.includes("forum") + ) { + return "forums"; + } + + if ( + normalizedSubject.includes("invoice") + || normalizedSubject.includes("receipt") + || normalizedSubject.includes("order") + || normalizedSubject.includes("shipment") + || normalizedSubject.includes("statement") + || normalizedSubject.includes("payment") + || normalizedSubject.includes("alert") + || normalizedSubject.includes("security") + ) { + return "updates"; + } + + if ( + from.includes("newsletter") + || from.includes("marketing") + || from.includes("promo") + || from.includes("deals") + || normalizedSubject.includes("sale") + || normalizedSubject.includes("discount") + || normalizedSubject.includes("offer") + || normalizedSubject.includes("coupon") + ) { + return "promotions"; + } + + return "primary"; +} + +function tokenizeSearchQuery(input: string): string[] { + const pattern = /([a-z]+:"[^"]+"|[a-z]+:[^\s]+|"[^"]+"|\S+)/gi; + const tokens: string[] = []; + let match: RegExpExecArray | null; + while ((match = pattern.exec(input)) !== null) { + const token = (match[1] ?? "").trim(); + if (token) tokens.push(token); + } + return tokens; +} + +function normalizeTokenValue(value: string): string { + return value.replace(/^"|"$/g, "").trim().toLowerCase(); +} + +export function parseSearchTokens(rawQuery: string): SearchTokens { + const parsed: SearchTokens = { + from: [], + to: [], + subject: [], + text: [], + categories: [], + inMailbox: [], + isFlags: [], + hasFlags: [], + }; + + const tokens = tokenizeSearchQuery(rawQuery); + for (const originalToken of tokens) { + const separator = originalToken.indexOf(":"); + if (separator <= 0) { + parsed.text.push(normalizeTokenValue(originalToken)); + continue; + } + + const key = originalToken.slice(0, separator).toLowerCase().trim(); + const value = normalizeTokenValue(originalToken.slice(separator + 1)); + if (!value) { + continue; + } + + if (key === "from") { + parsed.from.push(value); + continue; + } + if (key === "to") { + parsed.to.push(value); + continue; + } + if (key === "subject") { + parsed.subject.push(value); + continue; + } + if (key === "category") { + if (CATEGORY_VALUES.includes(value as MailCategory)) { + parsed.categories.push(value as MailCategory); + } + continue; + } + if (key === "in") { + if (["inbox", "archive", "trash", "anywhere", "snoozed"].includes(value)) { + parsed.inMailbox.push(value as SearchTokens["inMailbox"][number]); + } + continue; + } + if (key === "is") { + if (["read", "unread", "starred", "important", "muted"].includes(value)) { + parsed.isFlags.push(value as SearchTokens["isFlags"][number]); + } + continue; + } + if (key === "has") { + if (value === "attachment") { + parsed.hasFlags.push("attachment"); + } + continue; + } + parsed.text.push(value); + } + + return parsed; +} + +function escapeSqlLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +function normaliseSnippet(value: string | null): string { + if (!value) return ""; + return value.replace(/\s+/g, " ").trim().slice(0, 180); +} + +async function ensureGmailUiTables(db: D1Database): Promise { + await db.prepare( + ` + CREATE TABLE IF NOT EXISTS mail_states ( + raw_id INTEGER PRIMARY KEY, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, + is_important INTEGER NOT NULL DEFAULT 0, + is_muted INTEGER NOT NULL DEFAULT 0, + category TEXT, + labels_json TEXT, + snoozed_until DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (raw_id) REFERENCES raw_mails(id) ON DELETE CASCADE + ) + ` + ).run(); + + await db.prepare( + ` + CREATE TABLE IF NOT EXISTS ui_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + density TEXT NOT NULL DEFAULT 'default', + reading_pane TEXT NOT NULL DEFAULT 'right', + theme TEXT NOT NULL DEFAULT 'dark', + shortcuts_enabled INTEGER NOT NULL DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ` + ).run(); + + await db.prepare( + ` + INSERT OR IGNORE INTO ui_settings (id, density, reading_pane, theme, shortcuts_enabled) + VALUES (1, 'default', 'right', 'dark', 1) + ` + ).run(); + + await db.prepare("CREATE INDEX IF NOT EXISTS idx_mail_states_archived ON mail_states (is_archived, updated_at DESC)").run(); + await db.prepare("CREATE INDEX IF NOT EXISTS idx_mail_states_deleted ON mail_states (is_deleted, updated_at DESC)").run(); + await db.prepare("CREATE INDEX IF NOT EXISTS idx_mail_states_read ON mail_states (is_read, updated_at DESC)").run(); + await db.prepare("CREATE INDEX IF NOT EXISTS idx_mail_states_starred ON mail_states (is_starred, updated_at DESC)").run(); +} + +async function getUiSettings(db: D1Database): Promise<{ + density: DensityMode; + readingPane: ReadingPaneMode; + theme: ThemeMode; + shortcutsEnabled: boolean; +}> { + const row = await db.prepare( + ` + SELECT density, reading_pane, theme, shortcuts_enabled + FROM ui_settings + WHERE id = 1 + LIMIT 1 + ` + ).first(); + + return { + density: sanitizeDensityMode(row?.density ?? null), + readingPane: sanitizeReadingPaneMode(row?.reading_pane ?? null), + theme: sanitizeThemeMode(row?.theme ?? null), + shortcutsEnabled: Number(row?.shortcuts_enabled ?? 1) === 1, + }; +} + +async function upsertUiSettings( + db: D1Database, + payload: Partial<{ + density: DensityMode; + readingPane: ReadingPaneMode; + theme: ThemeMode; + shortcutsEnabled: boolean; + }> +): Promise { + const current = await getUiSettings(db); + const density = sanitizeDensityMode(payload.density ?? current.density); + const readingPane = sanitizeReadingPaneMode(payload.readingPane ?? current.readingPane); + const theme = sanitizeThemeMode(payload.theme ?? current.theme); + const shortcutsEnabled = payload.shortcutsEnabled ?? current.shortcutsEnabled; + + await db.prepare( + ` + INSERT INTO ui_settings (id, density, reading_pane, theme, shortcuts_enabled, updated_at) + VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + density = excluded.density, + reading_pane = excluded.reading_pane, + theme = excluded.theme, + shortcuts_enabled = excluded.shortcuts_enabled, + updated_at = CURRENT_TIMESTAMP + ` + ) + .bind(density, readingPane, theme, shortcutsEnabled ? 1 : 0) + .run(); +} + +async function ensureMailStateRows(db: D1Database, rawIds: number[]): Promise { + for (const rawId of rawIds) { + await db.prepare("INSERT OR IGNORE INTO mail_states (raw_id) VALUES (?)").bind(rawId).run(); + } +} + +function parseRawHeaders(rawEmail: string): Headers { + const headers = new Headers(); + const headerBlock = rawEmail.split(/\r?\n\r?\n/, 1)[0] ?? ""; + const lines = headerBlock.split(/\r?\n/); + let currentName: string | null = null; + for (const line of lines) { + if (/^[ \t]/.test(line) && currentName) { + const previous = headers.get(currentName) ?? ""; + headers.set(currentName, `${previous} ${line.trim()}`.trim()); + continue; + } + const separator = line.indexOf(":"); + if (separator <= 0) { + currentName = null; + continue; + } + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + currentName = key; + headers.set(key, value); + } + return headers; +} + +function computeCategory(row: ThreadQueryResultRow): MailCategory { + if (row.category && CATEGORY_VALUES.includes(row.category as MailCategory)) { + return row.category as MailCategory; + } + + if (row.raw) { + const headers = parseRawHeaders(row.raw); + if (isPromotionalEmail(headers, row.raw)) { + return "promotions"; + } + } + + return inferCategoryFromFields(row.fromAddr, row.subject); +} + +function buildThreadSnippet(row: ThreadQueryResultRow): string { + const { textBody } = extractMailBodies(String(row.raw ?? "")); + if (textBody) { + return normaliseSnippet(textBody); + } + return normaliseSnippet(row.topic ?? row.subject ?? null); +} + +function resolveWhereClause(options: { + tokens: SearchTokens; + categoryFilter: string | null; + includeDeleted: boolean; + includeArchived: boolean; +}): { clauses: string[]; bindings: Array } { + const clauses: string[] = []; + const bindings: Array = []; + const { tokens } = options; + + if (!options.includeDeleted) { + clauses.push("COALESCE(ms.is_deleted, 0) = 0"); + } + if (!options.includeArchived) { + clauses.push("COALESCE(ms.is_archived, 0) = 0"); + } + + if (options.categoryFilter && options.categoryFilter !== "all") { + clauses.push(`${SQL_CATEGORY_EXPRESSION} = ?`); + bindings.push(options.categoryFilter); + } + + for (const fromToken of tokens.from) { + clauses.push("LOWER(COALESCE(r.from_addr, '')) LIKE ? ESCAPE '\\'"); + bindings.push(`%${escapeSqlLike(fromToken)}%`); + } + + for (const toToken of tokens.to) { + clauses.push("LOWER(COALESCE(r.to_addr, '')) LIKE ? ESCAPE '\\'"); + bindings.push(`%${escapeSqlLike(toToken)}%`); + } + + for (const subjectToken of tokens.subject) { + clauses.push("LOWER(COALESCE(r.subject, '')) LIKE ? ESCAPE '\\'"); + bindings.push(`%${escapeSqlLike(subjectToken)}%`); + } + + for (const isToken of tokens.isFlags) { + if (isToken === "read") clauses.push("COALESCE(ms.is_read, 0) = 1"); + if (isToken === "unread") clauses.push("COALESCE(ms.is_read, 0) = 0"); + if (isToken === "starred") clauses.push("COALESCE(ms.is_starred, 0) = 1"); + if (isToken === "important") clauses.push("COALESCE(ms.is_important, 0) = 1"); + if (isToken === "muted") clauses.push("COALESCE(ms.is_muted, 0) = 1"); + } + + for (const inToken of tokens.inMailbox) { + if (inToken === "inbox") { + clauses.push("COALESCE(ms.is_archived, 0) = 0"); + clauses.push("COALESCE(ms.is_deleted, 0) = 0"); + } + if (inToken === "archive") { + clauses.push("COALESCE(ms.is_archived, 0) = 1"); + clauses.push("COALESCE(ms.is_deleted, 0) = 0"); + } + if (inToken === "trash") { + clauses.push("COALESCE(ms.is_deleted, 0) = 1"); + } + if (inToken === "snoozed") { + clauses.push("ms.snoozed_until IS NOT NULL"); + clauses.push("ms.snoozed_until > CURRENT_TIMESTAMP"); + } + } + + for (const hasToken of tokens.hasFlags) { + if (hasToken === "attachment") { + clauses.push("LOWER(COALESCE(r.raw, '')) LIKE '%content-disposition: attachment%'"); + } + } + + for (const categoryToken of tokens.categories) { + clauses.push(`${SQL_CATEGORY_EXPRESSION} = ?`); + bindings.push(categoryToken); + } + + for (const textToken of tokens.text) { + const likeTerm = `%${escapeSqlLike(textToken)}%`; + clauses.push( + "(LOWER(COALESCE(r.subject, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(r.from_addr, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(r.to_addr, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.topic, '')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(c.from_org, '')) LIKE ? ESCAPE '\\')" + ); + bindings.push(likeTerm, likeTerm, likeTerm, likeTerm, likeTerm); + } + + return { clauses, bindings }; +} + +async function parseJsonBody(request: Request): Promise { + try { + return (await request.json()) as T; + } catch { + return null; + } +} + +export function normalizeAuthMode(rawValue: string | undefined): AuthMode { + if (rawValue === "basic" || rawValue === "session" || rawValue === "both") { + return rawValue; + } + return "both"; +} + +export function isPublicAssetPath(pathname: string): boolean { + return ( + pathname.startsWith("/assets/") + || pathname === "/favicon.ico" + || pathname === "/manifest.webmanifest" + || pathname === "/robots.txt" + || pathname === "/apple-touch-icon.png" + ); +} + +function parseCookies(request: Request): Map { + const result = new Map(); + const cookieHeader = request.headers.get("Cookie"); + if (!cookieHeader) { + return result; + } + + const pairs = cookieHeader.split(";"); + for (const pair of pairs) { + const separatorIndex = pair.indexOf("="); + if (separatorIndex <= 0) continue; + const key = pair.slice(0, separatorIndex).trim(); + const value = pair.slice(separatorIndex + 1).trim(); + if (!key) continue; + try { + result.set(key, decodeURIComponent(value)); + } catch { + result.set(key, value); + } + } + return result; +} + +function buildCookie(options: { + name: string; + value: string; + maxAgeSeconds: number; + httpOnly?: boolean; +}): string { + const encodedValue = encodeURIComponent(options.value); + const parts = [ + `${options.name}=${encodedValue}`, + "Path=/", + "Secure", + "SameSite=Strict", + `Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`, + ]; + if (options.httpOnly ?? true) { + parts.push("HttpOnly"); + } + return parts.join("; "); +} + +function clearCookie(name: string, httpOnly = true): string { + return buildCookie({ + name, + value: "", + maxAgeSeconds: 0, + httpOnly, + }); +} + +function withSetCookies(response: Response, cookies: string[]): Response { + if (cookies.length === 0) return response; + const headers = new Headers(response.headers); + for (const cookieValue of cookies) { + headers.append("Set-Cookie", cookieValue); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function getClientIp(request: Request): string { + return request.headers.get("CF-Connecting-IP")?.trim() || "unknown"; +} + +function randomHex(bytes = 24): string { + const array = new Uint8Array(bytes); + crypto.getRandomValues(array); + return Array.from(array) + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +async function sha256Hex(value: string): Promise { + const input = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", input); + const bytes = Array.from(new Uint8Array(digest)); + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function hmacSha256Hex(secret: string, message: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return Array.from(new Uint8Array(signature)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export async function createSignedSessionToken( + signingKey: string, + sessionId: string, + expiresAtUnixSeconds: number +): Promise { + const payload = `${sessionId}.${expiresAtUnixSeconds}`; + const signature = await hmacSha256Hex(signingKey, payload); + return `${payload}.${signature}`; +} + +export async function verifySignedSessionToken( + signingKey: string, + token: string +): Promise<{ sessionId: string; expiresAtUnixSeconds: number } | null> { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + + const [sessionId, expiresPart, signature] = parts; + const expiresAtUnixSeconds = Number.parseInt(expiresPart, 10); + if (!Number.isFinite(expiresAtUnixSeconds) || expiresAtUnixSeconds <= 0) { + return null; + } + if (Math.floor(Date.now() / 1000) >= expiresAtUnixSeconds) { + return null; + } + + const expected = await hmacSha256Hex(signingKey, `${sessionId}.${expiresAtUnixSeconds}`); + if (!fixedTimeEquals(signature, expected)) { + return null; + } + + return { sessionId, expiresAtUnixSeconds }; +} + +async function ensureAuthTables(db: D1Database): Promise { + await db.prepare( + ` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + csrf_token TEXT NOT NULL, + ip_hash TEXT, + user_agent_hash TEXT, + expires_at DATETIME NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ` + ).run(); + + await db.prepare( + ` + CREATE TABLE IF NOT EXISTS auth_login_attempts ( + ip_key TEXT PRIMARY KEY, + attempt_count INTEGER NOT NULL DEFAULT 0, + blocked_until DATETIME, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ` + ).run(); + + await db.prepare("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at)").run(); + await db.prepare("CREATE INDEX IF NOT EXISTS idx_auth_sessions_revoked ON auth_sessions (revoked, expires_at)").run(); +} + +async function checkLoginAttempt( + db: D1Database, + ipKey: string +): Promise<{ blocked: boolean; retryAfterSeconds: number }> { + const row = await db.prepare( + ` + SELECT ip_key, attempt_count, blocked_until + FROM auth_login_attempts + WHERE ip_key = ? + LIMIT 1 + ` + ).bind(ipKey).first(); + + if (!row?.blocked_until) { + return { blocked: false, retryAfterSeconds: 0 }; + } + + const blockedUntil = new Date(row.blocked_until); + if (Number.isNaN(blockedUntil.getTime()) || blockedUntil.getTime() <= Date.now()) { + return { blocked: false, retryAfterSeconds: 0 }; + } + + const retryAfterSeconds = Math.max(1, Math.ceil((blockedUntil.getTime() - Date.now()) / 1000)); + return { blocked: true, retryAfterSeconds }; +} + +async function recordLoginFailure(db: D1Database, ipKey: string): Promise { + const row = await db.prepare( + ` + SELECT attempt_count, blocked_until + FROM auth_login_attempts + WHERE ip_key = ? + LIMIT 1 + ` + ).bind(ipKey).first<{ attempt_count: number; blocked_until: string | null }>(); + + const blockedUntilMillis = row?.blocked_until ? new Date(row.blocked_until).getTime() : Number.NaN; + const hasExpiredBlock = Number.isFinite(blockedUntilMillis) && blockedUntilMillis <= Date.now(); + const previousAttempts = hasExpiredBlock ? 0 : Number(row?.attempt_count ?? 0); + const nextAttempts = previousAttempts + 1; + const shouldBlock = nextAttempts >= MAX_LOGIN_ATTEMPTS; + const blockedUntil = shouldBlock + ? new Date(Date.now() + LOGIN_BLOCK_MINUTES * 60 * 1000).toISOString() + : null; + + await db.prepare( + ` + INSERT INTO auth_login_attempts (ip_key, attempt_count, blocked_until, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(ip_key) DO UPDATE SET + attempt_count = excluded.attempt_count, + blocked_until = excluded.blocked_until, + updated_at = CURRENT_TIMESTAMP + ` + ) + .bind(ipKey, nextAttempts, blockedUntil) + .run(); +} + +async function clearLoginFailures(db: D1Database, ipKey: string): Promise { + await db.prepare("DELETE FROM auth_login_attempts WHERE ip_key = ?").bind(ipKey).run(); +} + +async function createSession( + env: Env, + request: Request, + username: string +): Promise<{ sessionId: string; csrfToken: string; token: string; expiresAtUnixSeconds: number }> { + const signingKey = env.SESSION_SIGNING_KEY; + if (!signingKey) { + throw new Error("SESSION_SIGNING_KEY is not configured"); + } + + await ensureAuthTables(env.DB); + + const sessionId = crypto.randomUUID(); + const csrfToken = randomHex(24); + const expiresAtUnixSeconds = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; + const expiresAtIso = new Date(expiresAtUnixSeconds * 1000).toISOString(); + const ipHash = await sha256Hex(getClientIp(request)); + const userAgentHash = await sha256Hex(request.headers.get("User-Agent") ?? ""); + + await env.DB.prepare( + ` + INSERT INTO auth_sessions ( + session_id, username, csrf_token, ip_hash, user_agent_hash, expires_at, revoked, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) + ` + ) + .bind(sessionId, username, csrfToken, ipHash, userAgentHash, expiresAtIso) + .run(); + + const token = await createSignedSessionToken(signingKey, sessionId, expiresAtUnixSeconds); + return { + sessionId, + csrfToken, + token, + expiresAtUnixSeconds, + }; +} + +async function resolveSessionContext(env: Env, request: Request): Promise { + const signingKey = env.SESSION_SIGNING_KEY; + if (!signingKey) return null; + + const cookies = parseCookies(request); + const sessionToken = cookies.get(SESSION_COOKIE_NAME); + if (!sessionToken) return null; + await ensureAuthTables(env.DB); + + const verified = await verifySignedSessionToken(signingKey, sessionToken); + if (!verified) return null; + + const row = await env.DB.prepare( + ` + SELECT session_id, username, csrf_token, ip_hash, user_agent_hash, expires_at, revoked + FROM auth_sessions + WHERE session_id = ? + LIMIT 1 + ` + ).bind(verified.sessionId).first(); + + if (!row || Number(row.revoked) === 1) { + return null; + } + + const expiresAt = new Date(row.expires_at); + if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= Date.now()) { + return null; + } + + const requestIpHash = await sha256Hex(getClientIp(request)); + const requestUserAgentHash = await sha256Hex(request.headers.get("User-Agent") ?? ""); + if (row.ip_hash && !fixedTimeEquals(row.ip_hash, requestIpHash)) { + return null; + } + if (row.user_agent_hash && !fixedTimeEquals(row.user_agent_hash, requestUserAgentHash)) { + return null; + } + + await env.DB.prepare( + ` + UPDATE auth_sessions + SET last_seen_at = CURRENT_TIMESTAMP + WHERE session_id = ? + ` + ).bind(row.session_id).run(); + + return { + method: "session", + username: row.username, + sessionId: row.session_id, + csrfToken: row.csrf_token, + }; +} + +function isStateChangingMethod(method: string): boolean { + return method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; +} + +function validateCsrf(request: Request, authContext: AuthContext): boolean { + if (authContext.method !== "session") { + return true; + } + if (!isStateChangingMethod(request.method)) { + return true; + } + const cookies = parseCookies(request); + const csrfCookie = cookies.get(CSRF_COOKIE_NAME); + const csrfHeader = request.headers.get(CSRF_HEADER_NAME); + if (!csrfCookie || !csrfHeader || !authContext.csrfToken) { + return false; + } + return fixedTimeEquals(csrfCookie, csrfHeader) && fixedTimeEquals(authContext.csrfToken, csrfHeader); +} + export default class extends WorkerEntrypoint { async fetch(request: Request): Promise { const env: Env = this.env; const FrontEndAdminID = env.FrontEndAdminID; const FrontEndAdminPassword = env.FrontEndAdminPassword; + const requestedAuthMode = normalizeAuthMode(env.AUTH_MODE); + const authMode: AuthMode = + (!env.SESSION_SIGNING_KEY && requestedAuthMode !== "basic") + ? "basic" + : requestedAuthMode; + const url = new URL(request.url); + const pathname = url.pathname; + const isApiPath = pathname.startsWith("/api/"); + const isAuthPath = pathname.startsWith("/auth/"); + const isLoginPath = pathname === "/login"; + const isPublicPath = isLoginPath || isPublicAssetPath(pathname); - // Basic-auth gate for the admin console // 使用 Basic Auth 保护管理界面 const authHeader = request.headers.get("Authorization"); + const basicAuthorized = isAuthorizedBasicAuth(authHeader, FrontEndAdminID, FrontEndAdminPassword); + const sessionContext = authMode === "basic" ? null : await resolveSessionContext(env, request); + + let authContext: AuthContext | null = null; + if (sessionContext) { + authContext = sessionContext; + } else if ((authMode === "basic" || authMode === "both") && basicAuthorized) { + authContext = { method: "basic", username: FrontEndAdminID }; + } - if (!authHeader) { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": "Basic realm=\"User Visible Realm\"", - }, + if (pathname === "/auth/login") { + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + + const signingKey = env.SESSION_SIGNING_KEY; + if (!signingKey) { + return jsonResponse({ error: "SESSION_SIGNING_KEY is not configured" }, 503); + } + + const payload = await parseJsonBody<{ username?: string; password?: string }>(request); + const username = String(payload?.username ?? "").trim(); + const password = String(payload?.password ?? ""); + if (!username || !password) { + return jsonResponse({ error: "Username and password are required" }, 400); + } + + await ensureAuthTables(env.DB); + const ipKey = await sha256Hex(`auth-login:${getClientIp(request)}`); + const attemptState = await checkLoginAttempt(env.DB, ipKey); + if (attemptState.blocked) { + return toSecureResponse( + JSON.stringify({ error: "Too many failed attempts. Try again later." }), + { + status: 429, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Retry-After": String(attemptState.retryAfterSeconds), + }, + } + ); + } + + const credentialsMatch = + fixedTimeEquals(username, FrontEndAdminID) + && fixedTimeEquals(password, FrontEndAdminPassword); + + if (!credentialsMatch) { + await recordLoginFailure(env.DB, ipKey); + return jsonResponse({ error: "Invalid credentials" }, 401); + } + + await clearLoginFailures(env.DB, ipKey); + const createdSession = await createSession(env, request, username); + const response = jsonResponse({ + authenticated: true, + username, + method: "session", + csrfToken: createdSession.csrfToken, }); + return withSetCookies(response, [ + buildCookie({ + name: SESSION_COOKIE_NAME, + value: createdSession.token, + maxAgeSeconds: SESSION_TTL_SECONDS, + httpOnly: true, + }), + buildCookie({ + name: CSRF_COOKIE_NAME, + value: createdSession.csrfToken, + maxAgeSeconds: SESSION_TTL_SECONDS, + httpOnly: false, + }), + ]); } - if (!authHeader.startsWith("Basic ")) { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": "Basic realm=\"User Visible Realm\"", - }, + if (pathname === "/auth/session") { + if (request.method !== "GET") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + if (!authContext) { + return jsonResponse({ authenticated: false }, 401); + } + return jsonResponse({ + authenticated: true, + username: authContext.username, + method: authContext.method, + csrfToken: authContext.method === "session" ? authContext.csrfToken : null, }); } - const base64Credentials = authHeader.substring("Basic ".length); - const decodedCredentials = atob(base64Credentials); - const [username, password] = decodedCredentials.split(":"); + if (pathname === "/auth/logout") { + if (request.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + if (authContext?.method === "session" && !validateCsrf(request, authContext)) { + return jsonResponse({ error: "CSRF validation failed" }, 403); + } + if (authContext?.method === "session" && authContext.sessionId) { + await ensureAuthTables(env.DB); + await env.DB.prepare( + ` + UPDATE auth_sessions + SET revoked = 1, last_seen_at = CURRENT_TIMESTAMP + WHERE session_id = ? + ` + ).bind(authContext.sessionId).run(); + } + const response = jsonResponse({ success: true }); + return withSetCookies(response, [ + clearCookie(SESSION_COOKIE_NAME, true), + clearCookie(CSRF_COOKIE_NAME, false), + ]); + } - if (username !== FrontEndAdminID || password !== FrontEndAdminPassword) { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": "Basic realm=\"User Visible Realm\"", - }, - }); + if (authMode === "basic" && !authContext) { + return unauthorizedResponse(); } - const url = new URL(request.url); + if (!authContext && !isAuthPath && !isPublicPath) { + if (isApiPath) { + return unauthorizedJsonResponse(); + } + return redirectResponse("/login"); + } + + if (authContext && isLoginPath && !(authMode === "both" && authContext.method === "basic")) { + return redirectResponse("/"); + } + + if ( + authContext + && authContext.method === "session" + && isApiPath + && isStateChangingMethod(request.method) + && !validateCsrf(request, authContext) + ) { + return jsonResponse({ error: "CSRF validation failed" }, 403); + } + + if (pathname.startsWith("/api/v2/")) { + await ensureGmailUiTables(env.DB); + + if (url.pathname === "/api/v2/settings" && request.method === "GET") { + return jsonResponse(await getUiSettings(env.DB)); + } + + if (url.pathname === "/api/v2/settings" && request.method === "PUT") { + const payload = await parseJsonBody<{ + density?: string; + readingPane?: string; + theme?: string; + shortcutsEnabled?: boolean; + }>(request); + if (!payload || typeof payload !== "object") { + return jsonResponse({ error: "Invalid settings payload" }, 400); + } + + await upsertUiSettings(env.DB, { + density: sanitizeDensityMode(payload.density), + readingPane: sanitizeReadingPaneMode(payload.readingPane), + theme: sanitizeThemeMode(payload.theme), + shortcutsEnabled: + typeof payload.shortcutsEnabled === "boolean" ? payload.shortcutsEnabled : undefined, + }); + return jsonResponse(await getUiSettings(env.DB)); + } + + if (url.pathname === "/api/v2/threads" && request.method === "GET") { + const page = clampPage(url.searchParams.get("page"), 1); + const pageSize = clampPageSize(url.searchParams.get("pageSize"), DEFAULT_PAGE_SIZE); + const offset = (page - 1) * pageSize; + const rawQuery = (url.searchParams.get("q") ?? "").trim(); + const inbox = (url.searchParams.get("inbox") ?? "inbox").toLowerCase(); + const categoryFilter = (url.searchParams.get("category") ?? "all").toLowerCase(); + const tokens = parseSearchTokens(rawQuery); + + const includeDeleted = + inbox === "trash" + || tokens.inMailbox.includes("trash") + || tokens.inMailbox.includes("anywhere"); + const includeArchived = + inbox === "archive" + || tokens.inMailbox.includes("archive") + || tokens.inMailbox.includes("anywhere"); + const where = resolveWhereClause({ + tokens, + categoryFilter, + includeDeleted, + includeArchived, + }); + + if (inbox === "starred") { + where.clauses.push("COALESCE(ms.is_starred, 0) = 1"); + } else if (inbox === "important") { + where.clauses.push("COALESCE(ms.is_important, 0) = 1"); + } else if (inbox === "unread") { + where.clauses.push("COALESCE(ms.is_read, 0) = 0"); + } else if (inbox === "trash") { + where.clauses.push("COALESCE(ms.is_deleted, 0) = 1"); + } else if (inbox === "archive") { + where.clauses.push("COALESCE(ms.is_archived, 0) = 1"); + where.clauses.push("COALESCE(ms.is_deleted, 0) = 0"); + } else if (inbox === "snoozed") { + where.clauses.push("ms.snoozed_until IS NOT NULL"); + where.clauses.push("ms.snoozed_until > CURRENT_TIMESTAMP"); + where.clauses.push("COALESCE(ms.is_deleted, 0) = 0"); + } + + const whereClause = where.clauses.length > 0 ? `WHERE ${where.clauses.join(" AND ")}` : ""; + + const totalResult = await env.DB.prepare( + ` + SELECT COUNT(*) AS total + FROM raw_mails r + LEFT JOIN code_mails c ON c.message_id = r.message_id + LEFT JOIN mail_states ms ON ms.raw_id = r.id + ${whereClause} + ` + ) + .bind(...where.bindings) + .first<{ total: number }>(); + + const threadResult = await env.DB.prepare( + ` + SELECT + r.id AS rawId, + r.message_id AS messageId, + r.from_addr AS fromAddr, + r.to_addr AS toAddr, + r.subject AS subject, + r.raw AS raw, + r.created_at AS createdAt, + c.from_org AS fromOrg, + c.topic AS topic, + c.code AS code, + ms.is_read AS isRead, + ms.is_starred AS isStarred, + ms.is_archived AS isArchived, + ms.is_deleted AS isDeleted, + ms.is_important AS isImportant, + ms.is_muted AS isMuted, + ms.category AS category, + ms.labels_json AS labelsJson, + ms.snoozed_until AS snoozedUntil + FROM raw_mails r + LEFT JOIN code_mails c ON c.message_id = r.message_id + LEFT JOIN mail_states ms ON ms.raw_id = r.id + ${whereClause} + ORDER BY r.created_at DESC + LIMIT ? OFFSET ? + ` + ) + .bind(...where.bindings, pageSize, offset) + .all(); + + const rows = threadResult.results ?? []; + await ensureMailStateRows(env.DB, rows.map((row) => row.rawId)); + + const items = []; + for (const row of rows) { + const category = computeCategory(row); + if (!row.category) { + await env.DB.prepare( + ` + UPDATE mail_states + SET category = ?, updated_at = CURRENT_TIMESTAMP + WHERE raw_id = ? + ` + ) + .bind(category, row.rawId) + .run(); + } + + items.push({ + id: row.rawId, + threadId: `thread-${row.rawId}`, + messageId: row.messageId, + fromAddr: row.fromAddr, + fromOrg: row.fromOrg, + toAddr: row.toAddr, + subject: row.subject, + topic: row.topic, + code: row.code, + snippet: buildThreadSnippet(row), + createdAt: row.createdAt, + isRead: Number(row.isRead ?? 0) === 1, + isStarred: Number(row.isStarred ?? 0) === 1, + isArchived: Number(row.isArchived ?? 0) === 1, + isDeleted: Number(row.isDeleted ?? 0) === 1, + isImportant: Number(row.isImportant ?? 0) === 1, + isMuted: Number(row.isMuted ?? 0) === 1, + category, + labels: parseLabels(row.labelsJson), + hasCode: Boolean(row.code), + hasHtml: Boolean(extractMailBodies(String(row.raw ?? "")).htmlBody), + snoozedUntil: row.snoozedUntil, + }); + } + + return jsonResponse({ + page, + pageSize, + total: Number(totalResult?.total ?? 0), + items, + }); + } + + const threadMatch = url.pathname.match(/^\/api\/v2\/threads\/(\d+)$/); + if (threadMatch && request.method === "GET") { + const rawId = Number.parseInt(threadMatch[1], 10); + if (!Number.isFinite(rawId) || rawId < 1) { + return jsonResponse({ error: "Invalid thread id" }, 400); + } + + const row = await env.DB.prepare( + ` + SELECT + r.id AS rawId, + r.message_id AS messageId, + r.from_addr AS fromAddr, + r.to_addr AS toAddr, + r.subject AS subject, + r.raw AS raw, + r.created_at AS createdAt, + c.from_org AS fromOrg, + c.topic AS topic, + c.code AS code, + ms.is_read AS isRead, + ms.is_starred AS isStarred, + ms.is_archived AS isArchived, + ms.is_deleted AS isDeleted, + ms.is_important AS isImportant, + ms.is_muted AS isMuted, + ms.category AS category, + ms.labels_json AS labelsJson, + ms.snoozed_until AS snoozedUntil + FROM raw_mails r + LEFT JOIN code_mails c ON c.message_id = r.message_id + LEFT JOIN mail_states ms ON ms.raw_id = r.id + WHERE r.id = ? + LIMIT 1 + ` + ) + .bind(rawId) + .first(); + + if (!row) { + return jsonResponse({ error: "Thread not found" }, 404); + } + + await ensureMailStateRows(env.DB, [rawId]); + const category = computeCategory(row); + if (!row.category) { + await env.DB.prepare( + ` + UPDATE mail_states + SET category = ?, updated_at = CURRENT_TIMESTAMP + WHERE raw_id = ? + ` + ) + .bind(category, rawId) + .run(); + } + + const { textBody, htmlBody } = extractMailBodies(String(row.raw ?? "")); + return jsonResponse({ + id: row.rawId, + threadId: `thread-${row.rawId}`, + messageId: row.messageId, + fromAddr: row.fromAddr, + fromOrg: row.fromOrg, + toAddr: row.toAddr, + subject: row.subject, + topic: row.topic, + code: row.code, + createdAt: row.createdAt, + raw: row.raw, + textBody, + htmlBody, + category, + labels: parseLabels(row.labelsJson), + isRead: Number(row.isRead ?? 0) === 1, + isStarred: Number(row.isStarred ?? 0) === 1, + isArchived: Number(row.isArchived ?? 0) === 1, + isDeleted: Number(row.isDeleted ?? 0) === 1, + isImportant: Number(row.isImportant ?? 0) === 1, + isMuted: Number(row.isMuted ?? 0) === 1, + snoozedUntil: row.snoozedUntil, + }); + } + + if (url.pathname === "/api/v2/threads/actions" && request.method === "POST") { + const payload = await parseJsonBody<{ + action: ThreadAction; + ids: number[]; + until?: string; + label?: string; + }>(request); + + if (!payload || typeof payload !== "object") { + return jsonResponse({ error: "Invalid payload" }, 400); + } + + const allowedActions: ThreadAction[] = [ + "read", + "unread", + "star", + "unstar", + "archive", + "unarchive", + "delete", + "restore", + "important", + "not-important", + "snooze", + "label-add", + "label-remove", + ]; + + if (!allowedActions.includes(payload.action)) { + return jsonResponse({ error: "Invalid action" }, 400); + } + + const ids = Array.isArray(payload.ids) + ? payload.ids + .map((value) => Number(value)) + .filter((value) => Number.isInteger(value) && value > 0) + .slice(0, 200) + : []; + if (ids.length === 0) { + return jsonResponse({ error: "No valid ids provided" }, 400); + } + + await ensureMailStateRows(env.DB, ids); + const placeholders = ids.map(() => "?").join(", "); + + if (payload.action === "read" || payload.action === "unread") { + await env.DB.prepare( + `UPDATE mail_states SET is_read = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(payload.action === "read" ? 1 : 0, ...ids) + .run(); + } else if (payload.action === "star" || payload.action === "unstar") { + await env.DB.prepare( + `UPDATE mail_states SET is_starred = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(payload.action === "star" ? 1 : 0, ...ids) + .run(); + } else if (payload.action === "archive" || payload.action === "unarchive") { + await env.DB.prepare( + `UPDATE mail_states SET is_archived = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(payload.action === "archive" ? 1 : 0, ...ids) + .run(); + } else if (payload.action === "delete") { + await env.DB.prepare( + `UPDATE mail_states SET is_deleted = 1, is_archived = 1, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(...ids) + .run(); + } else if (payload.action === "restore") { + await env.DB.prepare( + `UPDATE mail_states SET is_deleted = 0, is_archived = 0, snoozed_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(...ids) + .run(); + } else if (payload.action === "important" || payload.action === "not-important") { + await env.DB.prepare( + `UPDATE mail_states SET is_important = ?, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(payload.action === "important" ? 1 : 0, ...ids) + .run(); + } else if (payload.action === "snooze") { + const untilDate = payload.until ? new Date(payload.until) : null; + if (!untilDate || Number.isNaN(untilDate.getTime())) { + return jsonResponse({ error: "Invalid snooze timestamp" }, 400); + } + await env.DB.prepare( + `UPDATE mail_states SET snoozed_until = ?, is_archived = 0, is_deleted = 0, updated_at = CURRENT_TIMESTAMP WHERE raw_id IN (${placeholders})` + ) + .bind(untilDate.toISOString(), ...ids) + .run(); + } else if (payload.action === "label-add" || payload.action === "label-remove") { + const normalizedLabel = String(payload.label ?? "").trim().toLowerCase().slice(0, 64); + if (!normalizedLabel) { + return jsonResponse({ error: "Label is required for label actions" }, 400); + } + + const currentRows = await env.DB.prepare( + `SELECT raw_id, labels_json FROM mail_states WHERE raw_id IN (${placeholders})` + ) + .bind(...ids) + .all<{ raw_id: number; labels_json: string | null }>(); + + for (const row of currentRows.results ?? []) { + const labels = parseLabels(row.labels_json); + const next = + payload.action === "label-add" + ? Array.from(new Set([...labels, normalizedLabel])) + : labels.filter((label) => label !== normalizedLabel); + await env.DB.prepare( + ` + UPDATE mail_states + SET labels_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE raw_id = ? + ` + ) + .bind(stringifyLabels(next), row.raw_id) + .run(); + } + } + + return jsonResponse({ success: true, updated: ids.length }); + } + + return jsonResponse({ error: "Not found" }, 404); + } if (url.pathname === "/api/mails" && request.method === "GET") { - const page = Math.max(1, Number.parseInt(url.searchParams.get("page") ?? "1", 10) || 1); - const pageSize = Math.min(100, Math.max(1, Number.parseInt(url.searchParams.get("pageSize") ?? "20", 10) || 20)); + const page = clampPage(url.searchParams.get("page"), 1); + const pageSize = clampPageSize(url.searchParams.get("pageSize"), DEFAULT_PAGE_SIZE); const offset = (page - 1) * pageSize; const { results } = await env.DB.prepare( ` @@ -389,15 +2075,46 @@ export default class extends WorkerEntrypoint { if (env.ASSETS) { const assetResponse = await env.ASSETS.fetch(request); - if (assetResponse.status !== 404) { - return assetResponse; + const isStaticAssetRequest = isPublicAssetPath(pathname); + const isAssetRedirect = assetResponse.status >= 300 && assetResponse.status < 400; + const shouldFallbackToSpaIndex = isAssetRedirect && !isStaticAssetRequest; + + if (assetResponse.status !== 404 && !shouldFallbackToSpaIndex) { + const contentType = assetResponse.headers.get("Content-Type")?.toLowerCase() ?? ""; + const shouldNoStore = contentType.includes("text/html") || !isPublicAssetPath(pathname); + return toSecureResponse(assetResponse.body, { + status: assetResponse.status, + statusText: assetResponse.statusText, + headers: assetResponse.headers, + }, { noStore: shouldNoStore }); } if (request.method === "GET" || request.method === "HEAD") { const indexRequest = new Request(new URL("/index.html", request.url).toString(), request); const indexResponse = await env.ASSETS.fetch(indexRequest); + const indexIsRedirect = indexResponse.status >= 300 && indexResponse.status < 400; + + if (indexIsRedirect) { + const redirectLocation = indexResponse.headers.get("Location"); + if (redirectLocation) { + const redirectedIndexRequest = new Request(new URL(redirectLocation, request.url).toString(), request); + const redirectedIndexResponse = await env.ASSETS.fetch(redirectedIndexRequest); + if (redirectedIndexResponse.status !== 404) { + return toSecureResponse(redirectedIndexResponse.body, { + status: redirectedIndexResponse.status, + statusText: redirectedIndexResponse.statusText, + headers: redirectedIndexResponse.headers, + }); + } + } + } + if (indexResponse.status !== 404) { - return indexResponse; + return toSecureResponse(indexResponse.body, { + status: indexResponse.status, + statusText: indexResponse.statusText, + headers: indexResponse.headers, + }); } } } @@ -405,29 +2122,11 @@ export default class extends WorkerEntrypoint { try { const { results } = await env.DB.prepare( "SELECT from_org, to_addr, topic, code, created_at FROM code_mails ORDER BY created_at DESC" - ).all(); + ).all(); let dataHtml = ""; for (const row of results) { - const codeLinkParts = row.code.split(","); - let codeLinkContent; - - if (codeLinkParts.length > 1) { - const [code, link] = codeLinkParts; - codeLinkContent = `${code}
${row.topic}`; - } else if (row.code.startsWith("http")) { - codeLinkContent = `${row.topic}`; - } else { - codeLinkContent = row.code; - } - - dataHtml += ` - ${row.from_org} - ${row.to_addr} - ${row.topic} - ${codeLinkContent} - ${row.created_at} - `; + dataHtml += buildLegacyTableRow(row); } const responseHtml = indexHtml @@ -445,14 +2144,19 @@ export default class extends WorkerEntrypoint { ) .replace("{{DATA}}", dataHtml); - return new Response(responseHtml, { + return toSecureResponse(responseHtml, { headers: { - "Content-Type": "text/html", + "Content-Type": "text/html; charset=utf-8", }, }); } catch (error) { console.error("Error querying database:", error); - return new Response("Internal Server Error", { status: 500 }); + return toSecureResponse("Internal Server Error", { + status: 500, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); } } @@ -460,6 +2164,7 @@ export default class extends WorkerEntrypoint { async email(message: ForwardableEmailMessage): Promise { const env: Env = this.env; const useBark = env.UseBark.toLowerCase() === "true"; + await ensureGmailUiTables(env.DB); const primary: ProviderConfig = { baseUrl: env.AI_BASE_URL, @@ -479,57 +2184,55 @@ export default class extends WorkerEntrypoint { : null; // Pull raw email content // 获取原始邮件内容 - const rawEmail = + const rawEmail: string = message instanceof RPCEmailMessage ? (message as RPCEmailMessage).rawEmail : await new Response(message.raw).text(); const messageId = message.headers.get("Message-ID"); const rawSubject = message.headers.get("Subject"); + const { textBody } = extractMailBodies(rawEmail); + + const promotionalEmail = isPromotionalEmail(message.headers, rawEmail); // Persist raw mail payload for auditing // 将原始邮件持久化以便审计 - const { success } = await env.DB.prepare( + const insertRawResult = await env.DB.prepare( "INSERT INTO raw_mails (from_addr, to_addr, subject, raw, message_id) VALUES (?, ?, ?, ?, ?)" ) .bind(message.from, message.to, rawSubject, rawEmail, messageId) .run(); + const success = insertRawResult.success; + const rawRowId = Number((insertRawResult.meta as { last_row_id?: number } | undefined)?.last_row_id ?? 0); if (!success) { - message.setReject(`Failed to save message from ${message.from} to ${message.to}`); - console.log(`Failed to save message from ${message.from} to ${message.to}`); + message.setReject("Failed to save message payload"); + console.log(`Failed to save raw mail payload for messageId=${messageId ?? "unknown"}`); + } else if (rawRowId > 0) { + const initialCategory = promotionalEmail + ? "promotions" + : inferCategoryFromFields(message.from, rawSubject); + await env.DB.prepare( + ` + INSERT OR IGNORE INTO mail_states (raw_id, category, is_read, is_starred, is_archived, is_deleted, is_important, is_muted, labels_json) + VALUES (?, ?, 0, 0, 0, 0, 0, 0, '[]') + ` + ) + .bind(rawRowId, initialCategory) + .run(); } // Skip promotional/bulk emails before hitting the LLM - if (isPromotionalEmail(message.headers, rawEmail)) { - console.log(`Skipping promotional email from ${message.from}: ${rawSubject}`); + if (promotionalEmail) { + console.log(`Skipping promotional email messageId=${messageId ?? "unknown"}`); return; } // Prompt instructs model how to format extraction // 提示词说明提取格式和字段要求 - const aiPrompt = ` - Email content: ${rawEmail}. - - Please read the email and extract the following information: - 1. Code/Link/Password from the email (if available). - 2. Organization name (title) from which the email is sent. - 3. A brief summary of the email's topic (e.g., 'line register verification'). - - Please provide the following information in JSON format: - { - "title": "The organization or company that sent the verification code (e.g., 'Netflix')", - "code": "The extracted verification code, link, or password (e.g., '123456' or 'https://example.com/verify?code=123456')", - "topic": "A brief summary of the email's topic (e.g., 'line register verification')", - "codeExist": 1 - } - - - If both a code and a link are present, include both in the 'code' field like this: - "code": "code, link" - - If there is no code, clickable link, or this is an advertisement email, return: - { - "codeExist": 0 - } -`; + const aiPrompt = buildAiPrompt({ + from: message.from, + to: message.to, + subject: rawSubject ?? "", + textBody: textBody ?? stripHtmlTags(rawEmail).slice(0, MAX_PROMPT_BODY_LENGTH), + }); try { const maxRetries = 3; @@ -543,7 +2246,7 @@ export default class extends WorkerEntrypoint { const parsed = extractJsonFromText(text); if (parsed) { extractedData = parsed; - console.log("[primary] extracted data:", extractedData); + console.log(`[primary] extracted fields: ${summarizeExtractionForLog(parsed)}`); break; } } @@ -559,7 +2262,7 @@ export default class extends WorkerEntrypoint { const parsed = extractJsonFromText(text); if (parsed) { extractedData = parsed; - console.log("[fallback] extracted data:", extractedData); + console.log(`[fallback] extracted fields: ${summarizeExtractionForLog(parsed)}`); } else { console.error("[fallback] failed to parse response"); } @@ -585,11 +2288,9 @@ export default class extends WorkerEntrypoint { .run(); if (!codeMailSuccess) { - message.setReject( - `Failed to save extracted code for message from ${message.from} to ${message.to}` - ); + message.setReject("Failed to save extracted code payload"); console.log( - `Failed to save extracted code for message from ${message.from} to ${message.to}` + `Failed to save extracted code for messageId=${messageId ?? "unknown"}` ); } @@ -599,12 +2300,13 @@ export default class extends WorkerEntrypoint { const barkTokens = env.barkTokens .replace(/^\[|\]$/g, "") .split(",") - .map((token) => token.trim()); + .map((token) => token.trim()) + .filter(Boolean); const barkUrlEncodedTitle = encodeURIComponent(title); const barkUrlEncodedCode = encodeURIComponent(code); - for (const token of barkTokens) { + for (const [index, token] of barkTokens.entries()) { const barkRequestUrl = `${barkUrl}/${token}/${barkUrlEncodedTitle}/${barkUrlEncodedCode}`; const barkResponse = await fetch(barkRequestUrl, { @@ -613,13 +2315,11 @@ export default class extends WorkerEntrypoint { if (barkResponse.ok) { console.log( - `Successfully sent notification to Bark for token ${token} for message from ${message.from} to ${message.to}` + `Successfully sent Bark notification ${index + 1}/${barkTokens.length} for messageId=${messageId ?? "unknown"}` ); - const responseData = await barkResponse.json(); - console.log("Bark response:", responseData); } else { console.error( - `Failed to send notification to Bark for token ${token}: ${barkResponse.status} ${barkResponse.statusText}` + `Failed Bark notification ${index + 1}/${barkTokens.length}: ${barkResponse.status} ${barkResponse.statusText}` ); } } @@ -637,10 +2337,31 @@ export default class extends WorkerEntrypoint { // Expose RPC helper for other workers // 暴露 RPC 接口供其他 Worker 调用 async rpcEmail(requestBody: string): Promise { - console.log(`Received RPC email , request body: ${requestBody}`); - const bodyObject = JSON.parse(requestBody); - const headersObject = bodyObject.headers; - const headers = new Headers(headersObject); + console.log("Received RPC email request"); + let bodyObject: { + from?: string; + to?: string; + rawEmail?: string; + headers?: Record; + }; + + try { + bodyObject = JSON.parse(requestBody); + } catch { + console.error("rpcEmail received invalid JSON"); + return; + } + + if ( + typeof bodyObject.from !== "string" + || typeof bodyObject.to !== "string" + || typeof bodyObject.rawEmail !== "string" + ) { + console.error("rpcEmail missing required fields"); + return; + } + + const headers = new Headers(bodyObject.headers ?? {}); const rpcEmailMessage: RPCEmailMessage = new RPCEmailMessage( bodyObject.from, bodyObject.to, diff --git a/src/rpcEmail.ts b/src/rpcEmail.ts index dc505b0..a8b21c8 100644 --- a/src/rpcEmail.ts +++ b/src/rpcEmail.ts @@ -1,7 +1,7 @@ class RPCEmailMessage implements ForwardableEmailMessage { readonly from: string; readonly to: string; - readonly rawEmail: String; + readonly rawEmail: string; readonly raw: ReadableStream; readonly headers: Headers; readonly rawSize: number; @@ -29,13 +29,15 @@ class RPCEmailMessage implements ForwardableEmailMessage { console.log(`rpcEmail default implementation: Message rejected: ${reason}`); } - async forward(rcptTo: string, headers: Headers = new Headers()): Promise { + async forward(rcptTo: string, headers: Headers = new Headers()): Promise { console.log(`rpcEmail default implementation: Forwarding message to: ${rcptTo}, with headers:`, headers); + return { messageId: "rpc-email-forward-not-implemented" }; } - async reply(message: EmailMessage): Promise { + async reply(message: EmailMessage): Promise { console.log(`rpcEmail default implementation: Replying to: ${message}`); + return { messageId: "rpc-email-reply-not-implemented" }; } } -export { RPCEmailMessage }; \ No newline at end of file +export { RPCEmailMessage }; diff --git a/test/security.spec.ts b/test/security.spec.ts new file mode 100644 index 0000000..3ba4f54 --- /dev/null +++ b/test/security.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("cloudflare:workers", () => { + class WorkerEntrypoint { + protected env!: TEnv; + } + return { WorkerEntrypoint }; +}); + +import { + buildAiPrompt, + buildLegacyCodeCell, + createSignedSessionToken, + isPublicAssetPath, + isAuthorizedBasicAuth, + normalizeAuthMode, + parseBasicAuthCredentials, + summarizeExtractionForLog, + verifySignedSessionToken, +} from "../src/index"; + +describe("security hardening helpers", () => { + it("sanitizes legacy fallback links and blocks non-http(s) schemes", () => { + const html = buildLegacyCodeCell("123456, javascript:alert(1)", "Verify Account"); + expect(html).toContain("123456"); + expect(html).not.toContain("javascript:"); + expect(html).not.toContain(" { + const html = buildLegacyCodeCell("", "Unsafe"); + expect(html).toContain("<img"); + expect(html).not.toContain(" { + expect(parseBasicAuthCredentials("Basic !!!not-base64!!!")).toBeNull(); + expect(isAuthorizedBasicAuth("Basic !!!not-base64!!!", "admin", "password")).toBe(false); + }); + + it("authenticates valid basic auth credentials", () => { + const encoded = Buffer.from("admin:password", "utf8").toString("base64"); + expect(isAuthorizedBasicAuth(`Basic ${encoded}`, "admin", "password")).toBe(true); + }); + + it("builds minimal AI prompt without raw MIME headers", () => { + const prompt = buildAiPrompt({ + from: "service@example.com", + to: "inbox@example.com", + subject: "Your verification code", + textBody: "Your code is 123456", + }); + + expect(prompt).toContain("From: service@example.com"); + expect(prompt).toContain("Your code is 123456"); + expect(prompt).not.toContain("Content-Transfer-Encoding"); + }); + + it("redacts extraction logs to codeExist only", () => { + const logLine = summarizeExtractionForLog({ + codeExist: 1, + code: "123456", + title: "Sensitive Org", + }); + + expect(logLine).toBe("codeExist=1"); + expect(logLine).not.toContain("123456"); + expect(logLine).not.toContain("Sensitive Org"); + }); + + it("keeps public-assets allowlist narrow to avoid API auth bypass", () => { + expect(isPublicAssetPath("/assets/index-abc123.js")).toBe(true); + expect(isPublicAssetPath("/api/v2/threads.js")).toBe(false); + expect(isPublicAssetPath("/auth/session")).toBe(false); + }); + + it("creates and verifies signed session tokens and rejects tampering/expiry", async () => { + const key = "test-signing-key"; + const future = Math.floor(Date.now() / 1000) + 60; + const validToken = await createSignedSessionToken(key, "session-123", future); + const verified = await verifySignedSessionToken(key, validToken); + expect(verified).toEqual({ + sessionId: "session-123", + expiresAtUnixSeconds: future, + }); + + const tamperedToken = validToken.replace("session-123", "session-999"); + expect(await verifySignedSessionToken(key, tamperedToken)).toBeNull(); + + const expiredToken = await createSignedSessionToken(key, "session-123", Math.floor(Date.now() / 1000) - 1); + expect(await verifySignedSessionToken(key, expiredToken)).toBeNull(); + }); + + it("normalizes invalid auth mode values to both", () => { + expect(normalizeAuthMode(undefined)).toBe("both"); + expect(normalizeAuthMode("invalid-value")).toBe("both"); + expect(normalizeAuthMode("session")).toBe("session"); + }); +}); diff --git a/test/ui-v2.spec.ts b/test/ui-v2.spec.ts new file mode 100644 index 0000000..e1ca803 --- /dev/null +++ b/test/ui-v2.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('cloudflare:workers', () => { + class WorkerEntrypoint { + protected env!: TEnv; + } + return { WorkerEntrypoint }; +}); + +import { inferCategoryFromFields, parseSearchTokens } from '../src/index'; + +describe('gmail-like query helpers', () => { + it('parses Gmail operators and free text', () => { + const tokens = parseSearchTokens('from:noreply@example.com subject:"security code" is:unread has:attachment category:promotions token'); + + expect(tokens.from).toEqual(['noreply@example.com']); + expect(tokens.subject).toEqual(['security code']); + expect(tokens.isFlags).toContain('unread'); + expect(tokens.hasFlags).toContain('attachment'); + expect(tokens.categories).toEqual(['promotions']); + expect(tokens.text).toContain('token'); + }); + + it('infers social and updates categories from metadata', () => { + expect(inferCategoryFromFields('notifications@facebookmail.com', 'New follower alert')).toBe('social'); + expect(inferCategoryFromFields('billing@service.com', 'Your payment receipt')).toBe('updates'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3e3c735..f1a235b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,7 +101,10 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, "exclude": ["test"], - "include": ["worker-configuration.d.ts", - "src/**/*.ts" - ] + "include": [ + "worker-configuration.d.ts", + "src/**/*.ts", + "src/**/*.d.ts", + "test/**/*.ts" + ] } diff --git a/vitest.config.mts b/vitest.config.mts index 503a717..a25cd8c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,11 +1,9 @@ -import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; +import { defineConfig } from "vitest/config"; -export default defineWorkersConfig({ - test: { - poolOptions: { - workers: { - wrangler: { configPath: './wrangler.toml' }, - }, - }, - }, +export default defineConfig({ + assetsInclude: ["**/*.html"], + test: { + environment: "node", + include: ["test/**/*.spec.ts"], + }, }); diff --git a/web/src/App.tsx b/web/src/App.tsx index aa88854..0deb5bd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,53 +1,150 @@ -import { useEffect, useMemo, useState } from 'react'; +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import DOMPurify from 'dompurify'; -import { Copy, Inbox, RefreshCw, ShieldCheck } from 'lucide-react'; +import { + Archive, + Bell, + ChevronLeft, + ChevronRight, + Copy, + Inbox, + Menu, + RefreshCw, + Search, + Settings, + ShieldCheck, + Star, + Trash2, + X, +} from 'lucide-react'; import { toast, Toaster } from 'sonner'; -import { Badge } from './components/ui/badge'; import { Button } from './components/ui/button'; -import { Card, CardHeader, CardTitle } from './components/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs'; import { cn } from './lib/utils'; -const PAGE_SIZE = 20; +type DensityMode = 'default' | 'comfortable' | 'compact'; +type ReadingPaneMode = 'none' | 'right' | 'bottom'; +type ThemeMode = 'dark' | 'light' | 'system'; +type InboxView = 'inbox' | 'starred' | 'important' | 'unread' | 'archive' | 'trash' | 'snoozed'; +type CategoryView = 'all' | 'primary' | 'social' | 'promotions' | 'updates' | 'forums'; +type ThreadAction = + | 'read' + | 'unread' + | 'star' + | 'unstar' + | 'archive' + | 'unarchive' + | 'delete' + | 'restore' + | 'important' + | 'not-important' + | 'snooze' + | 'label-add' + | 'label-remove'; -interface MailListItem { +interface MailThreadItem { id: number; + threadId: string; messageId: string | null; - fromOrg: string | null; fromAddr: string | null; + fromOrg: string | null; toAddr: string | null; + subject: string | null; topic: string | null; code: string | null; + snippet: string; createdAt: string | null; - subject: string | null; + isRead: boolean; + isStarred: boolean; + isArchived: boolean; + isDeleted: boolean; + isImportant: boolean; + isMuted: boolean; + category: Exclude; + labels: string[]; + hasCode: boolean; + hasHtml: boolean; + snoozedUntil: string | null; } -interface MailListResponse { +interface MailThreadsResponse { page: number; pageSize: number; total: number; - items: MailListItem[]; + items: MailThreadItem[]; } -interface MailDetail { - id: number; - messageId: string | null; - fromOrg: string | null; - fromAddr: string | null; - toAddr: string | null; - subject: string | null; - topic: string | null; - code: string | null; - createdAt: string | null; +interface MailThreadDetail extends MailThreadItem { raw: string | null; textBody: string | null; htmlBody: string | null; } +interface UiSettings { + density: DensityMode; + readingPane: ReadingPaneMode; + theme: ThemeMode; + shortcutsEnabled: boolean; +} + +interface SessionPayload { + authenticated: boolean; + username?: string; + method?: 'basic' | 'session'; + csrfToken?: string | null; +} + +const PAGE_SIZE = 30; +const DEFAULT_SETTINGS: UiSettings = { + density: 'default', + readingPane: 'right', + theme: 'dark', + shortcutsEnabled: true, +}; + +const CATEGORY_TABS: Array<{ id: CategoryView; label: string }> = [ + { id: 'all', label: 'All' }, + { id: 'primary', label: 'Primary' }, + { id: 'social', label: 'Social' }, + { id: 'promotions', label: 'Promotions' }, + { id: 'updates', label: 'Updates' }, + { id: 'forums', label: 'Forums' }, +]; + +const INBOX_ITEMS: Array<{ id: InboxView; label: string }> = [ + { id: 'inbox', label: 'Inbox' }, + { id: 'starred', label: 'Starred' }, + { id: 'important', label: 'Important' }, + { id: 'unread', label: 'Unread' }, + { id: 'archive', label: 'Archive' }, + { id: 'snoozed', label: 'Snoozed' }, + { id: 'trash', label: 'Trash' }, +]; + +class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + function formatDate(value: string | null): string { - if (!value) { - return '-'; + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const now = new Date(); + const sameDay = + now.getFullYear() === date.getFullYear() + && now.getMonth() === date.getMonth() + && now.getDate() === date.getDate(); + if (sameDay) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +function formatDateLong(value: string | null): string { + if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } @@ -57,13 +154,13 @@ function codeAndLink(value: string | null): { code: string | null; link: string const urlMatch = value.match(/https?:\/\/\S+/); if (urlMatch) { const link = urlMatch[0]; - const beforeUrl = value.slice(0, urlMatch.index).replace(/,\s*$/, '').trim(); - return { code: beforeUrl || null, link }; + const before = value.slice(0, urlMatch.index).replace(/,\s*$/, '').trim(); + return { code: before || null, link }; } return { code: value.trim() || null, link: null }; } -function toPreviewHtml(htmlBody: string, hideRemoteImages: boolean): string { +function toPreviewHtml(htmlBody: string, hideRemoteImages: boolean, theme: 'dark' | 'light'): string { const sanitized = DOMPurify.sanitize(htmlBody, { USE_PROFILES: { html: true }, FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'], @@ -79,14 +176,15 @@ function toPreviewHtml(htmlBody: string, hideRemoteImages: boolean): string { anchor.setAttribute('rel', 'noopener noreferrer'); }); + const isDark = theme === 'dark'; return ` @@ -96,301 +194,1022 @@ function toPreviewHtml(htmlBody: string, hideRemoteImages: boolean): string { } async function fetchJson(url: string): Promise { + const response = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + const text = await response.text(); + throw new HttpError(response.status, text || `Request failed (${response.status})`); + } + return (await response.json()) as T; +} + +async function sendJson( + url: string, + body: unknown, + method: 'POST' | 'PUT' = 'POST', + csrfToken?: string +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (csrfToken) { + headers['x-csrf-token'] = csrfToken; + } + const response = await fetch(url, { - headers: { - Accept: 'application/json', - }, + method, + headers, + body: JSON.stringify(body), }); - if (!response.ok) { const text = await response.text(); - throw new Error(text || `Request failed (${response.status})`); + throw new HttpError(response.status, text || `Request failed (${response.status})`); } - return (await response.json()) as T; + return (await response.json()) as TResponse; +} + +function isEditableElement(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable; +} + +function rowDensityClass(density: DensityMode): string { + if (density === 'compact') return 'min-h-11 py-1.5'; + if (density === 'comfortable') return 'min-h-14 py-2.5'; + return 'min-h-16 py-3'; } function App(): JSX.Element { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [systemThemeDark, setSystemThemeDark] = useState(true); + const [showSettingsPanel, setShowSettingsPanel] = useState(false); + const [showShortcutHelp, setShowShortcutHelp] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [isAuthLoading, setIsAuthLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [authMethod, setAuthMethod] = useState<'basic' | 'session' | null>(null); + const [authUser, setAuthUser] = useState(null); + const [csrfToken, setCsrfToken] = useState(''); + const [loginUsername, setLoginUsername] = useState('admin'); + const [loginPassword, setLoginPassword] = useState(''); + const [loginError, setLoginError] = useState(null); + const [isLoginSubmitting, setIsLoginSubmitting] = useState(false); + + const [inbox, setInbox] = useState('inbox'); + const [category, setCategory] = useState('all'); const [page, setPage] = useState(1); - const [list, setList] = useState({ page: 1, pageSize: PAGE_SIZE, total: 0, items: [] }); + const [queryInput, setQueryInput] = useState(''); + const [query, setQuery] = useState(''); + + const [list, setList] = useState({ page: 1, pageSize: PAGE_SIZE, total: 0, items: [] }); const [isListLoading, setIsListLoading] = useState(true); const [listError, setListError] = useState(null); - const [selectedMailId, setSelectedMailId] = useState(null); - const [detail, setDetail] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [detail, setDetail] = useState(null); const [isDetailLoading, setIsDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); + const [detailTab, setDetailTab] = useState<'extracted' | 'raw' | 'rendered'>('extracted'); const [hideRemoteImages, setHideRemoteImages] = useState(true); + const [singlePaneOpen, setSinglePaneOpen] = useState(false); + const [isActionLoading, setIsActionLoading] = useState(false); + + const searchInputRef = useRef(null); + const comboRef = useRef(null); + const comboTimeoutRef = useRef(null); + const currentPath = typeof window === 'undefined' ? '/' : window.location.pathname; + const isLoginRoute = currentPath === '/login'; + + const effectiveTheme: 'dark' | 'light' = + settings.theme === 'system' ? (systemThemeDark ? 'dark' : 'light') : settings.theme; + const totalPages = Math.max(1, Math.ceil(list.total / list.pageSize)); + + const handleUnauthorized = useCallback((error: unknown): boolean => { + if (error instanceof HttpError && error.status === 401) { + setIsAuthenticated(false); + setAuthMethod(null); + setAuthUser(null); + setCsrfToken(''); + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.replace('/login'); + } + return true; + } + return false; + }, []); + + const loadSession = useCallback(async (): Promise => { + try { + const payload = await fetchJson('/auth/session'); + setIsAuthenticated(Boolean(payload.authenticated)); + setAuthMethod(payload.method === 'basic' || payload.method === 'session' ? payload.method : null); + setAuthUser(payload.username ?? null); + setCsrfToken(payload.csrfToken ?? ''); + } catch (error) { + if (error instanceof HttpError && error.status === 401) { + setIsAuthenticated(false); + setAuthMethod(null); + setAuthUser(null); + setCsrfToken(''); + } else { + console.error(error); + } + } finally { + setIsAuthLoading(false); + } + }, []); - const totalPages = Math.max(1, Math.ceil(list.total / PAGE_SIZE)); + const loadSettings = useCallback(async (): Promise => { + if (!isAuthenticated) return; + try { + const payload = await fetchJson('/api/v2/settings'); + setSettings(payload); + } catch (error) { + if (handleUnauthorized(error)) return; + console.error(error); + } + }, [handleUnauthorized, isAuthenticated]); + + const updateSettings = useCallback( + async (patch: Partial): Promise => { + const previous = settings; + const next = { ...settings, ...patch }; + setSettings(next); + try { + const payload = await sendJson('/api/v2/settings', next, 'PUT', csrfToken); + setSettings(payload); + } catch (error) { + if (handleUnauthorized(error)) return; + setSettings(previous); + toast.error(error instanceof Error ? error.message : 'Unable to update settings'); + } + }, + [csrfToken, handleUnauthorized, settings] + ); - const refreshList = (): void => { + const loadList = useCallback(async (): Promise => { + if (!isAuthenticated) return; setIsListLoading(true); setListError(null); - fetchJson(`/api/mails?page=${page}&pageSize=${PAGE_SIZE}`) - .then((payload) => { - setList(payload); - setSelectedMailId((previousId) => { - if (previousId && payload.items.some((item) => item.id === previousId)) { - return previousId; - } - return payload.items[0]?.id ?? null; - }); - }) - .catch((error: unknown) => { - setListError(error instanceof Error ? error.message : 'Unable to load mail list.'); - }) - .finally(() => { - setIsListLoading(false); + try { + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('pageSize', String(PAGE_SIZE)); + params.set('inbox', inbox); + if (category !== 'all') params.set('category', category); + if (query.trim()) params.set('q', query.trim()); + + const payload = await fetchJson(`/api/v2/threads?${params.toString()}`); + setList(payload); + setSelectedIds((prev) => prev.filter((id) => payload.items.some((item) => item.id === id))); + setSelectedId((prev) => { + if (prev && payload.items.some((item) => item.id === prev)) return prev; + return payload.items[0]?.id ?? null; }); - }; + } catch (error) { + if (handleUnauthorized(error)) return; + setListError(error instanceof Error ? error.message : 'Unable to load threads'); + } finally { + setIsListLoading(false); + } + }, [category, handleUnauthorized, inbox, isAuthenticated, page, query]); + + const loadDetail = useCallback(async (id: number): Promise => { + if (!isAuthenticated) return; + setIsDetailLoading(true); + setDetailError(null); + setHideRemoteImages(true); + try { + const payload = await fetchJson(`/api/v2/threads/${id}`); + setDetail(payload); + } catch (error) { + if (handleUnauthorized(error)) return; + setDetailError(error instanceof Error ? error.message : 'Unable to load thread detail'); + } finally { + setIsDetailLoading(false); + } + }, [handleUnauthorized, isAuthenticated]); + + const selectionIds = selectedIds.length > 0 ? selectedIds : selectedId ? [selectedId] : []; + + const runAction = useCallback( + async (action: ThreadAction, extra: Record = {}): Promise => { + if (selectionIds.length === 0) { + toast.message('Select at least one email'); + return; + } + setIsActionLoading(true); + try { + await sendJson('/api/v2/threads/actions', { + action, + ids: selectionIds, + ...extra, + }, 'POST', csrfToken); + await loadList(); + if (selectedId && selectionIds.includes(selectedId)) { + await loadDetail(selectedId); + } + } catch (error) { + if (handleUnauthorized(error)) return; + toast.error(error instanceof Error ? error.message : 'Action failed'); + } finally { + setIsActionLoading(false); + } + }, + [csrfToken, handleUnauthorized, loadDetail, loadList, selectedId, selectionIds] + ); + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const sync = (): void => setSystemThemeDark(media.matches); + sync(); + media.addEventListener('change', sync); + return () => media.removeEventListener('change', sync); + }, []); + + useEffect(() => { + void loadSession(); + }, [loadSession]); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (!isAuthLoading && !isAuthenticated && !isLoginRoute) { + window.location.replace('/login'); + } + }, [isAuthLoading, isAuthenticated, isLoginRoute]); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (!isAuthLoading && isAuthenticated && isLoginRoute && authMethod === 'session') { + window.location.replace('/'); + } + }, [authMethod, isAuthLoading, isAuthenticated, isLoginRoute]); + + useEffect(() => { + if (!isAuthenticated) return; + void loadSettings(); + }, [isAuthenticated, loadSettings]); useEffect(() => { - refreshList(); - }, [page]); + if (!isAuthenticated) return; + void loadList(); + }, [isAuthenticated, loadList]); useEffect(() => { - if (!selectedMailId) { + if (!isAuthenticated) { setDetail(null); return; } - setHideRemoteImages(true); - setDetailError(null); - setIsDetailLoading(true); + if (!selectedId) { + setDetail(null); + return; + } + void loadDetail(selectedId); + }, [isAuthenticated, loadDetail, selectedId]); - fetchJson(`/api/mails/${selectedMailId}`) - .then((payload) => { - setDetail(payload); - }) - .catch((error: unknown) => { - setDetailError(error instanceof Error ? error.message : 'Unable to load mail details.'); - }) - .finally(() => { - setIsDetailLoading(false); - }); - }, [selectedMailId]); + useEffect(() => { + if (!isAuthenticated) return undefined; + if (!settings.shortcutsEnabled) return undefined; + + const clearCombo = (): void => { + comboRef.current = null; + if (comboTimeoutRef.current) { + window.clearTimeout(comboTimeoutRef.current); + comboTimeoutRef.current = null; + } + }; + + const onKeyDown = (event: KeyboardEvent): void => { + if (isEditableElement(event.target)) return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + const key = event.key.toLowerCase(); + + if (comboRef.current === 'g') { + clearCombo(); + if (key === 'i') { + event.preventDefault(); + setInbox('inbox'); + setPage(1); + return; + } + if (key === 's') { + event.preventDefault(); + setInbox('starred'); + setPage(1); + return; + } + if (key === 'a') { + event.preventDefault(); + setInbox('archive'); + setPage(1); + return; + } + if (key === 'u') { + event.preventDefault(); + setInbox('unread'); + setPage(1); + return; + } + } + + if (key === 'g') { + comboRef.current = 'g'; + if (comboTimeoutRef.current) { + window.clearTimeout(comboTimeoutRef.current); + } + comboTimeoutRef.current = window.setTimeout(clearCombo, 1500); + return; + } + + if (key === '/') { + event.preventDefault(); + searchInputRef.current?.focus(); + return; + } + + if (key === '?') { + event.preventDefault(); + setShowShortcutHelp(true); + return; + } + + if (list.items.length === 0) return; + const currentIndex = list.items.findIndex((item) => item.id === selectedId); + + if (key === 'j') { + event.preventDefault(); + const next = list.items[Math.min(list.items.length - 1, Math.max(0, currentIndex + 1))]; + if (next) setSelectedId(next.id); + return; + } + + if (key === 'k') { + event.preventDefault(); + const next = list.items[Math.max(0, currentIndex <= 0 ? 0 : currentIndex - 1)]; + if (next) setSelectedId(next.id); + return; + } + + if (key === 'x' && selectedId) { + event.preventDefault(); + setSelectedIds((prev) => (prev.includes(selectedId) ? prev.filter((id) => id !== selectedId) : [...prev, selectedId])); + return; + } + + if (key === 'u') { + event.preventDefault(); + setSelectedIds([]); + return; + } + + if (key === 'e') { + event.preventDefault(); + void runAction('archive'); + return; + } + + if (key === '#') { + event.preventDefault(); + void runAction('delete'); + return; + } + + if (key === 's' && selectedId) { + event.preventDefault(); + const current = list.items.find((item) => item.id === selectedId); + void runAction(current?.isStarred ? 'unstar' : 'star'); + return; + } + + if (event.shiftKey && key === 'i') { + event.preventDefault(); + void runAction('read'); + return; + } + + if (event.shiftKey && key === 'u') { + event.preventDefault(); + void runAction('unread'); + return; + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + clearCombo(); + }; + }, [isAuthenticated, list.items, runAction, selectedId, settings.shortcutsEnabled]); const previewHtml = useMemo(() => { - if (!detail?.htmlBody) { - return null; + if (!detail?.htmlBody) return null; + return toPreviewHtml(detail.htmlBody, hideRemoteImages, effectiveTheme); + }, [detail?.htmlBody, effectiveTheme, hideRemoteImages]); + + const selectThread = (id: number): void => { + setSelectedId(id); + if (settings.readingPane === 'none') { + setSinglePaneOpen(true); } - return toPreviewHtml(detail.htmlBody, hideRemoteImages); - }, [detail?.htmlBody, hideRemoteImages]); + }; - return ( -
- -
-
-
-
-
- - Private Mail Console + const applySearch = (next: string): void => { + setQuery(next.trim()); + setPage(1); + }; + + const applySearchToken = (token: string): void => { + const current = queryInput.trim(); + const next = current ? `${current} ${token}` : token; + setQueryInput(next); + applySearch(next); + }; + + const submitLogin = async (event: FormEvent): Promise => { + event.preventDefault(); + setIsLoginSubmitting(true); + setLoginError(null); + try { + const payload = await sendJson('/auth/login', { + username: loginUsername.trim(), + password: loginPassword, + }); + setIsAuthenticated(Boolean(payload.authenticated)); + setAuthMethod(payload.method === 'basic' || payload.method === 'session' ? payload.method : 'session'); + setAuthUser(payload.username ?? loginUsername.trim()); + setCsrfToken(payload.csrfToken ?? ''); + setLoginPassword(''); + if (typeof window !== 'undefined') { + window.location.replace('/'); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to sign in'; + setLoginError(message); + } finally { + setIsLoginSubmitting(false); + } + }; + + const logout = async (): Promise => { + try { + await sendJson('/auth/logout', {}, 'POST', csrfToken); + } catch (error) { + console.error(error); + } finally { + setIsAuthenticated(false); + setAuthMethod(null); + setAuthUser(null); + setCsrfToken(''); + if (typeof window !== 'undefined') { + window.location.replace('/login'); + } + } + }; + + const showDetailPane = settings.readingPane !== 'none' || singlePaneOpen; + const shouldShowLogin = !isAuthenticated || (isLoginRoute && authMethod === 'basic'); + + if (isAuthLoading) { + return ( +
+
+
+
AuthInbox
+
Validating session...
+
+
+
+ ); + } + + if (shouldShowLogin) { + return ( +
+ +
+
+
+
+
+
+ + Secure Access +
+

Sign in

+

Use your AuthInbox admin credentials.

+
+ +
+ + + {loginError ? ( +
+ {loginError} +
+ ) : null} +
-

Auth Inbox

-

Verification messages, raw source, and sanitized HTML preview.

+
+
+
+ ); + } + + return ( +
+ + +
+ +
+ +
AuthInbox Mail
+
+ +
{ + event.preventDefault(); + applySearch(queryInput); + }} + > + + setQueryInput(event.target.value)} + placeholder="Search mail (from:, subject:, is:, has:, in:, category:)" + className="flex-1 border-0 bg-transparent text-sm outline-none" + /> + {queryInput ? ( + + ) : null} + + +
+ {authUser ? `@${authUser}` : ''} + + + +
+
+ +
+
- -
- - - - - Mail List - -
Page {page} / {totalPages}
-
- - {listError ?
{listError}
: null} - -
-
- - - - - - - - - - - {isListLoading ? ( - Array.from({ length: 6 }).map((_, index) => ( - - - - - - - )) + + +
+
+
+
+
{list.total} conversations
+
+ + + + +
+
+
+ {CATEGORY_TABS.map((tab) => ( + + ))} +
+
+ +
+ {(settings.readingPane !== 'none' || !singlePaneOpen) && ( +
+
+ {listError ? ( +
{listError}
+ ) : isListLoading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+ ))} +
) : list.items.length === 0 ? ( -
- - +
No emails match this view.
) : ( list.items.map((item) => { - const active = item.id === selectedMailId; + const selected = item.id === selectedId; + const checked = selectedIds.includes(item.id); return ( - selectThread(item.id)} className={cn( - 'cursor-pointer bg-transparent transition-colors hover:bg-[#1a1a1a]', - active && 'bg-[#252525]' + 'gmail-thread-row mx-1 my-1 flex cursor-pointer items-start gap-2 rounded-xl px-3 transition-all', + rowDensityClass(settings.density), + selected && 'bg-primary/15 ring-1 ring-primary/30', + !selected && 'hover:bg-accent', + !item.isRead && 'font-medium' )} - onClick={() => setSelectedMailId(item.id)} > - - - - - + { + event.stopPropagation(); + setSelectedIds((prev) => (checked ? prev.filter((id) => id !== item.id) : [...prev, item.id])); + }} + className="mt-1 h-4 w-4 rounded border accent-primary" + /> + +
+
+
{item.fromOrg || item.fromAddr || 'Unknown sender'}
+
{formatDate(item.createdAt)}
+
+
+ {item.subject || item.topic || '(No subject)'} + - + {item.snippet || 'No preview available'} +
+
+ {item.category} + {item.hasCode ? Code : null} +
+
+ ); }) )} - -
FromToSubject / TopicTime
- No extracted mails available. -
{item.fromOrg || item.fromAddr || '-'}{item.toAddr || '-'}{item.subject || item.topic || '-'}{formatDate(item.createdAt)}
-
-
- -
- - -
-
- - - - Mail Detail - - - {detailError ?
{detailError}
: null} - - {!selectedMailId ? ( -
Select one row from the list to inspect details.
- ) : isDetailLoading ? ( -
-
-
-
-
- ) : detail ? ( - <> -
-
From: {detail.fromOrg || detail.fromAddr || '-'}
-
To: {detail.toAddr || '-'}
-
Subject: {detail.subject || '-'}
-
Received: {formatDate(detail.createdAt)}
-
+
+
+ +
Page {page} / {totalPages}
+ +
+ + )} - - - Extracted - Raw Email - Rendered - - - -
-
Topic: {detail.topic || '-'}
- {(() => { - const parsedCode = codeAndLink(detail.code); - return ( - <> -
- Code: - {parsedCode.code ? ( - parsedCode.code.startsWith('http') ? ( - - ) : ( - {parsedCode.code} - ) - ) : '-'} -
-
- Link: - {parsedCode.link ? ( - - ) : '-'} -
- - ); - })()} + {showDetailPane && ( +
+ {settings.readingPane === 'none' && singlePaneOpen ? ( +
+
- - - -
-											{detail.raw || 'No raw email payload saved.'}
-										
-
- - -
-
HTML is sanitized before preview and loaded in a sandboxed iframe.
- + ) : null} + + {detailError ? ( +
{detailError}
+ ) : !selectedId ? ( +
Select a thread to inspect details.
+ ) : isDetailLoading ? ( +
+
+
+
+ ) : detail ? ( +
+
+
+ + {detail.category} + {detail.hasCode ? code extracted : null} +
+

{detail.subject || detail.topic || '(No subject)'}

+
From: {detail.fromOrg || detail.fromAddr || '-'}
+
To: {detail.toAddr || '-'}
+
Received: {formatDateLong(detail.createdAt)}
+
- {previewHtml ? ( -