feat: Hono rewrite, multi-user grants, remote MCP server
This commit is contained in:
1 parent
1551de0868
commit
f031612996
32 files changed
+3484
-1571
No files matched your search
+3
-1
@@ -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
|
||||
*-backup.sql
|
||||
@@ -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!
|
||||
|
||||
[](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**
|
||||
|
||||
[](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 = "<your-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.<your-subdomain>.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` → `<your-domain>` → `Email` → `Email Routing` → `Routing Rules`.
|
||||
旧的 `src/index.html` 服务端兜底页在这版里去掉了,因为它无法感知用户身份。如果你想保留 no-JS 兜底,可以在 `app.ts` 的 SPA fallback 前加一个 session 校验的服务端渲染路由。
|
||||
|
||||
**Catch-all** (forwards all addresses to the Worker):
|
||||

|
||||
## 授权模型
|
||||
|
||||
**Custom address** (forwards a specific address):
|
||||

|
||||
- 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 注册流程的关键工具
|
||||
|
||||

|
||||
claude.ai 的远程连接器要求 OAuth,这版先不做;以后要接的话在 `/mcp` 前面套 Cloudflare 的 `workers-oauth-provider` 即可,tool 层不用动。
|
||||
|
||||

|
||||
## 验证清单
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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 推送不变
|
||||
@@ -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);
|
||||
@@ -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$<iterations>$<salt_b64>$<hash_b64>
|
||||
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);
|
||||
+16
-3
@@ -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"
|
||||
}
|
||||
Generated
+696
File diff suppressed because it is too large.
Load diff
@@ -1,2 +1,6 @@
|
||||
packages:
|
||||
- web
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
+57
@@ -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<AppEnv>();
|
||||
|
||||
// ---------- 认证边界 ----------
|
||||
// /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;
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
class RPCEmailMessage implements ForwardableEmailMessage {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly rawEmail: string;
|
||||
readonly rawEmail: String;
|
||||
readonly raw: ReadableStream<Uint8Array>;
|
||||
readonly headers: Headers;
|
||||
readonly rawSize: number;
|
||||
@@ -31,13 +31,13 @@ class RPCEmailMessage implements ForwardableEmailMessage {
|
||||
|
||||
async forward(rcptTo: string, headers: Headers = new Headers()): Promise<EmailSendResult> {
|
||||
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<EmailSendResult> {
|
||||
console.log(`rpcEmail default implementation: Replying to: ${message}`);
|
||||
return { messageId: `rpc-reply-${Date.now()}` };
|
||||
return { success: true, messageId: "" } as EmailSendResult;
|
||||
}
|
||||
}
|
||||
|
||||
export { RPCEmailMessage };
|
||||
export { RPCEmailMessage };
|
||||
+16
-693
@@ -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<string, unknown> | 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<string | null> {
|
||||
const base = normaliseBaseUrl(config.baseUrl);
|
||||
let endpoint: string;
|
||||
let body: unknown;
|
||||
const headers: Record<string, string> = { "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(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/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(/<html[\s\S]*<\/html>/i) ?? rawEmail.match(/<body[\s\S]*<\/body>/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<McpMailDetail[]> {
|
||||
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<Env> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
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<any>();
|
||||
|
||||
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<Response> {
|
||||
return app.fetch(request, this.env, this.ctx);
|
||||
}
|
||||
|
||||
// Primary email handler // 主要邮件处理入口
|
||||
async email(message: ForwardableEmailMessage): Promise<void> {
|
||||
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<string, unknown> | 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<void> {
|
||||
console.log(`Received RPC email , request body: ${requestBody}`);
|
||||
const bodyObject = JSON.parse(requestBody) as {
|
||||
from: string;
|
||||
to: string;
|
||||
rawEmail: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<AppEnv>();
|
||||
|
||||
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;
|
||||
@@ -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<AppEnv>(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<AppEnv>(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<AppEnv>(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();
|
||||
});
|
||||
@@ -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<AppEnv>();
|
||||
|
||||
// ---------- 用户管理 ----------
|
||||
|
||||
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;
|
||||
@@ -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<AppEnv>();
|
||||
|
||||
// 前端登录页用: 是否还没有任何用户 (需要走首次建号流程)
|
||||
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;
|
||||
@@ -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<AppEnv>();
|
||||
|
||||
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;
|
||||
@@ -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<AppEnv>();
|
||||
|
||||
// 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;
|
||||
@@ -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<ArrayBuffer> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
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<AuthedUser | null> {
|
||||
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<string> {
|
||||
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<AuthedUser | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string | null> {
|
||||
const base = normaliseBaseUrl(config.baseUrl);
|
||||
let endpoint: string;
|
||||
let body: unknown;
|
||||
const headers: Record<string, string> = { "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<ExtractedMail | null> {
|
||||
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<string, unknown> | 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),
|
||||
};
|
||||
}
|
||||
@@ -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<FilterResult> {
|
||||
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<GrantRow>();
|
||||
|
||||
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<MailRow>();
|
||||
|
||||
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<MailRow & { raw?: string | null }>();
|
||||
|
||||
if (!row) return null;
|
||||
return { ...row, raw: row.raw ?? null };
|
||||
}
|
||||
|
||||
// MCP list_addresses 用: 该用户实际可见的收件地址列表 (按 grant 展开去重)
|
||||
export async function visibleAddresses(db: D1Database, user: AuthedUser): Promise<string[]> {
|
||||
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);
|
||||
}
|
||||
@@ -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(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/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(/<html[\s\S]*<\/html>/i) ?? rawEmail.match(/<body[\s\S]*<\/body>/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 };
|
||||
}
|
||||
@@ -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"];
|
||||
+12
-105
@@ -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 `<reference>`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"]
|
||||
}
|
||||
+75
-573
@@ -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 `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font-family: "Manrope", sans-serif; color: #e5e5e5; background: #000000; line-height: 1.5; }
|
||||
a { color: #5fe0c0; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${doc.body.innerHTML}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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 <base64(username:password)>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(value: string, successMessage: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(successMessage);
|
||||
}
|
||||
type View = 'inbox' | 'keys' | 'admin';
|
||||
|
||||
function App(): JSX.Element {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('inbox');
|
||||
const [page, setPage] = useState(1);
|
||||
const [list, setList] = useState<MailListResponse>({ page: 1, pageSize: PAGE_SIZE, total: 0, items: [] });
|
||||
const [isListLoading, setIsListLoading] = useState(true);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [selectedMailId, setSelectedMailId] = useState<number | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [view, setView] = useState<View>('inbox');
|
||||
|
||||
const [detail, setDetail] = useState<MailDetail | null>(null);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [hideRemoteImages, setHideRemoteImages] = useState(true);
|
||||
|
||||
const [mcpConfig, setMcpConfig] = useState<McpConfigResponse | null>(null);
|
||||
const [isMcpLoading, setIsMcpLoading] = useState(false);
|
||||
const [mcpError, setMcpError] = useState<string | null>(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<MailListResponse>(`/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 (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 animate-pulse text-primary" />
|
||||
Auth Inbox
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
setHasAttemptedMcpConfigLoad(true);
|
||||
setIsMcpLoading(true);
|
||||
setMcpError(null);
|
||||
requestJson<McpConfigResponse>('/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 (
|
||||
<>
|
||||
<Toaster theme="dark" position="bottom-right" closeButton />
|
||||
<LoginPage onLogin={setUser} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshList();
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'mcp' && !hasAttemptedMcpConfigLoad) {
|
||||
refreshMcpConfig();
|
||||
}
|
||||
}, [viewMode, hasAttemptedMcpConfigLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMailId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setHideRemoteImages(true);
|
||||
setDetailError(null);
|
||||
setIsDetailLoading(true);
|
||||
|
||||
requestJson<MailDetail>(`/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 (
|
||||
<div className="min-h-screen bg-background text-slate-100">
|
||||
@@ -283,327 +74,38 @@ function App(): JSX.Element {
|
||||
Private Mail Console
|
||||
</div>
|
||||
<h1 className="font-sans text-3xl font-bold text-slate-100">Auth Inbox</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{viewMode === 'inbox'
|
||||
? 'Verification messages, raw source, and sanitized HTML preview.'
|
||||
: 'Remote MCP access reuses the same Basic Auth credentials as the admin site.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{viewMode === 'inbox' ? `${list.total} Entries` : 'MCP Ready'}</Badge>
|
||||
<Button variant={viewMode === 'inbox' ? 'default' : 'ghost'} onClick={() => setViewMode('inbox')} className="gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
Inbox
|
||||
</Button>
|
||||
<Button variant={viewMode === 'mcp' ? 'default' : 'ghost'} onClick={() => setViewMode('mcp')} className="gap-2">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
MCP
|
||||
</Button>
|
||||
<Button variant="outline" onClick={viewMode === 'inbox' ? refreshList : refreshMcpConfig} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-slate-100">{user.username}</div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">{user.role}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={logout} title="Sign out">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{viewMode === 'inbox' ? (
|
||||
<div className="grid gap-6 lg:grid-cols-[1.15fr_1fr] [&>*]:min-w-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4 text-primary" />
|
||||
Mail List
|
||||
</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">Page {page} / {totalPages}</div>
|
||||
</CardHeader>
|
||||
|
||||
{listError ? <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{listError}</div> : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/80">
|
||||
<div className="max-h-[640px] overflow-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 bg-[#111111] text-left text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">From</th>
|
||||
<th className="hidden px-3 py-3 sm:table-cell">To</th>
|
||||
<th className="px-3 py-3">Subject / Topic</th>
|
||||
<th className="hidden px-3 py-3 sm:table-cell">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/80">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 6 }).map((_, index) => (
|
||||
<tr key={`skeleton-${index}`} className="animate-pulse">
|
||||
<td className="px-3 py-3"><div className="h-3 w-24 rounded bg-slate-500/30" /></td>
|
||||
<td className="hidden px-3 py-3 sm:table-cell"><div className="h-3 w-24 rounded bg-slate-500/30" /></td>
|
||||
<td className="px-3 py-3"><div className="h-3 w-56 rounded bg-slate-500/30" /></td>
|
||||
<td className="hidden px-3 py-3 sm:table-cell"><div className="h-3 w-28 rounded bg-slate-500/30" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : list.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-8 text-center text-muted-foreground">
|
||||
No extracted mails available.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
list.items.map((item) => {
|
||||
const active = item.id === selectedMailId;
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'cursor-pointer bg-transparent transition-colors hover:bg-[#1a1a1a]',
|
||||
active && 'bg-[#252525]'
|
||||
)}
|
||||
onClick={() => setSelectedMailId(item.id)}
|
||||
>
|
||||
<td className="max-w-[140px] truncate px-3 py-3 font-medium text-slate-100">{item.fromOrg || item.fromAddr || '-'}</td>
|
||||
<td className="hidden max-w-[130px] truncate px-3 py-3 text-slate-300 sm:table-cell">{item.toAddr || '-'}</td>
|
||||
<td className="max-w-[200px] truncate px-3 py-3 text-slate-200 sm:max-w-[320px]">{item.subject || item.topic || '-'}</td>
|
||||
<td className="hidden whitespace-nowrap px-3 py-3 text-xs text-muted-foreground sm:table-cell">{formatDate(item.createdAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1 || isListLoading}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
disabled={page >= totalPages || isListLoading}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mail Detail</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
{detailError ? <div className="mb-4 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{detailError}</div> : null}
|
||||
|
||||
{!selectedMailId ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">Select one row from the list to inspect details.</div>
|
||||
) : isDetailLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-slate-500/30" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-500/30" />
|
||||
<div className="h-44 w-full animate-pulse rounded bg-slate-500/30" />
|
||||
</div>
|
||||
) : detail ? (
|
||||
<>
|
||||
<div className="mb-4 grid gap-2 rounded-lg border border-border/80 bg-[#111111] p-3 text-sm text-slate-300">
|
||||
<div><span className="text-muted-foreground">From:</span> {detail.fromOrg || detail.fromAddr || '-'}</div>
|
||||
<div><span className="text-muted-foreground">To:</span> {detail.toAddr || '-'}</div>
|
||||
<div><span className="text-muted-foreground">Subject:</span> {detail.subject || '-'}</div>
|
||||
<div><span className="text-muted-foreground">Received:</span> {formatDate(detail.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="parsed">
|
||||
<TabsList>
|
||||
<TabsTrigger value="parsed">Extracted</TabsTrigger>
|
||||
<TabsTrigger value="raw">Raw Email</TabsTrigger>
|
||||
<TabsTrigger value="rendered">Rendered</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="parsed">
|
||||
<div className="space-y-3 rounded-lg border border-border/80 bg-[#111111] p-4 text-sm text-slate-200">
|
||||
<div><span className="text-muted-foreground">Topic:</span> {detail.topic || '-'}</div>
|
||||
{(() => {
|
||||
const parsedCode = codeAndLink(detail.code);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Code:</span>
|
||||
{parsedCode.code ? (
|
||||
parsedCode.code.startsWith('http') ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
void copyText(parsedCode.code!, 'Code copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy code
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-mono font-semibold text-primary">{parsedCode.code}</span>
|
||||
)
|
||||
) : '-'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Link:</span>
|
||||
{parsedCode.link ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
void copyText(parsedCode.link!, 'Link copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy link
|
||||
</Button>
|
||||
) : '-'}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw">
|
||||
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
|
||||
{detail.raw || 'No raw email payload saved.'}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rendered">
|
||||
<div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div>HTML is sanitized before preview and loaded in a sandboxed iframe.</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary"
|
||||
checked={hideRemoteImages}
|
||||
onChange={(event) => setHideRemoteImages(event.target.checked)}
|
||||
/>
|
||||
Hide remote images
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{previewHtml ? (
|
||||
<iframe
|
||||
title="mail-preview"
|
||||
sandbox=""
|
||||
srcDoc={previewHtml}
|
||||
className="h-[460px] w-full rounded-lg border border-border bg-[#000000]"
|
||||
/>
|
||||
) : detail.textBody ? (
|
||||
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
|
||||
{detail.textBody}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
No renderable body found for this email.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">No details loaded.</div>
|
||||
<nav className="mb-6 inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
type="button"
|
||||
onClick={() => setView(item.view)}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
view === item.view && 'bg-background text-foreground shadow',
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] [&>*]:min-w-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-primary" />
|
||||
Remote MCP Config
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{mcpError ? <div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{mcpError}</div> : null}
|
||||
|
||||
<div className="space-y-4 text-sm text-slate-200">
|
||||
<div className="rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.08em] text-muted-foreground">How It Works</div>
|
||||
<p className="text-sm text-slate-300">
|
||||
The MCP endpoint reuses the same Basic Auth as the whole site. Configure the same username and password in
|
||||
your MCP client. This page only shows a template and does not expose the live <code>Authorization</code>{' '}
|
||||
header from the current browser session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="mb-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs uppercase tracking-[0.08em] text-muted-foreground">Endpoint</div>
|
||||
<div className="rounded-md border border-border bg-black/40 px-3 py-2 font-mono text-xs break-all">
|
||||
{displayedMcpUrl}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs uppercase tracking-[0.08em] text-muted-foreground">Header</div>
|
||||
<div className="rounded-md border border-border bg-black/40 px-3 py-2 font-mono text-xs break-all">
|
||||
Authorization: Basic <base64(username:password)>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => { void copyText(displayedMcpUrl, 'MCP URL copied'); }}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
void copyText(displayedConfigSnippet, 'MCP config copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy Config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.08em] text-muted-foreground">Client Config</div>
|
||||
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
|
||||
{isMcpLoading && !mcpConfig ? 'Loading MCP config...' : displayedConfigSnippet}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Tools</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<div className="space-y-4 text-sm text-slate-300">
|
||||
<div className="rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="font-medium text-slate-100"><code>get_latest_mail</code></div>
|
||||
<p className="mt-1 text-sm text-slate-300">Returns the latest extracted mail details, including decoded text and HTML bodies.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="font-medium text-slate-100"><code>get_multiple_mail</code></div>
|
||||
<p className="mt-1 text-sm text-slate-300">Returns the latest extracted mails sorted newest first. Default is 5 and the client can pass <code>limit</code>.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.08em] text-muted-foreground">Notes</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>The MCP endpoint is <code>POST {displayedMcpUrl}</code>.</li>
|
||||
<li>Keep sending the same Basic Auth header after initialize.</li>
|
||||
<li>If you later want separate per-agent credentials, we can add tokenized API keys on top.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{view === 'inbox' ? <InboxPage user={user} /> : null}
|
||||
{view === 'keys' ? <KeysPage /> : null}
|
||||
{view === 'admin' && user.role === 'admin' ? <AdminPage currentUser={user} /> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export interface MailListItem {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface MailListResponse {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: MailListItem[];
|
||||
}
|
||||
|
||||
// raw / textBody / htmlBody 只有 admin 的响应里才有
|
||||
export interface MailDetail extends MailListItem {
|
||||
raw?: string | null;
|
||||
textBody?: string | null;
|
||||
htmlBody?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiKeyItem {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface GrantItem {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
addressPattern: string;
|
||||
allowedCategories: string; // JSON string from backend
|
||||
allowSensitive: number;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
// 与后端 MAIL_CATEGORIES 保持一致
|
||||
export const CATEGORIES = [
|
||||
'login_code',
|
||||
'registration',
|
||||
'password_reset',
|
||||
'account_security',
|
||||
'payment',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
export const SENSITIVE_CATEGORIES = ['password_reset', 'account_security', 'legacy'];
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
login_code: 'Login code',
|
||||
registration: 'Registration',
|
||||
password_reset: 'Password reset',
|
||||
account_security: 'Account security',
|
||||
payment: 'Payment',
|
||||
other: 'Other',
|
||||
legacy: 'Legacy',
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed (${response.status})`;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string };
|
||||
if (body.error) message = body.error;
|
||||
} catch {
|
||||
// keep default message
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function getJson<T>(url: string): Promise<T> {
|
||||
return request<T>(url);
|
||||
}
|
||||
|
||||
export function postJson<T>(url: string, body?: unknown): Promise<T> {
|
||||
return request<T>(url, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function deleteJson<T>(url: string): Promise<T> {
|
||||
return request<T>(url, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CATEGORY_LABELS } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 分类色: 主色薄荷绿留给 login_code (最常用的一类),
|
||||
// 敏感类用暗红, 其余低饱和, 不和整体暗色主题打架。
|
||||
const STYLES: Record<string, string> = {
|
||||
login_code: 'border-primary/40 bg-primary/10 text-primary',
|
||||
registration: 'border-sky-400/40 bg-sky-400/10 text-sky-300',
|
||||
payment: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
|
||||
password_reset: 'border-red-500/40 bg-red-500/10 text-red-300',
|
||||
account_security: 'border-red-500/40 bg-red-500/10 text-red-300',
|
||||
legacy: 'border-border bg-transparent text-muted-foreground border-dashed',
|
||||
other: 'border-border bg-secondary text-muted-foreground',
|
||||
};
|
||||
|
||||
export function CategoryBadge({ category, className }: { category: string; className?: string }): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-mono text-[11px] leading-4',
|
||||
STYLES[category] ?? STYLES.other,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{CATEGORY_LABELS[category] ?? category}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, ShieldAlert, Trash2, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
CATEGORIES,
|
||||
CATEGORY_LABELS,
|
||||
SENSITIVE_CATEGORIES,
|
||||
deleteJson,
|
||||
getJson,
|
||||
postJson,
|
||||
type GrantItem,
|
||||
type User,
|
||||
type UserItem,
|
||||
} from '@/api';
|
||||
import { CategoryBadge } from '@/components/CategoryBadge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function parseCategories(json: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminPage({ currentUser }: { currentUser: User }): JSX.Element {
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [grants, setGrants] = useState<GrantItem[]>([]);
|
||||
|
||||
// New user form
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newRole, setNewRole] = useState<'user' | 'admin'>('user');
|
||||
|
||||
// New grant form
|
||||
const [grantUserId, setGrantUserId] = useState<number | ''>('');
|
||||
const [grantPattern, setGrantPattern] = useState('');
|
||||
const [grantCategories, setGrantCategories] = useState<string[]>(['login_code', 'registration']);
|
||||
const [grantSensitive, setGrantSensitive] = useState(false);
|
||||
|
||||
const refresh = (): void => {
|
||||
Promise.all([
|
||||
getJson<{ users: UserItem[] }>('/api/admin/users'),
|
||||
getJson<{ grants: GrantItem[] }>('/api/admin/grants'),
|
||||
])
|
||||
.then(([usersPayload, grantsPayload]) => {
|
||||
setUsers(usersPayload.users);
|
||||
setGrants(grantsPayload.grants);
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to load admin data'));
|
||||
};
|
||||
|
||||
useEffect(refresh, []);
|
||||
|
||||
const createUser = (): void => {
|
||||
if (!newUsername || newPassword.length < 8) {
|
||||
toast.error('Username and a password of at least 8 characters are required');
|
||||
return;
|
||||
}
|
||||
postJson('/api/admin/users', { username: newUsername, password: newPassword, role: newRole })
|
||||
.then(() => {
|
||||
toast.success(`User ${newUsername} created`);
|
||||
setNewUsername('');
|
||||
setNewPassword('');
|
||||
setNewRole('user');
|
||||
refresh();
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to create user'));
|
||||
};
|
||||
|
||||
const deleteUser = (target: UserItem): void => {
|
||||
if (!window.confirm(`Delete user ${target.username}? Their grants and API keys go with them.`)) return;
|
||||
deleteJson(`/api/admin/users/${target.id}`)
|
||||
.then(() => {
|
||||
toast.success(`User ${target.username} deleted`);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to delete user'));
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string): void => {
|
||||
setGrantCategories((current) =>
|
||||
current.includes(category) ? current.filter((c) => c !== category) : [...current, category],
|
||||
);
|
||||
};
|
||||
|
||||
const createGrant = (): void => {
|
||||
if (!grantUserId || !grantPattern || grantCategories.length === 0) {
|
||||
toast.error('Pick a user, an address pattern, and at least one category');
|
||||
return;
|
||||
}
|
||||
postJson('/api/admin/grants', {
|
||||
userId: grantUserId,
|
||||
addressPattern: grantPattern.trim(),
|
||||
allowedCategories: grantCategories,
|
||||
allowSensitive: grantSensitive,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success('Grant created');
|
||||
setGrantPattern('');
|
||||
setGrantSensitive(false);
|
||||
refresh();
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to create grant'));
|
||||
};
|
||||
|
||||
const deleteGrant = (id: number): void => {
|
||||
deleteJson(`/api/admin/grants/${id}`)
|
||||
.then(() => {
|
||||
toast.success('Grant removed');
|
||||
refresh();
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to remove grant'));
|
||||
};
|
||||
|
||||
const hasSensitiveSelected = grantCategories.some((c) => SENSITIVE_CATEGORIES.includes(c));
|
||||
const nonAdminUsers = users.filter((u) => u.role !== 'admin');
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_1.3fr] [&>*]:min-w-0">
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
Users
|
||||
</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">Admins see everything. Users only see what you grant below.</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="mb-4 space-y-3 rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-username">Username</Label>
|
||||
<Input id="new-username" value={newUsername} onChange={(event) => setNewUsername(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-password">Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex gap-2">
|
||||
{(['user', 'admin'] as const).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => setNewRole(role)}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||
newRole === role
|
||||
? 'border-primary/50 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={createUser} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/80">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="bg-[#111111] text-left text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">User</th>
|
||||
<th className="px-3 py-3">Role</th>
|
||||
<th className="px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/80">
|
||||
{users.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-3 font-medium text-slate-100">
|
||||
{item.username}
|
||||
{item.id === currentUser.id ? <span className="ml-2 text-xs text-muted-foreground">(you)</span> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 font-mono text-[11px]',
|
||||
item.role === 'admin'
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border bg-secondary text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
{item.id !== currentUser.id ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-300"
|
||||
onClick={() => deleteUser(item)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 pb-4">
|
||||
<CardTitle>Access Grants</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
A grant is one user, one address pattern, and the categories they may read there.
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="mb-4 space-y-3 rounded-lg border border-border/80 bg-[#111111] p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-user">User</Label>
|
||||
<select
|
||||
id="grant-user"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring [&>option]:bg-[#111111]"
|
||||
value={grantUserId}
|
||||
onChange={(event) => setGrantUserId(event.target.value ? Number(event.target.value) : '')}
|
||||
>
|
||||
<option value="">Select a user</option>
|
||||
{nonAdminUsers.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-pattern">Address pattern</Label>
|
||||
<Input
|
||||
id="grant-pattern"
|
||||
placeholder="netflix@mail.example.com or *@mail.example.com"
|
||||
className="font-mono text-xs"
|
||||
value={grantPattern}
|
||||
onChange={(event) => setGrantPattern(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Visible categories</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const selected = grantCategories.includes(category);
|
||||
const sensitive = SENSITIVE_CATEGORIES.includes(category);
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => toggleCategory(category)}
|
||||
className={cn(
|
||||
'rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors',
|
||||
selected
|
||||
? sensitive
|
||||
? 'border-red-500/50 bg-red-500/10 text-red-300'
|
||||
: 'border-primary/50 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSensitiveSelected ? (
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-xs text-red-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 accent-red-400"
|
||||
checked={grantSensitive}
|
||||
onChange={(event) => setGrantSensitive(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3.5 w-3.5" />
|
||||
Password reset and account security mail can take over the account behind an address. Without this
|
||||
confirmation the server drops those categories from the grant.
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={createGrant} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create grant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/80">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="bg-[#111111] text-left text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">User</th>
|
||||
<th className="px-3 py-3">Pattern</th>
|
||||
<th className="px-3 py-3">Categories</th>
|
||||
<th className="px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/80">
|
||||
{grants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-muted-foreground">
|
||||
No grants yet. Users see nothing until you create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
grants.map((grant) => (
|
||||
<tr key={grant.id}>
|
||||
<td className="px-3 py-3 font-medium text-slate-100">{grant.username}</td>
|
||||
<td className="max-w-[180px] truncate px-3 py-3 font-mono text-xs text-slate-300">{grant.addressPattern}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{parseCategories(grant.allowedCategories).map((category) => (
|
||||
<CategoryBadge key={category} category={category} />
|
||||
))}
|
||||
{grant.allowSensitive ? (
|
||||
<span className="rounded-md border border-red-500/40 px-2 py-0.5 font-mono text-[11px] text-red-300">
|
||||
sensitive allowed
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-red-300"
|
||||
onClick={() => deleteGrant(grant.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Copy, Inbox, RefreshCw, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getJson, type MailDetail, type MailListResponse, type User } from '@/api';
|
||||
import { CategoryBadge } from '@/components/CategoryBadge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
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 `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body { margin: 0; padding: 20px; font-family: "Manrope", sans-serif; color: #e5e5e5; background: #000000; line-height: 1.5; }
|
||||
a { color: #5fe0c0; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; }
|
||||
img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${doc.body.innerHTML}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function CopyChip({ value, label }: { value: string; label: string }): JSX.Element {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success(`${label} copied`);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy {label.toLowerCase()}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtractedPanel({ detail }: { detail: MailDetail }): JSX.Element {
|
||||
const parsedCode = codeAndLink(detail.code);
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border/80 bg-[#111111] p-4 text-sm text-slate-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Category:</span>
|
||||
<CategoryBadge category={detail.category} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Topic:</span> {detail.topic || '-'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Code:</span>
|
||||
{parsedCode.code ? <span className="font-mono font-semibold text-primary">{parsedCode.code}</span> : '-'}
|
||||
{parsedCode.code ? <CopyChip value={parsedCode.code} label="Code" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Link:</span>
|
||||
{parsedCode.link ? <CopyChip value={parsedCode.link} label="Link" /> : '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxPage({ user }: { user: User }): JSX.Element {
|
||||
const isAdmin = user.role === 'admin';
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeSearch, setActiveSearch] = useState('');
|
||||
const [list, setList] = useState<MailListResponse>({ page: 1, pageSize: PAGE_SIZE, total: 0, items: [] });
|
||||
const [isListLoading, setIsListLoading] = useState(true);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [selectedMailId, setSelectedMailId] = useState<number | null>(null);
|
||||
|
||||
const [detail, setDetail] = useState<MailDetail | null>(null);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [hideRemoteImages, setHideRemoteImages] = useState(true);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(list.total / PAGE_SIZE));
|
||||
|
||||
const refreshList = (): void => {
|
||||
setIsListLoading(true);
|
||||
setListError(null);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) });
|
||||
if (activeSearch) params.set('service', activeSearch);
|
||||
|
||||
getJson<MailListResponse>(`/api/mails?${params.toString()}`)
|
||||
.then((payload) => {
|
||||
setList(payload);
|
||||
setSelectedMailId((previousId) => {
|
||||
if (previousId && payload.items.some((item) => item.id === previousId)) {
|
||||
return previousId;
|
||||
}
|
||||
return payload.items[0]?.id ?? null;
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setListError(error instanceof Error ? error.message : 'Unable to load mail list.');
|
||||
})
|
||||
.finally(() => setIsListLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshList();
|
||||
}, [page, activeSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMailId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setHideRemoteImages(true);
|
||||
setDetailError(null);
|
||||
setIsDetailLoading(true);
|
||||
|
||||
getJson<MailDetail>(`/api/mails/${selectedMailId}`)
|
||||
.then(setDetail)
|
||||
.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]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1.15fr_1fr] [&>*]:min-w-0">
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4 text-primary" />
|
||||
Mail List
|
||||
</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{list.total} entries · page {page} / {totalPages}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="mb-3 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter by sender, e.g. netflix"
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
setPage(1);
|
||||
setActiveSearch(search.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setActiveSearch(search.trim());
|
||||
if (search.trim() === activeSearch) refreshList();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listError ? (
|
||||
<div className="mb-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{listError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/80">
|
||||
<div className="max-h-[600px] overflow-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 bg-[#111111] text-left text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">From</th>
|
||||
<th className="hidden px-3 py-3 md:table-cell">To</th>
|
||||
<th className="px-3 py-3">Category</th>
|
||||
<th className="hidden px-3 py-3 sm:table-cell">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/80">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 6 }).map((_, index) => (
|
||||
<tr key={`skeleton-${index}`} className="animate-pulse">
|
||||
<td className="px-3 py-3"><div className="h-3 w-24 rounded bg-slate-500/30" /></td>
|
||||
<td className="hidden px-3 py-3 md:table-cell"><div className="h-3 w-24 rounded bg-slate-500/30" /></td>
|
||||
<td className="px-3 py-3"><div className="h-3 w-20 rounded bg-slate-500/30" /></td>
|
||||
<td className="hidden px-3 py-3 sm:table-cell"><div className="h-3 w-28 rounded bg-slate-500/30" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : list.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-8 text-center text-muted-foreground">
|
||||
{activeSearch
|
||||
? `No mail matches "${activeSearch}".`
|
||||
: isAdmin
|
||||
? 'No extracted mails yet.'
|
||||
: 'No mail is visible to your account yet. Ask your admin to grant access.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
list.items.map((item) => {
|
||||
const active = item.id === selectedMailId;
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'cursor-pointer bg-transparent transition-colors hover:bg-[#1a1a1a]',
|
||||
active && 'bg-[#252525]',
|
||||
)}
|
||||
onClick={() => setSelectedMailId(item.id)}
|
||||
>
|
||||
<td className="max-w-[150px] px-3 py-3">
|
||||
<div className="truncate font-medium text-slate-100">{item.fromOrg || item.fromAddr || '-'}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{item.subject || item.topic || ''}</div>
|
||||
</td>
|
||||
<td className="hidden max-w-[150px] truncate px-3 py-3 font-mono text-xs text-slate-300 md:table-cell">
|
||||
{item.toAddr || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<CategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="hidden whitespace-nowrap px-3 py-3 text-xs text-muted-foreground sm:table-cell">
|
||||
{formatDate(item.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1 || isListLoading}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
disabled={page >= totalPages || isListLoading}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 pb-4">
|
||||
<CardTitle>Mail Detail</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
{detailError ? (
|
||||
<div className="mb-4 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-200">{detailError}</div>
|
||||
) : null}
|
||||
|
||||
{!selectedMailId ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Select one row from the list to inspect details.
|
||||
</div>
|
||||
) : isDetailLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-slate-500/30" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-500/30" />
|
||||
<div className="h-44 w-full animate-pulse rounded bg-slate-500/30" />
|
||||
</div>
|
||||
) : detail ? (
|
||||
<>
|
||||
<div className="mb-4 grid gap-2 rounded-lg border border-border/80 bg-[#111111] p-3 text-sm text-slate-300">
|
||||
<div><span className="text-muted-foreground">From:</span> {detail.fromOrg || detail.fromAddr || '-'}</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">To:</span>{' '}
|
||||
<span className="font-mono text-xs">{detail.toAddr || '-'}</span>
|
||||
</div>
|
||||
<div><span className="text-muted-foreground">Subject:</span> {detail.subject || '-'}</div>
|
||||
<div><span className="text-muted-foreground">Received:</span> {formatDate(detail.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<Tabs defaultValue="parsed">
|
||||
<TabsList>
|
||||
<TabsTrigger value="parsed">Extracted</TabsTrigger>
|
||||
<TabsTrigger value="raw">Raw Email</TabsTrigger>
|
||||
<TabsTrigger value="rendered">Rendered</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="parsed">
|
||||
<ExtractedPanel detail={detail} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw">
|
||||
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
|
||||
{detail.raw || 'No raw email payload saved.'}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rendered">
|
||||
<div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div>HTML is sanitized before preview and loaded in a sandboxed iframe.</div>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary"
|
||||
checked={hideRemoteImages}
|
||||
onChange={(event) => setHideRemoteImages(event.target.checked)}
|
||||
/>
|
||||
Hide remote images
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{previewHtml ? (
|
||||
<iframe
|
||||
title="mail-preview"
|
||||
sandbox=""
|
||||
srcDoc={previewHtml}
|
||||
className="h-[460px] w-full rounded-lg border border-border bg-[#000000]"
|
||||
/>
|
||||
) : detail.textBody ? (
|
||||
<pre className="max-h-[420px] overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-4 font-mono text-xs leading-6 text-slate-300">
|
||||
{detail.textBody}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
No renderable body found for this email.
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<>
|
||||
<ExtractedPanel detail={detail} />
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
You see the extracted fields. The original message is only visible to admins.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">No details loaded.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, KeyRound, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteJson, getJson, postJson, type ApiKeyItem } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
export function KeysPage(): JSX.Element {
|
||||
const [keys, setKeys] = useState<ApiKeyItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const mcpUrl = `${window.location.origin}/mcp`;
|
||||
|
||||
const refresh = (): void => {
|
||||
setIsLoading(true);
|
||||
getJson<{ keys: ApiKeyItem[] }>('/api/keys')
|
||||
.then((payload) => setKeys(payload.keys))
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to load keys'))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(refresh, []);
|
||||
|
||||
const createKey = (): void => {
|
||||
setIsCreating(true);
|
||||
postJson<{ id: number; key: string }>('/api/keys', { name: newKeyName.trim() || 'default' })
|
||||
.then((payload) => {
|
||||
setFreshKey(payload.key);
|
||||
setNewKeyName('');
|
||||
refresh();
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to create key'))
|
||||
.finally(() => setIsCreating(false));
|
||||
};
|
||||
|
||||
const revokeKey = (id: number): void => {
|
||||
deleteJson(`/api/keys/${id}`)
|
||||
.then(() => {
|
||||
toast.success('Key revoked');
|
||||
refresh();
|
||||
})
|
||||
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : 'Unable to revoke key'));
|
||||
};
|
||||
|
||||
const copy = (value: string, label: string): void => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success(`${label} copied`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_1fr] [&>*]:min-w-0">
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-primary" />
|
||||
API Keys
|
||||
</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Keys let MCP clients read mail as you, with the same access you have here.
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Input
|
||||
placeholder="Key name, e.g. claude-code"
|
||||
value={newKeyName}
|
||||
onChange={(event) => setNewKeyName(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && createKey()}
|
||||
/>
|
||||
<Button onClick={createKey} disabled={isCreating} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{freshKey ? (
|
||||
<div className="mb-4 rounded-lg border border-primary/40 bg-primary/10 p-3">
|
||||
<div className="mb-2 text-xs text-slate-200">
|
||||
Copy this key now. It won't be shown again.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded bg-[#0a0a0a] px-2 py-1.5 font-mono text-xs text-primary">{freshKey}</code>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={() => copy(freshKey, 'Key')}>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/80">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="bg-[#111111] text-left text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3">Name</th>
|
||||
<th className="hidden px-3 py-3 sm:table-cell">Created</th>
|
||||
<th className="hidden px-3 py-3 sm:table-cell">Last used</th>
|
||||
<th className="px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/80">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-muted-foreground">Loading…</td>
|
||||
</tr>
|
||||
) : keys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-muted-foreground">
|
||||
No keys yet. Create one to connect an MCP client.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<tr key={key.id}>
|
||||
<td className="px-3 py-3 font-medium text-slate-100">{key.name}</td>
|
||||
<td className="hidden px-3 py-3 text-xs text-muted-foreground sm:table-cell">{formatDate(key.createdAt)}</td>
|
||||
<td className="hidden px-3 py-3 text-xs text-muted-foreground sm:table-cell">{formatDate(key.lastUsedAt)}</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-red-300" onClick={() => revokeKey(key.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 pb-4">
|
||||
<CardTitle>Connect an MCP client</CardTitle>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
The server exposes list_addresses, list_codes, get_latest_code, and wait_for_code.
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<div className="space-y-4 text-sm text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs uppercase tracking-[0.08em] text-muted-foreground">Server URL</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded bg-[#0a0a0a] px-2 py-1.5 font-mono text-xs">{mcpUrl}</code>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={() => copy(mcpUrl, 'URL')}>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs uppercase tracking-[0.08em] text-muted-foreground">Claude Code</div>
|
||||
<pre className="overflow-auto rounded-lg border border-border/80 bg-[#0a0a0a] p-3 font-mono text-xs leading-6 text-slate-300">
|
||||
{`claude mcp add --transport http authinbox \\
|
||||
${mcpUrl} \\
|
||||
--header "Authorization: Bearer <your-key>"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Authenticate with the Authorization header, value "Bearer" plus your key. Revoking a key here cuts that client off immediately.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getJson, postJson, type User } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (user: User) => void;
|
||||
}
|
||||
|
||||
export function LoginPage({ onLogin }: LoginPageProps): JSX.Element {
|
||||
const [setupNeeded, setSetupNeeded] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getJson<{ needed: boolean }>('/api/auth/setup')
|
||||
.then((payload) => setSetupNeeded(payload.needed))
|
||||
.catch(() => setSetupNeeded(false));
|
||||
}, []);
|
||||
|
||||
const submit = (): void => {
|
||||
if (!username || !password) {
|
||||
toast.error('Enter a username and password');
|
||||
return;
|
||||
}
|
||||
if (setupNeeded && password.length < 8) {
|
||||
toast.error('Password needs at least 8 characters');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
|
||||
const flow = setupNeeded
|
||||
? postJson('/api/auth/setup', { username, password }).then(() =>
|
||||
postJson<{ user: User }>('/api/auth/login', { username, password }),
|
||||
)
|
||||
: postJson<{ user: User }>('/api/auth/login', { username, password });
|
||||
|
||||
flow
|
||||
.then((payload) => {
|
||||
onLogin(payload.user);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Sign in failed');
|
||||
})
|
||||
.finally(() => setIsSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(900px_400px_at_50%_-10%,rgba(95,224,192,0.1),transparent)]" />
|
||||
<Card className="relative w-full max-w-sm p-8">
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
Private Mail Console
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">
|
||||
{setupNeeded ? 'Create the admin account' : 'Auth Inbox'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{setupNeeded
|
||||
? 'This inbox has no users yet. The first account becomes the admin.'
|
||||
: 'Sign in to read your verification mail.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={setupNeeded ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||
/>
|
||||
{setupNeeded ? <p className="text-xs text-muted-foreground">At least 8 characters.</p> : null}
|
||||
</div>
|
||||
<Button className="w-full" onClick={submit} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in…' : setupNeeded ? 'Create admin and sign in' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user