feat: Hono rewrite, multi-user grants, remote MCP server

This commit is contained in:
TooonyChen committed 2026-07-07 23:47:08 +12:00
1 parent 1551de0868
commit f031612996
32 files changed
+3484 -1571

No files matched your search

+3 -1
View File
@@ -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
+86 -192
View File
@@ -1,230 +1,124 @@
# Auth Inbox 📬
# AuthInbox v2 骨架
[English](https://github.com/TooonyChen/AuthInbox/blob/main/README.md) | [简体中文](https://github.com/TooonyChen/AuthInbox/blob/main/README_CN.md)
Hono 重构 + 多用户/授权 + Remote MCP Server。本目录是可直接落进现有仓库的骨架,前端部分需要你自己把 Basic Auth 换成登录页(见下文)。
**Auth Inbox** is an open-source, self-hosted email verification code platform built on [Cloudflare](https://cloudflare.com/)'s free serverless services. It automatically processes incoming emails, filters out promotional mail before hitting the AI, extracts verification codes or links, and stores them in a database. A modern React dashboard lets administrators review extracted codes, inspect raw emails, and render HTML email previews — all protected by Basic Auth.
## 目录结构
Don't want ads and spam in your main inbox? Need a bunch of alternative addresses for signups? Try this **secure**, **serverless**, **lightweight** service!
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
```mermaid
flowchart LR
A([Your Inboxes]) --> B[Email Worker]
B --> C{Promotional?}
C -->|Yes| D[Discard]
C -->|No| E[AI Extraction]
E --> F[(D1 Database)]
F --> G[React Dashboard]
F --> H([Bark iOS Push])
```
migrations/
0001_baseline.sql # 现有 schema, 幂等
0002_multi_user.sql # users / api_keys / grants / category 列
src/
index.ts # WorkerEntrypoint 壳: fetch → Hono, email → handler
app.ts # Hono app + 认证边界
types.ts # Env / AppEnv / 分类枚举
middleware/auth.ts # sessionAuth / requireAdmin / apiKeyAuth
routes/
auth.ts # /api/auth: setup, login, logout, me
mails.ts # /api/mails: 列表 + 详情 (自动按用户过滤)
admin.ts # /api/admin: users + grants 管理
keys.ts # /api/keys: 自助签发 MCP API key
services/
auth.ts # PBKDF2 / JWT / API key
mail.ts # visibleMails — 唯一的权限过滤查询层
classify.ts # LLM 提取 (prompt 加了 category)
mime.ts # 旧版 MIME 工具原样迁移
email/
handler.ts # email() 逻辑, 写入 category
rpcEmail.ts # 原样保留
mcp/server.ts # /mcp: list_addresses / list_codes / get_latest_code / wait_for_code
```
---
## 从现有仓库迁移
## Table of Contents 📑
- [Features](#features-)
- [Technologies Used](#technologies-used-)
- [Installation](#installation-)
- [License](#license-)
- [Screenshots](#screenshots-)
---
## Features ✨
- **Promotional Filter**: Detects and skips bulk/marketing emails via headers (`List-Unsubscribe`, `Precedence: bulk`, etc.) before calling the AI — saves tokens.
- **AI Code Extraction**: Uses Google Gemini (with OpenAI as fallback) to extract verification codes, links, and organization names.
- **Modern Dashboard**: React 18 + shadcn/ui interface with mail list, detail panel, and three tabs — Extracted, Raw Email, Rendered HTML preview.
- **Safe HTML Preview**: Email HTML is sanitized with DOMPurify and rendered in a sandboxed iframe.
- **One-click Copy**: Verification codes and links have copy buttons with toast confirmation.
- **Real-Time Notifications**: Optionally sends Bark push notifications when new codes arrive.
- **Database**: Stores all raw emails and AI-extracted results in Cloudflare D1.
---
## Technologies Used 🛠️
- **Cloudflare Workers** — Serverless runtime for email handling and API.
- **Cloudflare D1** — SQLite-compatible serverless database.
- **Cloudflare Email Routing** — Routes incoming emails to the Worker.
- **React 18 + Vite + Tailwind CSS + shadcn/ui** — Frontend dashboard.
- **Any OpenAI-compatible / Anthropic AI provider** — Configurable via env vars; Gemini, OpenAI, DeepSeek, Groq, Anthropic, and more.
- **Bark API** — Optional iOS push notifications.
- **TypeScript** — End-to-end type safety.
---
## Installation ⚙️
### Prerequisites
- A [Google AI Studio API key](https://aistudio.google.com/)
- A domain bound to your [Cloudflare](https://dash.cloudflare.com/) account
- Cloudflare Account ID and API Token from [here](https://dash.cloudflare.com/profile/api-tokens)
- *(Optional)* [Bark App](https://bark.day.app/) for iOS push notifications
---
### Option 1 — Deploy via GitHub Actions
1. **Create D1 Database**
Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) → `Workers & Pages` → `D1 SQL Database` → `Create`. Name it `inbox-d1`.
Open the database → `Console`, paste and run the contents of [`db/schema.sql`](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql).
Copy the `database_id` for the next step.
2. **Fork & Deploy**
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/TooonyChen/AuthInbox)
In your forked repository, go to `Settings` → `Secrets and variables` → `Actions` and add:
- `CLOUDFLARE_ACCOUNT_ID`
- `CLOUDFLARE_API_TOKEN`
- `TOML` — use the [comment-free template](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear) to avoid parse errors.
Then go to `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`.
After success, **delete the workflow logs** to avoid leaking your config.
3. Jump to [Set Email Forwarding](#3-set-email-forwarding-).
---
### Option 2 — Deploy via CLI
1. **Clone and install**
1. 安装依赖:
```bash
git clone https://github.com/TooonyChen/AuthInbox.git
cd AuthInbox
pnpm install
pnpm add hono @hono/mcp @modelcontextprotocol/sdk zod
```
2. **Create D1 database**
2. 设置 secret(JWT 签名用,随便一串长随机字符串):
```bash
pnpm wrangler d1 create inbox-d1
pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql
openssl rand -hex 32 | wrangler secret put JWT_SECRET
```
Copy the `database_id` from the output.
3. wrangler.toml 里删掉 `FrontEndAdminID` / `FrontEndAdminPassword`(已被 users 表取代),其余 vars 不变。
3. **Configure**
4. 迁移数据库。首次使用 wrangler migrations 需要先建 `migrations/` 目录并让 wrangler 认领 0001 为已执行(现有库已经有这两张表,0001 是幂等的所以直接 apply 也安全):
```bash
cp wrangler.toml.example wrangler.toml
pnpm run db:migrate:remote
```
Edit `wrangler.toml` — at minimum fill in:
```toml
[vars]
FrontEndAdminID = "your-username"
FrontEndAdminPassword = "your-password"
UseBark = "false"
# AI provider — choose any compatible service
AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
AI_API_KEY = "your-api-key"
AI_API_FORMAT = "openai"
AI_MODEL = "gemini-2.0-flash"
[[d1_databases]]
binding = "DB"
database_name = "inbox-d1"
database_id = "<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):
![image](https://github.com/user-attachments/assets/53e5a939-6b03-4ca6-826a-7a5f02f361ac)
## 授权模型
**Custom address** (forwards a specific address):
![image](https://github.com/user-attachments/assets/b0d0ab94-c2ad-4870-ac08-d53e64b2c880)
- grant = (user, 地址 pattern, 允许的分类, 是否放行敏感类)
- pattern 用 SQLite GLOB:`netflix@mail.tony.dev` 精确,`*@mail.tony.dev` 通配
- 分类由 LLM 在提取时打标:`login_code` / `registration` / `password_reset` / `account_security` / `payment` / `other`
- `password_reset` 和 `account_security` 是敏感类:grant 里就算写了,`allow_sensitive = 0` 时服务层也会剔除
- 迁移前的历史邮件 category 为 `legacy`,永远只有 admin 可见
- 普通用户在任何接口(含 MCP)都拿不到邮件原文 raw,只能看到提取后的结构化结果
### 4. Done! 🎉
给 user 开一个典型 grant:
Visit your Worker URL, log in with the credentials you set, and start receiving verification emails.
```bash
curl -X POST https://your.domain/api/admin/grants \
-H 'Content-Type: application/json' -b 'authinbox_session=...' \
-d '{
"userId": 2,
"addressPattern": "*@mail.tony.dev",
"allowedCategories": ["login_code", "registration"]
}'
```
---
这样 user 2 能看到发到该域名任何地址的 Netflix 登录验证码,但看不到 Netflix 改密码邮件。
## License 📜
## MCP 接入
[MIT License](LICENSE)
1. 用户登录 web 后 POST `/api/keys` 拿到 `aik_xxx`(只显示一次)。
2. Claude Code:
---
```bash
claude mcp add --transport http authinbox https://your.domain/mcp \
--header "Authorization: Bearer aik_xxx"
```
## Screenshots 📸
3. Tools:
- `list_addresses`: 该 key 对应用户可见的收件地址
- `list_codes` / `get_latest_code`: 查验证码
- `wait_for_code`: 阻塞等新码(最长 55s,只返回调用之后到达的邮件),agent 注册流程的关键工具
![Mail list and extracted code](img/Group%201.png)
claude.ai 的远程连接器要求 OAuth,这版先不做;以后要接的话在 `/mcp` 前面套 Cloudflare 的 `workers-oauth-provider` 即可,tool 层不用动。
![Mail detail with verification code](img/Group%202.png)
## 验证清单
![Rendered HTML email preview](img/Group%203.png)
---
## Acknowledgements 🙏
- **Cloudflare Workers** for the serverless platform.
- **Google Gemini AI** for intelligent email extraction.
- **Bark** for real-time notifications.
- **shadcn/ui** for the component library.
- **Open Source Community** for the inspiration.
---
## TODO 📝
- [x] GitHub Actions deployment
- [x] OpenAI fallback support
- [x] React dashboard with shadcn/ui
- [x] Promotional email filter (skip ads before LLM)
- [x] Raw email viewer + sandboxed HTML preview
- [ ] Regex-based extraction as an AI-free option
- [ ] Multi-user support
- [ ] More notification methods (Slack, webhook, etc.)
- [ ] Sending email support
- [ ] 旧邮件进件流程不变:发一封测试邮件,raw_mails 和 code_mails 都有记录,category 有值
- [ ] 推广邮件仍然在 LLM 之前被拦截
- [ ] user 登录后 `/api/mails` 只返回 grant 范围内、非敏感分类的邮件
- [ ] user 请求 `/api/mails/:id` 越权时返回 404
- [ ] user 的 MCP key 调 `list_codes` 结果与 web 端一致
- [ ] `wait_for_code` 期间发送邮件,能在轮询里被捞到
- [ ] Bark 推送不变
+30
View File
@@ -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);
+44
View File
@@ -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
View File
@@ -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"
}
+696
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1,2 +1,6 @@
packages:
- web
allowBuilds:
esbuild: true
sharp: true
workerd: true
+57
View File
@@ -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;
+98
View File
@@ -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);
}
}
+4 -4
View File
@@ -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
View File
@@ -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);
}
+130
View File
@@ -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;
+38
View File
@@ -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();
});
+114
View File
@@ -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;
+80
View File
@@ -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;
+41
View File
@@ -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;
+46
View File
@@ -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;
+114
View File
@@ -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 };
}
+204
View File
@@ -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),
};
}
+176
View File
@@ -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);
}
+108
View File
@@ -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 };
}
+65
View File
@@ -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
View File
@@ -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
View File
@@ -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 &lt;base64(username:password)&gt;
</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
View File
@@ -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' });
}
+28
View File
@@ -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>
);
}
+22
View File
@@ -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 };
+16
View File
@@ -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 };
+366
View File
@@ -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>
);
}
+393
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}