Compare commits

..
61 changed files with 523 additions and 927 deletions
+2 -2
View File
@@ -10,8 +10,7 @@
### Features
- feat: |Webhook| 测试弹框支持随机邮件或指定邮件 ID,校验请求体及邮箱归属并适配现有前端语言及中英文错误提示
- feat: |Webhook| 支持无需 S3 的多附件签名链接、纯 URL 与 Markdown 链接列表,绑定本次入库邮件并保留下载文件名(issue #1142
- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
@@ -23,6 +22,7 @@
### Bug Fixes
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload``data-onerror` 属性
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
+2 -2
View File
@@ -10,8 +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: |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: |Mailbox Login| Add a password-only switch that rejects legacy credentials, 30-day mailbox login JWTs renewed with less than 7 days remaining, and password reset for bound mailboxes
- 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
@@ -23,6 +22,7 @@
### Bug Fixes
- fix: |Mailbox Login| Cache both login methods using backend settings without decoding JWTs in the frontend; use non-expiring Telegram binding tokens independently of web mailbox login JWTs
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; move E2E test endpoints out of production code
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
-2
View File
@@ -30,8 +30,6 @@ 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,8 +21,6 @@ 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,7 +18,6 @@ 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}}
+52
View File
@@ -11,6 +11,58 @@ function signToken(payload: Record<string, unknown>) {
return `${header}.${body}.${signature}`;
}
test('password login tokens renew below seven days; legacy credentials do not renew', async ({ request }) => {
const mailbox = await createTestAddress(request, 'password-renewal');
const day = 24 * 60 * 60;
const now = Math.floor(Date.now() / 1000);
const identity = { address: mailbox.address, address_id: mailbox.address_id };
try {
for (const remainingDays of [8, 6]) {
const token = signToken({
...identity, type: 'address_password_login',
iat: now - (30 - remainingDays) * day, exp: now + remainingDays * day,
});
const response = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok()).toBe(true);
const settings = await response.json();
expect(settings.type).toBe('address_password_login');
if (remainingDays > 7) {
expect(settings.new_address_token).toBeNull();
continue;
}
expect(settings.new_address_token).toEqual(expect.any(String));
const renewed = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${settings.new_address_token}` },
});
expect(renewed.ok()).toBe(true);
const renewedSettings = await renewed.json();
expect(renewedSettings).toMatchObject({ ...identity, type: 'address_password_login', new_address_token: null });
expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * day);
}
const legacy = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
expect(legacy.ok()).toBe(true);
expect(await legacy.json()).toMatchObject({ ...identity, new_address_token: null });
for (const claims of [
{ type: 'telegram_binding' },
{ type: 'unknown' },
{ type: 'address_password_login' },
{ type: 'address_password_login', iat: now - 30 * day, exp: now - 1 },
{ type: 'address_password_login', iat: now, exp: now + 31 * day },
]) {
const response = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${signToken({ ...identity, ...claims })}` },
});
expect(response.status(), JSON.stringify(claims)).toBe(401);
}
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
async function expectRejected(request: APIRequestContext, jwt: string) {
for (const [method, path] of [
['GET', '/api/settings'],
+44
View File
@@ -30,6 +30,10 @@ test.describe('Address Password Login', () => {
headers: { Authorization: `Bearer ${loginBody.jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.type).toBe('address_password_login');
expect(settings.exp - settings.iat).toBe(30 * 24 * 60 * 60);
expect(settings.new_address_token).toBeNull();
} finally {
await deleteAddress(request, jwt);
}
@@ -164,4 +168,44 @@ test.describe('Address Password Login', () => {
await deleteAddress(request, jwt);
}
});
test('users can reset a mailbox password only while it is bound to them', async ({ request }) => {
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-user-reset');
const email = `pwd-reset-user-${Date.now()}@test.example.com`;
const password = hashPassword('password-reset-user');
const newPassword = hashPassword('replacement-mailbox-password');
try {
const enable = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: { enable: true, enableMailVerify: false },
});
expect(enable.ok()).toBe(true);
const register = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password } });
expect(register.ok()).toBe(true);
const login = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password } });
expect(login.ok()).toBe(true);
const { jwt: userJwt } = await login.json();
const headers = { 'x-user-token': userJwt };
const reset = (value = newPassword) => request.post(`${WORKER_URL}/user_api/address/${address_id}/reset_password`, {
headers, data: { new_password: value },
});
expect((await reset()).status()).toBe(403);
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: { ...headers, Authorization: `Bearer ${jwt}` },
});
expect(bind.ok()).toBe(true);
expect((await reset('plaintext')).status()).toBe(400);
expect((await reset()).ok()).toBe(true);
const mailboxLogin = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: newPassword },
});
expect(mailboxLogin.ok()).toBe(true);
const unbind = await request.post(`${WORKER_URL}/user_api/unbind_address`, {
headers, data: { address_id },
});
expect(unbind.ok()).toBe(true);
expect((await reset()).status()).toBe(403);
} finally {
await deleteAddress(request, jwt);
}
});
});
-185
View File
@@ -1,185 +0,0 @@
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()));
}
});
});
}
@@ -1,91 +0,0 @@
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()));
}
});
-90
View File
@@ -126,96 +126,6 @@ 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();
-74
View File
@@ -1,74 +0,0 @@
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');
});
+24 -10
View File
@@ -1,5 +1,6 @@
import { useGlobalState } from '../store'
import { h } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import axios from 'axios'
import i18n from '../i18n'
@@ -9,6 +10,7 @@ import { sanitizeHtml } from '../utils/sanitize-html'
import { APP_CONFIG } from '../config'
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
import { ErrorCode } from './error-codes'
import { updateLocalAddressCache } from '../utils/local-address-cache'
const API_BASE = APP_CONFIG.API_BASE || "";
const {
@@ -17,6 +19,8 @@ const {
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const localAddressCache = useLocalStorage('LocalAddressCache', []);
const instance = axios.create({
baseURL: API_BASE,
timeout: 30000,
@@ -55,7 +59,7 @@ const apiFetch = async (path, options = {}) => {
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
const adminAuthHeader = safeHeaderValue(adminAuth.value);
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
const authorizationHeader = safeBearerHeader(jwt.value);
const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value);
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
const initialResponse = await instance.request(path, {
@@ -122,6 +126,7 @@ const getOpenSettings = async (message, notification) => {
isS3Enabled: res["isS3Enabled"] || false,
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false,
addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
enableRedeemCode: res["enableRedeemCode"] || false,
redeemCodeUrl: res["redeemCodeUrl"] || "",
@@ -154,18 +159,27 @@ const getOpenSettings = async (message, notification) => {
}
const getSettings = async () => {
let addressToken = jwt.value;
try {
if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') {
return "";
if (!safeHeaderValue(addressToken)) return;
const res = await apiFetch('/api/settings', { addressJwt: addressToken });
if (jwt.value !== addressToken) return;
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, addressToken, res);
settings.value = res;
const renewedAddressToken = res.new_address_token;
if (!renewedAddressToken) return;
try {
const renewedSettings = await apiFetch('/api/settings', { addressJwt: renewedAddressToken });
if (jwt.value !== addressToken) return;
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, renewedAddressToken, renewedSettings);
addressToken = renewedAddressToken;
jwt.value = renewedAddressToken;
settings.value = renewedSettings;
} catch (error) {
console.error('Failed to renew mailbox JWT', error);
}
const res = await apiFetch("/api/settings");;
settings.value = {
address: res["address"],
auto_reply: res["auto_reply"],
send_balance: res["send_balance"],
};
} finally {
settings.value.fetched = true;
if (jwt.value === addressToken) settings.value.fetched = true;
}
}
@@ -98,10 +98,10 @@ const copyText = async (text) => {
<template>
<div class="credential-content">
<n-alert type="info" :show-icon="false" :bordered="false">
{{ t('tip') }}
{{ t(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
</n-alert>
<section class="credential-panel">
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
<h3 class="credential-title">{{ t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'addressCredential') }}</h3>
<div class="credential-section">
<div v-if="address" class="credential-field">
<span class="credential-label">{{ t('currentAddress') }}</span>
@@ -112,7 +112,7 @@ const copyText = async (text) => {
</n-button>
</div>
</div>
<div class="credential-field">
<div v-if="!openSettings.addressPasswordLoginOnly" class="credential-field">
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
<div class="credential-copy-row">
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
@@ -128,7 +128,7 @@ const copyText = async (text) => {
</div>
</section>
<n-collapse accordion class="credential-collapse">
<n-collapse v-if="!openSettings.addressPasswordLoginOnly" accordion class="credential-collapse">
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
@@ -4,6 +4,9 @@ import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import AddressCredentialContent from './AddressCredentialContent.vue'
import { useGlobalState } from '../store'
const { openSettings } = useGlobalState()
const props = defineProps({
show: {
@@ -34,7 +37,7 @@ const modalShow = computed({
</script>
<template>
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
<n-modal v-model:show="modalShow" preset="card" :title="t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'title')"
style="width: min(760px, calc(100vw - 32px));">
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
</n-modal>
+14 -27
View File
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getCachedAddresses } from '../utils/local-address-cache'
const props = defineProps({
showCopy: {
@@ -28,6 +29,8 @@ const {
} = useGlobalState()
const { t } = useScopedI18n('components.AddressSelect')
const { t: loginT } = useScopedI18n('views.common.Login')
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
const addressOptions = ref([])
const addressValue = ref(null)
@@ -45,21 +48,6 @@ const formatAddressLabel = (address) => {
return address.replace('@' + domain, `@${domainLabel}`);
}
const parseJwtAddress = (curJwt) => {
try {
const payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return payload.address;
} catch (e) {
return null;
}
}
const getOptionValue = (key, scope, payload, address) => {
if (optionValueMap.has(key)) {
const cached = optionValueMap.get(key)
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
}
const buildLocalOptions = (excludeAddresses = new Set()) => {
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
localAddressCache.value.push(jwt.value)
}
const children = localAddressCache.value
.map((curJwt) => {
const address = parseJwtAddress(curJwt);
if (!address) return null;
const children = getCachedAddresses(localAddressCache.value)
.map(({ token, address, type }, index) => {
if (excludeAddresses.has(address)) return null;
const label = formatAddressLabel(address);
const key = `local:${curJwt}`;
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
if (settings.value.address && address === settings.value.address) {
const isPasswordLogin = type === 'address_password_login';
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
const label = address
? `${formatAddressLabel(address)} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
: localAddressT('savedMailbox', { index: index + 1 });
const key = `local:${token}`;
const option = { label, value: getOptionValue(key, 'local', token, address), address };
if (token === jwt.value) {
addressValue.value = option.value;
}
return option;
@@ -207,7 +194,7 @@ onMounted(async () => {
await refreshAddressOptions();
});
watch([userJwt, isTelegram, () => settings.value.address], async () => {
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
await refreshAddressOptions();
});
</script>
+2 -39
View File
@@ -163,10 +163,6 @@ 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 {
@@ -192,27 +188,15 @@ 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,
...(testMode.value === 'specified' ? { mail_id: testMailId.value } : {}),
})
await props.testSettings(webhookSettings.value)
message.success(t('successTip'))
showTestModal.value = false
} catch (error) {
message.error((error as Error).message || "error");
} finally {
testing.value = false
}
}
@@ -230,7 +214,7 @@ onMounted(async () => {
{{ t('presets') }}
</n-button>
</n-dropdown>
<n-button v-if="webhookSettings.enabled" @click="showTestModal = true" secondary>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary">
@@ -258,27 +242,6 @@ 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,9 +1,4 @@
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,9 +1,4 @@
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,9 +1,4 @@
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,9 +1,4 @@
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...",
+24 -5
View File
@@ -1,10 +1,5 @@
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": "启用"
@@ -307,6 +302,10 @@ export const MESSAGE_REGISTRY = {
}
},
"components.AddressCredentialModal": {
"passwordOnlyTip": {
"en": "Save your mailbox password. Credential and login-link access are disabled. Bound mailboxes can also be opened from the user center.",
"zh": "请保存邮箱密码。凭据及链接登录已禁用;已绑定邮箱仍可从用户中心进入。"
},
"addressCredential": {
"en": "Address Credential",
"zh": "地址凭证"
@@ -1045,6 +1044,22 @@ export const MESSAGE_REGISTRY = {
}
},
"views.user.AddressManagement": {
"resetPassword": {
"en": "Reset Password",
"zh": "重置密码"
},
"resetPasswordTip": {
"en": "Set a new password for this bound mailbox without its old password.",
"zh": "为已绑定邮箱设置新密码,无需提供原邮箱密码。"
},
"newPasswordRequired": {
"en": "Enter a new password.",
"zh": "请输入新密码。"
},
"unbindPasswordTip": {
"en": "Save the mailbox password before unlinking so you can log in again.",
"zh": "解绑前请保存邮箱密码,以便之后重新登录。"
},
"actions": {
"en": "Actions",
"zh": "操作"
@@ -1933,6 +1948,10 @@ export const MESSAGE_REGISTRY = {
}
},
"views.index.LocalAddress": {
"savedMailbox": {
"en": "Saved mailbox {index}",
"zh": "已保存邮箱 {index}"
},
"actions": {
"en": "Actions",
"zh": "操作"
+1
View File
@@ -42,6 +42,7 @@ export const useGlobalState = createGlobalState(
showGithubForUser: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
addressPasswordLoginOnly: false,
enableAgentEmailInfo: false,
enableRedeemCode: false,
redeemCodeUrl: '',
+1 -2
View File
@@ -19,8 +19,7 @@ export const getRouterPathWithLang = (path: string, lang: string) => {
return getPathWithLocale(path, normalizedLang);
}
export const utcToLocalDate = (utcDate: string | null | undefined, useUTCDate: boolean) => {
if (!utcDate) return '';
export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
const utcDateString = `${utcDate} UTC`;
if (useUTCDate) {
return utcDateString;
+26
View File
@@ -0,0 +1,26 @@
export type CachedAddress = {
token: string;
address?: string;
type?: 'address_password_login' | null;
};
export const getCachedAddresses = (cache: (string | CachedAddress)[]): CachedAddress[] => cache
.map(entry => typeof entry === 'string' ? { token: entry } : entry)
.filter(entry => typeof entry?.token === 'string' && entry.token);
export const updateLocalAddressCache = (
cache: (string | CachedAddress)[], token: string,
{ address, type }: { address: string; type?: 'address_password_login' },
) => {
const entries = getCachedAddresses(cache);
const loginType = type ?? null;
const existing = entries.find(entry => entry.token === token
|| (entry.address === address && entry.type === loginType));
const updated = { token, address, type: loginType };
if (!existing) return [...entries, updated];
return entries.flatMap(entry => {
if (entry === existing) return [updated];
if (entry.token === token || (entry.address === address && entry.type === loginType)) return [];
return [entry];
});
};
+4 -11
View File
@@ -5,15 +5,14 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword, utcToLocalDate } from '../../utils'
import { hashPassword } from '../../utils'
import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const {
loading, adminTab, openSettings,
adminMailTabAddress, adminSendBoxTabAddress,
useUTCDate
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -275,19 +274,13 @@ const columns = computed(() => [
title: t('created_at'),
key: "created_at",
sorter: true,
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false,
render(row) {
return utcToLocalDate(row.created_at, useUTCDate.value);
}
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
},
{
title: t('updated_at'),
key: "updated_at",
sorter: true,
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false,
render(row) {
return utcToLocalDate(row.updated_at, useUTCDate.value);
}
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
},
{
title: t('source_meta'),
+2 -6
View File
@@ -4,9 +4,8 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { utcToLocalDate } from '../../utils';
const { loading, useUTCDate } = useGlobalState()
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useScopedI18n('views.admin.SenderAccess')
@@ -71,10 +70,7 @@ const columns = [
},
{
title: t('created_at'),
key: "created_at",
render(row) {
return utcToLocalDate(row.created_at, useUTCDate.value);
}
key: "created_at"
},
{
title: t('balance'),
+3 -6
View File
@@ -6,11 +6,11 @@ import { MenuFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword, utcToLocalDate } from '../../utils';
import { hashPassword } from '../../utils';
import UserAddressManagement from './UserAddressManagement.vue'
const { loading, openSettings, useUTCDate } = useGlobalState()
const { loading, openSettings } = useGlobalState()
const message = useMessage()
const { t } = useScopedI18n('views.admin.UserManagement')
@@ -193,10 +193,7 @@ const columns = [
},
{
title: t('created_at'),
key: "created_at",
render(row) {
return utcToLocalDate(row.created_at, useUTCDate.value);
}
key: "created_at"
},
{
title: t('actions'),
+4 -1
View File
@@ -66,6 +66,7 @@ const initLoginMethod = () => {
}
const login = async () => {
if (openSettings.value.addressPasswordLoginOnly) loginMethod.value = 'password';
if (loginMethod.value === 'password') {
// Password login
if (!loginAddress.value || !loginPassword.value) {
@@ -246,6 +247,8 @@ const showNewAddressTab = computed(() => {
return openSettings.value.enableUserCreateEmail;
});
watch(() => openSettings.value.addressPasswordLoginOnly, initLoginMethod);
onMounted(async () => {
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
await api.getOpenSettings(message, notification);
@@ -283,7 +286,7 @@ onMounted(async () => {
v-model:value="loginCfToken" />
<div class="switch-login-button">
<n-button v-if="openSettings?.enableAddressPassword"
<n-button v-if="openSettings?.enableAddressPassword && !openSettings.addressPasswordLoginOnly"
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
type="info" quaternary size="tiny">
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
+1 -1
View File
@@ -93,7 +93,7 @@ const changePassword = async () => {
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded class="account-card">
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
<n-button v-if="!openSettings.addressPasswordLoginOnly" @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
+19 -43
View File
@@ -3,63 +3,39 @@ import { ref, h, computed } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useScopedI18n } from '@/i18n/app'
import { NPopconfirm, NButton } from 'naive-ui'
import { getCachedAddresses } from '../../utils/local-address-cache'
import type { CachedAddress } from '../../utils/local-address-cache'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
const { jwt, openSettings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useScopedI18n('views.index.LocalAddress')
const { t: loginT } = useScopedI18n('views.common.Login')
const tabValue = ref('address')
const localAddressCache = useLocalStorage("LocalAddressCache", []);
const localAddressCache = useLocalStorage<(string | CachedAddress)[]>("LocalAddressCache", []);
const data = computed(() => {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
return localAddressCache.value.map((curJwt: string) => {
try {
const payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return {
valid: true,
address: payload.address,
jwt: curJwt
}
} catch (e) {
return {
valid: false,
address: `invalid jwt [${curJwt}]`,
jwt: curJwt
}
return getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
const isPasswordLogin = type === 'address_password_login';
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
return {
address: address
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
: t('savedMailbox', { index: index + 1 }),
jwt: token
}
})
}).filter(Boolean)
})
const bindAddress = async () => {
try {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
tabValue.value = 'address'
message.success(t('bindAddressSuccess'));
} catch (error) {
message.error((error as Error).message || "error");
}
const bindAddress = () => {
tabValue.value = 'address'
message.success(t('bindAddressSuccess'));
}
const columns = [
@@ -96,8 +72,8 @@ const columns = [
if (jwt.value === row.jwt) {
return;
}
localAddressCache.value = localAddressCache.value.filter(
(curJwt: string) => curJwt !== row.jwt
localAddressCache.value = getCachedAddresses(localAddressCache.value).filter(
entry => entry.token !== row.jwt
);
}
},
+71 -5
View File
@@ -6,12 +6,12 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import { getRouterPathWithLang, hashPassword } from '../../utils'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
const { jwt, openSettings, loading } = useGlobalState()
const message = useMessage()
const router = useRouter()
@@ -29,6 +29,43 @@ const targetUserEmail = ref('')
const showAddressCredential = ref(false)
const currentAddressCredential = ref('')
const credentialAddress = ref('')
const passwordResetAddress = ref(null)
const newPassword = ref('')
const confirmPassword = ref('')
const isResettingPassword = ref(false)
const { t: accountSettingsT } = useScopedI18n('views.index.AccountSettings')
const clearPasswordResetForm = () => {
passwordResetAddress.value = null;
newPassword.value = '';
confirmPassword.value = '';
}
const resetBoundAddressPassword = async () => {
if (!passwordResetAddress.value || isResettingPassword.value) return;
if (!newPassword.value) {
message.error(t('newPasswordRequired'));
return;
}
if (newPassword.value !== confirmPassword.value) {
message.error(accountSettingsT('passwordMismatch'));
return;
}
isResettingPassword.value = true;
try {
await api.fetch(`/user_api/address/${passwordResetAddress.value.id}/reset_password`, {
method: 'POST',
body: JSON.stringify({ new_password: await hashPassword(newPassword.value) }),
});
message.success(accountSettingsT('passwordChanged'));
clearPasswordResetForm();
} catch (error) {
message.error(error.message || 'error');
} finally {
isResettingPassword.value = false;
}
}
const showCredential = async (row) => {
try {
@@ -161,14 +198,26 @@ const columns = [
key: 'actions',
render(row) {
return h('div', [
h(NButton,
!openSettings.value.addressPasswordLoginOnly ? h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => showCredential(row)
},
{ default: () => credentialT('addressCredential') }
),
) : null,
openSettings.value.enableAddressPassword ? h(NButton,
{
tertiary: true,
type: 'warning',
onClick: () => {
newPassword.value = '';
confirmPassword.value = '';
passwordResetAddress.value = row;
},
},
{ default: () => t('resetPassword') }
) : null,
h(NPopconfirm,
{
onPositiveClick: () => changeMailAddress(row.id)
@@ -208,7 +257,7 @@ const columns = [
},
{ default: () => t('unbindAddress') }
),
default: () => t('unbindAddressTip')
default: () => t(openSettings.value.addressPasswordLoginOnly ? 'unbindPasswordTip' : 'unbindAddressTip')
}
),
])
@@ -227,6 +276,23 @@ watch([page, pageSize], async () => {
<template>
<div>
<n-modal :show="!!passwordResetAddress" @update:show="show => { if (!show && !isResettingPassword) clearPasswordResetForm() }"
preset="dialog" :title="t('resetPassword')" :mask-closable="!isResettingPassword" :closable="!isResettingPassword">
<p>{{ passwordResetAddress?.name }}</p>
<p>{{ t('resetPasswordTip') }}</p>
<n-form @submit.prevent="resetBoundAddressPassword">
<n-form-item :label="accountSettingsT('newPassword')">
<n-input v-model:value="newPassword" type="password" show-password-on="click" :disabled="isResettingPassword" />
</n-form-item>
<n-form-item :label="accountSettingsT('confirmPassword')">
<n-input v-model:value="confirmPassword" type="password" show-password-on="click"
:disabled="isResettingPassword" @keyup.enter="resetBoundAddressPassword" />
</n-form-item>
</n-form>
<template #action>
<n-button type="warning" :loading="isResettingPassword" @click="resetBoundAddressPassword">{{ t('resetPassword') }}</n-button>
</template>
</n-modal>
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
:jwt="currentAddressCredential" />
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
@@ -1,5 +1,29 @@
# Mail API
## Mailbox password login
`ADDRESS_PASSWORD_LOGIN_ONLY` defaults to `false` and only takes effect with `ENABLE_ADDRESS_PASSWORD=true`. It rejects legacy credentials for login and API access, and hides credential displays and automatic login links. Legacy credential links also fail API authentication. Existing mailboxes without passwords need a bound user or administrator to set one; no database migration is needed.
- Password login issues a 30-day JWT with `type: "address_password_login"`, `address`, `address_id`, `iat`, and `exp`. Mailbox APIs retain `Authorization: Bearer <jwt>` and use middleware for authentication.
- `GET /api/settings` returns this login information, `send_balance`, and `new_address_token`. A valid JWT with less than 7 days remaining receives a new 30-day token; otherwise the field is `null`. Expired JWTs require login again, and legacy credentials cannot obtain new tokens.
- When loading settings, the frontend validates the new token with another settings request before replacing the current token. Ordinary requests do not refresh tokens. External clients should also save `new_address_token`; the switch rejects legacy credentials used directly by SMTP/IMAP and Agent clients.
- The local cache uses server-returned mailbox information without decoding JWTs and retains both login methods independently. Historical token-only entries display “Saved mailbox” until selected and validated. Password-only login hides identified legacy entries without deleting them.
- Mailbox creation and authorized access through user accounts, administrators, and Telegram issue mailbox JWTs according to the switch. Telegram KV stores separate permanent `telegram_binding` tokens, which mailbox APIs reject. Expiration is ignored only for historical stored bindings accessed after Telegram identity verification; tokens submitted for new bindings must pass mailbox authentication.
### Reset a bound mailbox password
With `ENABLE_ADDRESS_PASSWORD` enabled, the user center offers password reset without the previous password:
```http
POST /user_api/address/:address_id/reset_password
x-user-token: <user JWT>
Content-Type: application/json
{"new_password":"<64-character lowercase SHA-256 hex digest of the new password>"}
```
A single SQL statement checks that the user exists and owns the binding, updating only the existing password and update time. Success returns `{"success":true}`; missing authentication returns 401, an unbound mailbox or disabled feature returns 403, and invalid input returns 400. Resetting a password does not revoke existing JWTs; valid JWTs can still renew. No session table or revocation state is added.
## Viewing Emails via Mail API
This is a `python` example using the `requests` library to view emails.
@@ -99,21 +99,6 @@ 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
@@ -126,7 +111,6 @@ 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}",
@@ -134,9 +118,3 @@ 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`.
+1 -1
View File
@@ -50,6 +50,7 @@ When `ADMIN_API_IP_WHITELIST` is unset or empty, source IPs are not restricted.
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance. When greater than `0`, it is auto-initialized when users open the settings page or send mail for the first time. Defaults to `0` if unset | `1` |
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
| `ADDRESS_PASSWORD_LOGIN_ONLY` | Text/JSON | Default `false`; only effective with mailbox passwords enabled. Rejects legacy credentials for login and API access, using renewable mailbox login JWTs. See [mailbox password login](./feature/mail-api#mailbox-password-login). | `true` |
| `ENABLE_AGENT_EMAIL_INFO` | Text/JSON | Whether to show AI Agent access info in the frontend "Address Credentials & Connection Methods" dialog (Address JWT, parsed-mail APIs, skill link) | `true` |
| `SMTP_IMAP_PROXY_CONFIG` | JSON | Show SMTP/IMAP proxy connection info in the frontend "Address Credentials & Connection Methods" dialog; display-only, does not start the proxy service, which must be deployed separately | See example below |
| `SEND_MAIL_DOMAINS` | JSON | Restrict which sender domains can use the `SEND_MAIL` binding; when unset or empty, all domains are allowed | `["example.com", "mail.example.com"]` |
@@ -128,7 +129,6 @@ 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
@@ -1,5 +1,29 @@
# 查看邮件 API
## 邮箱密码登录
`ADDRESS_PASSWORD_LOGIN_ONLY` 默认 `false`,仅在 `ENABLE_ADDRESS_PASSWORD=true` 时生效。启用后,后端拒绝旧凭据登录及 API 访问,前端隐藏凭据和自动登录链接;旧凭据登录链接也无法通过 API 鉴权。历史无密码邮箱需由绑定用户或管理员设置密码,无需数据库迁移。
- 密码登录返回 `type: "address_password_login"``address``address_id``iat``exp` 的 JWT,有效期 30 天。邮箱 API 仍使用 `Authorization: Bearer <jwt>`,由中间件统一鉴权。
- `GET /api/settings` 返回上述登录信息、`send_balance``new_address_token`;有效 JWT 剩余不足 7 天时返回新签发的 30 天 token,否则为 `null`。已过期 JWT 必须重新登录,旧凭据不能换取新 token。
- 网页加载设置时使用新 token 再次请求 `settings`,验证成功后替换当前 token,普通请求不额外刷新。外部客户端也应保存 `new_address_token`SMTP/IMAP、Agent 使用旧凭据直接调用 API 同样受开关限制。
- 本地缓存使用后端返回的邮箱信息,不解码 JWT;两种登录方式独立保留。历史 token 缓存先显示“已保存邮箱”,选中并验证后补全名称。仅密码登录时隐藏已识别的旧凭据入口,保留缓存。
- 创建邮箱及从用户中心、管理员、Telegram 打开有权访问的邮箱时,按开关签发邮箱 JWT。Telegram KV 单独保存永久的 `telegram_binding` token,邮箱 API 拒绝该类型;仅对已验证 Telegram 身份后读取的历史绑定忽略过期时间,新绑定提交的 token 仍须通过邮箱鉴权。
### 重置绑定邮箱密码
启用 `ENABLE_ADDRESS_PASSWORD` 后,用户中心提供“重置密码”,不需要原密码:
```http
POST /user_api/address/:address_id/reset_password
x-user-token: <JWT>
Content-Type: application/json
{"new_password":"<64SHA-256>"}
```
后端在同一条 SQL 中检查用户存在及绑定关系,仅更新现有密码和更新时间。成功返回 `{"success":true}`;未登录返回 401,未绑定或功能关闭返回 403,输入错误返回 400。密码重置不撤销已有 JWT,有效 JWT 仍可续期;不新增会话表或撤销状态。
## 通过 邮件 API 查看邮件
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
@@ -99,21 +99,6 @@
## 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
@@ -126,7 +111,6 @@ Body 中可以将附件链接直接插入最终文本:
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
"attachments": ${attachments},
"aiExtractType": "${aiExtractType}",
"aiExtractResult": "${aiExtractResult}",
"aiExtractResultText": "${aiExtractResultText}",
@@ -134,9 +118,3 @@ Body 中可以将附件链接直接插入最终文本:
```
启用 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`
+1 -1
View File
@@ -50,6 +50,7 @@
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额;当值大于 `0` 时,用户打开前端设置页或首次发送邮件时会自动初始化该额度。如果不设置,将为 `0` | `1` |
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
| `ADDRESS_PASSWORD_LOGIN_ONLY` | 文本/JSON | 默认 `false`,仅在启用邮箱密码时生效;禁用旧凭据登录及 API 访问,使用可自动续期的邮箱登录 JWT。见[邮箱密码登录](./feature/mail-api#邮箱密码登录) | `true` |
| `ENABLE_AGENT_EMAIL_INFO` | 文本/JSON | 是否在前端“地址凭证与连接方式”弹窗中展示 AI Agent 接入信息(Address JWT、parsed-mail API、skill 链接) | `true` |
| `SMTP_IMAP_PROXY_CONFIG` | JSON | 在前端“地址凭证与连接方式”弹窗中展示 SMTP/IMAP 代理连接信息;仅用于展示给用户,不会启动代理服务,代理服务仍需单独部署 | 见下方示例 |
| `SEND_MAIL_DOMAINS` | JSON | 限制 `SEND_MAIL` binding 可用于哪些发件域名;留空或不配置时允许所有域名 | `["example.com", "mail.example.com"]` |
@@ -123,7 +124,6 @@
| ---------------- | --------- | ------------------------------------- | ------------------ |
| `ENABLE_WEBHOOK` | 文本/JSON | 是否启用 webhook | `true` |
| `FRONTEND_URL` | 文本 | 前端地址,用于发送 webhook 的邮件 url | `https://xxxx.xxx` |
| `BACKEND_URL` | 文本 | 后端公网根地址,用于附件签名链接;未配置时附件 URL 为空 | `https://temp-email-api.example.com` |
> [!NOTE]
> webhook 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
+43 -2
View File
@@ -3,11 +3,34 @@ import { jwt } from 'hono/jwt';
import { Jwt } from 'hono/utils/jwt';
import i18n from './i18n';
import { isAddressPasswordLoginOnly } from './utils';
export const validateAddressPayload = async (
const ADDRESS_PASSWORD_LOGIN_TTL_SECONDS = 30 * 24 * 60 * 60;
export const ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS = 7 * 24 * 60 * 60;
export const createAddressPasswordLoginToken = (
c: Context<HonoCustomType>, address: string, addressId: number,
) => {
const now = Math.floor(Date.now() / 1000);
const payload: AddressPasswordLoginPayload = {
address, address_id: addressId, type: 'address_password_login',
iat: now, exp: now + ADDRESS_PASSWORD_LOGIN_TTL_SECONDS,
};
return Jwt.sign(payload, c.env.JWT_SECRET, 'HS256');
};
export const createAddressToken = (
c: Context<HonoCustomType>, address: string, addressId: number,
) => {
if (isAddressPasswordLoginOnly(c)) return createAddressPasswordLoginToken(c, address, addressId);
const payload: AddressCredentialPayload = { address, address_id: addressId };
return Jwt.sign(payload, c.env.JWT_SECRET, 'HS256');
};
export const validateAddressIdentity = async (
c: Context<HonoCustomType>,
payload: Record<string, unknown>,
): Promise<JwtPayload | null> => {
): Promise<AddressCredentialPayload | null> => {
const { address, address_id } = payload;
if (typeof address !== 'string' || !address) return null;
if (typeof address_id !== 'number'
@@ -21,6 +44,24 @@ export const validateAddressPayload = async (
return exists ? { address, address_id: addressId } : null;
};
export const validateAddressPayload = async (
c: Context<HonoCustomType>,
payload: Record<string, unknown>,
): Promise<JwtPayload | null> => {
if (payload.type !== undefined && payload.type !== 'address_password_login') return null;
if (isAddressPasswordLoginOnly(c) && payload.type !== 'address_password_login') return null;
const identity = await validateAddressIdentity(c, payload);
if (!identity) return null;
if (payload.type !== 'address_password_login') return identity;
const { iat, exp } = payload;
const now = Math.floor(Date.now() / 1000);
if (typeof iat !== 'number' || !Number.isSafeInteger(iat) || iat > now
|| typeof exp !== 'number' || !Number.isSafeInteger(exp) || exp <= now
|| exp <= iat || exp - iat > ADDRESS_PASSWORD_LOGIN_TTL_SECONDS
) return null;
return { ...identity, type: 'address_password_login', iat, exp };
};
export const verifyAddressToken = async (
c: Context<HonoCustomType>,
token: string,
+4 -6
View File
@@ -1,5 +1,5 @@
import { Context } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import { createAddressToken } from '../address_auth';
import i18n from '../i18n'
import { getBooleanValue } from '../utils'
@@ -134,11 +134,9 @@ const showPassword = async (c: Context<HonoCustomType>) => {
const { id } = c.req.param();
const name = await c.env.DB.prepare(
`SELECT name FROM address WHERE id = ? `
).bind(id).first("name");
const jwt = await Jwt.sign({
address: name,
address_id: id
}, c.env.JWT_SECRET, "HS256")
).bind(id).first<string>("name");
if (!name) return c.text(i18n.getMessagesbyContext(c).AddressNotFoundMsg, 404);
const jwt = await createAddressToken(c, name, Number(id));
return c.json({ jwt });
};
+3 -18
View File
@@ -3,8 +3,6 @@ import { CONSTANTS } from "../constants";
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>(
@@ -22,29 +20,16 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
}
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
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(
const settings = await c.req.json<WebhookSettings>();
// random raw email
const mailRow = 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);
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",
+2 -4
View File
@@ -1,10 +1,9 @@
import { Hono } from 'hono'
import utils from './utils';
import utils, { isAddressPasswordLoginOnly } 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>
@@ -23,6 +22,7 @@ api.get('/open_api/settings', async (c) => {
const imapProxyConfig = smtpImapProxyConfig.imap || {};
return c.json({
"addressPasswordLoginOnly": isAddressPasswordLoginOnly(c),
"title": c.env.TITLE,
"announcement": utils.getStringValue(c.env.ANNOUNCEMENT),
"alwaysShowAnnouncement": utils.getBooleanValue(c.env.ALWAYS_SHOW_ANNOUNCEMENT),
@@ -73,6 +73,4 @@ api.get('/open_api/settings', async (c) => {
});
})
api.get('/open_api/a/:mail_id/:index/:expires/:signature', getWebhookAttachment)
export { api }
+16 -16
View File
@@ -1,5 +1,5 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { createAddressToken } from './address_auth';
import { WorkerMailerOptions } from 'worker-mailer';
import { getBooleanValue, getDomains, getStringArray, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains, getDomainMapValue, isDomainOrSubdomain, normalizeDomains, trimLower } from './utils';
@@ -7,7 +7,6 @@ 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;
@@ -453,10 +452,7 @@ export const newAddress = async (
const generatedPassword = await generatePasswordForAddress(c, address);
// create jwt
const jwt = await Jwt.sign({
address: address,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
const jwt = await createAddressToken(c, address, address_id);
return {
jwt: jwt,
address: address,
@@ -839,13 +835,22 @@ export async function sendWebhook(
settings: WebhookSettings, formatMap: WebhookMail
): Promise<{ success: boolean, message?: string }> {
// send webhook
const body = formatWebhookBody(settings.body, formatMap);
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 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}` };
}
@@ -856,7 +861,7 @@ export async function triggerWebhook(
c: Context<HonoCustomType>,
address: string,
parsedEmailContext: ParsedEmailContext,
storedMailId: number | undefined,
message_id: string | null,
aiExtract?: ExtractResult | null
): Promise<void> {
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
@@ -885,22 +890,17 @@ export async function triggerWebhook(
if (webhookList.length === 0) {
return
}
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 mailId = await c.env.DB.prepare(
`SELECT id FROM raw_mails where address = ? and message_id = ?`
).bind(address, message_id).first<string>("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,8 +48,4 @@ export const remove_attachment_if_need = async (
});
}
parsedEmailContext.rawEmail = msg.asRaw();
parsedEmailContext.parsedEmail = {
...parsedEmail,
attachments: [],
};
}
+2 -4
View File
@@ -65,13 +65,11 @@ 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, meta } = await storeRawMail(
const { success } = 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}`);
@@ -100,7 +98,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
try {
await triggerWebhook(
{ env: env } as Context<HonoCustomType>,
toAddress, parsedEmailContext, storedMailId, aiExtractResult
toAddress, parsedEmailContext, message_id, aiExtractResult
);
} catch (error) {
console.error("send webhook error", error);
+1 -3
View File
@@ -1,9 +1,6 @@
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",
@@ -52,6 +49,7 @@ const messages: LocaleMessages = {
NewPasswordRequiredMsg: "New password is required",
InvalidAddressTokenMsg: "Invalid address token",
FailedUpdatePasswordMsg: "Failed to update password",
CredentialLoginDisabledMsg: "Mailbox password login is required; credential login is disabled",
PasswordLoginDisabledMsg: "Password login is disabled",
EmailPasswordRequiredMsg: "Email and password are required",
AddressNotFoundMsg: "Address not found",
+1 -3
View File
@@ -1,7 +1,4 @@
export type LocaleMessages = {
InvalidRequestBodyMsg: string
InvalidMailIdMsg: string
MailNotFoundMsg: string
CustomAuthPasswordMsg: string
UserTokenExpiredMsg: string
UserAcceesTokenExpiredMsg: string
@@ -50,6 +47,7 @@ export type LocaleMessages = {
NewPasswordRequiredMsg: string
InvalidAddressTokenMsg: string
FailedUpdatePasswordMsg: string
CredentialLoginDisabledMsg: string
PasswordLoginDisabledMsg: string
EmailPasswordRequiredMsg: string
AddressNotFoundMsg: string
+1 -3
View File
@@ -1,9 +1,6 @@
import { LocaleMessages } from "./type";
const messages: LocaleMessages = {
InvalidRequestBodyMsg: "无效的请求体",
InvalidMailIdMsg: "无效的邮件 ID",
MailNotFoundMsg: "邮件不存在",
CustomAuthPasswordMsg: "你已启用私有站点密码,请提供密码",
UserTokenExpiredMsg: "您的令牌已过期, 请重新登录",
UserAcceesTokenExpiredMsg: "您的访问令牌已过期, 请刷新页面",
@@ -52,6 +49,7 @@ const messages: LocaleMessages = {
NewPasswordRequiredMsg: "新密码不能为空",
InvalidAddressTokenMsg: "无效的地址令牌",
FailedUpdatePasswordMsg: "更新密码失败",
CredentialLoginDisabledMsg: "仅允许邮箱密码登录,凭据登录已禁用",
PasswordLoginDisabledMsg: "密码登录已禁用",
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
AddressNotFoundMsg: "邮箱地址不存在",
+4 -7
View File
@@ -1,7 +1,7 @@
import { Context } from 'hono';
import i18n from '../i18n';
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
import { Jwt } from 'hono/utils/jwt';
import utils, { getBooleanValue, checkCfTurnstile } from '../utils';
import { createAddressPasswordLoginToken } from '../address_auth';
export default {
// 修改地址密码
@@ -61,7 +61,7 @@ export default {
// 查找地址
const address = await c.env.DB.prepare(
`SELECT * FROM address WHERE name = ?`
).bind(email).first();
).bind(email).first<{ id: number; name: string; password: string | null }>();
if (!address) {
return c.text(msgs.AddressNotFoundMsg, 404);
@@ -73,10 +73,7 @@ export default {
}
// 创建JWT
const jwt = await Jwt.sign({
address: address.name,
address_id: address.id
}, c.env.JWT_SECRET, "HS256");
const jwt = await createAddressPasswordLoginToken(c, address.name, address.id);
return c.json({
jwt: jwt,
+8 -2
View File
@@ -5,6 +5,7 @@ import { getBooleanValue } from '../utils';
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
import { getSendBalanceState } from './send_balance';
import { createAddressPasswordLoginToken, ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS } from '../address_auth';
const listMails = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
@@ -62,14 +63,19 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
};
const getSettings = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
const payload = c.get("jwtPayload");
const { address } = payload;
const renewedAddressToken = payload.type === 'address_password_login'
&& payload.exp < Math.floor(Date.now() / 1000) + ADDRESS_PASSWORD_LOGIN_RENEWAL_WINDOW_SECONDS
? await createAddressPasswordLoginToken(c, address, payload.address_id) : null;
updateAddressUpdatedAt(c, address);
const { balance } = await getSendBalanceState(c, address);
return c.json({
address: address,
...payload,
send_balance: balance || 0,
new_address_token: renewedAddressToken,
});
};
+3 -17
View File
@@ -3,7 +3,6 @@ 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";
@@ -36,30 +35,17 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
}
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
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 settings = await c.req.json<WebhookSettings>();
const { address } = c.get("jwtPayload");
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(
// random raw email
const mailRow = 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);
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,7 +26,6 @@ export class AdminWebhookSettings {
export type WebhookMail = {
id: string;
url?: string;
attachments?: { filename: string, mimeType: string, url: string }[];
from: string;
to: string;
subject: string;
+3 -2
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { verifyAddressToken } from '../address_auth';
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword, isAddressPasswordLoginOnly } from '../utils';
import i18n from '../i18n';
import { ErrorCode } from '../error_codes';
@@ -44,8 +44,9 @@ api.post('/open_api/admin_login', async (c) => {
})
api.post('/open_api/credential_login', async (c) => {
const { credential, cf_token } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (isAddressPasswordLoginOnly(c)) return c.text(msgs.CredentialLoginDisabledMsg, 403);
const { credential, cf_token } = await c.req.json();
if (utils.isGlobalTurnstileEnabled(c)) {
try {
await checkCfTurnstile(c, cf_token);
-57
View File
@@ -1,57 +0,0 @@
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',
},
});
}
+25 -8
View File
@@ -1,12 +1,27 @@
import { Context } from "hono";
import { Jwt } from "hono/utils/jwt";
import { validateAddressPayload, verifyAddressToken } from '../address_auth';
import { validateAddressIdentity, verifyAddressToken } from '../address_auth';
import { CONSTANTS } from "../constants";
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
import { LocaleMessages } from "../i18n/type";
import i18n from '../i18n';
const createTelegramBindingToken = (c: Context<HonoCustomType>, address: string, addressId: number) =>
Jwt.sign({ type: 'telegram_binding', address, address_id: addressId }, c.env.JWT_SECRET, 'HS256');
// Only for tokens read from the authenticated Telegram user's stored bindings.
export const verifyTelegramBindingToken = async (c: Context<HonoCustomType>, token: string) => {
const payload = await Jwt.verify(token, c.env.JWT_SECRET, { alg: 'HS256', exp: false });
if (payload.type !== undefined && payload.type !== 'telegram_binding'
&& payload.type !== 'address_password_login') {
throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
}
const identity = await validateAddressIdentity(c, payload);
if (!identity) throw new Error(i18n.getMessagesbyContext(c).InvalidAddressCredentialMsg);
return identity;
};
export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string,
msgs: LocaleMessages,
@@ -48,7 +63,8 @@ export const tgUserNewAddress = async (
sourceMeta: `tg:${userId}`
});
// for mail push to telegram
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
const bindingToken = await createTelegramBindingToken(c, res.address, res.address_id);
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, bindingToken]));
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${res.address}`, userId.toString());
return res;
}
@@ -65,7 +81,7 @@ export const jwtListToAddressData = async (
const invalidJwtList = [] as string[];
for (const jwt of jwtList) {
try {
const { address, address_id } = await verifyAddressToken(c, jwt);
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
addressList.push(address as string);
addressIdMap[address as string] = address_id as number;
} catch (e) {
@@ -81,7 +97,7 @@ export const bindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, jwt: string,
msgs: LocaleMessages
): Promise<string> => {
const { address } = await verifyAddressToken(c, jwt);
const { address, address_id } = await verifyAddressToken(c, jwt);
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
if (address as string in addressIdMap) {
@@ -91,7 +107,8 @@ export const bindTelegramAddress = async (
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error(msgs.TgMaxAddressReachedCleanMsg);
}
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
const bindingToken = await createTelegramBindingToken(c, address, address_id);
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, bindingToken]));
// for mail push to telegram
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${address}`, userId.toString());
return address as string;
@@ -101,7 +118,7 @@ const getTelegramBindings = async (c: Context<HonoCustomType>, userId: string) =
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
return Promise.all(jwtList.map(async (jwt) => {
try {
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256") };
return { jwt, payload: await Jwt.verify(jwt, c.env.JWT_SECRET, { alg: 'HS256', exp: false }) };
} catch (e) {
console.log(`解绑失败: ${(e as Error).message}`);
return { jwt, payload: null };
@@ -125,10 +142,10 @@ export const unbindTelegramAddress = async (
): Promise<boolean> => {
const msgs = i18n.getMessagesbyContext(c);
const bindings = await getTelegramBindings(c, userId);
for (const { payload } of bindings) {
for (const { jwt, payload } of bindings) {
if (payload?.address !== address) continue;
try {
if (!await validateAddressPayload(c, payload)) continue;
await verifyTelegramBindingToken(c, jwt);
} catch (e) {
console.log(`Failed to validate Telegram binding: ${(e as Error).message}`);
continue;
+4 -4
View File
@@ -1,7 +1,7 @@
import { Context } from "hono";
import { verifyAddressToken } from '../address_auth';
import { createAddressToken } from '../address_auth';
import { CONSTANTS } from "../constants";
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, verifyTelegramBindingToken } from "./common";
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
import { resolveRawEmailRow } from "../gzip";
import { TelegramSettings } from "./settings";
@@ -69,8 +69,8 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
const res = [];
for (const jwt of jwtList) {
try {
const { address } = await verifyAddressToken(c, jwt);
res.push({ address, jwt });
const { address, address_id } = await verifyTelegramBindingToken(c, jwt);
res.push({ address, jwt: await createAddressToken(c, address, address_id) });
} catch (e) {
console.error(`failed to verify jwt with error: ${e}`)
continue;
+13 -2
View File
@@ -59,6 +59,7 @@ type Bindings = {
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
ADDRESS_PASSWORD_LOGIN_ONLY: string | boolean | undefined
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
ENABLE_AGENT_EMAIL_INFO: string | boolean | undefined
ENABLE_REDEEM_CODE: string | boolean | undefined
@@ -114,7 +115,6 @@ type Bindings = {
// webhook config
FRONTEND_URL: string | undefined
BACKEND_URL: string | undefined
// AI extraction config
ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined
@@ -126,11 +126,22 @@ type Bindings = {
CLEANUP_BATCH_SIZE: string | number | undefined
}
type JwtPayload = {
type AddressCredentialPayload = {
address: string
address_id: number
type?: never
}
type AddressPasswordLoginPayload = {
address: string
address_id: number
type: 'address_password_login'
iat: number
exp: number
}
type JwtPayload = AddressCredentialPayload | AddressPasswordLoginPayload
type UserPayload = {
user_email: string
user_id: number
+28 -6
View File
@@ -1,7 +1,7 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { createAddressToken } from '../address_auth';
import { isAddressCountLimitReached } from "../utils"
import { getBooleanValue, isAddressCountLimitReached } from "../utils"
import { unbindTelegramByAddress } from '../telegram_api/common';
import i18n from '../i18n';
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
@@ -23,6 +23,31 @@ export const getBindedAddressById = async (
}
const UserBindAddressModule = {
resetPassword: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text(msgs.PasswordChangeDisabledMsg, 403);
}
const addressId = Number(c.req.param('address_id'));
const userId = c.get('userPayload')?.user_id;
if (!Number.isSafeInteger(addressId) || addressId <= 0 || !userId) {
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400);
}
const body = await c.req.json<{ new_password?: unknown }>().catch(() => null);
if (typeof body?.new_password !== 'string' || !/^[a-f0-9]{64}$/.test(body.new_password)) {
return c.text(msgs.InvalidInputMsg, 400);
}
const result = await c.env.DB.prepare(
`UPDATE address SET password = ?, updated_at = datetime('now')
WHERE id = ? AND EXISTS (
SELECT 1 FROM users_address ua JOIN users u ON u.id = ua.user_id
WHERE ua.address_id = address.id AND ua.user_id = ?
)`
).bind(body.new_password, addressId, userId).run();
if (!result.success) return c.text(msgs.FailedUpdatePasswordMsg, 500);
if (result.meta.changes !== 1) return c.text(msgs.AddressNotBindedMsg, 403);
return c.json({ success: true });
},
bind: async (c: Context<HonoCustomType>) => {
const { user_id } = c.get("userPayload");
const { address_id } = c.get("jwtPayload");
@@ -178,10 +203,7 @@ const UserBindAddressModule = {
if (!name) {
return c.text(msgs.AddressNotBindedMsg, 400)
}
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
const jwt = await createAddressToken(c, name, Number(address_id));
return c.json({
jwt: jwt
})
+1
View File
@@ -38,6 +38,7 @@ api.post('/user_api/oauth2/callback', oauth2.oauth2Login);
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind);
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
api.post('/user_api/address/:address_id/reset_password', bind_address.resetPassword);
api.post('/user_api/unbind_address', bind_address.unbind);
api.post('/user_api/transfer_address', bind_address.transferAddress);
+4
View File
@@ -2,6 +2,10 @@ import { Context } from "hono";
import { UserSettings, RoleAddressConfig } from "./models";
import { CONSTANTS } from "./constants";
export const isAddressPasswordLoginOnly = (c: Context<HonoCustomType>): boolean =>
getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)
&& getBooleanValue(c.env.ADDRESS_PASSWORD_LOGIN_ONLY);
export const getJsonObjectValue = <T = any>(
value: string | any
): T | null => {
-80
View File
@@ -1,80 +0,0 @@
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');
});
}
+3 -2
View File
@@ -93,6 +93,9 @@ ENABLE_AUTO_REPLY = false
# ENABLE_WEBHOOK = true
# Enable address password feature, if set true, will generate password for new address and support password login and change
# ENABLE_ADDRESS_PASSWORD = false
# Only allow password login to mailboxes (requires ENABLE_ADDRESS_PASSWORD).
# Rejects legacy mailbox credentials, including direct API access.
# ADDRESS_PASSWORD_LOGIN_ONLY = false
# Show AI Agent mailbox connection info in the address credential modal
# ENABLE_AGENT_EMAIL_INFO = true
# Show SMTP/IMAP client connection info in the address credential modal
@@ -136,8 +139,6 @@ 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