feat: WmailHub v1.0.0 —— Cloudflare Email Workers 邮件接收与转发平台(本地可用版本)

- Console:React 19 + antd 6.6.5 前端(登录 admin/admin)+ Hono 管理 API
  (Nodes/Domains/Mailboxes/Emails/Webhooks/Keys/OAuth/Admin)
- Node:Bun 转发节点(HMAC 签名、有限重试、心跳、结构化日志、优雅退出)
- Email Worker:Cloudflare email handler + 本地联调入口
- shared:HMAC 协议(docs/04)、wmh_ Key(32 位)、AES-GCM 加密、SSRF 防护
- 邮件管线:postal-mime 解析、附件、catch-all、限额、malformed 降级、TTL 1 天
- Webhook:签名投递、指数退避重试、SSRF 校验、投递日志
- API v1 + MCP(JSON-RPC,六工具,凭据 kind 隔离)
- Cloudflare OAuth:真实 + 本地 sim 模式,自动安装管线(幂等,无需手工命令)
- 测试:bun test 41 例(协议安全/管线/TTL/Webhook/SSRF/MCP/OAuth/鉴权)+ Playwright UI 冒烟
- LOGO/favicon 由 codex CLI 生成
This commit is contained in:
BigCode
2026-09-30 03:34:37 +08:00
commit d4c1334143
87 changed files with 9268 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
/**
* WmailHub Console API / MCP Key 生成与校验
*
* 规则:Key 以 `wmh_` 开头,后接随机字符串,总长度 32 位
* (即 `wmh_` + 28 位随机 [A-Za-z0-9])。
*/
/** # 成员变量: Key 前缀 **/
export const KEY_PREFIX = "wmh_";
/** # 成员变量: Key 总长度(含前缀) **/
export const KEY_TOTAL_LENGTH = 32;
/** # 成员变量: Key 随机部分字母表 **/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
/** # 函数
* 生成随机 token(拒绝采样避免模偏差)
* 参数: length - 随机串长度
* 返回值: 随机字符串
*/
export function randomToken(length: number): string {
let out = "";
while (out.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
for (const b of bytes) {
if (b >= 248) continue; // 248 = 62 * 4,超出可整除区间则丢弃重采样
out += ALPHABET[b % ALPHABET.length];
if (out.length === length) break;
}
}
return out;
}
/** # 函数
* 生成 Console API / MCP Key(wmh_ 前缀,总长 32)
* 返回值: 形如 wmh_XXXXXXXXXXXXXXXXXXXXXXXXXXXX 的明文 Key(仅在创建时展示一次)
*/
export function generateWmailKey(): string {
return KEY_PREFIX + randomToken(KEY_TOTAL_LENGTH - KEY_PREFIX.length);
}
/** # 函数
* 校验 Key 格式是否符合 wmh_ + 28 位随机串、总长 32 的规范
*/
export function isValidWmailKey(key: string): boolean {
if (typeof key !== "string") return false;
if (key.length !== KEY_TOTAL_LENGTH) return false;
if (!key.startsWith(KEY_PREFIX)) return false;
for (const ch of key.slice(KEY_PREFIX.length)) {
if (!ALPHABET.includes(ch)) return false;
}
return true;
}