mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-11 18:37:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7225cf1d85 |
@@ -10,6 +10,7 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Webhook| 新增首个邮件附件的 24 小时短签名路径 `${firstAttachmentPath}`,无需 S3 即可通过后端临时读取附件(issue #1142)
|
||||
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
||||
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
||||
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Webhook| Add the 24-hour signed `${firstAttachmentPath}` path for temporarily retrieving the first email attachment through the backend without S3 (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
|
||||
|
||||
@@ -126,6 +126,73 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('signed first attachment path serves only the matching attachment', 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: JSON.stringify({ firstAttachmentPath: '${firstAttachmentPath}' }),
|
||||
},
|
||||
});
|
||||
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}--`,
|
||||
].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.firstAttachmentPath).toMatch(/^\/open_api\/a\/\d+\/\d+\/[A-Za-z0-9_-]{43}$/);
|
||||
|
||||
const attachmentRes = await request.get(`${WORKER_URL}${payload.firstAttachmentPath}`);
|
||||
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(Buffer.from(await attachmentRes.body())).toEqual(attachment);
|
||||
|
||||
const lastCharacter = payload.firstAttachmentPath.slice(-1);
|
||||
const tamperedPath = `${payload.firstAttachmentPath.slice(0, -1)}${lastCharacter === 'A' ? 'B' : 'A'}`;
|
||||
const tamperedRes = await request.get(`${WORKER_URL}${tamperedPath}`);
|
||||
expect(tamperedRes.status()).toBe(404);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('webhook is NOT called when disabled', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
"firstAttachmentPath": "${firstAttachmentPath}",
|
||||
"aiExtractType": "${aiExtractType}",
|
||||
"aiExtractResult": "${aiExtractResult}",
|
||||
"aiExtractResultText": "${aiExtractResultText}",
|
||||
@@ -118,3 +119,5 @@ 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.
|
||||
|
||||
`firstAttachmentPath` is a backend-relative path for the first attachment. It is signed with `JWT_SECRET` and expires after 24 hours. To produce an absolute URL, prepend your Worker origin in the template, for example `https://temp-email-api.example.com${firstAttachmentPath}`. The value is empty when the email has no attachment. Files other than PNG, JPEG, GIF, or WebP images are served as downloads. The link becomes unavailable when the email is deleted or attachments are removed by configuration.
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
"firstAttachmentPath": "${firstAttachmentPath}",
|
||||
"aiExtractType": "${aiExtractType}",
|
||||
"aiExtractResult": "${aiExtractResult}",
|
||||
"aiExtractResultText": "${aiExtractResultText}",
|
||||
@@ -118,3 +119,5 @@
|
||||
```
|
||||
|
||||
启用 AI 邮件内容提取后,Webhook 模板可使用 `aiExtractType`、`aiExtractResult`、`aiExtractResultText` 占位符。未提取到结果时这些字段为空字符串。
|
||||
|
||||
`firstAttachmentPath` 是首个附件的后端相对路径,链接使用 `JWT_SECRET` 签名并在 24 小时后失效。需要完整 URL 时,请在模板中拼接自己的 Worker 地址,例如 `https://temp-email-api.example.com${firstAttachmentPath}`。邮件没有附件时该字段为空;非 PNG、JPEG、GIF、WebP 图片会作为文件下载。邮件被删除或配置已移除附件时,链接不可用。
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Hono } from 'hono'
|
||||
import utils from './utils';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { isS3Enabled } from './mails_api/s3_attachment';
|
||||
import { isAnySendMailEnabled } from './common';
|
||||
import { commonParseMail, isAnySendMailEnabled } from './common';
|
||||
import { getWebhookAttachment } from './open_api/webhook_attachment';
|
||||
|
||||
const api = new Hono<HonoCustomType>
|
||||
|
||||
@@ -72,4 +73,8 @@ api.get('/open_api/settings', async (c) => {
|
||||
});
|
||||
})
|
||||
|
||||
api.get('/open_api/a/:mail_id/:expires/:signature', (c) => (
|
||||
getWebhookAttachment(c, commonParseMail)
|
||||
))
|
||||
|
||||
export { api }
|
||||
|
||||
+15
-7
@@ -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 { createWebhookAttachmentPath } from './open_api/webhook_attachment';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
@@ -839,11 +840,12 @@ export async function sendWebhook(
|
||||
): Promise<{ success: boolean, message?: string }> {
|
||||
// send webhook
|
||||
let body = settings.body;
|
||||
for (const key of Object.keys(formatMap)) {
|
||||
const values = { firstAttachmentPath: '', ...formatMap };
|
||||
for (const key of Object.keys(values)) {
|
||||
body = body.replace(
|
||||
new RegExp(`\\$\\{${key}\\}`, "g"),
|
||||
JSON.stringify(
|
||||
formatMap[key as keyof WebhookMail]
|
||||
values[key as keyof typeof values]
|
||||
).replace(/^"(.*)"$/, '$1')
|
||||
);
|
||||
}
|
||||
@@ -893,17 +895,23 @@ 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 = await c.env.DB.prepare(
|
||||
`SELECT id, created_at FROM raw_mails WHERE address = ? AND message_id IS ? ORDER BY id DESC LIMIT 1`
|
||||
).bind(address, message_id).first<{ id: number, created_at: string }>();
|
||||
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const firstAttachmentPath = mailRow
|
||||
&& parsedEmail?.attachments?.length
|
||||
&& webhookList.some((settings) => settings.body.includes('${firstAttachmentPath}'))
|
||||
? await createWebhookAttachmentPath(c.env.JWT_SECRET, mailRow.id, address, mailRow.created_at)
|
||||
: '';
|
||||
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}` : "",
|
||||
id: String(mailRow?.id || ""),
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailRow?.id || ""}` : "",
|
||||
firstAttachmentPath,
|
||||
from: parsedEmail?.sender || "",
|
||||
to: address,
|
||||
subject: parsedEmail?.subject || "",
|
||||
|
||||
@@ -48,4 +48,5 @@ export const remove_attachment_if_need = async (
|
||||
});
|
||||
}
|
||||
parsedEmailContext.rawEmail = msg.asRaw();
|
||||
parsedEmailContext.parsedEmail = undefined;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export class AdminWebhookSettings {
|
||||
export type WebhookMail = {
|
||||
id: string;
|
||||
url?: string;
|
||||
firstAttachmentPath?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
@@ -160,6 +161,7 @@ export class WebhookSettings {
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
"firstAttachmentPath": "${firstAttachmentPath}",
|
||||
"aiExtractType": "${aiExtractType}",
|
||||
"aiExtractResult": "${aiExtractResult}",
|
||||
"aiExtractResultText": "${aiExtractResultText}",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { resolveRawEmail } from '../gzip';
|
||||
import { RawMailRow } from '../models';
|
||||
|
||||
const WEBHOOK_ATTACHMENT_TTL_SECONDS = 24 * 60 * 60;
|
||||
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(/=+$/, '');
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const getSignaturePayload = (
|
||||
mailId: number, address: string, createdAt: string, expires: number
|
||||
): Uint8Array => textEncoder.encode(JSON.stringify([
|
||||
'webhook-attachment-v1', mailId, address, createdAt, expires
|
||||
]));
|
||||
|
||||
export const createWebhookAttachmentPath = async (
|
||||
secret: string, mailId: number, address: string, createdAt: string
|
||||
): 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)
|
||||
);
|
||||
return `/open_api/a/${mailId}/${expires}/${encodeBase64Url(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export const getWebhookAttachment = async (
|
||||
c: Context<HonoCustomType>,
|
||||
parseMail: (context: ParsedEmailContext) => Promise<ParsedEmailContext['parsedEmail']>
|
||||
): Promise<Response> => {
|
||||
const mailId = Number(c.req.param('mail_id'));
|
||||
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(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)
|
||||
);
|
||||
if (!valid) return c.text('Not Found', 404);
|
||||
|
||||
const rawEmail = await resolveRawEmail(mail);
|
||||
const parsedEmail = await parseMail({ rawEmail });
|
||||
const attachment = parsedEmail?.attachments?.[0];
|
||||
if (!attachment) return c.text('Not Found', 404);
|
||||
|
||||
const inline = SAFE_INLINE_IMAGE_TYPES.has(attachment.mimeType.toLowerCase());
|
||||
return new Response(Uint8Array.from(attachment.content).buffer, {
|
||||
headers: {
|
||||
'Cache-Control': `private, max-age=${expires - now}`,
|
||||
'Content-Disposition': inline ? 'inline' : 'attachment',
|
||||
'Content-Type': inline ? attachment.mimeType : 'application/octet-stream',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user