diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c5a5ba3..98ed0de 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,38 +10,38 @@ jobs: deploy: runs-on: ubuntu-latest permissions: - contents: write + contents: read name: Deploy Auth Inbox Worker steps: - # 步骤 1: 检出代码 - name: Checkout Repository uses: actions/checkout@v4 - # 步骤 2: 设置 Node.js 环境 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' # 根据您的项目需求选择 Node.js 版本 + node-version: '20' + cache: 'pnpm' - # 步骤 3: 安装依赖项 - name: Install Dependencies - run: npm install - - - name: Install Web Dependencies - run: npm --prefix web install + run: pnpm install --frozen-lockfile - name: Build Web App - run: npm run build:web + run: pnpm run build:web - # 步骤 4: 部署 Worker - - name: Deploy Backend for ${{ github.ref_name }} + - name: Apply D1 Migrations run: | - - # Write secrets.TOML to wrangler.toml file - echo '${{ secrets.TOML }}' > wrangler.toml - - # Deploy the worker using Wrangler - npx wrangler deploy + echo '${{ secrets.TOML }}' > wrangler.toml + pnpm run db:migrate:remote + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + + - name: Deploy Backend for ${{ github.ref_name }} + run: | + pnpm exec wrangler deploy env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/README.md b/README.md index 1367981..d69a719 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ src/ auth.ts # PBKDF2 / JWT / API key mail.ts # visibleMails — 唯一的权限过滤查询层 classify.ts # LLM 提取 (prompt 加了 category) - mime.ts # 旧版 MIME 工具原样迁移 + mime.ts # MIME/encoded-word 解码、HTML 去标签、推广邮件识别 email/ handler.ts # email() 逻辑, 写入 category rpcEmail.ts # 原样保留 @@ -37,21 +37,21 @@ src/ pnpm add hono @hono/mcp @modelcontextprotocol/sdk zod ``` -2. 设置 secret(JWT 签名用,随便一串长随机字符串): +2. 设置 secret(JWT 签名用,随便一串长随机字符串)。生产环境不要把它写进 `wrangler.toml`: ```bash - openssl rand -hex 32 | wrangler secret put JWT_SECRET + pnpm exec wrangler secret put JWT_SECRET ``` -3. wrangler.toml 里删掉 `FrontEndAdminID` / `FrontEndAdminPassword`(已被 users 表取代),其余 vars 不变。 +3. `FrontEndAdminID` / `FrontEndAdminPassword` 已被 users 表取代,不再需要配置。首次部署后在登录页创建第一个 admin。 -4. 迁移数据库。首次使用 wrangler migrations 需要先建 `migrations/` 目录并让 wrangler 认领 0001 为已执行(现有库已经有这两张表,0001 是幂等的所以直接 apply 也安全): +4. 迁移数据库。`pnpm run deploy` 会自动先执行远端 D1 migrations 再部署 Worker;也可以单独执行: ```bash pnpm run db:migrate:remote ``` -5. 部署后创建第一个 admin(users 表为空时此端点才可用,之后永久关闭): +5. 部署后创建第一个 admin。登录页会自动检测 `GET /api/auth/setup`;users 表为空时会显示创建 admin 表单。也可以用 API: ```bash curl -X POST https://your.domain/api/auth/setup \ diff --git a/README_CN.md b/README_CN.md index ce92202..b30a7bc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -72,9 +72,7 @@ flowchart LR 进入 [Cloudflare 仪表盘](https://dash.cloudflare.com/) → `Workers & Pages` → `D1 SQL Database` → `Create`,名称填 `inbox-d1`。 - 点击进入数据库 → `Console`,粘贴并执行 [`db/schema.sql`](https://github.com/TooonyChen/AuthInbox/blob/main/db/schema.sql) 中的内容。 - - 复制 `database_id`,下一步会用到。 + 复制 `database_id`,下一步会用到。数据库表由仓库里的 `migrations/` 管理,部署 workflow 会先执行 D1 migrations。 2. **Fork 并部署** @@ -83,11 +81,13 @@ flowchart LR 在你 Fork 的仓库中,进入 `Settings` → `Secrets and variables` → `Actions`,添加以下 Secrets: - `CLOUDFLARE_ACCOUNT_ID` - `CLOUDFLARE_API_TOKEN` - - `TOML` — 使用[不带注释的模板](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear),避免解析报错。 + - `TOML` — 使用[不带注释的模板](https://github.com/TooonyChen/AuthInbox/blob/main/wrangler.toml.example.clear),填入 D1 `database_id` 和 AI 配置,避免解析报错。 然后进入 `Actions` → `Deploy Auth Inbox to Cloudflare Workers` → `Run workflow`。 - 部署成功后,**务必删除 workflow 日志**,防止配置信息泄露。 + 部署成功后,到 Cloudflare Worker 的 `Settings` → `Variables and Secrets` 添加 secret:`JWT_SECRET`(一串足够长的随机字符串)。首次打开登录页时,系统会在 users 表为空时引导你创建第一个 admin。 + + **务必删除 workflow 日志**,防止配置信息泄露。 3. 跳转到[设置邮件转发](#3-设置邮件转发-)。 @@ -107,7 +107,6 @@ flowchart LR ```bash pnpm wrangler d1 create inbox-d1 - pnpm wrangler d1 execute inbox-d1 --remote --file=./db/schema.sql ``` 复制输出中的 `database_id`。 @@ -122,9 +121,7 @@ flowchart LR ```toml [vars] - FrontEndAdminID = "你的登录账号" - FrontEndAdminPassword = "你的登录密码" - UseBark = "false" + UseBark = "false" # AI 提供商配置 AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" @@ -138,6 +135,8 @@ flowchart LR database_id = "<你的数据库ID>" ``` + `FrontEndAdminID` / `FrontEndAdminPassword` 已不再使用。用户存储在 D1 的 `users` 表里;首次部署后通过登录页创建第一个 admin。 + **`AI_API_FORMAT`** 三选一: | 值 | 实际请求路径 | 适用提供商 | @@ -165,12 +164,20 @@ flowchart LR 可选 Bark 配置:`barkTokens`、`barkUrl`。 + 设置 JWT secret(生产环境不要写进 `wrangler.toml`): + + ```bash + pnpm exec wrangler secret put JWT_SECRET + ``` + 4. **构建并部署** ```bash pnpm run deploy ``` + `pnpm run deploy` 会依次构建前端、执行远端 D1 migrations、部署 Worker。 + 输出:`https://auth-inbox.<你的子域名>.workers.dev` --- diff --git a/package.json b/package.json index 3aadee6..947f076 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build:web": "pnpm -C web run build", "dev:web": "pnpm -C web run dev", - "deploy": "pnpm run build:web && wrangler deploy", + "deploy": "pnpm run build:web && pnpm run db:migrate:remote && wrangler deploy", "dev": "wrangler dev", "start": "wrangler dev", "test": "vitest", diff --git a/src/email/handler.ts b/src/email/handler.ts index e6c0047..174a5a5 100644 --- a/src/email/handler.ts +++ b/src/email/handler.ts @@ -1,6 +1,6 @@ import type { Env } from "../types"; import { extractMailInfo } from "../services/classify"; -import { isPromotionalEmail } from "../services/mime"; +import { decodeMimeHeader, isPromotionalEmail } from "../services/mime"; import { RPCEmailMessage } from "./rpcEmail"; /* @@ -39,7 +39,7 @@ export async function handleEmail(message: ForwardableEmailMessage, env: Env): P ? String((message as RPCEmailMessage).rawEmail) : await new Response(message.raw).text(); const messageId = message.headers.get("Message-ID"); - const rawSubject = message.headers.get("Subject"); + const rawSubject = decodeMimeHeader(message.headers.get("Subject")); // 边界 2: 进件必写 raw_mails const { success } = await env.DB.prepare( diff --git a/src/services/classify.ts b/src/services/classify.ts index d5b57b5..efb0fcc 100644 --- a/src/services/classify.ts +++ b/src/services/classify.ts @@ -1,5 +1,6 @@ import type { Env, ProviderConfig, MailCategory } from "../types"; import { MAIL_CATEGORIES } from "../types"; +import { extractMailBodies, stripHtmlTags } from "./mime"; /* * callProvider / extractJsonFromText 从旧 index.ts 原样迁移, @@ -111,8 +112,11 @@ async function callProvider(config: ProviderConfig, prompt: string): Promise String.fromCharCode(Number.parseInt(hex, 16))); + return decodeBytes(decodeQuotedPrintableBytes(text), "utf-8"); +} + +function normaliseCharset(charset: string | null | undefined): string { + const value = (charset ?? "utf-8").trim().replace(/^["']|["']$/g, "").toLowerCase(); + if (!value || value === "utf8" || value === "us-ascii") return "utf-8"; + if (value === "gb2312" || value === "gbk" || value === "gb_2312-80") return "gb18030"; + return value; +} + +function decodeBytes(bytes: Uint8Array, charset: string | null | undefined): string { + try { + return new TextDecoder(normaliseCharset(charset)).decode(bytes); + } catch { + return new TextDecoder("utf-8").decode(bytes); + } +} + +function binaryStringToBytes(binary: string): Uint8Array { + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) & 0xff; + } + return bytes; +} + +function decodeBase64Bytes(text: string): Uint8Array { + return binaryStringToBytes(atob(text.replace(/\s/g, ""))); +} + +function decodeQuotedPrintableBytes(text: string, headerMode = false): Uint8Array { + const input = (headerMode ? text.replace(/_/g, " ") : text).replace(/=\r?\n/g, ""); + const bytes: number[] = []; + + for (let i = 0; i < input.length; i++) { + if (input[i] === "=" && /^[A-Fa-f0-9]{2}$/.test(input.slice(i + 1, i + 3))) { + bytes.push(Number.parseInt(input.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(input.charCodeAt(i) & 0xff); + } + } + + return new Uint8Array(bytes); +} + +function unfoldHeaders(headers: string): string { + return headers.replace(/\r?\n[ \t]+/g, " "); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getHeader(headers: string, name: string): string | null { + const match = unfoldHeaders(headers).match(new RegExp(`^${escapeRegExp(name)}:\\s*([^\\r\\n]*)`, "im")); + return match?.[1]?.trim() ?? null; +} + +function getCharset(contentType: string | null): string { + return contentType?.match(/;\s*charset="?([^";\r\n]+)"?/i)?.[1] ?? "utf-8"; +} + +function decodeBodyPart(body: string, encoding: string, charset: string): string { + if (encoding === "base64") { + try { + return decodeBytes(decodeBase64Bytes(body), charset); + } catch { + return body; + } + } + if (encoding === "quoted-printable") { + return decodeBytes(decodeQuotedPrintableBytes(body), charset); + } + return body; +} + +export function decodeMimeHeader(value: string | null): string | null { + if (!value) return value; + + return value + .replace(/(=\?[^?]+\?[bqBQ]\?[^?]*\?=)\s+(?==\?[^?]+\?[bqBQ]\?)/g, "$1") + .replace(/=\?([^?]+)\?([bqBQ])\?([^?]*)\?=/g, (_match, charset, encoding, encoded) => { + try { + const bytes = + encoding.toLowerCase() === "b" + ? decodeBase64Bytes(encoded) + : decodeQuotedPrintableBytes(encoded, true); + return decodeBytes(bytes, charset); + } catch { + return _match; + } + }); } export function stripHtmlTags(text: string): string { @@ -47,11 +137,16 @@ export function extractMailBodies(rawEmail: string): { textBody: string | null; htmlBody: string | null; } { - const boundaryMatch = rawEmail.match(/boundary="?([^"\r\n;]+)"?/i); + const topLevel = rawEmail.match(/^([\s\S]*?)\r?\n\r?\n([\s\S]*)$/); + const topHeaders = topLevel?.[1] ?? ""; + const topBody = topLevel?.[2] ?? rawEmail; + const topContentType = getHeader(topHeaders, "Content-Type"); + const boundaryMatch = topContentType?.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?`)); + const parts = topBody.split(new RegExp(`--${escapedBoundary}(?:--)?\\r?\\n?`)); let textBody: string | null = null; let htmlBody: string | null = null; @@ -67,21 +162,10 @@ export function extractMailBodies(rawEmail: string): { 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); - } + const contentTypeHeader = getHeader(headers, "Content-Type"); + const contentType = contentTypeHeader?.split(";")[0]?.trim().toLowerCase() ?? ""; + const encoding = getHeader(headers, "Content-Transfer-Encoding")?.toLowerCase() ?? ""; + const decoded = decodeBodyPart(body, encoding, getCharset(contentTypeHeader)); if (contentType.includes("text/html") && !htmlBody) { htmlBody = decoded.trim(); @@ -96,12 +180,13 @@ export function extractMailBodies(rawEmail: string): { } } - const htmlMatch = rawEmail.match(//i) ?? rawEmail.match(//i); - const htmlBody = htmlMatch ? decodeQuotedPrintable(htmlMatch[0]).trim() : null; + const encoding = getHeader(topHeaders, "Content-Transfer-Encoding")?.toLowerCase() ?? ""; + const decodedBody = decodeBodyPart(topBody, encoding, getCharset(topContentType)); + const topMimeType = topContentType?.split(";")[0]?.trim().toLowerCase() ?? ""; + const htmlMatch = decodedBody.match(//i) ?? decodedBody.match(//i); + const htmlBody = htmlMatch ? 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 decodedText = topMimeType.includes("text/html") ? stripHtmlTags(decodedBody) : decodedBody.trim(); const textBody = decodedText ? decodedText : htmlBody ? stripHtmlTags(htmlBody) : null; return { textBody, htmlBody }; diff --git a/test/mime.test.ts b/test/mime.test.ts new file mode 100644 index 0000000..dfe7849 --- /dev/null +++ b/test/mime.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decodeMimeHeader, decodeQuotedPrintable, extractMailBodies } from "../src/services/mime"; + +describe("mime decoding", () => { + it("decodes UTF-8 base64 body parts as Unicode text", () => { + const rawEmail = [ + 'Content-Type: multipart/alternative; boundary="mail-boundary"', + "", + "--mail-boundary", + "Content-Type: text/plain; charset=UTF-8", + "Content-Transfer-Encoding: base64", + "", + "5Lit5paH6aqM6K+B56CB77yaMTIzNDU2", + "--mail-boundary--", + "", + ].join("\r\n"); + + expect(extractMailBodies(rawEmail).textBody).toBe("中文验证码:123456"); + }); + + it("decodes single-part base64 HTML emails", () => { + const rawEmail = [ + "Content-Type: text/html; charset=utf-8", + "Content-Transfer-Encoding: base64", + "", + "PGh0bWw+PGJvZHk+PHA+5Lit5paH6aqM6K+B56CB77yaMTIzNDU2PC9wPjwvYm9keT48L2h0bWw+", + "", + ].join("\r\n"); + + const bodies = extractMailBodies(rawEmail); + expect(bodies.htmlBody).toContain("中文验证码:123456"); + expect(bodies.textBody).toBe("中文验证码:123456"); + }); + + it("decodes UTF-8 quoted-printable body parts as Unicode text", () => { + expect(decodeQuotedPrintable("=E4=B8=AD=E6=96=87=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=9A123456")).toBe( + "中文验证码:123456", + ); + }); + + it("decodes RFC 2047 encoded subject headers", () => { + expect(decodeMimeHeader("=?UTF-8?B?6aqM6K+B56CB?=")).toBe("验证码"); + }); +}); diff --git a/wrangler.toml.example b/wrangler.toml.example index 39d95df..4a9396c 100644 --- a/wrangler.toml.example +++ b/wrangler.toml.example @@ -46,17 +46,6 @@ barkTokens = "[token1, token2]" # 设定你的 bark tokens,从 https://bark.day.app/ 下载iOS APP,你可以使用多个 tokens,如果你只使用一个 token,那么设置为 "[token1]" # 如果你不使用 bark 推送,那么无需改动 -FrontEndAdminID = "admin" -# FrontEndAdminID: String -# your login **Please change it to your own login** -# 你的登录账号 **请更改为你自己的登录账号** - -FrontEndAdminPassword = "password" -# FrontEndAdminPassword: String -# your password **Please change it to your own password** -# 你的登录密码 **请更改为你自己的登录密码** - - # --- Primary AI provider --- AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" # Base URL of your AI provider (no trailing slash). diff --git a/wrangler.toml.example.clear b/wrangler.toml.example.clear index c590d85..60f7721 100644 --- a/wrangler.toml.example.clear +++ b/wrangler.toml.example.clear @@ -14,8 +14,6 @@ enabled = true UseBark = "true" barkUrl = "https://api.day.app" barkTokens = "[token1, token2]" -FrontEndAdminID = "admin" -FrontEndAdminPassword = "password" AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" AI_API_KEY = "your-api-key" AI_API_FORMAT = "openai"