feat: add webhook preset templates for Message Pusher, Bark, and ntfy (#877)

This commit is contained in:
Bowl42
2026-03-06 19:53:08 +08:00
committed by GitHub
parent f5ca8afcce
commit fca9bade48
6 changed files with 296 additions and 115 deletions

View File

@@ -13,6 +13,7 @@
- feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则
- feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接
- feat: |SMTP| SMTP 代理服务支持 STARTTLS通过 `smtp_tls_cert``smtp_tls_key` 环境变量配置
- feat: |Webhook| Webhook 设置页面新增预设模板下拉菜单,支持 Message Pusher、Bark、ntfy 一键填充配置
### Bug Fixes

View File

@@ -13,6 +13,7 @@
- feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules
- feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link
- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables
- feat: |Webhook| Add preset templates dropdown to Webhook settings page, supporting one-click fill for Message Pusher, Bark, and ntfy
### Bug Fixes

View File

@@ -11,32 +11,39 @@ test.describe('Inbox Browser Flow', () => {
test('login via JWT, view inbox, open email', async ({ page }) => {
// Create API context for setup
const api = await apiRequest.newContext();
let jwt: string | undefined;
const { jwt, address } = await createTestAddress(api, 'inbox-browser');
try {
const created = await createTestAddress(api, 'inbox-browser');
jwt = created.jwt;
const address = created.address;
// Seed an email
const subject = `Browser Test ${Date.now()}`;
await seedTestMail(api, address, {
subject,
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
});
// Seed an email
const subject = `Browser Test ${Date.now()}`;
await seedTestMail(api, address, {
subject,
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
});
// Login via JWT query param with /en/ path to force English locale
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// Login via JWT query param with /en/ path to force English locale
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// The mail subject should be visible in the inbox list item
const mailItem = page.getByRole('listitem').getByText(subject);
await expect(mailItem).toBeVisible({ timeout: 10_000 });
// The mail subject should be visible in the inbox list item
const mailItem = page.getByRole('listitem').getByText(subject);
await expect(mailItem).toBeVisible({ timeout: 10_000 });
// Click to open the email
await mailItem.click();
// Click to open the email
await mailItem.click();
// Verify the email detail panel shows the subject as a heading
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
// Cleanup
await deleteAddress(api, jwt);
await api.dispose();
// Verify the email detail panel shows the subject as a heading
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
});

View File

@@ -12,86 +12,94 @@ import { request as apiRequest } from '@playwright/test';
test.describe('Reply HTML & XSS Sanitization', () => {
test('reply to HTML email — XSS payloads stripped, HTML preserved', async ({ page }) => {
const api = await apiRequest.newContext();
await deleteAllMailpitMessages(api);
let jwt: string | undefined;
const { jwt, address } = await createTestAddress(api, 'reply-xss');
try {
await deleteAllMailpitMessages(api);
// Request send access so Reply can navigate to compose form
await requestSendAccess(api, jwt);
const created = await createTestAddress(api, 'reply-xss');
jwt = created.jwt;
const address = created.address;
// Seed email with XSS payloads embedded in HTML
const xssHtml = [
'<div>',
' <h1>Important Message</h1>',
' <p>Please review this content.</p>',
' <script>alert("xss")</script>',
' <img src=x onerror="alert(1)">',
' <a href="javascript:alert(2)">click me</a>',
' <p style="color:red">Styled paragraph</p>',
'</div>',
].join('\n');
// Request send access so Reply can navigate to compose form
await requestSendAccess(api, jwt);
await seedTestMail(api, address, {
subject: 'XSS Test Email',
html: xssHtml,
from: 'attacker@test.example.com',
});
// Seed email with XSS payloads embedded in HTML
const xssHtml = [
'<div>',
' <h1>Important Message</h1>',
' <p>Please review this content.</p>',
' <script>alert("xss")</script>',
' <img src=x onerror="alert(1)">',
' <a href="javascript:alert(2)">click me</a>',
' <p style="color:red">Styled paragraph</p>',
'</div>',
].join('\n');
// Single dialog handler with phase tracking.
// During email rendering, the mail viewer uses an unsandboxed iframe so
// inline event handlers like onerror may fire — we dismiss those.
// After clicking Reply, any dialog means the compose path failed to sanitize.
let inComposePhase = false;
let composeDialogAppeared = false;
page.on('dialog', async (dialog) => {
if (inComposePhase) composeDialogAppeared = true;
await dialog.dismiss();
});
await seedTestMail(api, address, {
subject: 'XSS Test Email',
html: xssHtml,
from: 'attacker@test.example.com',
});
// Login with English locale
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// Single dialog handler with phase tracking.
// During email rendering, the mail viewer uses an unsandboxed iframe so
// inline event handlers like onerror may fire — we dismiss those.
// After clicking Reply, any dialog means the compose path failed to sanitize.
let inComposePhase = false;
let composeDialogAppeared = false;
page.on('dialog', async (dialog) => {
if (inComposePhase) composeDialogAppeared = true;
await dialog.dismiss();
});
// Open the email (use listitem to avoid strict mode violation
// when detail panel also shows the subject)
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
await expect(mailItem).toBeVisible({ timeout: 10_000 });
await mailItem.click();
// Login with English locale
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// Wait for Reply button to appear — signals email content has rendered
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
await expect(replyButton).toBeVisible({ timeout: 10_000 });
// Open the email (use listitem to avoid strict mode violation
// when detail panel also shows the subject)
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
await expect(mailItem).toBeVisible({ timeout: 10_000 });
await mailItem.click();
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
inComposePhase = true;
await replyButton.click();
// Wait for Reply button to appear — signals email content has rendered
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
await expect(replyButton).toBeVisible({ timeout: 10_000 });
// In the reply compose area, check that the forwarded HTML is sanitized:
// - <script> tags should be removed
// - onerror attributes should be removed
// - javascript: URLs should be removed
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
await expect(composeArea).toBeVisible({ timeout: 5_000 });
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
inComposePhase = true;
await replyButton.click();
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
// fall back to innerHTML() for contenteditable elements
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
const content = tagName === 'textarea'
? await composeArea.inputValue()
: await composeArea.innerHTML();
// In the reply compose area, check that the forwarded HTML is sanitized:
// - <script> tags should be removed
// - onerror attributes should be removed
// - javascript: URLs should be removed
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
await expect(composeArea).toBeVisible({ timeout: 5_000 });
// Verify content is non-empty (guard against vacuous pass)
expect(content.length).toBeGreaterThan(0);
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
// fall back to innerHTML() for contenteditable elements
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
const content = tagName === 'textarea'
? await composeArea.inputValue()
: await composeArea.innerHTML();
// XSS vectors must be stripped
expect(content).not.toContain('<script>');
expect(content).not.toContain('onerror');
expect(content).not.toContain('javascript:');
// Verify content is non-empty (guard against vacuous pass)
expect(content.length).toBeGreaterThan(0);
// No XSS dialog should have fired in the compose area
expect(composeDialogAppeared).toBe(false);
// XSS vectors must be stripped
expect(content).not.toContain('<script>');
expect(content).not.toContain('onerror');
expect(content).not.toContain('javascript:');
// Cleanup
await deleteAddress(api, jwt);
await api.dispose();
// No XSS dialog should have fired in the compose area
expect(composeDialogAppeared).toBe(false);
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
});

View File

@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
import { FRONTEND_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
import { request as apiRequest } from '@playwright/test';
test.describe('Webhook Presets', () => {
test('selecting each preset fills valid settings', async ({ page, context }) => {
test.setTimeout(60_000);
const api = await apiRequest.newContext();
let jwt: string | undefined;
// Block popups (presets open doc URLs in new tabs)
context.on('page', (p) => p.close());
try {
const created = await createTestAddress(api, 'webhook-preset');
jwt = created.jwt;
// Login via JWT
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// Click "Webhook Settings" in the sidebar menu
const webhookMenu = page.getByText('Webhook Settings');
await expect(webhookMenu).toBeVisible({ timeout: 10_000 });
await webhookMenu.click();
// Verify presets button is visible
const presetsBtn = page.getByRole('button', { name: 'Presets' });
await expect(presetsBtn).toBeVisible({ timeout: 5000 });
// Helper to get form field value by label text
const getFieldValue = async (label: string): Promise<string> => {
// Find the label, then get the sibling textbox in the same form row
const row = page.locator('div', { hasText: new RegExp(`^${label}$`) }).locator('..');
const textbox = row.getByRole('textbox');
return textbox.inputValue();
};
// Define expected presets and their key fields
const expectedPresets = [
{
name: 'Message Pusher',
urlPattern: 'msgpusher.com',
bodyKeys: ['token', 'title', 'description', 'content'],
},
{
name: 'Bark',
urlPattern: 'api.day.app',
bodyKeys: ['title', 'body', 'group'],
},
{
name: 'ntfy',
urlPattern: 'ntfy.sh',
bodyKeys: ['topic', 'title', 'message', 'tags'],
},
];
for (const preset of expectedPresets) {
// Open dropdown and select preset
await presetsBtn.click();
const option = page.locator('.n-dropdown-option', { hasText: preset.name });
await expect(option).toBeVisible({ timeout: 5_000 });
await option.click();
// Wait for dropdown to close, then for URL field to contain preset pattern
await expect(option).toBeHidden({ timeout: 5_000 });
await page.waitForTimeout(500);
const allTextboxes = page.getByRole('textbox');
const count = await allTextboxes.count();
// Find URL, HEADERS, BODY values by reading all textboxes
let urlValue = '';
let headersValue = '';
let bodyValue = '';
for (let i = 0; i < count; i++) {
const val = await allTextboxes.nth(i).inputValue();
if (val.includes(preset.urlPattern)) {
urlValue = val;
} else if (val.includes('Content-Type')) {
headersValue = val;
} else if (val.includes('${subject}')) {
bodyValue = val;
}
}
// Verify URL
expect(urlValue, `${preset.name}: URL should contain ${preset.urlPattern}`).toContain(preset.urlPattern);
// Verify HEADERS is valid JSON with Content-Type
expect(headersValue, `${preset.name}: HEADERS should not be empty`).toBeTruthy();
const headers = JSON.parse(headersValue);
expect(headers, `${preset.name}: headers should have Content-Type`).toHaveProperty('Content-Type', 'application/json');
// Verify BODY is valid JSON with expected keys
expect(bodyValue, `${preset.name}: BODY should not be empty`).toBeTruthy();
const body = JSON.parse(bodyValue);
for (const key of preset.bodyKeys) {
expect(body, `${preset.name}: body should have key "${key}"`).toHaveProperty(key);
}
}
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
});

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, h } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DropdownOption } from 'naive-ui'
const props = defineProps({
fetchData: {
@@ -32,8 +33,7 @@ const { t } = useI18n({
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
enable: 'Enable',
messagePusherDemo: 'Fill with Message Pusher Demo',
messagePusherDoc: 'Message Pusher Doc',
presets: 'Presets',
fillInDemoTip: 'Please modify the URL and other settings to your own',
},
zh: {
@@ -43,8 +43,7 @@ const { t } = useI18n({
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
enable: '启用',
messagePusherDemo: '填入MessagePusher示例',
messagePusherDoc: 'MessagePusher文档',
presets: '示例模板',
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
}
}
@@ -58,26 +57,82 @@ class WebhookSettings {
body: string = JSON.stringify({}, null, 2)
}
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
interface WebhookPreset {
name: string
doc: string
settings: WebhookSettings
}
const messagePusherDemo = {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
} as WebhookSettings;
const presets: WebhookPreset[] = [
{
name: 'Message Pusher',
doc: 'https://github.com/songquanpeng/message-pusher',
settings: {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
},
},
{
name: 'Bark',
doc: 'https://github.com/Finb/Bark',
settings: {
enabled: true,
url: 'https://api.day.app/YOUR_KEY',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"title": "${subject}",
"body": "From: ${from}\nTo: ${to}\n\n${parsedText}",
"group": "email"
}, null, 2),
},
},
{
name: 'ntfy',
doc: 'https://docs.ntfy.sh/publish/',
settings: {
enabled: true,
url: 'https://ntfy.sh/YOUR_TOPIC',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"topic": "YOUR_TOPIC",
"title": "${subject}",
"message": "From: ${from}\nTo: ${to}\n\n${parsedText}",
"tags": ["envelope"]
}, null, 2),
},
},
]
const fillMessagePuhserDemo = () => {
Object.assign(webhookSettings.value, messagePusherDemo)
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({
label: preset.name,
key: index,
}))
const handlePresetSelect = (key: number) => {
const preset = presets[key]
if (!preset) {
message.error('Invalid preset')
return
}
Object.assign(webhookSettings.value, preset.settings)
message.success(t('fillInDemoTip'))
window.open(preset.doc, '_blank', 'noopener,noreferrer')
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
@@ -128,12 +183,11 @@ onMounted(async () => {
<div class="center">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
{{ t('messagePusherDoc') }}
</n-button>
<n-button @click="fillMessagePuhserDemo" secondary>
{{ t('messagePusherDemo') }}
</n-button>
<n-dropdown :options="presetDropdownOptions" @select="handlePresetSelect">
<n-button secondary>
{{ t('presets') }}
</n-button>
</n-dropdown>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
{{ t('test') }}
</n-button>