feat: select email for webhook tests (#1147)

This commit is contained in:
Dream Hunter
2026-09-12 16:25:25 +08:00
committed by GitHub
parent 479bb94525
commit 5e4823a115
17 changed files with 275 additions and 8 deletions
+1
View File
@@ -10,6 +10,7 @@
### Features
- feat: |Webhook| 测试弹框支持随机邮件或指定邮件 ID,校验请求体及邮箱归属并适配现有前端语言及中英文错误提示
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
+1
View File
@@ -10,6 +10,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: |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
@@ -0,0 +1,91 @@
import { test, expect } from '@playwright/test';
import http from 'node:http';
import { WORKER_URL, createTestAddress, seedTestMail } from '../../fixtures/test-helpers';
test('Webhook tests support random and specified mail IDs with ownership checks', async ({ request }) => {
const mailbox = await createTestAddress(request, 'webhookid');
const other = await createTestAddress(request, 'webhookother');
const headers = { Authorization: `Bearer ${mailbox.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 settings = {
enabled: true, url: `http://${process.env.CI ? 'e2e-runner' : 'localhost'}:${port}`,
method: 'POST', headers: '{"Content-Type":"application/json"}',
body: '{"id":"${id}","subject":"${subject}"}',
};
try {
await seedTestMail(request, mailbox.address, { subject: 'First selected email' });
await seedTestMail(request, mailbox.address, { subject: 'Second selected email' });
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { headers });
expect(list.ok()).toBe(true);
const { results } = await list.json();
expect(results).toHaveLength(2);
const selected = results[1];
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
const count = payloads.length;
expect((await request.post(`${WORKER_URL}${endpoint}`, { headers, data: settings })).ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 1);
if (endpoint.startsWith('/api/')) {
expect(results.map((mail: any) => String(mail.id))).toContain(payloads[count].id);
}
expect((await request.post(`${WORKER_URL}${endpoint}`, {
headers, data: { ...settings, mail_id: Number(selected.id) },
})).ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 2);
expect(payloads[count + 1].id).toBe(String(selected.id));
for (const mail_id of [0, -1, 1.5, '1', null]) {
expect((await request.post(`${WORKER_URL}${endpoint}`, {
headers, data: { ...settings, mail_id },
})).status()).toBe(400);
}
expect((await request.post(`${WORKER_URL}${endpoint}`, {
headers, data: { ...settings, mail_id: Number.MAX_SAFE_INTEGER },
})).status()).toBe(404);
expect(payloads).toHaveLength(count + 2);
for (const [lang, invalid, missing] of [
['zh', '无效的邮件 ID', '邮件不存在'],
['en', 'Invalid mail ID', 'Mail not found'],
]) {
for (const body of ['null', '[]', '1', 'true', '"text"', '{', '']) {
const response = await request.post(`${WORKER_URL}${endpoint}`, {
headers: { ...headers, 'x-lang': lang, 'Content-Type': 'application/json' },
data: body,
});
expect(response.status()).toBe(400);
expect(await response.text()).toBe(lang === 'zh' ? '无效的请求体' : 'Invalid request body');
}
for (const [mail_id, status, message] of [[0, 400, invalid], [999999999, 404, missing]] as const) {
const response = await request.post(`${WORKER_URL}${endpoint}`, {
headers: { ...headers, 'x-lang': lang }, data: { ...settings, mail_id },
});
expect(response.status()).toBe(status);
expect(await response.text()).toBe(message);
}
}
expect(payloads).toHaveLength(count + 2);
}
const count = payloads.length;
expect((await request.post(`${WORKER_URL}/api/webhook/test`, {
headers: { Authorization: `Bearer ${other.jwt}` },
data: { ...settings, mail_id: Number(selected.id) },
})).status()).toBe(404);
expect(payloads).toHaveLength(count);
} finally {
await request.delete(`${WORKER_URL}/admin/delete_address/${mailbox.address_id}`);
await request.delete(`${WORKER_URL}/admin/delete_address/${other.address_id}`);
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
+74
View File
@@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL } from '../../fixtures/test-helpers';
for (const [locale, mails, webhook, testLabel, specified, mailId, invalid] of [
['zh', '邮件', '邮件 Webhook', '测试', '指定 ID', '邮件 ID', '请输入有效的正整数邮件 ID'],
['en', 'Emails', 'Mail Webhook', 'Test', 'Specify ID', 'Email ID', 'Enter a valid positive integer email ID'],
['es', 'Correos', 'Webhook de correo', 'Prueba', 'Especificar ID', 'ID del correo', 'Introduce un ID de correo válido que sea un entero positivo'],
['pt-BR', 'E-mails', 'Webhook de e-mail', 'Teste', 'Especificar ID', 'ID do e-mail', 'Digite um ID de e-mail válido que seja um número inteiro positivo'],
['ja', 'メール', 'メールWebhook', 'テスト', 'ID を指定', 'メール ID', '有効な正の整数のメール ID を入力してください'],
['de', 'E-Mails', 'Mail-Webhook', 'Test', 'ID angeben', 'E-Mail-ID', 'Gib eine gültige positive ganze Zahl als E-Mail-ID ein'],
]) {
test(`Webhook test dialog translations: ${locale}`, async ({ page }) => {
await page.route('**/admin/mail_webhook/settings', route => route.fulfill({
json: { enabled: true, url: 'https://example.com/webhook', method: 'POST', headers: '{}', body: '{}' },
}));
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
await page.getByText(mails, { exact: true }).click();
await page.getByText(webhook, { exact: true }).click();
await page.getByRole('button', { name: testLabel, exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByText(specified, { exact: true }).click();
await expect(dialog.getByPlaceholder(mailId, { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: testLabel, exact: true }).click();
await expect(page.getByText(invalid, { exact: true })).toBeVisible();
});
}
test('Webhook test dialog selects random or specified email', async ({ page }) => {
const requests: any[] = [];
let fail = false;
await page.route('**/admin/mail_webhook/settings', route => route.fulfill({
json: { enabled: true, url: 'https://example.com/webhook', method: 'POST', headers: '{}', body: '{}' },
}));
await page.route('**/admin/mail_webhook/test', route => {
requests.push(route.request().postDataJSON());
return route.fulfill({ status: fail ? 404 : 200, body: fail ? 'Mail not found' : '{"success":true}' });
});
await page.goto(`${FRONTEND_URL}/zh/admin`);
await page.getByText('邮件', { exact: true }).click();
await page.getByText('邮件 Webhook', { exact: true }).click();
const open = page.locator('#app').getByRole('button', { name: '测试', exact: true });
await open.click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('随机邮件', { exact: true })).toBeVisible();
await expect(dialog.getByPlaceholder('邮件 ID', { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: '取消', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests).toHaveLength(0);
await open.click();
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests).toHaveLength(1);
expect(requests[0]).not.toHaveProperty('mail_id');
await open.click();
await dialog.getByText('指定 ID', { exact: true }).click();
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(page.getByText('请输入有效的正整数邮件 ID', { exact: true })).toBeVisible();
expect(requests).toHaveLength(1);
await dialog.getByPlaceholder('邮件 ID', { exact: true }).fill('123');
fail = true;
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(page.getByText(/Mail not found/).first()).toBeVisible();
await expect(dialog).toBeVisible();
expect(requests[1].mail_id).toBe(123);
fail = false;
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests[2].mail_id).toBe(123);
await open.click();
await dialog.getByText('随机邮件', { exact: true }).click();
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests[3]).not.toHaveProperty('mail_id');
});
+39 -2
View File
@@ -163,6 +163,10 @@ const handlePresetSelect = (key: number) => {
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
const enableWebhook = ref(false)
const showTestModal = ref(false)
const testMode = ref('random')
const testMailId = ref<number | null>(null)
const testing = ref(false)
const fetchData = async () => {
try {
@@ -188,15 +192,27 @@ const saveSettings = async () => {
}
const testSettings = async () => {
if (testing.value) return
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
if (testMode.value === 'specified' && (!Number.isSafeInteger(testMailId.value) || (testMailId.value ?? 0) <= 0)) {
message.error(t('invalidMailId'))
return
}
testing.value = true
try {
await props.testSettings(webhookSettings.value)
await props.testSettings({
...webhookSettings.value,
...(testMode.value === 'specified' ? { mail_id: testMailId.value } : {}),
})
message.success(t('successTip'))
showTestModal.value = false
} catch (error) {
message.error((error as Error).message || "error");
} finally {
testing.value = false
}
}
@@ -214,7 +230,7 @@ onMounted(async () => {
{{ t('presets') }}
</n-button>
</n-dropdown>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
<n-button v-if="webhookSettings.enabled" @click="showTestModal = true" secondary>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary">
@@ -242,6 +258,27 @@ onMounted(async () => {
</div>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" />
<n-modal v-model:show="showTestModal" preset="card" :title="t('test')"
style="width: min(420px, calc(100vw - 32px))" :mask-closable="!testing"
:close-on-esc="!testing" :closable="!testing">
<n-radio-group v-model:value="testMode" :disabled="testing">
<n-space>
<n-radio value="random">{{ t('randomMail') }}</n-radio>
<n-radio value="specified">{{ t('specifiedMail') }}</n-radio>
</n-space>
</n-radio-group>
<n-form-item v-if="testMode === 'specified'" :label="t('mailId')" style="margin-top: 16px">
<n-input-number v-model:value="testMailId" :min="1" :max="Number.MAX_SAFE_INTEGER"
:precision="0" :show-button="false" :disabled="testing" :placeholder="t('mailId')"
style="width: 100%" />
</n-form-item>
<template #footer>
<n-flex justify="end">
<n-button :disabled="testing" @click="showTestModal = false">{{ t('cancel') }}</n-button>
<n-button type="primary" :loading="testing" @click="testSettings">{{ t('test') }}</n-button>
</n-flex>
</template>
</n-modal>
</div>
</template>
+5
View File
@@ -1,4 +1,9 @@
export const deMessages = {
"components.WebhookComponent.randomMail": "Zufällige E-Mail",
"components.WebhookComponent.specifiedMail": "ID angeben",
"components.WebhookComponent.mailId": "E-Mail-ID",
"components.WebhookComponent.invalidMailId": "Gib eine gültige positive ganze Zahl als E-Mail-ID ein",
"components.WebhookComponent.cancel": "Abbrechen",
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
"views.index.SendMail.composeMail": "E-Mail verfassen",
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
+5
View File
@@ -1,4 +1,9 @@
export const esMessages = {
"components.WebhookComponent.randomMail": "Correo aleatorio",
"components.WebhookComponent.specifiedMail": "Especificar ID",
"components.WebhookComponent.mailId": "ID del correo",
"components.WebhookComponent.invalidMailId": "Introduce un ID de correo válido que sea un entero positivo",
"components.WebhookComponent.cancel": "Cancelar",
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
"views.index.SendMail.composeMail": "Redactar correo",
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
+5
View File
@@ -1,4 +1,9 @@
export const jaMessages = {
"components.WebhookComponent.randomMail": "ランダムなメール",
"components.WebhookComponent.specifiedMail": "ID を指定",
"components.WebhookComponent.mailId": "メール ID",
"components.WebhookComponent.invalidMailId": "有効な正の整数のメール ID を入力してください",
"components.WebhookComponent.cancel": "キャンセル",
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
"views.index.SendMail.composeMail": "メールを作成",
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
+5
View File
@@ -1,4 +1,9 @@
export const ptBRMessages = {
"components.WebhookComponent.randomMail": "E-mail aleatório",
"components.WebhookComponent.specifiedMail": "Especificar ID",
"components.WebhookComponent.mailId": "ID do e-mail",
"components.WebhookComponent.invalidMailId": "Digite um ID de e-mail válido que seja um número inteiro positivo",
"components.WebhookComponent.cancel": "Cancelar",
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
"views.index.SendMail.composeMail": "Escrever e-mail",
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
+5
View File
@@ -1,5 +1,10 @@
export const MESSAGE_REGISTRY = {
"components.WebhookComponent": {
"randomMail": { "en": "Random email", "zh": "随机邮件" },
"specifiedMail": { "en": "Specify ID", "zh": "指定 ID" },
"mailId": { "en": "Email ID", "zh": "邮件 ID" },
"invalidMailId": { "en": "Enter a valid positive integer email ID", "zh": "请输入有效的正整数邮件 ID" },
"cancel": { "en": "Cancel", "zh": "取消" },
"enable": {
"en": "Enable",
"zh": "启用"
@@ -118,3 +118,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.
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.
@@ -118,3 +118,5 @@
```
启用 AI 邮件内容提取后,Webhook 模板可使用 `aiExtractType``aiExtractResult``aiExtractResultText` 占位符。未提取到结果时这些字段为空字符串。
点击“测试”会弹出选择框:默认随机选择邮件,也可以选择“指定 ID”并输入邮件 ID。指定邮件不存在时会报错,不会回退随机;邮箱页面只能使用当前邮箱的邮件,管理员页面可指定任意邮件。现有测试接口 `/api/webhook/test``/admin/mail_webhook/test` 的请求 Body 支持可选正整数 `mail_id`,不传则沿用随机逻辑。页面仅在测试请求中传入该参数,不会保存到 Webhook 配置。
+16 -3
View File
@@ -3,6 +3,7 @@ import { CONSTANTS } from "../constants";
import { WebhookSettings, RawMailRow } from "../models";
import { commonParseMail, sendWebhook } from "../common";
import { resolveRawEmail } from "../gzip";
import i18n from "../i18n";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<WebhookSettings>(
@@ -20,12 +21,24 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
}
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
// random raw email
const mailRow = await c.env.DB.prepare(
const msgs = i18n.getMessagesbyContext(c);
const settings = await c.req.json<WebhookSettings & { mail_id?: number }>().catch(() => null);
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
return c.text(msgs.InvalidRequestBodyMsg, 400);
}
const requestedMailId = settings.mail_id;
if (requestedMailId !== undefined && (!Number.isSafeInteger(requestedMailId) || requestedMailId <= 0)) {
return c.text(msgs.InvalidMailIdMsg, 400);
}
const mailRow = requestedMailId !== undefined ? await c.env.DB.prepare(
`SELECT * FROM raw_mails WHERE id = ?`
).bind(requestedMailId).first<RawMailRow>() : await c.env.DB.prepare(
`SELECT * FROM raw_mails ORDER BY RANDOM() LIMIT 1`
).first<RawMailRow>();
const mailId = mailRow?.id;
if (requestedMailId !== undefined && !mailRow) {
return c.text(msgs.MailNotFoundMsg, 404);
}
const raw = mailRow ? await resolveRawEmail(mailRow) : "";
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
const parsedEmail = await commonParseMail(parsedEmailContext);
+3
View File
@@ -1,6 +1,9 @@
import { LocaleMessages } from "./type";
const messages: LocaleMessages = {
InvalidRequestBodyMsg: "Invalid request body",
InvalidMailIdMsg: "Invalid mail ID",
MailNotFoundMsg: "Mail not found",
CustomAuthPasswordMsg: "You have enabled the private site password, please provide the password",
UserTokenExpiredMsg: "Your token has expired, please login again",
UserAcceesTokenExpiredMsg: "Your access token has expired, please refresh the page",
+3
View File
@@ -1,4 +1,7 @@
export type LocaleMessages = {
InvalidRequestBodyMsg: string
InvalidMailIdMsg: string
MailNotFoundMsg: string
CustomAuthPasswordMsg: string
UserTokenExpiredMsg: string
UserAcceesTokenExpiredMsg: string
+3
View File
@@ -1,6 +1,9 @@
import { LocaleMessages } from "./type";
const messages: LocaleMessages = {
InvalidRequestBodyMsg: "无效的请求体",
InvalidMailIdMsg: "无效的邮件 ID",
MailNotFoundMsg: "邮件不存在",
CustomAuthPasswordMsg: "你已启用私有站点密码,请提供密码",
UserTokenExpiredMsg: "您的令牌已过期, 请重新登录",
UserAcceesTokenExpiredMsg: "您的访问令牌已过期, 请刷新页面",
+15 -3
View File
@@ -35,13 +35,25 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
}
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
const msgs = i18n.getMessagesbyContext(c);
const settings = await c.req.json<WebhookSettings & { mail_id?: number }>().catch(() => null);
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
return c.text(msgs.InvalidRequestBodyMsg, 400);
}
const requestedMailId = settings.mail_id;
if (requestedMailId !== undefined && (!Number.isSafeInteger(requestedMailId) || requestedMailId <= 0)) {
return c.text(msgs.InvalidMailIdMsg, 400);
}
const { address } = c.get("jwtPayload");
// random raw email
const mailRow = await c.env.DB.prepare(
const mailRow = requestedMailId !== undefined ? await c.env.DB.prepare(
`SELECT * FROM raw_mails WHERE id = ? AND address = ?`
).bind(requestedMailId, address).first<RawMailRow>() : await c.env.DB.prepare(
`SELECT * FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
).bind(address).first<RawMailRow>();
const mailId = mailRow?.id;
if (requestedMailId !== undefined && !mailRow) {
return c.text(msgs.MailNotFoundMsg, 404);
}
const raw = mailRow ? await resolveRawEmail(mailRow) : "";
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
const parsedEmail = await commonParseMail(parsedEmailContext);