feat: add signed webhook attachment URLs

This commit is contained in:
dreamhunter2333
2026-09-12 18:12:46 +08:00
parent 5e4823a115
commit a205fc6185
22 changed files with 491 additions and 16 deletions
+1
View File
@@ -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: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
+1
View File
@@ -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
+2
View File
@@ -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}}
+2
View File
@@ -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}}
"""
+1
View File
@@ -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}}
+185
View File
@@ -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<void>(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: '<svg onload="alert(1)"/>' },
];
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<void>(resolve => server.close(() => resolve()));
}
});
});
}
+90
View File
@@ -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: <webhook-attachment-${Date.now()}@test>`,
`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();
@@ -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 emails 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`.
@@ -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
@@ -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`
@@ -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 有限,可能会导致大邮件解析超时
@@ -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<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<WebhookSettings>(
@@ -43,6 +44,7 @@ async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
const parsedEmail = await commonParseMail(parsedEmailContext);
const res = await sendWebhook(settings, {
attachments: await getWebhookAttachments(c.env, mailRow, parsedEmail?.attachments),
id: mailId || "0",
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
from: parsedEmail?.sender || "test@test.com",
+3
View File
@@ -4,6 +4,7 @@ import utils from './utils';
import { CONSTANTS } from './constants';
import { isS3Enabled } from './mails_api/s3_attachment';
import { isAnySendMailEnabled } from './common';
import { getWebhookAttachment } from './open_api/webhook_attachment';
const api = new Hono<HonoCustomType>
@@ -72,4 +73,6 @@ api.get('/open_api/settings', async (c) => {
});
})
api.get('/open_api/a/:mail_id/:index/:expires/:signature', getWebhookAttachment)
export { api }
+11 -14
View File
@@ -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<HonoCustomType>,
address: string,
parsedEmailContext: ParsedEmailContext,
message_id: string | null,
storedMailId: number | undefined,
aiExtract?: ExtractResult | null
): Promise<void> {
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<string>("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 || "",
+4
View File
@@ -48,4 +48,8 @@ export const remove_attachment_if_need = async (
});
}
parsedEmailContext.rawEmail = msg.asRaw();
parsedEmailContext.parsedEmail = {
...parsedEmail,
attachments: [],
};
}
+4 -2
View File
@@ -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<HonoCustomType>,
toAddress, parsedEmailContext, message_id, aiExtractResult
toAddress, parsedEmailContext, storedMailId, aiExtractResult
);
} catch (error) {
console.error("send webhook error", error);
+2
View File
@@ -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<HonoCustomType>): Promise<Response
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
const parsedEmail = await commonParseMail(parsedEmailContext);
const res = await sendWebhook(settings, {
attachments: await getWebhookAttachments(c.env, mailRow, parsedEmail?.attachments),
id: mailId || "0",
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
from: parsedEmail?.sender || "test@test.com",
+1
View File
@@ -26,6 +26,7 @@ export class AdminWebhookSettings {
export type WebhookMail = {
id: string;
url?: string;
attachments?: { filename: string, mimeType: string, url: string }[];
from: string;
to: string;
subject: string;
+57
View File
@@ -0,0 +1,57 @@
import { Context } from 'hono';
import { resolveRawEmail } from '../gzip';
import { RawMailRow } from '../models';
import { commonParseMail } from '../common';
import {
WEBHOOK_ATTACHMENT_TTL_SECONDS, SAFE_INLINE_IMAGE_TYPES,
decodeBase64Url, getSigningKey, getSignaturePayload
} from '../utils/webhook';
export const getWebhookAttachment = async (
c: Context<HonoCustomType>
): Promise<Response> => {
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<RawMailRow>();
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',
},
});
}
+1
View File
@@ -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
+80
View File
@@ -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<CryptoKey> } | 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<CryptoKey> => {
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<string> => {
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<NonNullable<WebhookMail['attachments']>> => {
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');
});
}
+2
View File
@@ -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