diff --git a/CHANGELOG.md b/CHANGELOG.md index 915f5f1e..0a8dd34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - feat: |Webhook| 测试弹框支持随机邮件或指定邮件 ID,校验请求体及邮箱归属并适配现有前端语言及中英文错误提示 +- feat: |Webhook| 支持无需 S3 的多附件签名链接、纯 URL 与 Markdown 链接列表,绑定本次入库邮件并保留下载文件名(issue #1142) - feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量 - feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置 - feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index c25a2f80..4295a13e 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -11,6 +11,7 @@ ### Features - feat: |Webhook| Support random or specified email IDs in the test dialog, with request-body validation, mailbox ownership checks, existing UI languages and Chinese/English errors +- feat: |Webhook| Support signed attachment URLs without S3, plain URL and Markdown link lists, bound to the inserted email with original download filenames (issue #1142) - feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes - feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html` - feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e index 6239e04d..3fd03633 100644 --- a/e2e/fixtures/wrangler.toml.e2e +++ b/e2e/fixtures/wrangler.toml.e2e @@ -30,6 +30,8 @@ DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_USER_ROLE = "admin" ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true +BACKEND_URL = "http://worker:8787/" +REMOVE_EXCEED_SIZE_ATTACHMENT = true CLEANUP_BATCH_SIZE = 10 SMTP_CONFIG = """ {"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}} diff --git a/e2e/fixtures/wrangler.toml.e2e.env-off b/e2e/fixtures/wrangler.toml.e2e.env-off index e46573d2..e343e8e5 100644 --- a/e2e/fixtures/wrangler.toml.e2e.env-off +++ b/e2e/fixtures/wrangler.toml.e2e.env-off @@ -21,6 +21,8 @@ ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true +BACKEND_URL = "http://worker-env-off:8790" +REMOVE_ALL_ATTACHMENT = true SMTP_CONFIG = """ {"test.example.com":{"host":"mailpit","port":1025,"secure":false}} """ diff --git a/e2e/fixtures/wrangler.toml.e2e.gzip b/e2e/fixtures/wrangler.toml.e2e.gzip index 1550a7c5..01f90d6f 100644 --- a/e2e/fixtures/wrangler.toml.e2e.gzip +++ b/e2e/fixtures/wrangler.toml.e2e.gzip @@ -18,6 +18,7 @@ ENABLE_ADDRESS_PASSWORD = true DISABLE_ADMIN_PASSWORD_CHECK = true ADMIN_PASSWORDS = '["e2e-admin-pass"]' ENABLE_WEBHOOK = true +BACKEND_URL = "http://worker-gzip:8788" ENABLE_MAIL_GZIP = true SMTP_CONFIG = """ {"test.example.com":{"host":"mailpit","port":1025,"secure":false}} diff --git a/e2e/tests/api/webhook-attachments.spec.ts b/e2e/tests/api/webhook-attachments.spec.ts new file mode 100644 index 00000000..a7e3e374 --- /dev/null +++ b/e2e/tests/api/webhook-attachments.spec.ts @@ -0,0 +1,185 @@ +import { test, expect } from '@playwright/test'; +import http from 'node:http'; +import { createHmac, randomUUID } from 'node:crypto'; +import { createTestAddress } from '../../fixtures/test-helpers'; + +const variants = [ + { name: 'on', url: process.env.WORKER_URL!, removeAll: false, removeLarge: true }, + { name: 'gzip', url: process.env.WORKER_GZIP_URL!, removeAll: false, removeLarge: false }, + { name: 'off', url: process.env.WORKER_URL_ENV_OFF!, removeAll: true, removeLarge: false }, +]; + +for (const variant of variants) { + test.describe(`Webhook attachments: ${variant.name}`, () => { + test('links, expiry, ownership binding, deletion and removal settings', async ({ request }) => { + test.setTimeout(60_000); + expect(variant.url, 'Worker variant must be configured in CI').toBeTruthy(); + const { jwt, address, address_id } = await createTestAddress(request, 'attachments', undefined, variant.url); + const jwtSecret = variant.removeAll ? 'e2e-test-secret-key-env-off' : 'e2e-test-secret-key'; + const headers = { Authorization: `Bearer ${jwt}` }; + const payloads: any[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + try { + payloads.push(JSON.parse(Buffer.concat(chunks).toString())); + res.writeHead(200).end(); + } catch { + res.writeHead(400).end(); + } + }); + }); + await new Promise(resolve => server.listen(0, '0.0.0.0', resolve)); + const port = (server.address() as import('node:net').AddressInfo).port; + const hostname = process.env.CI ? 'e2e-runner' : 'localhost'; + + try { + const settings = { + enabled: true, url: `http://${hostname}:${port}`, method: 'POST', + headers: '{"Content-Type":"application/json"}', + body: '{"id":"${id}","ai":${aiExtract},"attachments":${attachments},"text":"${parsedText}","links":"${attachmentLinks}","markdownLinks":"${attachmentMarkdownLinks}"}', + }; + expect((await request.post(`${variant.url}/api/webhook/settings`, { headers, data: settings })).ok()).toBe(true); + + const receive = async ( + files: { name: string, type: string, content: string }[], large = false, + messageId: string | null = `<${randomUUID()}@test>`, waitForWebhook = true, + ) => { + const boundary = randomUUID(); + const raw = [ + 'From: sender@test.example.com', `To: ${address}`, 'Subject: Attachment coverage', + ...(messageId === null ? [] : [`Message-ID: ${messageId}`]), 'MIME-Version: 1.0', + `Content-Type: multipart/mixed; boundary="${boundary}"`, '', + `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '', + 'Preserved body: "quoted" \\path $& ${from}' + (large ? 'x'.repeat(2 * 1024 * 1024) : ''), + ...files.flatMap(file => [ + `--${boundary}`, `Content-Type: ${file.type}`, + `Content-Disposition: attachment; filename="${file.name}"`, + 'Content-Transfer-Encoding: base64', '', Buffer.from(file.content).toString('base64'), + ]), `--${boundary}--`, + ].join('\r\n'); + const count = payloads.length; + const result = await request.post(`${variant.url}/__test/receive_mail`, { + data: { from: 'sender@test.example.com', to: address, raw }, + }); + expect(result.ok()).toBe(true); + expect((await result.json()).success).toBe(true); + if (!waitForWebhook) return; + await expect.poll(() => payloads.length).toBe(count + 1); + return payloads[count]; + }; + + const files = [ + { name: 'first.png', type: 'image/png', content: 'first bytes' }, + { name: "报告 (copy)'!.txt", type: 'text/plain', content: 'second bytes' }, + { name: 'third.svg', type: 'image/svg+xml', content: '' }, + ]; + const payload = await receive(files); + expect(payload.text).toContain('Preserved body: "quoted" \\path $& ${from}'); + expect(payload.attachments).toHaveLength(variant.removeAll ? 0 : files.length); + expect(payload.links).toBe(payload.attachments.map((a: any) => a.url).join('\n')); + const markdownNames = ['first.png', "报告 \\(copy\\)'\\!.txt", 'third.svg']; + expect(payload.markdownLinks).toBe(payload.attachments.map((a: any, index: number) => `[${markdownNames[index]}](${a.url})`).join('\n')); + + for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) { + const count = payloads.length; + expect((await request.post(`${variant.url}${endpoint}`, { headers, data: settings })).ok()).toBe(true); + await expect.poll(() => payloads.length).toBe(count + 1); + const preview = payloads[count]; + expect(preview.ai).toBeNull(); + expect(Array.isArray(preview.attachments)).toBe(true); + if (endpoint.startsWith('/api/')) { + expect(preview.attachments).toHaveLength(variant.removeAll ? 0 : files.length); + } + for (const attachment of preview.attachments) { + expect((await request.get(attachment.url)).status()).toBe(200); + } + } + + const detail = await request.get(`${variant.url}/api/mail/${payload.id}`, { headers }); + expect(detail.ok()).toBe(true); + const row = await detail.json(); + const now = Math.floor(Date.now() / 1000); + const sign = (index: number, expires = now + 600, id = Number(payload.id), recipient = address, created = row.created_at, secret = jwtSecret) => { + const signature = createHmac('sha256', secret).update(JSON.stringify([ + 'webhook-attachment-v1', id, recipient, created, expires, index, + ])).digest('base64url'); + return `/open_api/a/${id}/${index}/${expires}/${signature}`; + }; + const get = (path: string) => request.get(new URL(path, variant.url).href); + + for (const [index, attachment] of payload.attachments.entries()) { + expect(attachment.filename).toBe(files[index].name); + expect(attachment.mimeType).toBe(files[index].type); + expect(new URL(attachment.url).origin).toBe(new URL(variant.url).origin); + const response = await get(attachment.url); + expect(response.status()).toBe(200); + expect(await response.text()).toBe(files[index].content); + expect(response.headers()['cache-control']).toBe('no-store'); + expect(response.headers()['x-content-type-options']).toBe('nosniff'); + const filename = encodeURIComponent(files[index].name).replace(/[!'()*]/g, + character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + expect(response.headers()['content-disposition']).toBe(index === 0 ? 'inline' : `attachment; filename*=UTF-8''${filename}`); + expect(response.headers()['content-type']).toBe(index === 0 ? 'image/png' : 'application/octet-stream'); + } + + expect((await get(sign(0))).status()).toBe(variant.removeAll ? 404 : 200); + for (const path of [ + sign(0, now - 1), sign(0, now + 86460), sign(-1), sign(999), + sign(0, now + 600, Number(payload.id), 'other@test.example.com'), + sign(0, now + 600, Number(payload.id), address, '2000-01-01 00:00:00'), + sign(0, now + 600, Number(payload.id), address, row.created_at, 'wrong-secret'), + sign(0, now + 600, Number.MAX_SAFE_INTEGER), + `/open_api/a/${payload.id}/invalid/${now + 600}/${'A'.repeat(43)}`, + `/open_api/a/${payload.id}/0/${now + 600}/short`, + ]) { + expect((await get(path)).status(), path).toBe(404); + } + + const empty = await receive([]); + expect(empty.attachments).toEqual([]); + expect(empty.links).toBe(''); + expect(empty.markdownLinks).toBe(''); + const emptyRow = await (await request.get(`${variant.url}/api/mail/${empty.id}`, { headers })).json(); + expect((await get(sign(0, now + 600, Number(empty.id), address, emptyRow.created_at))).status()).toBe(404); + const deleted = await request.delete(`${variant.url}/admin/mails/${payload.id}`); + expect(deleted.ok()).toBe(true); + expect((await get(sign(0))).status()).toBe(404); + + if (variant.removeLarge) { + const large = await receive(files, true); + expect(large.attachments).toEqual([]); + expect(large.text).toContain('Preserved body'); + const largeRow = await (await request.get(`${variant.url}/api/mail/${large.id}`, { headers })).json(); + expect((await get(sign(0, now + 600, Number(large.id), address, largeRow.created_at))).status()).toBe(404); + } + + if (!variant.removeAll) { + for (const messageId of [`<${randomUUID()}@duplicate.test>`, null]) { + const count = payloads.length; + const names = Array.from({ length: 4 }, () => `${randomUUID()}.txt`); + await Promise.all(names.map(name => receive([ + { name, type: 'text/plain', content: name }, + ], false, messageId, false))); + await expect.poll(() => payloads.length).toBe(count + names.length); + const received = payloads.slice(count); + expect(new Set(received.map(mail => mail.id)).size).toBe(names.length); + expect(received.map(mail => mail.attachments[0].filename).sort()).toEqual([...names].sort()); + for (const mail of received) { + expect(mail.attachments).toHaveLength(1); + const attachment = mail.attachments[0]; + const response = await request.get(attachment.url); + expect(response.status()).toBe(200); + expect(await response.text()).toBe(attachment.filename); + expect(new URL(attachment.url).pathname.split('/')[3]).toBe(String(mail.id)); + } + } + } + } finally { + await request.delete(`${variant.url}/admin/delete_address/${address_id}`); + await new Promise(resolve => server.close(() => resolve())); + } + }); + }); +} diff --git a/e2e/tests/api/webhook-trigger.spec.ts b/e2e/tests/api/webhook-trigger.spec.ts index 596fab26..cade17df 100644 --- a/e2e/tests/api/webhook-trigger.spec.ts +++ b/e2e/tests/api/webhook-trigger.spec.ts @@ -126,6 +126,96 @@ test.describe('Webhook — triggered on incoming mail', () => { } }); + test('signed attachment paths serve each attachment and reject tampering', async ({ request }) => { + const { server, firstRequest, url } = await startWebhookReceiver(); + + try { + const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, { + headers: { Authorization: `Bearer ${jwt}` }, + data: { + enabled: true, + url, + method: 'POST', + headers: JSON.stringify({ 'Content-Type': 'application/json' }), + body: '{"attachments":${attachments}}', + }, + }); + expect(saveRes.ok()).toBe(true); + + const attachment = Buffer.from('89504e470d0a1a0a', 'hex'); + const boundary = `webhook-attachment-${Date.now()}`; + const raw = [ + `From: attachment-sender@test.example.com`, + `To: ${address}`, + `Subject: Webhook Attachment ${Date.now()}`, + `Message-ID: `, + `MIME-Version: 1.0`, + `Content-Type: multipart/mixed; boundary="${boundary}"`, + ``, + `--${boundary}`, + `Content-Type: text/plain; charset=utf-8`, + ``, + `Attachment test`, + `--${boundary}`, + `Content-Type: image/png`, + `Content-Disposition: attachment; filename="test.png"`, + `Content-Transfer-Encoding: base64`, + ``, + attachment.toString('base64'), + `--${boundary}`, + `Content-Type: text/plain`, + `Content-Disposition: attachment; filename="second.txt"`, + `Content-Transfer-Encoding: base64`, + ``, + Buffer.from('Second attachment').toString('base64'), + `--${boundary}--`, + ].join('\r\n'); + + const res = await request.post(`${WORKER_URL}/__test/receive_mail`, { + data: { + from: 'attachment-sender@test.example.com', + to: address, + raw, + }, + }); + expect(res.ok()).toBe(true); + + const payload = JSON.parse((await firstRequest).body); + expect(payload.attachments).toHaveLength(2); + expect(payload.attachments[0]).toMatchObject({ filename: 'test.png', mimeType: 'image/png' }); + expect(new URL(payload.attachments[0].url).origin).toBe(new URL(WORKER_URL).origin); + const attachmentPath = new URL(payload.attachments[0].url).pathname; + expect(attachmentPath).toMatch(/^\/open_api\/a\/\d+\/0\/\d+\/[A-Za-z0-9_-]{43}$/); + expect(payload.attachments[1].filename).toBe('second.txt'); + + const attachmentRes = await request.get(payload.attachments[0].url); + expect(attachmentRes.ok()).toBe(true); + expect(attachmentRes.headers()['content-type']).toBe('image/png'); + expect(attachmentRes.headers()['content-disposition']).toBe('inline'); + expect(attachmentRes.headers()['x-content-type-options']).toBe('nosniff'); + expect(attachmentRes.headers()['cache-control']).toBe('no-store'); + expect(Buffer.from(await attachmentRes.body())).toEqual(attachment); + + const secondRes = await request.get(payload.attachments[1].url); + expect(secondRes.status()).toBe(200); + expect(secondRes.headers()['content-disposition']).toBe("attachment; filename*=UTF-8''second.txt"); + expect(secondRes.headers()['content-type']).toBe('application/octet-stream'); + expect(await secondRes.text()).toBe('Second attachment'); + + const parts = attachmentPath.split('/'); + parts[parts.length - 1] = (parts.at(-1)[0] === 'A' ? 'B' : 'A') + parts.at(-1).slice(1); + const tamperedPath = parts.join('/'); + const tamperedRes = await request.get(`${WORKER_URL}${tamperedPath}`); + expect(tamperedRes.status()).toBe(404); + for (const index of ['1', '999', '-1']) { + const changedIndex = attachmentPath.replace('/0/', `/${index}/`); + expect((await request.get(`${WORKER_URL}${changedIndex}`)).status()).toBe(404); + } + } finally { + server.close(); + } + }); + test('webhook is NOT called when disabled', async ({ request }) => { const { server, firstRequest, url } = await startWebhookReceiver(); diff --git a/vitepress-docs/docs/en/guide/feature/webhook.md b/vitepress-docs/docs/en/guide/feature/webhook.md index ffcf267c..7ad97e79 100644 --- a/vitepress-docs/docs/en/guide/feature/webhook.md +++ b/vitepress-docs/docs/en/guide/feature/webhook.md @@ -99,6 +99,21 @@ Push email notifications by calling the Telegram Bot API directly via webhook. S ## Webhook Data Format +Insert attachment links directly into the final Body text: + +- `${attachmentLinks}`: Plain URLs for all attachments, one per line, without file-type filtering. +- `${attachmentMarkdownLinks}`: Markdown links `[filename](URL)` for all attachments, one per line, without file-type filtering. + +For example, `{"content":"Attachments:\n${attachmentMarkdownLinks}"}`. Expanded lists are empty without attachments or a backend URL. Link rendering is determined by the receiving platform. The Webhook test buttons also support these variables using the selected test email’s attachments. + +`${attachments}` provides a JSON array of all attachments, each with `filename`, `mimeType`, and `url`. Insert this placeholder directly as a JSON value, **without quotes**: + +```json +{"attachments": ${attachments}} +``` + +Example output: `{"attachments":[{"filename":"a.png","mimeType":"image/png","url":"https://temp-email-api.example.com/open_api/a/123/0/..."}]}`. Emails without attachments produce `[]`. Attachment URLs use BACKEND_URL and can be used directly. Attachment indices are included in the signature, so modifying an index cannot grant access to another attachment. Links are temporary access credentials: use HTTPS and avoid sharing them publicly. + To get the url, you need to configure the worker's `FRONTEND_URL` to your frontend address, or you can construct the url yourself using `id` = `${FRONTEND_URL}?mail_id=${id}` ```json @@ -111,6 +126,7 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte "raw": "${raw}", "parsedText": "${parsedText}", "parsedHtml": "${parsedHtml}", + "attachments": ${attachments}, "aiExtractType": "${aiExtractType}", "aiExtractResult": "${aiExtractResult}", "aiExtractResultText": "${aiExtractResultText}", @@ -120,3 +136,7 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte When AI email extraction is enabled, webhook templates can use the `aiExtractType`, `aiExtractResult`, and `aiExtractResultText` placeholders. They are empty strings when no extraction result is available. Click **Test** to choose a random email (default) or specify an email ID. Missing specified emails return an error without falling back to a random email. Mailbox tests can only use that mailbox's emails; administrators can select any email. The existing `/api/webhook/test` and `/admin/mail_webhook/test` endpoints accept an optional positive integer `mail_id` in the request body. Omitting it preserves random selection. The UI sends this field only for testing, without saving it in the Webhook configuration. + +Each `url` directly accesses the backend attachment endpoint. It is signed with `JWT_SECRET`, and expires after 24 hours. Files other than PNG, JPEG, GIF, or WebP images are served as downloads. The endpoint cannot retrieve attachments after the email is deleted or when configuration removed them before storage. + +Set `BACKEND_URL = "https://temp-email-api.example.com"` in the Worker to its public base URL (a trailing slash is supported). No frontend proxy is required. Attachment URLs are empty when unset; mail-page links continue to use `FRONTEND_URL`. diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md index d91fbdb7..9ea276cd 100644 --- a/vitepress-docs/docs/en/guide/worker-vars.md +++ b/vitepress-docs/docs/en/guide/worker-vars.md @@ -128,6 +128,7 @@ When `ADMIN_API_IP_WHITELIST` is unset or empty, source IPs are not restricted. | ---------------- | --------- | ------------------------------------------------- | ------------------ | | `ENABLE_WEBHOOK` | Text/JSON | Whether to enable webhook | `true` | | `FRONTEND_URL` | Text | Frontend URL, used for sending webhook email URLs | `https://xxxx.xxx` | +| `BACKEND_URL` | Text | Public backend base URL for signed attachment links; attachment URLs are empty when unset | `https://temp-email-api.example.com` | > [!NOTE] > Webhook functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout diff --git a/vitepress-docs/docs/zh/guide/feature/webhook.md b/vitepress-docs/docs/zh/guide/feature/webhook.md index 13226d53..4a776c84 100644 --- a/vitepress-docs/docs/zh/guide/feature/webhook.md +++ b/vitepress-docs/docs/zh/guide/feature/webhook.md @@ -99,6 +99,21 @@ ## webhook 数据格式 +Body 中可以将附件链接直接插入最终文本: + +- `${attachmentLinks}`:所有附件的纯 URL,每行一个,不按文件类型过滤。 +- `${attachmentMarkdownLinks}`:所有附件的 Markdown 链接 `[文件名](URL)`,每行一个,不按文件类型过滤。 + +例如 `{"content":"附件:\n${attachmentMarkdownLinks}"}`。无附件或未配置后端地址时,展开的链接列表为空。链接如何展示由接收平台决定。页面上的 Webhook 测试按钮也支持这些变量,使用所选测试邮件的附件。 + +`${attachments}` 返回所有附件的 JSON 数组,每项包含 `filename`、`mimeType`、`url`。将此变量直接放在 JSON 值的位置,**不要加引号**: + +```json +{"attachments": ${attachments}} +``` + +例如返回 `{"attachments":[{"filename":"a.png","mimeType":"image/png","url":"https://temp-email-api.example.com/open_api/a/123/0/..."}]}`。无附件时为 `[]`。附件链接使用 `BACKEND_URL`,接收端可直接使用每项 `url`;附件序号也参与签名,不能修改路径读取其他附件。链接本身是临时访问凭证,请使用 HTTPS 传输并避免公开分享。 + 要获取 url 需要配置 worker 的 `FRONTEND_URL` 为你的前端地址,或者你可以通过 `id` 自己拼接 url = `${FRONTEND_URL}?mail_id=${id}` ```json @@ -111,6 +126,7 @@ "raw": "${raw}", "parsedText": "${parsedText}", "parsedHtml": "${parsedHtml}", + "attachments": ${attachments}, "aiExtractType": "${aiExtractType}", "aiExtractResult": "${aiExtractResult}", "aiExtractResultText": "${aiExtractResultText}", @@ -120,3 +136,7 @@ 启用 AI 邮件内容提取后,Webhook 模板可使用 `aiExtractType`、`aiExtractResult`、`aiExtractResultText` 占位符。未提取到结果时这些字段为空字符串。 点击“测试”会弹出选择框:默认随机选择邮件,也可以选择“指定 ID”并输入邮件 ID。指定邮件不存在时会报错,不会回退随机;邮箱页面只能使用当前邮箱的邮件,管理员页面可指定任意邮件。现有测试接口 `/api/webhook/test` 和 `/admin/mail_webhook/test` 的请求 Body 支持可选正整数 `mail_id`,不传则沿用随机逻辑。页面仅在测试请求中传入该参数,不会保存到 Webhook 配置。 + +每项 `url` 直接访问后端附件接口,使用 `JWT_SECRET` 签名并在 24 小时后失效。非 PNG、JPEG、GIF、WebP 图片会作为文件下载。邮件被删除或附件在入库前被配置移除时,无法通过接口读取附件。 + +在 Worker 中配置 `BACKEND_URL = "https://temp-email-api.example.com"`,使用后端公网根地址(支持末尾斜杠),不需要前端代理。未配置时附件的 `url` 为空;邮件页面链接仍使用 `FRONTEND_URL`。 diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md index a3398229..2b18077a 100644 --- a/vitepress-docs/docs/zh/guide/worker-vars.md +++ b/vitepress-docs/docs/zh/guide/worker-vars.md @@ -123,6 +123,7 @@ | ---------------- | --------- | ------------------------------------- | ------------------ | | `ENABLE_WEBHOOK` | 文本/JSON | 是否启用 webhook | `true` | | `FRONTEND_URL` | 文本 | 前端地址,用于发送 webhook 的邮件 url | `https://xxxx.xxx` | +| `BACKEND_URL` | 文本 | 后端公网根地址,用于附件签名链接;未配置时附件 URL 为空 | `https://temp-email-api.example.com` | > [!NOTE] > webhook 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时 diff --git a/worker/src/admin_api/mail_webhook_settings.ts b/worker/src/admin_api/mail_webhook_settings.ts index 1da82b5f..3b469860 100644 --- a/worker/src/admin_api/mail_webhook_settings.ts +++ b/worker/src/admin_api/mail_webhook_settings.ts @@ -4,6 +4,7 @@ import { WebhookSettings, RawMailRow } from "../models"; import { commonParseMail, sendWebhook } from "../common"; import { resolveRawEmail } from "../gzip"; import i18n from "../i18n"; +import { getWebhookAttachments } from '../utils/webhook'; async function getWebhookSettings(c: Context): Promise { const settings = await c.env.KV.get( @@ -43,6 +44,7 @@ async function testWebhookSettings(c: Context): Promise @@ -72,4 +73,6 @@ api.get('/open_api/settings', async (c) => { }); }) +api.get('/open_api/a/:mail_id/:index/:expires/:signature', getWebhookAttachment) + export { api } diff --git a/worker/src/common.ts b/worker/src/common.ts index b4aa8cb4..53194d32 100644 --- a/worker/src/common.ts +++ b/worker/src/common.ts @@ -7,6 +7,7 @@ import { unbindTelegramByAddress } from './telegram_api/common'; import { CONSTANTS } from './constants'; import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models'; import i18n from './i18n'; +import { formatWebhookBody, getWebhookAttachments } from './utils/webhook'; const DEFAULT_NAME_REGEX = /[^a-z0-9]/g; const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8; @@ -838,22 +839,13 @@ export async function sendWebhook( settings: WebhookSettings, formatMap: WebhookMail ): Promise<{ success: boolean, message?: string }> { // send webhook - let body = settings.body; - for (const key of Object.keys(formatMap)) { - body = body.replace( - new RegExp(`\\$\\{${key}\\}`, "g"), - JSON.stringify( - formatMap[key as keyof WebhookMail] - ).replace(/^"(.*)"$/, '$1') - ); - } + const body = formatWebhookBody(settings.body, formatMap); const response = await fetch(settings.url, { method: settings.method, headers: JSON.parse(settings.headers), body: body }); if (!response.ok) { - console.log("send webhook error", settings.url, settings.method, settings.headers, body); console.log("send webhook error", response.status, response.statusText); return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` }; } @@ -864,7 +856,7 @@ export async function triggerWebhook( c: Context, address: string, parsedEmailContext: ParsedEmailContext, - message_id: string | null, + storedMailId: number | undefined, aiExtract?: ExtractResult | null ): Promise { if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) { @@ -893,17 +885,22 @@ export async function triggerWebhook( if (webhookList.length === 0) { return } - const mailId = await c.env.DB.prepare( - `SELECT id FROM raw_mails where address = ? and message_id = ?` - ).bind(address, message_id).first("id"); + const mailRow = storedMailId ? await c.env.DB.prepare( + `SELECT id, address, created_at FROM raw_mails WHERE id = ? AND address = ?` + ).bind(storedMailId, address).first<{ id: number, address: string, created_at: string }>() : null; + const mailId = String(mailRow?.id || ''); const parsedEmail = await commonParseMail(parsedEmailContext); + const needsAttachments = webhookList.some(settings => settings.body.includes('${attachment')); + const attachments = needsAttachments + ? await getWebhookAttachments(c.env, mailRow, parsedEmail?.attachments) : []; const usableAiExtract = aiExtract?.type !== "none" && aiExtract?.result ? aiExtract : null; const webhookMail = { id: mailId || "", url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "", + attachments, from: parsedEmail?.sender || "", to: address, subject: parsedEmail?.subject || "", diff --git a/worker/src/email/check_attachment.ts b/worker/src/email/check_attachment.ts index 3e596550..5cbfb5f9 100644 --- a/worker/src/email/check_attachment.ts +++ b/worker/src/email/check_attachment.ts @@ -48,4 +48,8 @@ export const remove_attachment_if_need = async ( }); } parsedEmailContext.rawEmail = msg.asRaw(); + parsedEmailContext.parsedEmail = { + ...parsedEmail, + attachments: [], + }; } diff --git a/worker/src/email/index.ts b/worker/src/email/index.ts index 73bdec6f..50eb5613 100644 --- a/worker/src/email/index.ts +++ b/worker/src/email/index.ts @@ -65,11 +65,13 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu } const message_id = message.headers.get("Message-ID"); + let storedMailId: number | undefined; // save email try { - const { success } = await storeRawMail( + const { success, meta } = await storeRawMail( env, message.from, toAddress, message_id, parsedEmailContext.rawEmail ); + if (success) storedMailId = meta.last_row_id; if (!success) { message.setReject(`Failed save message to ${toAddress}`); console.error(`Failed save message from ${message.from} to ${toAddress}`); @@ -98,7 +100,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu try { await triggerWebhook( { env: env } as Context, - toAddress, parsedEmailContext, message_id, aiExtractResult + toAddress, parsedEmailContext, storedMailId, aiExtractResult ); } catch (error) { console.error("send webhook error", error); diff --git a/worker/src/mails_api/webhook_settings.ts b/worker/src/mails_api/webhook_settings.ts index e71fef21..9013d6a2 100644 --- a/worker/src/mails_api/webhook_settings.ts +++ b/worker/src/mails_api/webhook_settings.ts @@ -3,6 +3,7 @@ import { CONSTANTS } from "../constants"; import { AdminWebhookSettings, WebhookSettings, RawMailRow } from "../models"; import { commonParseMail, sendWebhook } from "../common"; import { resolveRawEmail } from "../gzip"; +import { getWebhookAttachments } from '../utils/webhook'; import i18n from "../i18n"; @@ -58,6 +59,7 @@ async function testWebhookSettings(c: Context): Promise +): Promise => { + const mailId = Number(c.req.param('mail_id')); + const index = Number(c.req.param('index')); + const expires = Number(c.req.param('expires')); + const signatureValue = c.req.param('signature') || ''; + const now = Math.floor(Date.now() / 1000); + if ( + !Number.isSafeInteger(mailId) || mailId <= 0 + || !Number.isSafeInteger(index) || index < 0 + || !Number.isSafeInteger(expires) + || expires <= now || expires > now + WEBHOOK_ATTACHMENT_TTL_SECONDS + || !/^[A-Za-z0-9_-]{43}$/.test(signatureValue) + ) { + return c.text('Not Found', 404); + } + + const mail = await c.env.DB.prepare( + `SELECT * FROM raw_mails WHERE id = ?` + ).bind(mailId).first(); + if (!mail?.address || !mail.created_at) return c.text('Not Found', 404); + + const valid = await crypto.subtle.verify( + 'HMAC', await getSigningKey(c.env.JWT_SECRET), decodeBase64Url(signatureValue), + getSignaturePayload(mailId, mail.address, mail.created_at, expires, index) + ); + if (!valid) return c.text('Not Found', 404); + + const rawEmail = await resolveRawEmail(mail); + const parsedEmail = await commonParseMail({ rawEmail }); + const attachment = parsedEmail?.attachments?.[index]; + if (!attachment) return c.text('Not Found', 404); + + const inline = SAFE_INLINE_IMAGE_TYPES.has(attachment.mimeType.toLowerCase()); + const filename = encodeURIComponent(attachment.filename).replace(/[!'()*]/g, + character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + return new Response(Uint8Array.from(attachment.content).buffer, { + headers: { + 'Cache-Control': 'no-store', + 'Content-Disposition': inline ? 'inline' : `attachment; filename*=UTF-8''${filename}`, + 'Content-Type': inline ? attachment.mimeType : 'application/octet-stream', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} diff --git a/worker/src/types.d.ts b/worker/src/types.d.ts index 39fba8f3..0c8d7c54 100644 --- a/worker/src/types.d.ts +++ b/worker/src/types.d.ts @@ -114,6 +114,7 @@ type Bindings = { // webhook config FRONTEND_URL: string | undefined + BACKEND_URL: string | undefined // AI extraction config ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined diff --git a/worker/src/utils/webhook.ts b/worker/src/utils/webhook.ts new file mode 100644 index 00000000..79731290 --- /dev/null +++ b/worker/src/utils/webhook.ts @@ -0,0 +1,80 @@ +import type { RawMailRow, WebhookMail } from '../models'; + +export const WEBHOOK_ATTACHMENT_TTL_SECONDS = 24 * 60 * 60; +export const SAFE_INLINE_IMAGE_TYPES = new Set([ + 'image/png', 'image/jpeg', 'image/gif', 'image/webp' +]); +const textEncoder = new TextEncoder(); +let signingKey: { secret: string, key: Promise } | undefined; + +const encodeBase64Url = (value: Uint8Array): string => { + let binary = ''; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export const decodeBase64Url = (value: string): Uint8Array => { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +export const getSigningKey = (secret: string): Promise => { + if (signingKey?.secret === secret) return signingKey.key; + const key = crypto.subtle.importKey( + 'raw', textEncoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'] + ); + signingKey = { secret, key }; + return key; +} + +export const getSignaturePayload = ( + mailId: number, address: string, createdAt: string, expires: number, index: number +): Uint8Array => textEncoder.encode(JSON.stringify([ + 'webhook-attachment-v1', mailId, address, createdAt, expires, index +])); + +export const createWebhookAttachmentPath = async ( + secret: string, mailId: number, address: string, createdAt: string, index: number +): Promise => { + const expires = Math.floor(Date.now() / 1000) + WEBHOOK_ATTACHMENT_TTL_SECONDS; + const signature = await crypto.subtle.sign( + 'HMAC', await getSigningKey(secret), + getSignaturePayload(mailId, address, createdAt, expires, index) + ); + return `/open_api/a/${mailId}/${index}/${expires}/${encodeBase64Url(new Uint8Array(signature))}`; +} + +export const getWebhookAttachments = async ( + env: Bindings, mail: RawMailRow | null, attachments: ParsedEmailAttachment[] = [] +): Promise> => { + if (!mail?.address || !mail.created_at) return []; + const { id, address, created_at } = mail; + const backendUrl = env.BACKEND_URL?.replace(/\/$/, ''); + return Promise.all(attachments.map(async (attachment, index) => ({ + filename: attachment.filename, + mimeType: attachment.mimeType, + url: backendUrl + ? `${backendUrl}${await createWebhookAttachmentPath(env.JWT_SECRET, id, address, created_at, index)}` + : '', + }))); +} + +export const formatWebhookBody = (body: string, mail: WebhookMail): string => { + const attachments = mail.attachments || []; + const linkedAttachments = attachments.filter(attachment => attachment.url); + const formatMap = { + ...mail, + attachments, + attachmentLinks: linkedAttachments.map(attachment => attachment.url).join('\n'), + attachmentMarkdownLinks: linkedAttachments.map(attachment => { + const filename = attachment.filename.replace(/[\r\n]/g, ' ').replace(/[\\[\]()`*_!<>]/g, '\\$&'); + return `[${filename}](${attachment.url})`; + }).join('\n'), + }; + return body.replace(/\$\{(\w+)\}/g, (placeholder, key: string) => { + if (!Object.hasOwn(formatMap, key)) return placeholder; + return JSON.stringify(formatMap[key as keyof typeof formatMap]).replace(/^"(.*)"$/, '$1'); + }); +} diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template index cf6027d5..99fa8905 100644 --- a/worker/wrangler.toml.template +++ b/worker/wrangler.toml.template @@ -136,6 +136,8 @@ ENABLE_AUTO_REPLY = false # """ # Frontend URL # FRONTEND_URL = "https://xxxx.xxx" +# Backend public URL for signed webhook attachment links +# BACKEND_URL = "https://temp-email-api.example.com" # Enable check junk mail # ENABLE_CHECK_JUNK_MAIL = false # junk mail check list: reject registered failure/error results; none and SPF/DKIM neutral are treated as absent