From f0316129963bf43e5cd0195724d39b414299dc95 Mon Sep 17 00:00:00 2001 From: TooonyChen <26252739+TooonyChen@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:47:08 +1200 Subject: [PATCH] feat: Hono rewrite, multi-user grants, remote MCP server --- .gitignore | 4 +- README.md | 278 ++++------- migrations/0001_baseline.sql | 30 ++ migrations/0002_multi_user.sql | 44 ++ package.json | 19 +- pnpm-lock.yaml | 696 ++++++++++++++++++++++++++ pnpm-workspace.yaml | 4 + src/app.ts | 57 +++ src/email/handler.ts | 98 ++++ src/{ => email}/rpcEmail.ts | 8 +- src/index.ts | 709 +-------------------------- src/mcp/server.ts | 130 +++++ src/middleware/auth.ts | 38 ++ src/routes/admin.ts | 114 +++++ src/routes/auth.ts | 80 +++ src/routes/keys.ts | 41 ++ src/routes/mails.ts | 46 ++ src/services/auth.ts | 114 +++++ src/services/classify.ts | 204 ++++++++ src/services/mail.ts | 176 +++++++ src/services/mime.ts | 108 ++++ src/types.ts | 65 +++ tsconfig.json | 117 +---- web/src/App.tsx | 648 +++--------------------- web/src/api.ts | 122 +++++ web/src/components/CategoryBadge.tsx | 28 ++ web/src/components/ui/input.tsx | 22 + web/src/components/ui/label.tsx | 16 + web/src/pages/AdminPage.tsx | 366 ++++++++++++++ web/src/pages/InboxPage.tsx | 393 +++++++++++++++ web/src/pages/KeysPage.tsx | 177 +++++++ web/src/pages/LoginPage.tsx | 103 ++++ 32 files changed, 3484 insertions(+), 1571 deletions(-) create mode 100644 migrations/0001_baseline.sql create mode 100644 migrations/0002_multi_user.sql create mode 100644 src/app.ts create mode 100644 src/email/handler.ts rename src/{ => email}/rpcEmail.ts (87%) create mode 100644 src/mcp/server.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/routes/admin.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/keys.ts create mode 100644 src/routes/mails.ts create mode 100644 src/services/auth.ts create mode 100644 src/services/classify.ts create mode 100644 src/services/mail.ts create mode 100644 src/services/mime.ts create mode 100644 src/types.ts create mode 100644 web/src/api.ts create mode 100644 web/src/components/CategoryBadge.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/pages/AdminPage.tsx create mode 100644 web/src/pages/InboxPage.tsx create mode 100644 web/src/pages/KeysPage.tsx create mode 100644 web/src/pages/LoginPage.tsx diff --git a/.gitignore b/.gitignore index b4e2520..0a35f53 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* +.DS_Store # Diagnostic reports (https://nodejs.org/api/report.html) @@ -64,6 +65,7 @@ web_modules/ # Optional npm cache directory .npm +.pnpm-store/ # Optional eslint cache @@ -175,4 +177,4 @@ wrangler.toml # local db exports — may contain real email data *-data.sql -*-backup.sql \ No newline at end of file +*-backup.sql diff --git a/README.md b/README.md index 8d91298..1367981 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,124 @@ -# Auth Inbox 📬 +# AuthInbox v2 骨架 -[English](https://github.com/TooonyChen/AuthInbox/blob/main/README.md) | [简体中文](https://github.com/TooonyChen/AuthInbox/blob/main/README_CN.md) +Hono 重构 + 多用户/授权 + Remote MCP Server。本目录是可直接落进现有仓库的骨架,前端部分需要你自己把 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 — all protected by Basic Auth. +## 目录结构 -Don't want ads and spam in your main inbox? Need a bunch of alternative addresses for signups? Try this **secure**, **serverless**, **lightweight** service! - -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox) - -```mermaid -flowchart LR - A([Your Inboxes]) --> B[Email Worker] - B --> C{Promotional?} - C -->|Yes| D[Discard] - C -->|No| E[AI Extraction] - E --> F[(D1 Database)] - F --> G[React Dashboard] - F --> H([Bark iOS Push]) +``` +migrations/ + 0001_baseline.sql # 现有 schema, 幂等 + 0002_multi_user.sql # users / api_keys / grants / category 列 +src/ + index.ts # WorkerEntrypoint 壳: fetch → Hono, email → handler + app.ts # Hono app + 认证边界 + types.ts # Env / AppEnv / 分类枚举 + middleware/auth.ts # sessionAuth / requireAdmin / apiKeyAuth + routes/ + auth.ts # /api/auth: setup, login, logout, me + mails.ts # /api/mails: 列表 + 详情 (自动按用户过滤) + admin.ts # /api/admin: users + grants 管理 + keys.ts # /api/keys: 自助签发 MCP API key + services/ + auth.ts # PBKDF2 / JWT / API key + mail.ts # visibleMails — 唯一的权限过滤查询层 + classify.ts # LLM 提取 (prompt 加了 category) + mime.ts # 旧版 MIME 工具原样迁移 + email/ + handler.ts # email() 逻辑, 写入 category + rpcEmail.ts # 原样保留 + mcp/server.ts # /mcp: list_addresses / list_codes / get_latest_code / wait_for_code ``` ---- +## 从现有仓库迁移 -## Table of Contents 📑 - -- [Features](#features-) -- [Technologies Used](#technologies-used-) -- [Installation](#installation-) -- [License](#license-) -- [Screenshots](#screenshots-) - ---- - -## Features ✨ - -- **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. -- **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. -- **Database**: Stores all raw emails and AI-extracted results in Cloudflare D1. - ---- - -## Technologies Used 🛠️ - -- **Cloudflare Workers** — Serverless runtime for email handling and API. -- **Cloudflare D1** — SQLite-compatible serverless database. -- **Cloudflare Email Routing** — Routes incoming emails to the Worker. -- **React 18 + Vite + Tailwind CSS + shadcn/ui** — Frontend dashboard. -- **Any OpenAI-compatible / Anthropic AI provider** — Configurable via env vars; Gemini, OpenAI, DeepSeek, Groq, Anthropic, and more. -- **Bark API** — Optional iOS push notifications. -- **TypeScript** — End-to-end type safety. - ---- - -## Installation ⚙️ - -### Prerequisites - -- A [Google AI Studio API key](https://aistudio.google.com/) -- A domain bound to your [Cloudflare](https://dash.cloudflare.com/) account -- Cloudflare Account ID and API Token from [here](https://dash.cloudflare.com/profile/api-tokens) -- *(Optional)* [Bark App](https://bark.day.app/) for iOS push notifications - ---- - -### Option 1 — Deploy via GitHub Actions - -1. **Create D1 Database** - - Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Workers & Pages` → `D1 SQL Database` → `Create`. Name it `inbox-d1`. - - Open the database → `Console`, paste and run the contents of [`db/schema.sql`](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql). - - Copy the `database_id` for the next step. - -2. **Fork & Deploy** - - [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox) - - 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. - - Then go to `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`. - - After success, **delete the workflow logs** to avoid leaking your config. - -3. Jump to [Set Email Forwarding](#3-set-email-forwarding-). - ---- - -### Option 2 — Deploy via CLI - -1. **Clone and install** +1. 安装依赖: ```bash - git clone https://github.com/TooonyChen/AuthInbox.git - cd AuthInbox - pnpm install + pnpm add hono @hono/mcp @modelcontextprotocol/sdk zod ``` -2. **Create D1 database** +2. 设置 secret(JWT 签名用,随便一串长随机字符串): ```bash - pnpm wrangler d1 create inbox-d1 - pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql + openssl rand -hex 32 | wrangler secret put JWT_SECRET ``` - Copy the `database_id` from the output. +3. wrangler.toml 里删掉 `FrontEndAdminID` / `FrontEndAdminPassword`(已被 users 表取代),其余 vars 不变。 -3. **Configure** +4. 迁移数据库。首次使用 wrangler migrations 需要先建 `migrations/` 目录并让 wrangler 认领 0001 为已执行(现有库已经有这两张表,0001 是幂等的所以直接 apply 也安全): ```bash - cp wrangler.toml.example wrangler.toml + pnpm run db:migrate:remote ``` - Edit `wrangler.toml` — at minimum fill in: - - ```toml - [vars] - FrontEndAdminID = "your-username" - FrontEndAdminPassword = "your-password" - 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" - - [[d1_databases]] - binding = "DB" - database_name = "inbox-d1" - database_id = "" - ``` - - **`AI_API_FORMAT`** options: - - | Value | Endpoint | Compatible providers | - |---|---|---| - | `openai` | `/v1/chat/completions` | OpenAI, Gemini (OpenAI-compat), DeepSeek, Groq, Mistral, … | - | `responses` | `/v1/responses` | OpenAI Responses API | - | `anthropic` | `/v1/messages` | Anthropic Claude | - - **Common `AI_BASE_URL` values:** - ``` - OpenAI: https://api.openai.com - Gemini (OAI-compat): https://generativelanguage.googleapis.com/v1beta/openai - Anthropic: https://api.anthropic.com - DeepSeek: https://api.deepseek.com - Groq: https://api.groq.com/openai - ``` - - **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`. - -4. **Build and deploy** +5. 部署后创建第一个 admin(users 表为空时此端点才可用,之后永久关闭): ```bash - pnpm run deploy + curl -X POST https://your.domain/api/auth/setup \ + -H 'Content-Type: application/json' \ + -d '{"username":"tony","password":"一个强密码"}' ``` - Output: `https://auth-inbox..workers.dev` +## 前端 (web/, 已完成) ---- +沿用原有主题(近黑 + 薄荷绿 #5fe0c0 + Manrope / IBM Plex Mono + 左上光晕),零新依赖,新增两个 shadcn 组件(input、label)。 -### 3. Set Email Forwarding ✉️ +- `src/api.ts`: 统一 fetch 层、类型、分类常量(与后端 MAIL_CATEGORIES 同步维护) +- `src/App.tsx`: 挂载时 GET `/api/auth/me`,401 进登录页;顶部导航按角色渲染,右上角显示当前用户和角色 +- `src/pages/LoginPage.tsx`: 登录页。会先查 GET `/api/auth/setup`,users 表为空时自动切成"创建 admin 账号"模式,建号后直接登录,不用手动 curl setup 接口 +- `src/pages/InboxPage.tsx`: 列表加了分类徽章列和发件方搜索(回车触发,走 `service` 参数)。详情页按角色分叉:admin 保留 Extracted / Raw / Rendered 三个 tab(DOMPurify + sandboxed iframe 逻辑原样保留);user 只渲染 Extracted 面板,底部一行说明原文仅 admin 可见 +- `src/pages/KeysPage.tsx`: 自助创建 / 吊销 API key,明文只展示一次(带复制按钮),右侧卡片给出 MCP server URL 和 Claude Code 接入命令(自动取当前域名) +- `src/pages/AdminPage.tsx`: 左卡片用户管理(建号、删号,不能删自己),右卡片授权管理(选用户、填 GLOB pattern、点选分类)。选中敏感分类时出现红色确认框,对应后端的 `allow_sensitive`,不勾的话服务端会把敏感分类从 grant 里剔除 +- 分类徽章配色:login_code 用主题薄荷绿,registration 天蓝,payment 琥珀,敏感类暗红,legacy 虚线灰 -Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Websites` → `` → `Email` → `Email Routing` → `Routing Rules`. +旧的 `src/index.html` 服务端兜底页在这版里去掉了,因为它无法感知用户身份。如果你想保留 no-JS 兜底,可以在 `app.ts` 的 SPA fallback 前加一个 session 校验的服务端渲染路由。 -**Catch-all** (forwards all addresses to the Worker): -![image](https://github.com/user-attachments/assets/53e5a939-6b03-4ca6-826a-7a5f02f361ac) +## 授权模型 -**Custom address** (forwards a specific address): -![image](https://github.com/user-attachments/assets/b0d0ab94-c2ad-4870-ac08-d53e64b2c880) +- grant = (user, 地址 pattern, 允许的分类, 是否放行敏感类) +- pattern 用 SQLite GLOB:`netflix@mail.tony.dev` 精确,`*@mail.tony.dev` 通配 +- 分类由 LLM 在提取时打标:`login_code` / `registration` / `password_reset` / `account_security` / `payment` / `other` +- `password_reset` 和 `account_security` 是敏感类:grant 里就算写了,`allow_sensitive = 0` 时服务层也会剔除 +- 迁移前的历史邮件 category 为 `legacy`,永远只有 admin 可见 +- 普通用户在任何接口(含 MCP)都拿不到邮件原文 raw,只能看到提取后的结构化结果 -### 4. Done! 🎉 +给 user 开一个典型 grant: -Visit your Worker URL, log in with the credentials you set, and start receiving verification emails. +```bash +curl -X POST https://your.domain/api/admin/grants \ + -H 'Content-Type: application/json' -b 'authinbox_session=...' \ + -d '{ + "userId": 2, + "addressPattern": "*@mail.tony.dev", + "allowedCategories": ["login_code", "registration"] + }' +``` ---- +这样 user 2 能看到发到该域名任何地址的 Netflix 登录验证码,但看不到 Netflix 改密码邮件。 -## License 📜 +## MCP 接入 -[MIT License](LICENSE) +1. 用户登录 web 后 POST `/api/keys` 拿到 `aik_xxx`(只显示一次)。 +2. Claude Code: ---- + ```bash + claude mcp add --transport http authinbox https://your.domain/mcp \ + --header "Authorization: Bearer aik_xxx" + ``` -## Screenshots 📸 +3. Tools: + - `list_addresses`: 该 key 对应用户可见的收件地址 + - `list_codes` / `get_latest_code`: 查验证码 + - `wait_for_code`: 阻塞等新码(最长 55s,只返回调用之后到达的邮件),agent 注册流程的关键工具 -![Mail list and extracted code](img/Group%201.png) +claude.ai 的远程连接器要求 OAuth,这版先不做;以后要接的话在 `/mcp` 前面套 Cloudflare 的 `workers-oauth-provider` 即可,tool 层不用动。 -![Mail detail with verification code](img/Group%202.png) +## 验证清单 -![Rendered HTML email preview](img/Group%203.png) - ---- - -## Acknowledgements 🙏 - -- **Cloudflare Workers** for the serverless platform. -- **Google Gemini AI** for intelligent email extraction. -- **Bark** for real-time notifications. -- **shadcn/ui** for the component library. -- **Open Source Community** for the inspiration. - ---- - -## TODO 📝 - -- [x] GitHub Actions deployment -- [x] OpenAI fallback support -- [x] React dashboard with shadcn/ui -- [x] Promotional email filter (skip ads before LLM) -- [x] Raw email viewer + sandboxed HTML preview -- [ ] Regex-based extraction as an AI-free option -- [ ] Multi-user support -- [ ] More notification methods (Slack, webhook, etc.) -- [ ] Sending email support +- [ ] 旧邮件进件流程不变:发一封测试邮件,raw_mails 和 code_mails 都有记录,category 有值 +- [ ] 推广邮件仍然在 LLM 之前被拦截 +- [ ] user 登录后 `/api/mails` 只返回 grant 范围内、非敏感分类的邮件 +- [ ] user 请求 `/api/mails/:id` 越权时返回 404 +- [ ] user 的 MCP key 调 `list_codes` 结果与 web 端一致 +- [ ] `wait_for_code` 期间发送邮件,能在轮询里被捞到 +- [ ] Bark 推送不变 diff --git a/migrations/0001_baseline.sql b/migrations/0001_baseline.sql new file mode 100644 index 0000000..b757fbc --- /dev/null +++ b/migrations/0001_baseline.sql @@ -0,0 +1,30 @@ +-- Migration number: 0001 +-- Baseline: 与现有生产库一致。IF NOT EXISTS 保证在已有 D1 上执行为 no-op。 +-- 新库从零建也能跑。 + +CREATE TABLE IF NOT EXISTS raw_mails ( + id INTEGER PRIMARY KEY, + message_id TEXT UNIQUE, + from_addr TEXT, + to_addr TEXT, + subject TEXT, + raw TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + processed INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS code_mails ( + id INTEGER PRIMARY KEY, + message_id TEXT UNIQUE, + from_addr TEXT, + from_org TEXT, + to_addr TEXT, + topic TEXT, + code TEXT, + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (message_id) REFERENCES raw_mails(message_id) +); + +CREATE INDEX IF NOT EXISTS idx_raw_message_id ON raw_mails (message_id); +CREATE INDEX IF NOT EXISTS idx_code_message_id ON code_mails (message_id); diff --git a/migrations/0002_multi_user.sql b/migrations/0002_multi_user.sql new file mode 100644 index 0000000..19937ec --- /dev/null +++ b/migrations/0002_multi_user.sql @@ -0,0 +1,44 @@ +-- Migration number: 0002 +-- 多用户 + 授权模型 + 邮件分类 + +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, -- 格式: pbkdf2$$$ + role TEXT NOT NULL DEFAULT 'user' + CHECK (role IN ('admin', 'user')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- MCP / 程序化访问用。key 明文只在创建时返回一次,库里只存 SHA-256。 +CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_hash TEXT UNIQUE NOT NULL, -- SHA-256 hex of "aik_xxx" + name TEXT NOT NULL DEFAULT 'default', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME +); + +-- 核心授权表: admin 给 user 授权哪些收件地址 + 哪些分类可见。 +-- address_pattern 用 SQLite GLOB 语法: 'netflix@mail.example.com' 或 '*@mail.example.com' +-- allowed_categories 是 JSON array 字符串, 例如 '["login_code","registration"]' +-- allow_sensitive = 0 时, password_reset / account_security 即使写进 allowed_categories 也会被服务层剔除。 +CREATE TABLE grants ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + address_pattern TEXT NOT NULL, + allowed_categories TEXT NOT NULL DEFAULT '["login_code","registration"]', + allow_sensitive INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_grants_user ON grants (user_id); +CREATE INDEX idx_api_keys_hash ON api_keys (key_hash); + +-- 邮件分类列。历史数据统一落到 'legacy', 服务层视同敏感 (仅 admin 可见), +-- 避免旧邮件未分类就漏给普通用户。 +ALTER TABLE code_mails ADD COLUMN category TEXT NOT NULL DEFAULT 'legacy'; + +CREATE INDEX idx_code_mails_to_cat ON code_mails (to_addr, category); +CREATE INDEX idx_code_mails_created ON code_mails (created_at); diff --git a/package.json b/package.json index 4ebadce..3aadee6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth-inbox", - "version": "0.0.0", + "version": "2.0.0", "private": true, "scripts": { "build:web": "pnpm -C web run build", @@ -9,7 +9,15 @@ "dev": "wrangler dev", "start": "wrangler dev", "test": "vitest", - "cf-typegen": "wrangler types" + "cf-typegen": "wrangler types", + "db:migrate:local": "wrangler d1 migrations apply inbox-d1 --local", + "db:migrate:remote": "wrangler d1 migrations apply inbox-d1 --remote" + }, + "dependencies": { + "@hono/mcp": "^0.1.4", + "@modelcontextprotocol/sdk": "^1.17.0", + "hono": "^4.9.0", + "zod": "^3.25.0" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.5.2", @@ -18,5 +26,10 @@ "vitest": "2.0.5", "wrangler": "^4.73.0" }, - "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" + "packageManager": "pnpm@10.32.1", + "description": "Hono 重构 + 多用户/授权 + Remote MCP Server。本目录是可直接落进现有仓库的骨架,前端部分需要你自己把 Basic Auth 换成登录页(见下文)。", + "main": "index.js", + "keywords": [], + "author": "", + "license": "ISC" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d4fb2c..9f6b921 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,19 @@ settings: importers: .: + dependencies: + '@hono/mcp': + specifier: ^0.1.4 + version: 0.1.5(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(hono@4.12.28) + '@modelcontextprotocol/sdk': + specifier: ^1.17.0 + version: 1.29.0(zod@3.25.76) + hono: + specifier: ^4.9.0 + version: 4.12.28 + zod: + specifier: ^3.25.0 + version: 3.25.76 devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.5.2 @@ -715,6 +728,18 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@hono/mcp@0.1.5': + resolution: {integrity: sha512-q6Yurx9VUwVEpqnwVXtzIYaq4kgQgWWq9lYLM7NFS2W0sg1RzL+RdKh6jO4/dGyvBLKrahPd2v+NC6rr0XWBvQ==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.12.0 + hono: '>=4.0.0' + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -887,6 +912,16 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1276,6 +1311,10 @@ packages: '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -1285,6 +1324,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1324,6 +1374,10 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1333,10 +1387,22 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1380,9 +1446,25 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -1391,6 +1473,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1425,6 +1511,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1441,12 +1531,35 @@ packages: dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.313: resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.17.19: resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} engines: {node: '>=12'} @@ -1466,6 +1579,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1476,6 +1592,18 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -1484,10 +1612,26 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1504,9 +1648,21 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1519,6 +1675,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-source@2.0.12: resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} @@ -1537,14 +1701,45 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hono@4.12.28: + resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1565,6 +1760,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1579,6 +1777,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1587,6 +1788,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1624,6 +1831,18 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1635,6 +1854,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -1672,6 +1899,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -1695,13 +1926,28 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + ohash@1.1.6: resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1716,6 +1962,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1745,6 +1994,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -1798,9 +2051,25 @@ packages: printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -1825,6 +2094,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1849,9 +2122,16 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1868,6 +2148,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1880,6 +2171,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1911,6 +2218,10 @@ packages: stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1978,12 +2289,20 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2009,6 +2328,10 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2018,6 +2341,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@2.0.5: resolution: {integrity: sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2120,6 +2447,9 @@ packages: '@cloudflare/workers-types': optional: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2159,6 +2489,11 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2580,6 +2915,15 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@hono/mcp@0.1.5(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(hono@4.12.28)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + hono: 4.12.28 + + '@hono/node-server@1.19.14(hono@4.12.28)': + dependencies: + hono: 4.12.28 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2700,6 +3044,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.28) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.28 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3031,12 +3397,28 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3069,6 +3451,20 @@ snapshots: blake3-wasm@2.1.5: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3081,8 +3477,20 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001778: {} @@ -3132,12 +3540,25 @@ snapshots: confbox@0.1.8: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3160,6 +3581,8 @@ snapshots: defu@6.1.4: {} + depd@2.0.0: {} + detect-libc@2.1.2: {} devalue@4.3.3: {} @@ -3172,10 +3595,28 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.313: {} + encodeurl@2.0.0: {} + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.17.19: optionalDependencies: '@esbuild/android-arm': 0.17.19 @@ -3258,6 +3699,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} estree-walker@0.6.1: {} @@ -3266,6 +3709,14 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -3280,6 +3731,46 @@ snapshots: exit-hook@2.2.1: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3288,6 +3779,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.3: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3300,8 +3793,23 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + fraction.js@5.3.4: {} + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -3309,6 +3817,24 @@ snapshots: gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-source@2.0.12: dependencies: data-uri-to-buffer: 2.0.2 @@ -3326,12 +3852,36 @@ snapshots: glob-to-regexp@0.4.1: {} + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 + hono@4.12.28: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-signals@5.0.0: {} + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -3348,6 +3898,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-stream@3.0.0: {} isexe@2.0.0: {} @@ -3356,10 +3908,16 @@ snapshots: jiti@1.21.7: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} kleur@4.1.5: {} @@ -3390,6 +3948,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -3399,6 +3963,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@3.0.0: {} mimic-fn@4.0.0: {} @@ -3453,6 +4023,8 @@ snapshots: nanoid@3.3.11: {} + negotiator@1.0.0: {} + node-forge@1.3.3: {} node-releases@2.0.36: {} @@ -3467,12 +4039,24 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: {} + ohash@1.1.6: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 + parseurl@1.3.3: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -3481,6 +4065,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -3497,6 +4083,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -3542,8 +4130,27 @@ snapshots: printable-characters@1.0.42: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -3566,6 +4173,8 @@ snapshots: readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -3619,10 +4228,22 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -3636,6 +4257,33 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -3673,6 +4321,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -3695,6 +4371,8 @@ snapshots: as-table: 1.0.55 get-source: 2.0.12 + statuses@2.0.2: {} + std-env@3.10.0: {} stoppable@1.1.0: {} @@ -3774,10 +4452,18 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: {} + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} @@ -3802,6 +4488,8 @@ snapshots: dependencies: pathe: 2.0.3 + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -3810,6 +4498,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite-node@2.0.5(@types/node@25.5.0): dependencies: cac: 6.7.14 @@ -3939,6 +4629,8 @@ snapshots: - bufferutil - utf-8-validate + wrappy@1.0.2: {} + ws@8.18.0: {} ws@8.19.0: {} @@ -3966,4 +4658,8 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 02c9f93..0092d3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,6 @@ packages: - web +allowBuilds: + esbuild: true + sharp: true + workerd: true diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..44b84c6 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,57 @@ +import { Hono } from "hono"; +import type { AppEnv } from "./types"; +import { apiKeyAuth, requireAdmin, sessionAuth } from "./middleware/auth"; +import authRoutes from "./routes/auth"; +import mailRoutes from "./routes/mails"; +import adminRoutes from "./routes/admin"; +import keyRoutes from "./routes/keys"; +import mcpApp from "./mcp/server"; + +const app = new Hono(); + +// ---------- 认证边界 ---------- +// /api/auth/* 开放 (login/setup 本身不能要求登录) +// /api/* 其余全部要求 session +// /api/admin/* 额外要求 admin +// /mcp 走 API key +app.route("/api/auth", authRoutes); + +app.use("/api/mails/*", sessionAuth); +app.use("/api/mails", sessionAuth); +app.route("/api/mails", mailRoutes); + +app.use("/api/keys/*", sessionAuth); +app.use("/api/keys", sessionAuth); +app.route("/api/keys", keyRoutes); + +app.use("/api/admin/*", sessionAuth, requireAdmin); +app.route("/api/admin", adminRoutes); + +app.use("/mcp", apiKeyAuth); +app.route("/mcp", mcpApp); + +app.all("/api/*", (c) => c.json({ error: "Not found" }, 404)); + +// ---------- 静态资源 + SPA fallback ---------- +// 前端登录页需要公开可达, API 已在上面各自设卡, 所以资源本身不再加 Basic Auth。 +app.get("*", async (c) => { + if (!c.env.ASSETS) { + return c.text("Assets binding not configured", 500); + } + + const assetResponse = await c.env.ASSETS.fetch(c.req.raw); + if (assetResponse.status !== 404) { + return assetResponse; + } + + // SPA history fallback + const indexRequest = new Request(new URL("/index.html", c.req.url).toString(), c.req.raw); + const indexResponse = await c.env.ASSETS.fetch(indexRequest); + if (indexResponse.status !== 404) { + return indexResponse; + } + + return c.text("Not found", 404); +}); + +export default app; diff --git a/src/email/handler.ts b/src/email/handler.ts new file mode 100644 index 0000000..e6c0047 --- /dev/null +++ b/src/email/handler.ts @@ -0,0 +1,98 @@ +import type { Env } from "../types"; +import { extractMailInfo } from "../services/classify"; +import { isPromotionalEmail } from "../services/mime"; +import { RPCEmailMessage } from "./rpcEmail"; + +/* + * 架构边界 (沿袭旧版, 不可违反): + * 1. isPromotionalEmail() 必须先于任何 LLM 调用 + * 2. 每封进件必写 raw_mails, 只有 AI 提取结果写 code_mails + */ + +async function pushBark(env: Env, title: string, code: string): Promise { + const barkTokens = env.barkTokens + .replace(/^\[|\]$/g, "") + .split(",") + .map((token) => token.trim()); + + const encodedTitle = encodeURIComponent(title); + const encodedCode = encodeURIComponent(code); + + for (const token of barkTokens) { + const barkRequestUrl = `${env.barkUrl}/${token}/${encodedTitle}/${encodedCode}`; + try { + const res = await fetch(barkRequestUrl, { method: "GET" }); + if (!res.ok) { + console.error(`Bark push failed for token ${token}: ${res.status} ${res.statusText}`); + } + } catch (err) { + console.error(`Bark push error for token ${token}:`, err); + } + } +} + +export async function handleEmail(message: ForwardableEmailMessage, env: Env): Promise { + const useBark = env.UseBark.toLowerCase() === "true"; + + const rawEmail = + message instanceof RPCEmailMessage + ? String((message as RPCEmailMessage).rawEmail) + : await new Response(message.raw).text(); + const messageId = message.headers.get("Message-ID"); + const rawSubject = message.headers.get("Subject"); + + // 边界 2: 进件必写 raw_mails + const { success } = 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(); + + 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}`); + return; + } + + // 边界 1: 推广邮件在 LLM 之前拦截 + if (isPromotionalEmail(message.headers, rawEmail)) { + console.log(`Skipping promotional email from ${message.from}: ${rawSubject}`); + return; + } + + try { + const extracted = await extractMailInfo(env, rawEmail); + + if (!extracted) { + console.error("Failed to extract data from AI response after retries."); + return; + } + + if (extracted.codeExist !== 1) { + console.log("No code found in this email, skipping."); + return; + } + + const { title, code, topic, category } = extracted; + + const { success: codeMailSuccess } = await env.DB.prepare( + `INSERT INTO code_mails (from_addr, from_org, to_addr, code, topic, category, message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(message.from, title, message.to, code, topic, category, messageId) + .run(); + + if (!codeMailSuccess) { + message.setReject( + `Failed to save extracted code for message from ${message.from} to ${message.to}`, + ); + return; + } + + if (useBark) { + await pushBark(env, title!, code!); + } + } catch (e) { + console.error("Error calling AI or saving to database:", e); + } +} diff --git a/src/rpcEmail.ts b/src/email/rpcEmail.ts similarity index 87% rename from src/rpcEmail.ts rename to src/email/rpcEmail.ts index 1d06de3..9c041dd 100644 --- a/src/rpcEmail.ts +++ b/src/email/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; @@ -31,13 +31,13 @@ class RPCEmailMessage implements ForwardableEmailMessage { async forward(rcptTo: string, headers: Headers = new Headers()): Promise { console.log(`rpcEmail default implementation: Forwarding message to: ${rcptTo}, with headers:`, headers); - return { messageId: `rpc-forward-${Date.now()}` }; + return { success: true, messageId: "" } as EmailSendResult; } async reply(message: EmailMessage): Promise { console.log(`rpcEmail default implementation: Replying to: ${message}`); - return { messageId: `rpc-reply-${Date.now()}` }; + return { success: true, messageId: "" } as EmailSendResult; } } -export { RPCEmailMessage }; +export { RPCEmailMessage }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6321d51..bcdff6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,709 +1,32 @@ /* -index.ts -This is the main file for the Auth Inbox Email Worker. -created by: github@TooonyChen -created on: 2024 Oct 07 -Last updated: 2024 Oct 07 -*/ + * index.ts — AuthInbox v2 + * WorkerEntrypoint 外壳: fetch 交给 Hono, email 交给 handler, rpcEmail 保留。 + * Hono 只接管 HTTP; Email Workers 的 email() 和跨 Worker RPC 需要这个类。 + */ import { WorkerEntrypoint } from "cloudflare:workers"; -import { buildMcpBasicAuthConfigSnippet, handleMcpRequest, type McpMailDetail } from "./mcp"; -import { RPCEmailMessage } from "./rpcEmail"; - -type ApiFormat = "openai" | "responses" | "anthropic"; - -interface ProviderConfig { - baseUrl: string; - apiKey: string; - format: ApiFormat; - model: string; -} - -interface Env { - DB: D1Database; - ASSETS?: Fetcher; - FrontEndAdminID: string; - FrontEndAdminPassword: string; - UseBark: string; - barkTokens: string; - barkUrl: string; - // Primary AI provider - AI_BASE_URL: string; - AI_API_KEY: string; - AI_API_FORMAT: ApiFormat; - AI_MODEL: string; - // Fallback AI provider (all four must be set to enable fallback) - AI_FALLBACK_BASE_URL?: string; - AI_FALLBACK_API_KEY?: string; - AI_FALLBACK_API_FORMAT?: ApiFormat; - AI_FALLBACK_MODEL?: string; -} - -interface MailDetailRow { - id: number; - messageId: string | null; - fromOrg: string | null; - fromAddr: string | null; - toAddr: string | null; - topic: string | null; - code: string | null; - createdAt: string | null; - subject: string | null; - raw: string | null; -} - -interface MailDetailRecord extends MailDetailRow { - textBody: string | null; - htmlBody: string | null; -} - -// 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}"`); - } else { - console.log(`Assuming entire text is JSON: "${candidate}"`); - } - - try { - return JSON.parse(candidate); - } catch (parseError) { - console.error("JSON parsing error:", parseError); - console.log(`Problematic JSON Text: "${candidate}"`); - return null; - } -} - -// Remove trailing slashes to avoid double separators // 移除结尾斜杠防止重复拼接 -function normaliseBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ""); -} - -// Unified AI provider caller supporting openai / responses / anthropic formats -async function callProvider(config: ProviderConfig, prompt: string): Promise { - const base = normaliseBaseUrl(config.baseUrl); - let endpoint: string; - let body: unknown; - const headers: Record = { "Content-Type": "application/json" }; - - if (config.format === "openai") { - endpoint = `${base}/v1/chat/completions`; - headers["Authorization"] = `Bearer ${config.apiKey}`; - body = { - model: config.model, - temperature: 0, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: "You return only valid JSON that matches the requested schema." }, - { role: "user", content: prompt }, - ], - }; - } else if (config.format === "responses") { - endpoint = `${base}/v1/responses`; - headers["Authorization"] = `Bearer ${config.apiKey}`; - body = { - model: config.model, - input: [{ role: "user", content: prompt }], - text: { format: { type: "json_object" } }, - }; - } else { - // anthropic - endpoint = `${base}/v1/messages`; - headers["x-api-key"] = config.apiKey; - headers["anthropic-version"] = "2023-06-01"; - body = { - model: config.model, - max_tokens: 1024, - messages: [{ role: "user", content: prompt }], - }; - } - - let response: Response; - try { - response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(body) }); - } catch (err) { - console.error(`[callProvider:${config.format}] fetch error:`, err); - return null; - } - - if (!response.ok) { - console.error(`[callProvider:${config.format}] HTTP ${response.status} ${response.statusText}`); - return null; - } - - let payload: unknown; - try { - payload = await response.json(); - } catch { - console.error(`[callProvider:${config.format}] failed to parse JSON response`); - return null; - } - - // Extract text from response based on format - if (config.format === "openai") { - const content = (payload as any)?.choices?.[0]?.message?.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const part = content.find((p: any) => p?.type === "text" && typeof p.text === "string"); - return part?.text ?? null; - } - console.error("[callProvider:openai] unexpected response shape"); - return null; - } - - if (config.format === "responses") { - const text = (payload as any)?.output?.[0]?.content?.[0]?.text - ?? (payload as any)?.output?.[0]?.text; - if (typeof text === "string") return text; - console.error("[callProvider:responses] unexpected response shape"); - return null; - } - - // anthropic - const text = (payload as any)?.content?.[0]?.text; - if (typeof text === "string") return text; - console.error("[callProvider:anthropic] unexpected response shape"); - return null; -} - -function jsonResponse(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - }); -} - -function basicAuthUnauthorizedResponse(): Response { - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": "Basic realm=\"User Visible Realm\"", - }, - }); -} - -function parseBasicAuthCredentials(request: Request): { username: string; password: string } | null { - const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Basic ")) { - return null; - } - - try { - const decodedCredentials = atob(authHeader.substring("Basic ".length)); - const separatorIndex = decodedCredentials.indexOf(":"); - if (separatorIndex === -1) { - return null; - } - - return { - username: decodedCredentials.slice(0, separatorIndex), - password: decodedCredentials.slice(separatorIndex + 1), - }; - } catch { - return null; - } -} - -function isAdminAuthenticated(request: Request, env: Env): boolean { - const credentials = parseBasicAuthCredentials(request); - if (!credentials) { - return false; - } - - return credentials.username === env.FrontEndAdminID && credentials.password === env.FrontEndAdminPassword; -} - -function decodeQuotedPrintable(text: string): string { - return text - .replace(/=\r?\n/g, "") - .replace(/=([A-Fa-f0-9]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16))); -} - -function stripHtmlTags(text: string): string { - return text - .replace(//gi, "") - .replace(//gi, "") - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function isPromotionalEmail(headers: Headers, rawEmail: string): boolean { - // Bulk/marketing emails are legally required to carry these headers (CAN-SPAM, RFC 2369) - if (headers.get("List-Unsubscribe") || headers.get("List-ID") || headers.get("List-Post")) { - return true; - } - - const precedence = headers.get("Precedence")?.toLowerCase() ?? ""; - if (precedence === "bulk" || precedence === "list") { - return true; - } - - // Check raw headers section for campaign/bulk markers from known ESPs - const rawHeaders = rawEmail.slice(0, rawEmail.search(/\r?\n\r?\n/) + 1 || 4000); - if ( - /^X-Campaign(-ID)?:/im.test(rawHeaders) || - /^X-Mailer:\s*(mailchimp|sendgrid|klaviyo|brevo|sendinblue|constant.contact|hubspot)/im.test(rawHeaders) || - /^X-SFMC-Stack:/im.test(rawHeaders) || - /^X-Marketo-/im.test(rawHeaders) - ) { - return true; - } - - return false; -} - -function extractMailBodies(rawEmail: string): { textBody: string | null; htmlBody: string | null } { - // Try MIME multipart parsing first - const boundaryMatch = rawEmail.match(/boundary="?([^"\r\n;]+)"?/i); - if (boundaryMatch) { - const boundary = boundaryMatch[1].trim(); - const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const parts = rawEmail.split(new RegExp(`--${escapedBoundary}(?:--)?\\r?\\n?`)); - - let textBody: string | null = null; - let htmlBody: string | null = null; - - for (const part of parts) { - const trimmed = part.trim(); - if (!trimmed || trimmed === "--") continue; - - const headerBodyMatch = trimmed.match(/^([\s\S]*?)\r?\n\r?\n([\s\S]*)$/); - if (!headerBodyMatch) continue; - - const headers = headerBodyMatch[1]; - const body = headerBodyMatch[2].trim(); - if (!body) continue; - - const contentType = headers.match(/Content-Type:\s*([^\r\n;]+)/i)?.[1]?.trim().toLowerCase() ?? ""; - const encoding = headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)?.[1]?.trim().toLowerCase() ?? ""; - - let decoded = body; - if (encoding === "base64") { - try { - decoded = atob(body.replace(/\s/g, "")); - } catch { - decoded = body; - } - } else if (encoding === "quoted-printable") { - decoded = decodeQuotedPrintable(body); - } - - if (contentType.includes("text/html") && !htmlBody) { - htmlBody = decoded.trim(); - } else if (contentType.includes("text/plain") && !textBody) { - textBody = decoded.trim(); - } - } - - if (htmlBody || textBody) { - if (!textBody && htmlBody) textBody = stripHtmlTags(htmlBody); - return { textBody, htmlBody }; - } - } - - // Fallback: no MIME boundary, try regex approach - const htmlMatch = rawEmail.match(//i) ?? rawEmail.match(//i); - const htmlBody = htmlMatch ? decodeQuotedPrintable(htmlMatch[0]).trim() : null; - - const splitParts = rawEmail.split(/\r?\n\r?\n/); - const bodyText = splitParts.length > 1 ? splitParts.slice(1).join("\n\n") : rawEmail; - const decodedText = decodeQuotedPrintable(bodyText).trim(); - const textBody = decodedText ? decodedText : htmlBody ? stripHtmlTags(htmlBody) : null; - - return { textBody, htmlBody }; -} - -function toMailDetailRecord(row: MailDetailRow): MailDetailRecord { - const { textBody, htmlBody } = extractMailBodies(String(row.raw ?? "")); - return { - id: row.id, - messageId: row.messageId, - fromOrg: row.fromOrg, - fromAddr: row.fromAddr, - toAddr: row.toAddr, - topic: row.topic, - code: row.code, - createdAt: row.createdAt, - subject: row.subject ?? null, - raw: row.raw ?? null, - textBody, - htmlBody, - }; -} - -function toMcpMailDetail(record: MailDetailRecord): McpMailDetail { - return { - id: record.id, - messageId: record.messageId, - fromOrg: record.fromOrg, - fromAddr: record.fromAddr, - toAddr: record.toAddr, - subject: record.subject, - topic: record.topic, - code: record.code, - createdAt: record.createdAt, - textBody: record.textBody, - htmlBody: record.htmlBody, - }; -} - -async function getLatestMcpMailDetails(env: Env, limit: number): Promise { - const { results } = await env.DB.prepare( - ` - SELECT - c.id, - c.message_id AS messageId, - c.from_org AS fromOrg, - c.from_addr AS fromAddr, - c.to_addr AS toAddr, - c.topic, - c.code, - c.created_at AS createdAt, - r.subject, - r.raw - FROM code_mails c - LEFT JOIN raw_mails r ON r.message_id = c.message_id - ORDER BY c.created_at DESC, c.id DESC - LIMIT ? - ` - ) - .bind(limit) - .all(); - - return ((results ?? []) as unknown as MailDetailRow[]) - .map(toMailDetailRecord) - .map(toMcpMailDetail); -} +import app from "./app"; +import { handleEmail } from "./email/handler"; +import { RPCEmailMessage } from "./email/rpcEmail"; +import type { Env } from "./types"; export default class extends WorkerEntrypoint { - async fetch(request: Request): Promise { - const env: Env = this.env; - - if (!isAdminAuthenticated(request, env)) { - return basicAuthUnauthorizedResponse(); - } - - const url = new URL(request.url); - - if (url.pathname === "/mcp") { - return handleMcpRequest(request, (limit) => getLatestMcpMailDetails(env, limit)); - } - - if (url.pathname === "/api/mcp/config" && request.method === "GET") { - const mcpUrl = new URL("/mcp", request.url).toString(); - return new Response(JSON.stringify({ - mcpUrl, - configSnippet: buildMcpBasicAuthConfigSnippet(mcpUrl), - }), { - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - }, - }); - } - - 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 offset = (page - 1) * pageSize; - const { results } = await env.DB.prepare( - ` - SELECT - c.id, - c.message_id AS messageId, - c.from_org AS fromOrg, - c.from_addr AS fromAddr, - c.to_addr AS toAddr, - c.topic, - c.code, - c.created_at AS createdAt, - r.subject - FROM code_mails c - LEFT JOIN raw_mails r ON r.message_id = c.message_id - ORDER BY c.created_at DESC, c.id DESC - LIMIT ? OFFSET ? - ` - ) - .bind(pageSize, offset) - .all(); - - const totalResult = await env.DB.prepare("SELECT COUNT(*) AS total FROM code_mails").first<{ total: number }>(); - return jsonResponse({ - page, - pageSize, - total: Number(totalResult?.total ?? 0), - items: results ?? [], - }); - } - - const mailDetailMatch = url.pathname.match(/^\/api\/mails\/(\d+)$/); - if (mailDetailMatch && request.method === "GET") { - const mailId = Number.parseInt(mailDetailMatch[1], 10); - const row = await env.DB.prepare( - ` - SELECT - c.id, - c.message_id AS messageId, - c.from_org AS fromOrg, - c.from_addr AS fromAddr, - c.to_addr AS toAddr, - c.topic, - c.code, - c.created_at AS createdAt, - r.subject, - r.raw - FROM code_mails c - LEFT JOIN raw_mails r ON r.message_id = c.message_id - WHERE c.id = ? - LIMIT 1 - ` - ) - .bind(mailId) - .first(); - - if (!row) { - return jsonResponse({ error: "Mail not found" }, 404); - } - - const detail = toMailDetailRecord(row as MailDetailRow); - return jsonResponse({ - id: detail.id, - messageId: detail.messageId, - fromOrg: detail.fromOrg, - fromAddr: detail.fromAddr, - toAddr: detail.toAddr, - topic: detail.topic, - code: detail.code, - createdAt: detail.createdAt, - subject: detail.subject, - raw: detail.raw, - textBody: detail.textBody, - htmlBody: detail.htmlBody, - }); - } - - if (url.pathname.startsWith("/api/")) { - return jsonResponse({ error: "Not found" }, 404); - } - - if (env.ASSETS) { - const assetResponse = await env.ASSETS.fetch(request); - if (assetResponse.status !== 404) { - return assetResponse; - } - - 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); - if (indexResponse.status !== 404) { - return indexResponse; - } - } - } - - return new Response("ASSETS binding is required to serve the frontend.", { status: 500 }); + fetch(request: Request): Response | Promise { + return app.fetch(request, this.env, this.ctx); } - // Primary email handler // 主要邮件处理入口 async email(message: ForwardableEmailMessage): Promise { - const env: Env = this.env; - const useBark = env.UseBark.toLowerCase() === "true"; - - const primary: ProviderConfig = { - baseUrl: env.AI_BASE_URL, - apiKey: env.AI_API_KEY, - format: env.AI_API_FORMAT ?? "openai", - model: env.AI_MODEL, - }; - - const fallback: ProviderConfig | null = - env.AI_FALLBACK_BASE_URL && env.AI_FALLBACK_API_KEY && env.AI_FALLBACK_MODEL - ? { - baseUrl: env.AI_FALLBACK_BASE_URL, - apiKey: env.AI_FALLBACK_API_KEY, - format: env.AI_FALLBACK_API_FORMAT ?? "openai", - model: env.AI_FALLBACK_MODEL, - } - : null; - - // Pull raw email content // 获取原始邮件内容 - const rawEmail = - 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"); - - // Persist raw mail payload for auditing // 将原始邮件持久化以便审计 - const { success } = 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(); - - 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}`); - } - - // Skip promotional/bulk emails before hitting the LLM - if (isPromotionalEmail(message.headers, rawEmail)) { - console.log(`Skipping promotional email from ${message.from}: ${rawSubject}`); - 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 + return handleEmail(message, this.env); } - - 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 - } -`; - - try { - const maxRetries = 3; - let extractedData: Record | null = null; - - // Primary provider: up to 3 attempts - for (let attempt = 1; attempt <= maxRetries; attempt++) { - console.log(`[primary:${primary.format}] attempt ${attempt}`); - const text = await callProvider(primary, aiPrompt); - if (text) { - const parsed = extractJsonFromText(text); - if (parsed) { - extractedData = parsed; - console.log("[primary] extracted data:", extractedData); - break; - } - } - if (attempt < maxRetries) console.log("[primary] retrying..."); - else console.error("[primary] max retries reached"); - } - - // Fallback provider: one attempt if primary produced nothing and fallback is configured - if (!extractedData && fallback) { - console.log(`[fallback:${fallback.format}] attempting`); - const text = await callProvider(fallback, aiPrompt); - if (text) { - const parsed = extractJsonFromText(text); - if (parsed) { - extractedData = parsed; - console.log("[fallback] extracted data:", extractedData); - } else { - console.error("[fallback] failed to parse response"); - } - } else { - console.error("[fallback] provider returned nothing"); - } - } else if (!extractedData) { - console.error("[primary] failed and no fallback configured"); - } - - if (extractedData) { - // Only persist when a code exists // 仅在存在验证码时写入数据库 - if ((extractedData as any).codeExist === 1) { - const title = (extractedData as any).title || "Unknown Organization"; - const code = (extractedData as any).code || "No Code Found"; - const topic = (extractedData as any).topic || "No Topic Found"; - - // Store parsed metadata for UI display // 保存解析结果供前端展示 - const { success: codeMailSuccess } = await env.DB.prepare( - "INSERT INTO code_mails (from_addr, from_org, to_addr, code, topic, message_id) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(message.from, title, message.to, code, topic, messageId) - .run(); - - if (!codeMailSuccess) { - message.setReject( - `Failed to save extracted code for message from ${message.from} to ${message.to}` - ); - console.log( - `Failed to save extracted code for message from ${message.from} to ${message.to}` - ); - } - - if (useBark) { - // Fan-out Bark notifications for each token // 为每个 token 发送 Bark 推送 - const barkUrl = env.barkUrl; - const barkTokens = env.barkTokens - .replace(/^\[|\]$/g, "") - .split(",") - .map((token) => token.trim()); - - const barkUrlEncodedTitle = encodeURIComponent(title); - const barkUrlEncodedCode = encodeURIComponent(code); - - for (const token of barkTokens) { - const barkRequestUrl = `${barkUrl}/${token}/${barkUrlEncodedTitle}/${barkUrlEncodedCode}`; - - const barkResponse = await fetch(barkRequestUrl, { - method: "GET", - }); - - if (barkResponse.ok) { - console.log( - `Successfully sent notification to Bark for token ${token} for message from ${message.from} to ${message.to}` - ); - 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}` - ); - } - } - } - } else { - console.log("No code found in this email, skipping Bark notification."); - } - } else { - console.error("Failed to extract data from AI response after retries."); - } - } catch (e) { - console.error("Error calling AI or saving to database:", e); - } - } - - // Expose RPC helper for other workers // 暴露 RPC 接口供其他 Worker 调用 + // 保留给其他 Worker 的 RPC 入口, 行为与旧版一致 async rpcEmail(requestBody: string): Promise { - console.log(`Received RPC email , request body: ${requestBody}`); - const bodyObject = JSON.parse(requestBody) as { - from: string; - to: string; - rawEmail: string; - headers: Record; - }; - const headersObject = bodyObject.headers; - const headers = new Headers(headersObject); - const rpcEmailMessage: RPCEmailMessage = new RPCEmailMessage( + const bodyObject = JSON.parse(requestBody); + const headers = new Headers(bodyObject.headers); + const rpcEmailMessage = new RPCEmailMessage( bodyObject.from, bodyObject.to, bodyObject.rawEmail, - headers + headers, ); await this.email(rpcEmailMessage); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..d4507d2 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,130 @@ +import { Hono } from "hono"; +import { StreamableHTTPTransport } from "@hono/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { AppEnv, AuthedUser } from "../types"; +import { visibleAddresses, visibleMails } from "../services/mail"; + +/* + * Remote MCP server, Streamable HTTP, 无状态 (不需要 Durable Objects)。 + * 认证在路由层由 apiKeyAuth 完成, 这里拿到的 user 已经是校验过的身份。 + * 所有 tool 都走 services/mail.ts, 权限过滤和 REST 完全同源。 + * + * 客户端接入 (支持自定义 header 的客户端, 如 Claude Code): + * claude mcp add --transport http authinbox https://your.domain/mcp \ + * --header "Authorization: Bearer aik_xxx" + */ + +function textResult(data: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +function buildServer(db: D1Database, user: AuthedUser): McpServer { + const server = new McpServer({ name: "authinbox", version: "2.0.0" }); + + server.tool( + "list_addresses", + "List the inbox email addresses the current user is allowed to read. Use these addresses when signing up for services.", + {}, + async () => { + const addresses = await visibleAddresses(db, user); + return textResult({ addresses }); + }, + ); + + server.tool( + "list_codes", + "List recent verification codes/links visible to the current user, newest first. Optionally filter by recipient address or sender service name.", + { + to_addr: z.string().optional().describe("Exact recipient address, e.g. netflix@mail.example.com"), + service: z.string().optional().describe("Fuzzy match on sender org/address, e.g. 'netflix'"), + limit: z.number().int().min(1).max(50).default(10), + }, + async ({ to_addr, service, limit }) => { + const { items } = await visibleMails(db, user, { toAddr: to_addr, service, limit }); + return textResult({ + count: items.length, + mails: items.map((m) => ({ + id: m.id, + from: m.fromOrg ?? m.fromAddr, + to: m.toAddr, + topic: m.topic, + code: m.code, + category: m.category, + receivedAt: m.createdAt, + })), + }); + }, + ); + + server.tool( + "get_latest_code", + "Get the single most recent verification code/link for an address or service. Returns null if nothing is visible.", + { + to_addr: z.string().optional(), + service: z.string().optional(), + }, + async ({ to_addr, service }) => { + const { items } = await visibleMails(db, user, { toAddr: to_addr, service, limit: 1 }); + const m = items[0]; + return textResult( + m + ? { code: m.code, from: m.fromOrg ?? m.fromAddr, topic: m.topic, receivedAt: m.createdAt } + : { code: null }, + ); + }, + ); + + server.tool( + "wait_for_code", + "Block and wait for a NEW verification code to arrive at the given address. Use this right after triggering a signup/login email. Only returns codes received after this call started. Times out after timeout_s seconds.", + { + to_addr: z.string().describe("The address you expect the code at"), + service: z.string().optional().describe("Optionally narrow to a sender, e.g. 'netflix'"), + timeout_s: z.number().int().min(5).max(55).default(30), + }, + async ({ to_addr, service, timeout_s }) => { + const startedAt = Date.now(); + const pollIntervalMs = 3000; + + while (Date.now() - startedAt < timeout_s * 1000) { + const { items } = await visibleMails(db, user, { + toAddr: to_addr, + service, + sinceMs: startedAt, + limit: 1, + }); + if (items.length > 0) { + const m = items[0]; + return textResult({ + code: m.code, + from: m.fromOrg ?? m.fromAddr, + topic: m.topic, + category: m.category, + receivedAt: m.createdAt, + }); + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return textResult({ + code: null, + error: `Timed out after ${timeout_s}s. The email may not have arrived yet, or it was classified into a category you don't have access to. You can retry.`, + }); + }, + ); + + return server; +} + +// 挂载: app.route('/mcp', mcpApp), 外层已套 apiKeyAuth +const mcpApp = new Hono(); + +mcpApp.all("/", async (c) => { + const server = buildServer(c.env.DB, c.get("user")); + const transport = new StreamableHTTPTransport(); + await server.connect(transport); + return transport.handleRequest(c); +}); + +export default mcpApp; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..dbcfd3a --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,38 @@ +import { getCookie } from "hono/cookie"; +import { createMiddleware } from "hono/factory"; +import type { AppEnv } from "../types"; +import { findUserByApiKey, verifySession } from "../services/auth"; + +export const SESSION_COOKIE = "authinbox_session"; + +// Web 端: 从 HttpOnly cookie 读 session JWT +export const sessionAuth = createMiddleware(async (c, next) => { + const token = getCookie(c, SESSION_COOKIE); + if (!token) return c.json({ error: "Unauthorized" }, 401); + + const user = await verifySession(token, c.env.JWT_SECRET); + if (!user) return c.json({ error: "Unauthorized" }, 401); + + c.set("user", user); + await next(); +}); + +export const requireAdmin = createMiddleware(async (c, next) => { + if (c.get("user")?.role !== "admin") { + return c.json({ error: "Forbidden" }, 403); + } + await next(); +}); + +// MCP / 程序化访问: Authorization: Bearer aik_xxx +export const apiKeyAuth = createMiddleware(async (c, next) => { + const header = c.req.header("Authorization") ?? ""; + if (!header.startsWith("Bearer ")) { + return c.json({ error: "Missing bearer token" }, 401); + } + const user = await findUserByApiKey(c.env.DB, header.slice("Bearer ".length).trim()); + if (!user) return c.json({ error: "Invalid API key" }, 401); + + c.set("user", user); + await next(); +}); diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..06a42ac --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,114 @@ +import { Hono } from "hono"; +import type { AppEnv } from "../types"; +import { MAIL_CATEGORIES } from "../types"; +import { hashPassword } from "../services/auth"; + +const admin = new Hono(); + +// ---------- 用户管理 ---------- + +admin.get("/users", async (c) => { + const { results } = await c.env.DB.prepare( + "SELECT id, username, role, created_at AS createdAt FROM users ORDER BY id", + ).all(); + return c.json({ users: results ?? [] }); +}); + +admin.post("/users", async (c) => { + const body = await c.req + .json<{ username?: string; password?: string; role?: string }>() + .catch(() => null); + if (!body?.username || !body?.password || body.password.length < 8) { + return c.json({ error: "username and password (min 8 chars) required" }, 400); + } + const role = body.role === "admin" ? "admin" : "user"; + + const passwordHash = await hashPassword(body.password); + try { + const result = await c.env.DB.prepare( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + ) + .bind(body.username, passwordHash, role) + .run(); + return c.json({ id: result.meta.last_row_id, username: body.username, role }, 201); + } catch { + return c.json({ error: "Username already exists" }, 409); + } +}); + +admin.delete("/users/:id{[0-9]+}", async (c) => { + const targetId = Number.parseInt(c.req.param("id"), 10); + if (targetId === c.get("user").id) { + return c.json({ error: "Cannot delete yourself" }, 400); + } + await c.env.DB.prepare("DELETE FROM users WHERE id = ?").bind(targetId).run(); + return c.json({ ok: true }); +}); + +// ---------- Grants 管理 ---------- + +admin.get("/grants", async (c) => { + const userId = c.req.query("user_id"); + const stmt = userId + ? c.env.DB.prepare( + `SELECT g.id, g.user_id AS userId, u.username, g.address_pattern AS addressPattern, + g.allowed_categories AS allowedCategories, g.allow_sensitive AS allowSensitive, + g.created_at AS createdAt + FROM grants g JOIN users u ON u.id = g.user_id + WHERE g.user_id = ? ORDER BY g.id`, + ).bind(Number.parseInt(userId, 10)) + : c.env.DB.prepare( + `SELECT g.id, g.user_id AS userId, u.username, g.address_pattern AS addressPattern, + g.allowed_categories AS allowedCategories, g.allow_sensitive AS allowSensitive, + g.created_at AS createdAt + FROM grants g JOIN users u ON u.id = g.user_id ORDER BY g.id`, + ); + const { results } = await stmt.all(); + return c.json({ grants: results ?? [] }); +}); + +admin.post("/grants", async (c) => { + const body = await c.req + .json<{ + userId?: number; + addressPattern?: string; + allowedCategories?: string[]; + allowSensitive?: boolean; + }>() + .catch(() => null); + + if (!body?.userId || !body?.addressPattern || !Array.isArray(body.allowedCategories)) { + return c.json({ error: "userId, addressPattern, allowedCategories required" }, 400); + } + + // 分类白名单校验, 不接受未知分类 + const invalid = body.allowedCategories.filter( + (cat) => !(MAIL_CATEGORIES as readonly string[]).includes(cat), + ); + if (invalid.length > 0) { + return c.json({ error: `Unknown categories: ${invalid.join(", ")}` }, 400); + } + + const result = await c.env.DB.prepare( + `INSERT INTO grants (user_id, address_pattern, allowed_categories, allow_sensitive) + VALUES (?, ?, ?, ?)`, + ) + .bind( + body.userId, + body.addressPattern, + JSON.stringify(body.allowedCategories), + body.allowSensitive ? 1 : 0, + ) + .run(); + + return c.json({ id: result.meta.last_row_id }, 201); +}); + +admin.delete("/grants/:id{[0-9]+}", async (c) => { + await c.env.DB.prepare("DELETE FROM grants WHERE id = ?") + .bind(Number.parseInt(c.req.param("id"), 10)) + .run(); + return c.json({ ok: true }); +}); + +export default admin; diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..b620eb2 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,80 @@ +import { Hono } from "hono"; +import { deleteCookie, setCookie } from "hono/cookie"; +import type { AppEnv } from "../types"; +import { hashPassword, signSession, verifyPassword } from "../services/auth"; +import { SESSION_COOKIE, sessionAuth } from "../middleware/auth"; + +const auth = new Hono(); + +// 前端登录页用: 是否还没有任何用户 (需要走首次建号流程) +auth.get("/setup", async (c) => { + const count = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); + return c.json({ needed: (count?.n ?? 0) === 0 }); +}); + +// 首次部署引导: users 表为空时允许创建第一个 admin, 之后此端点永久关闭。 +// 替代旧版在 wrangler.toml 里写明文账号密码的方式。 +auth.post("/setup", async (c) => { + const count = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); + if ((count?.n ?? 0) > 0) { + return c.json({ error: "Setup already completed" }, 403); + } + + const body = await c.req.json<{ username?: string; password?: string }>().catch(() => null); + if (!body?.username || !body?.password || body.password.length < 8) { + return c.json({ error: "username and password (min 8 chars) required" }, 400); + } + + const passwordHash = await hashPassword(body.password); + await c.env.DB.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')") + .bind(body.username, passwordHash) + .run(); + + return c.json({ ok: true }); +}); + +auth.post("/login", async (c) => { + const body = await c.req.json<{ username?: string; password?: string }>().catch(() => null); + if (!body?.username || !body?.password) { + return c.json({ error: "username and password required" }, 400); + } + + const row = await c.env.DB.prepare( + "SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1", + ) + .bind(body.username) + .first<{ id: number; username: string; password_hash: string; role: "admin" | "user" }>(); + + // 用户不存在也走一次哈希, 避免时间侧信道暴露用户名是否存在 + const ok = row + ? await verifyPassword(body.password, row.password_hash) + : await verifyPassword(body.password, "pbkdf2$100000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").then(() => false); + + if (!row || !ok) { + return c.json({ error: "Invalid credentials" }, 401); + } + + const user = { id: row.id, username: row.username, role: row.role }; + const token = await signSession(user, c.env.JWT_SECRET); + + setCookie(c, SESSION_COOKIE, token, { + httpOnly: true, + secure: true, + sameSite: "Strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }); + + return c.json({ user }); +}); + +auth.post("/logout", (c) => { + deleteCookie(c, SESSION_COOKIE, { path: "/" }); + return c.json({ ok: true }); +}); + +auth.get("/me", sessionAuth, (c) => { + return c.json({ user: c.get("user") }); +}); + +export default auth; diff --git a/src/routes/keys.ts b/src/routes/keys.ts new file mode 100644 index 0000000..5b475a2 --- /dev/null +++ b/src/routes/keys.ts @@ -0,0 +1,41 @@ +import { Hono } from "hono"; +import type { AppEnv } from "../types"; +import { generateApiKey, sha256Hex } from "../services/auth"; + +// 每个登录用户可以给自己签发 API key, 供 MCP 客户端使用。 +// key 继承该用户的角色和 grants, 所以 user 的 key 永远看不到 admin 数据。 +const keys = new Hono(); + +keys.get("/", async (c) => { + const { results } = await c.env.DB.prepare( + `SELECT id, name, created_at AS createdAt, last_used_at AS lastUsedAt + FROM api_keys WHERE user_id = ? ORDER BY id DESC`, + ) + .bind(c.get("user").id) + .all(); + return c.json({ keys: results ?? [] }); +}); + +keys.post("/", async (c) => { + const body = await c.req.json<{ name?: string }>().catch(() => ({ name: undefined })); + const rawKey = generateApiKey(); + const keyHash = await sha256Hex(rawKey); + + const result = await c.env.DB.prepare( + "INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", + ) + .bind(c.get("user").id, keyHash, body?.name ?? "default") + .run(); + + // 明文只返回这一次 + return c.json({ id: result.meta.last_row_id, key: rawKey }, 201); +}); + +keys.delete("/:id{[0-9]+}", async (c) => { + await c.env.DB.prepare("DELETE FROM api_keys WHERE id = ? AND user_id = ?") + .bind(Number.parseInt(c.req.param("id"), 10), c.get("user").id) + .run(); + return c.json({ ok: true }); +}); + +export default keys; diff --git a/src/routes/mails.ts b/src/routes/mails.ts new file mode 100644 index 0000000..6e369a5 --- /dev/null +++ b/src/routes/mails.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; +import type { AppEnv } from "../types"; +import { visibleMailById, visibleMails } from "../services/mail"; +import { extractMailBodies } from "../services/mime"; + +const mails = new Hono(); + +// GET /api/mails?page=1&pageSize=20&to_addr=xxx&service=netflix +mails.get("/", async (c) => { + const user = c.get("user"); + const page = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1); + const pageSize = Math.min( + 100, + Math.max(1, Number.parseInt(c.req.query("pageSize") ?? "20", 10) || 20), + ); + + const { total, items } = await visibleMails(c.env.DB, user, { + toAddr: c.req.query("to_addr") || undefined, + service: c.req.query("service") || undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + + return c.json({ page, pageSize, total, items }); +}); + +// GET /api/mails/:id +mails.get("/:id{[0-9]+}", async (c) => { + const user = c.get("user"); + const mailId = Number.parseInt(c.req.param("id"), 10); + + const row = await visibleMailById(c.env.DB, user, mailId); + if (!row) { + // 越权和不存在返回同一个 404, 不泄露资源存在性 + return c.json({ error: "Mail not found" }, 404); + } + + // raw 只有 admin 拿得到 (mail service 已控制), body 解析同理 + const { textBody, htmlBody } = row.raw + ? extractMailBodies(row.raw) + : { textBody: null, htmlBody: null }; + + return c.json({ ...row, textBody, htmlBody }); +}); + +export default mails; diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..22d1add --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,114 @@ +import { sign, verify } from "hono/jwt"; +import type { AuthedUser, Role } from "../types"; + +const PBKDF2_ITERATIONS = 100_000; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 天 + +// ---------- 密码哈希 (Workers 原生 WebCrypto, 无第三方依赖) ---------- + +function toB64(buf: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buf))); +} + +function fromB64(b64: string): Uint8Array { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise { + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveBits"], + ); + return crypto.subtle.deriveBits( + { name: "PBKDF2", hash: "SHA-256", salt, iterations }, + keyMaterial, + 256, + ); +} + +export async function hashPassword(password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); + return `pbkdf2$${PBKDF2_ITERATIONS}$${toB64(salt.buffer)}$${toB64(hash)}`; +} + +export async function verifyPassword(password: string, stored: string): Promise { + const parts = stored.split("$"); + if (parts.length !== 4 || parts[0] !== "pbkdf2") return false; + const iterations = Number.parseInt(parts[1], 10); + const salt = fromB64(parts[2]); + const expected = fromB64(parts[3]); + const actual = new Uint8Array(await pbkdf2(password, salt, iterations)); + if (actual.length !== expected.length) return false; + // 常数时间比较 + let diff = 0; + for (let i = 0; i < actual.length; i++) diff |= actual[i] ^ expected[i]; + return diff === 0; +} + +// ---------- Session JWT (HttpOnly cookie 里带) ---------- + +interface SessionPayload { + sub: number; + username: string; + role: Role; + exp: number; + [key: string]: unknown; +} + +export async function signSession(user: AuthedUser, secret: string): Promise { + const payload: SessionPayload = { + sub: user.id, + username: user.username, + role: user.role, + exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS, + }; + return sign(payload, secret); +} + +export async function verifySession(token: string, secret: string): Promise { + try { + const payload = (await verify(token, secret, "HS256")) as unknown as SessionPayload; + return { id: Number(payload.sub), username: payload.username, role: payload.role }; + } catch { + return null; + } +} + +// ---------- API Key (MCP 用) ---------- + +export async function sha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function generateApiKey(): string { + const bytes = crypto.getRandomValues(new Uint8Array(24)); + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + return `aik_${hex}`; +} + +export async function findUserByApiKey(db: D1Database, rawKey: string): Promise { + if (!rawKey.startsWith("aik_")) return null; + const keyHash = await sha256Hex(rawKey); + const row = await db + .prepare( + `SELECT u.id, u.username, u.role, k.id AS key_id + FROM api_keys k JOIN users u ON u.id = k.user_id + WHERE k.key_hash = ? LIMIT 1`, + ) + .bind(keyHash) + .first<{ id: number; username: string; role: Role; key_id: number }>(); + if (!row) return null; + + // 更新 last_used_at, 失败不影响主流程 + db.prepare("UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?") + .bind(row.key_id) + .run() + .catch(() => {}); + + return { id: row.id, username: row.username, role: row.role }; +} diff --git a/src/services/classify.ts b/src/services/classify.ts new file mode 100644 index 0000000..d5b57b5 --- /dev/null +++ b/src/services/classify.ts @@ -0,0 +1,204 @@ +import type { Env, ProviderConfig, MailCategory } from "../types"; +import { MAIL_CATEGORIES } from "../types"; + +/* + * callProvider / extractJsonFromText 从旧 index.ts 原样迁移, + * 变化只有一处: prompt 增加 category 字段, extractMailInfo 返回强类型结果。 + */ + +export interface ExtractedMail { + codeExist: 0 | 1; + title?: string; + code?: string; + topic?: string; + category?: MailCategory; +} + +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(); + } + try { + return JSON.parse(candidate); + } catch (parseError) { + console.error("JSON parsing error:", parseError); + return null; + } +} + +function normaliseBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +async function callProvider(config: ProviderConfig, prompt: string): Promise { + const base = normaliseBaseUrl(config.baseUrl); + let endpoint: string; + let body: unknown; + const headers: Record = { "Content-Type": "application/json" }; + + if (config.format === "openai") { + endpoint = `${base}/v1/chat/completions`; + headers["Authorization"] = `Bearer ${config.apiKey}`; + body = { + model: config.model, + temperature: 0, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: "You return only valid JSON that matches the requested schema." }, + { role: "user", content: prompt }, + ], + }; + } else if (config.format === "responses") { + endpoint = `${base}/v1/responses`; + headers["Authorization"] = `Bearer ${config.apiKey}`; + body = { + model: config.model, + input: [{ role: "user", content: prompt }], + text: { format: { type: "json_object" } }, + }; + } else { + endpoint = `${base}/v1/messages`; + headers["x-api-key"] = config.apiKey; + headers["anthropic-version"] = "2023-06-01"; + body = { + model: config.model, + max_tokens: 1024, + messages: [{ role: "user", content: prompt }], + }; + } + + let response: Response; + try { + response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(body) }); + } catch (err) { + console.error(`[callProvider:${config.format}] fetch error:`, err); + return null; + } + + if (!response.ok) { + console.error(`[callProvider:${config.format}] HTTP ${response.status} ${response.statusText}`); + return null; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + console.error(`[callProvider:${config.format}] failed to parse JSON response`); + return null; + } + + if (config.format === "openai") { + const content = (payload as any)?.choices?.[0]?.message?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const part = content.find((p: any) => p?.type === "text" && typeof p.text === "string"); + return part?.text ?? null; + } + return null; + } + + if (config.format === "responses") { + const text = + (payload as any)?.output?.[0]?.content?.[0]?.text ?? (payload as any)?.output?.[0]?.text; + return typeof text === "string" ? text : null; + } + + const text = (payload as any)?.content?.[0]?.text; + return typeof text === "string" ? text : null; +} + +function buildPrompt(rawEmail: string): string { + return ` +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'). +4. A category classifying the email's purpose. + +"category" MUST be exactly one of: +- "login_code": a one-time code or link used to LOG IN to an existing account (2FA, OTP, magic link) +- "registration": verifying a NEW account signup (confirm email, activation link) +- "password_reset": resetting or changing a password +- "account_security": account security notices (email/phone change confirmation, new device login alert, account recovery) +- "payment": billing, receipts, payment confirmations +- "other": anything else that still contains a code or link + +Please provide the following information in JSON format: +{ + "title": "The organization or company that sent the email (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')", + "category": "login_code", + "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 +} +`; +} + +function coerceCategory(value: unknown): MailCategory { + if (typeof value === "string" && (MAIL_CATEGORIES as readonly string[]).includes(value)) { + return value as MailCategory; + } + // 分类器输出不合法时落到 other, 不落到敏感类, 也不落到 login_code + return "other"; +} + +export async function extractMailInfo(env: Env, rawEmail: string): Promise { + const primary: ProviderConfig = { + baseUrl: env.AI_BASE_URL, + apiKey: env.AI_API_KEY, + format: env.AI_API_FORMAT ?? "openai", + model: env.AI_MODEL, + }; + + const fallback: ProviderConfig | null = + env.AI_FALLBACK_BASE_URL && env.AI_FALLBACK_API_KEY && env.AI_FALLBACK_MODEL + ? { + baseUrl: env.AI_FALLBACK_BASE_URL, + apiKey: env.AI_FALLBACK_API_KEY, + format: env.AI_FALLBACK_API_FORMAT ?? "openai", + model: env.AI_FALLBACK_MODEL, + } + : null; + + const prompt = buildPrompt(rawEmail); + const maxRetries = 3; + let parsed: Record | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const text = await callProvider(primary, prompt); + if (text) { + parsed = extractJsonFromText(text); + if (parsed) break; + } + } + + if (!parsed && fallback) { + const text = await callProvider(fallback, prompt); + if (text) parsed = extractJsonFromText(text); + } + + if (!parsed) return null; + + if (parsed.codeExist !== 1) return { codeExist: 0 }; + + return { + codeExist: 1, + title: typeof parsed.title === "string" ? parsed.title : "Unknown Organization", + code: typeof parsed.code === "string" ? parsed.code : "No Code Found", + topic: typeof parsed.topic === "string" ? parsed.topic : "No Topic Found", + category: coerceCategory(parsed.category), + }; +} diff --git a/src/services/mail.ts b/src/services/mail.ts new file mode 100644 index 0000000..209d064 --- /dev/null +++ b/src/services/mail.ts @@ -0,0 +1,176 @@ +import type { AuthedUser } from "../types"; +import { SENSITIVE_CATEGORIES } from "../types"; + +/* + * 这是整个项目唯一的邮件可见性入口。 + * REST 路由和 MCP tools 都必须经过 visibleMails / visibleMailById, + * 权限过滤在 SQL WHERE 里完成, 不存在"先查全量再在内存里过滤"的路径。 + */ + +export interface MailQueryOpts { + toAddr?: string; // 精确匹配收件地址 + service?: string; // 模糊匹配 from_org / from_addr, 如 'netflix' + sinceMs?: number; // 只要这个时间戳 (epoch ms) 之后的 + limit?: number; + offset?: number; +} + +export interface MailRow { + id: number; + messageId: string | null; + fromOrg: string | null; + fromAddr: string | null; + toAddr: string | null; + topic: string | null; + code: string | null; + category: string; + createdAt: string | null; + subject: string | null; +} + +interface GrantRow { + address_pattern: string; + allowed_categories: string; + allow_sensitive: number; +} + +interface FilterResult { + clause: string; // 拼进 WHERE 的 SQL 片段, 已含括号 + binds: unknown[]; + empty: boolean; // 用户没有任何有效授权 → 直接短路返回空 +} + +// 把当前用户的 grants 展开成 SQL 条件。admin 返回恒真。 +async function buildPermissionFilter(db: D1Database, user: AuthedUser): Promise { + if (user.role === "admin") { + return { clause: "1 = 1", binds: [], empty: false }; + } + + const { results } = await db + .prepare("SELECT address_pattern, allowed_categories, allow_sensitive FROM grants WHERE user_id = ?") + .bind(user.id) + .all(); + + const grantClauses: string[] = []; + const binds: unknown[] = []; + + for (const g of results ?? []) { + let cats: string[]; + try { + cats = JSON.parse(g.allowed_categories); + } catch { + continue; // 配置损坏的 grant 直接跳过, 宁缺勿滥 + } + // 敏感分类兜底: 没有显式 allow_sensitive 就剔除, 即使 admin 手滑写进了 JSON + if (!g.allow_sensitive) { + cats = cats.filter((c) => !SENSITIVE_CATEGORIES.includes(c)); + } + // 'legacy' 永远不对 user 开放, 不管 allow_sensitive + cats = cats.filter((c) => c !== "legacy"); + if (cats.length === 0) continue; + + grantClauses.push( + `(c.to_addr GLOB ? AND c.category IN (${cats.map(() => "?").join(", ")}))`, + ); + binds.push(g.address_pattern, ...cats); + } + + if (grantClauses.length === 0) { + return { clause: "0 = 1", binds: [], empty: true }; + } + return { clause: `(${grantClauses.join(" OR ")})`, binds, empty: false }; +} + +export async function visibleMails( + db: D1Database, + user: AuthedUser, + opts: MailQueryOpts = {}, +): Promise<{ total: number; items: MailRow[] }> { + const perm = await buildPermissionFilter(db, user); + if (perm.empty) return { total: 0, items: [] }; + + const where: string[] = [perm.clause]; + const binds: unknown[] = [...perm.binds]; + + if (opts.toAddr) { + where.push("c.to_addr = ?"); + binds.push(opts.toAddr); + } + if (opts.service) { + where.push("(c.from_org LIKE ? OR c.from_addr LIKE ?)"); + binds.push(`%${opts.service}%`, `%${opts.service}%`); + } + if (opts.sinceMs) { + where.push("c.created_at >= datetime(?, 'unixepoch')"); + binds.push(Math.floor(opts.sinceMs / 1000)); + } + + const whereSql = where.join(" AND "); + const limit = Math.min(100, Math.max(1, opts.limit ?? 20)); + const offset = Math.max(0, opts.offset ?? 0); + + const countRow = await db + .prepare(`SELECT COUNT(*) AS total FROM code_mails c WHERE ${whereSql}`) + .bind(...binds) + .first<{ total: number }>(); + + const { results } = await db + .prepare( + `SELECT + c.id, c.message_id AS messageId, c.from_org AS fromOrg, + c.from_addr AS fromAddr, c.to_addr AS toAddr, + c.topic, c.code, c.category, c.created_at AS createdAt, + r.subject + FROM code_mails c + LEFT JOIN raw_mails r ON r.message_id = c.message_id + WHERE ${whereSql} + ORDER BY c.created_at DESC + LIMIT ? OFFSET ?`, + ) + .bind(...binds, limit, offset) + .all(); + + return { total: Number(countRow?.total ?? 0), items: results ?? [] }; +} + +// 详情页。raw 原文只对 admin 返回: 原始邮件正文里可能混有敏感内容, 分类器管不到它。 +export async function visibleMailById( + db: D1Database, + user: AuthedUser, + id: number, +): Promise<(MailRow & { raw: string | null }) | null> { + const perm = await buildPermissionFilter(db, user); + if (perm.empty) return null; + + const includeRaw = user.role === "admin"; + const row = await db + .prepare( + `SELECT + c.id, c.message_id AS messageId, c.from_org AS fromOrg, + c.from_addr AS fromAddr, c.to_addr AS toAddr, + c.topic, c.code, c.category, c.created_at AS createdAt, + r.subject${includeRaw ? ", r.raw" : ""} + FROM code_mails c + LEFT JOIN raw_mails r ON r.message_id = c.message_id + WHERE c.id = ? AND ${perm.clause} + LIMIT 1`, + ) + .bind(id, ...perm.binds) + .first(); + + if (!row) return null; + return { ...row, raw: row.raw ?? null }; +} + +// MCP list_addresses 用: 该用户实际可见的收件地址列表 (按 grant 展开去重) +export async function visibleAddresses(db: D1Database, user: AuthedUser): Promise { + const perm = await buildPermissionFilter(db, user); + if (perm.empty) return []; + + const { results } = await db + .prepare(`SELECT DISTINCT c.to_addr FROM code_mails c WHERE ${perm.clause} ORDER BY c.to_addr`) + .bind(...perm.binds) + .all<{ to_addr: string }>(); + + return (results ?? []).map((r) => r.to_addr).filter(Boolean); +} diff --git a/src/services/mime.ts b/src/services/mime.ts new file mode 100644 index 0000000..dddd68a --- /dev/null +++ b/src/services/mime.ts @@ -0,0 +1,108 @@ +/* + * 从旧 index.ts 原样迁移: quoted-printable 解码、HTML 去标签、 + * 推广邮件识别、MIME 正文提取。逻辑零改动。 + */ + +export function decodeQuotedPrintable(text: string): string { + return text + .replace(/=\r?\n/g, "") + .replace(/=([A-Fa-f0-9]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16))); +} + +export function stripHtmlTags(text: string): string { + return text + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function isPromotionalEmail(headers: Headers, rawEmail: string): boolean { + if (headers.get("List-Unsubscribe") || headers.get("List-ID") || headers.get("List-Post")) { + return true; + } + + const precedence = headers.get("Precedence")?.toLowerCase() ?? ""; + if (precedence === "bulk" || precedence === "list") { + return true; + } + + const rawHeaders = rawEmail.slice(0, rawEmail.search(/\r?\n\r?\n/) + 1 || 4000); + if ( + /^X-Campaign(-ID)?:/im.test(rawHeaders) || + /^X-Mailer:\s*(mailchimp|sendgrid|klaviyo|brevo|sendinblue|constant.contact|hubspot)/im.test( + rawHeaders, + ) || + /^X-SFMC-Stack:/im.test(rawHeaders) || + /^X-Marketo-/im.test(rawHeaders) + ) { + return true; + } + + return false; +} + +export function extractMailBodies(rawEmail: string): { + textBody: string | null; + htmlBody: string | null; +} { + const boundaryMatch = rawEmail.match(/boundary="?([^"\r\n;]+)"?/i); + if (boundaryMatch) { + const boundary = boundaryMatch[1].trim(); + const escapedBoundary = boundary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = rawEmail.split(new RegExp(`--${escapedBoundary}(?:--)?\\r?\\n?`)); + + let textBody: string | null = null; + let htmlBody: string | null = null; + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed || trimmed === "--") continue; + + const headerBodyMatch = trimmed.match(/^([\s\S]*?)\r?\n\r?\n([\s\S]*)$/); + if (!headerBodyMatch) continue; + + const headers = headerBodyMatch[1]; + const body = headerBodyMatch[2].trim(); + if (!body) continue; + + const contentType = + headers.match(/Content-Type:\s*([^\r\n;]+)/i)?.[1]?.trim().toLowerCase() ?? ""; + const encoding = + headers.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i)?.[1]?.trim().toLowerCase() ?? ""; + + let decoded = body; + if (encoding === "base64") { + try { + decoded = atob(body.replace(/\s/g, "")); + } catch { + decoded = body; + } + } else if (encoding === "quoted-printable") { + decoded = decodeQuotedPrintable(body); + } + + if (contentType.includes("text/html") && !htmlBody) { + htmlBody = decoded.trim(); + } else if (contentType.includes("text/plain") && !textBody) { + textBody = decoded.trim(); + } + } + + if (htmlBody || textBody) { + if (!textBody && htmlBody) textBody = stripHtmlTags(htmlBody); + return { textBody, htmlBody }; + } + } + + const htmlMatch = rawEmail.match(//i) ?? rawEmail.match(//i); + const htmlBody = htmlMatch ? decodeQuotedPrintable(htmlMatch[0]).trim() : null; + + const splitParts = rawEmail.split(/\r?\n\r?\n/); + const bodyText = splitParts.length > 1 ? splitParts.slice(1).join("\n\n") : rawEmail; + const decodedText = decodeQuotedPrintable(bodyText).trim(); + const textBody = decodedText ? decodedText : htmlBody ? stripHtmlTags(htmlBody) : null; + + return { textBody, htmlBody }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6fc770c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,65 @@ +export type ApiFormat = "openai" | "responses" | "anthropic"; + +export interface ProviderConfig { + baseUrl: string; + apiKey: string; + format: ApiFormat; + model: string; +} + +export interface Env { + DB: D1Database; + ASSETS?: Fetcher; + + // Auth + JWT_SECRET: string; // wrangler secret put JWT_SECRET + + // Bark push + UseBark: string; + barkTokens: string; + barkUrl: string; + + // Primary AI provider + AI_BASE_URL: string; + AI_API_KEY: string; + AI_API_FORMAT: ApiFormat; + AI_MODEL: string; + + // Fallback AI provider + AI_FALLBACK_BASE_URL?: string; + AI_FALLBACK_API_KEY?: string; + AI_FALLBACK_API_FORMAT?: ApiFormat; + AI_FALLBACK_MODEL?: string; +} + +export type Role = "admin" | "user"; + +export interface AuthedUser { + id: number; + username: string; + role: Role; +} + +// Hono generic: c.env 是 Env, c.get('user') 是 AuthedUser +export type AppEnv = { + Bindings: Env; + Variables: { + user: AuthedUser; + }; +}; + +// 邮件分类枚举。改这里要同步改 classify.ts 里的 prompt。 +export const MAIL_CATEGORIES = [ + "login_code", // 登录 / 2FA 验证码 + "registration", // 注册验证 + "password_reset", // 改密码 / 重置链接 (敏感) + "account_security", // 改绑邮箱、异地登录警告等 (敏感) + "payment", // 账单、扣款 + "other", +] as const; + +export type MailCategory = (typeof MAIL_CATEGORIES)[number]; + +// 这些分类默认只有 admin 可见。grant.allow_sensitive = 1 才对 user 放行。 +// 'legacy' 是迁移前的历史数据, 一律按敏感处理。 +export const SENSITIVE_CATEGORIES = ["password_reset", "account_security", "legacy"]; diff --git a/tsconfig.json b/tsconfig.json index 3e3c735..389c41c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,107 +1,14 @@ { - "compilerOptions": { - /* Visit https://aka.ms/tsconfig.json to read more about this file */ - - /* Projects */ - // "incremental": true, /* Enable incremental compilation */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - "lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, - "jsx": "react-jsx" /* Specify what JSX code is generated. */, - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ - // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - - /* Modules */ - "module": "es2022" /* Specify what module code is generated. */, - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "Bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ - "types": [ - "@cloudflare/workers-types/2023-07-01" - ] /* Specify type package names to be included without being referenced in a source file. */, - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - "resolveJsonModule": true /* Enable importing .json files */, - // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, - "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - "noEmit": true /* Disable emitting files from a compilation. */, - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, - "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, - // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - - /* Type Checking */ - "strict": true /* Enable all strict type-checking options. */, - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ - // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ - // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - }, - "exclude": ["test"], - "include": ["worker-configuration.d.ts", - "src/**/*.ts" - ] + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] } diff --git a/web/src/App.tsx b/web/src/App.tsx index 45db691..523071e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,275 +1,66 @@ -import { useEffect, useMemo, useState } from 'react'; -import DOMPurify from 'dompurify'; -import { Copy, Inbox, KeyRound, RefreshCw, ShieldCheck } 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'; +import { useEffect, useState } from 'react'; +import { LogOut, ShieldCheck } from 'lucide-react'; +import { Toaster, toast } from 'sonner'; +import { ApiError, getJson, postJson, type User } from '@/api'; +import { Button } from '@/components/ui/button'; +import { LoginPage } from '@/pages/LoginPage'; +import { InboxPage } from '@/pages/InboxPage'; +import { KeysPage } from '@/pages/KeysPage'; +import { AdminPage } from '@/pages/AdminPage'; +import { cn } from '@/lib/utils'; -const PAGE_SIZE = 20; - -type ViewMode = 'inbox' | 'mcp'; - -interface MailListItem { - id: number; - messageId: string | null; - fromOrg: string | null; - fromAddr: string | null; - toAddr: string | null; - topic: string | null; - code: string | null; - createdAt: string | null; - subject: string | null; -} - -interface MailListResponse { - page: number; - pageSize: number; - total: number; - items: MailListItem[]; -} - -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; - raw: string | null; - textBody: string | null; - htmlBody: string | null; -} - -interface McpConfigResponse { - mcpUrl: string; - configSnippet: string; -} - -function formatDate(value: string | null): string { - if (!value) { - return '-'; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); -} - -function codeAndLink(value: string | null): { code: string | null; link: string | null } { - if (!value) { - return { code: null, link: null }; - } - - 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 }; - } - - return { code: value.trim() || null, link: null }; -} - -function toPreviewHtml(htmlBody: string, hideRemoteImages: boolean): string { - const sanitized = DOMPurify.sanitize(htmlBody, { - USE_PROFILES: { html: true }, - FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'], - FORBID_ATTR: ['onerror', 'onload', 'onclick'], - }); - - const doc = new DOMParser().parseFromString(sanitized, 'text/html'); - if (hideRemoteImages) { - doc.querySelectorAll('img').forEach((img) => img.remove()); - } - doc.querySelectorAll('a').forEach((anchor) => { - anchor.setAttribute('target', '_blank'); - anchor.setAttribute('rel', 'noopener noreferrer'); - }); - - return ` - - - - - - -${doc.body.innerHTML} -`; -} - -async function requestJson(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { - ...init, - headers: { - Accept: 'application/json', - ...(init?.body ? { 'Content-Type': 'application/json' } : {}), - ...(init?.headers ?? {}), - }, - }); - - const text = await response.text(); - const payload = text ? safeParseJson(text) : null; - - if (!response.ok) { - if (typeof payload === 'string') { - throw new Error(payload); - } - - if (payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string') { - throw new Error(payload.error); - } - - throw new Error(`Request failed (${response.status})`); - } - - return payload as T; -} - -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return text; - } -} - -function buildPlaceholderMcpConfig(mcpUrl: string): string { - return JSON.stringify( - { - mcpServers: { - authInbox: { - url: mcpUrl, - headers: { - Authorization: 'Basic ', - }, - }, - }, - }, - null, - 2 - ); -} - -async function copyText(value: string, successMessage: string): Promise { - await navigator.clipboard.writeText(value); - toast.success(successMessage); -} +type View = 'inbox' | 'keys' | 'admin'; function App(): JSX.Element { - const [viewMode, setViewMode] = useState('inbox'); - const [page, setPage] = useState(1); - 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 [user, setUser] = useState(null); + const [isChecking, setIsChecking] = useState(true); + const [view, setView] = useState('inbox'); - const [detail, setDetail] = useState(null); - const [isDetailLoading, setIsDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); - const [hideRemoteImages, setHideRemoteImages] = useState(true); - - const [mcpConfig, setMcpConfig] = useState(null); - const [isMcpLoading, setIsMcpLoading] = useState(false); - const [mcpError, setMcpError] = useState(null); - const [hasAttemptedMcpConfigLoad, setHasAttemptedMcpConfigLoad] = useState(false); - - const totalPages = Math.max(1, Math.ceil(list.total / PAGE_SIZE)); - const defaultMcpUrl = useMemo( - () => (typeof window !== 'undefined' ? `${window.location.origin}/mcp` : '/mcp'), - [] - ); - - const refreshList = (): void => { - setIsListLoading(true); - setListError(null); - requestJson(`/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; - }); - }) + useEffect(() => { + getJson<{ user: User }>('/api/auth/me') + .then((payload) => setUser(payload.user)) .catch((error: unknown) => { - setListError(error instanceof Error ? error.message : 'Unable to load mail list.'); + if (!(error instanceof ApiError && error.status === 401)) { + toast.error('Unable to reach the server'); + } }) + .finally(() => setIsChecking(false)); + }, []); + + const logout = (): void => { + postJson('/api/auth/logout') + .catch(() => {}) .finally(() => { - setIsListLoading(false); + setUser(null); + setView('inbox'); }); }; - const refreshMcpConfig = (): void => { - if (isMcpLoading) { - return; - } + if (isChecking) { + return ( +
+
+ + Auth Inbox +
+
+ ); + } - setHasAttemptedMcpConfigLoad(true); - setIsMcpLoading(true); - setMcpError(null); - requestJson('/api/mcp/config') - .then((payload) => { - setMcpConfig(payload); - }) - .catch((error: unknown) => { - setMcpError(error instanceof Error ? error.message : 'Unable to load MCP config.'); - }) - .finally(() => { - setIsMcpLoading(false); - }); - }; + if (!user) { + return ( + <> + + + + ); + } - useEffect(() => { - refreshList(); - }, [page]); - - useEffect(() => { - if (viewMode === 'mcp' && !hasAttemptedMcpConfigLoad) { - refreshMcpConfig(); - } - }, [viewMode, hasAttemptedMcpConfigLoad]); - - useEffect(() => { - if (!selectedMailId) { - setDetail(null); - return; - } - - setHideRemoteImages(true); - setDetailError(null); - setIsDetailLoading(true); - - requestJson(`/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]); - - const previewHtml = useMemo(() => { - if (!detail?.htmlBody) { - return null; - } - return toPreviewHtml(detail.htmlBody, hideRemoteImages); - }, [detail?.htmlBody, hideRemoteImages]); - - const displayedMcpUrl = mcpConfig?.mcpUrl ?? defaultMcpUrl; - const displayedConfigSnippet = mcpConfig?.configSnippet ?? buildPlaceholderMcpConfig(displayedMcpUrl); + const navItems: { view: View; label: string }[] = [ + { view: 'inbox', label: 'Inbox' }, + { view: 'keys', label: 'API Keys' }, + ...(user.role === 'admin' ? [{ view: 'admin' as View, label: 'Users & Access' }] : []), + ]; return (
@@ -283,327 +74,38 @@ function App(): JSX.Element { Private Mail Console

Auth Inbox

-

- {viewMode === 'inbox' - ? 'Verification messages, raw source, and sanitized HTML preview.' - : 'Remote MCP access reuses the same Basic Auth credentials as the admin site.'} -

-
- {viewMode === 'inbox' ? `${list.total} Entries` : 'MCP Ready'} - - -
- {viewMode === 'inbox' ? ( -
- - - - - Mail List - -
Page {page} / {totalPages}
-
- - {listError ?
{listError}
: null} - -
-
- - - - - - - - - - - {isListLoading ? ( - Array.from({ length: 6 }).map((_, index) => ( - - - - - - - )) - ) : list.items.length === 0 ? ( - - - - ) : ( - list.items.map((item) => { - const active = item.id === selectedMailId; - return ( - setSelectedMailId(item.id)} - > - - - - - - ); - }) - )} - -
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)}
-
- - - - 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 ? ( - - ) : '-'} -
- - ); - })()} -
-
- - -
-												{detail.raw || 'No raw email payload saved.'}
-											
-
- - -
-
HTML is sanitized before preview and loaded in a sandboxed iframe.
- -
- - {previewHtml ? ( -