Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

### Features

- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
- feat: |Webhook| 新增邮件过滤规则,统一匹配条件/与/或/非四种节点,支持嵌套、字符串和原生正则匹配、可视化/JSON 编辑及规则检查;移除附件时保留全部原 From 头,缺失或无效时仍移除附件(issue #1155)
- feat: |用户系统| 邮箱地址管理支持按地址关键字搜索已绑定邮箱,并保持分页结果准确(issue #1156)
- feat: |AI 识别| 新增 `AI_EXTRACT_MODE`,可显式选择仅用本地规则(`local`)或优先用 Workers AI(`ai`)识别邮件;不填默认使用本地规则,邮件内容不会发送给 AI。**升级注意**:原先依赖 Workers AI 绑定自动启用 AI 识别的部署需设置 `AI_EXTRACT_MODE = "ai"`

### Bug Fixes

- fix: |测试| 补齐仅密码登录开关及其前提、绑定邮箱重置密码表单和本地邮箱缓存兼容与续期的回归测试
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离;明确仅禁用旧凭据及其登录链接
- fix: |收件| 黑名单同时检查 SMTP 信封发件人与邮件头 From 的全部邮箱地址,任意一个命中即拒收;独立解析地址,避免正文解析失败或多个发件地址导致漏拦截;地址解析异常时保留原有信封检查
- fix: |AI 识别| `ai` 模式下地址未命中 AI 提取白名单时只跳过 Workers AI 调用,仍回退到本地规则提取验证码
- fix: |Telegram| 当邮件没有纯文本正文(如纯 HTML 邮件)时回退到 HTML 转纯文本,避免 Telegram 通知显示“解析失败”
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@

### Features

- 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: |Webhook| Add email filters with condition/AND/OR/NOT nodes, nesting, string and native regex matching, visual/JSON editing and rule checking; preserve all original From headers while still removing attachments when From is missing or invalid (issue #1155)
- feat: |User| Search bound mailboxes by email address in address management with accurate pagination (issue #1156)
- feat: |AI Extract| Add `AI_EXTRACT_MODE` to explicitly choose local rules only (`local`) or prefer Workers AI (`ai`); defaults to local rules when unset so mail content is never sent to AI. **Upgrade note**: deployments that relied on the Workers AI binding to enable AI extraction automatically must set `AI_EXTRACT_MODE = "ai"`

### Bug Fixes

- fix: |Tests| Cover the password-only login switch and its prerequisite, bound-mailbox password reset form, and local mailbox cache compatibility and renewal
- 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; clarify that only legacy credentials and their login links are disabled
- fix: |Mail| Check the SMTP envelope sender and every From email address against sender blacklists; parse addresses independently so body parsing failures or multiple From addresses cannot skip matching; preserve envelope checks on address parsing errors
- fix: |AI Extract| In `ai` mode, an address allowlist miss now skips only the Workers AI call and still falls back to local verification-code extraction
- fix: |Telegram| Fall back to HTML-to-text conversion when plain text body is empty, preventing "parse failed" messages in Telegram notifications
Expand Down
10 changes: 9 additions & 1 deletion e2e/fixtures/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ testApi.get('/telegram_binding', async c => {

const app = new Hono<HonoCustomType>();
app.route('/__test', testApi);
app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx));
app.all('*', c => {
const passwordMode = c.req.header('x-test-address-password-mode');
const env = passwordMode ? {
...c.env,
ADDRESS_PASSWORD_LOGIN_ONLY: true,
ENABLE_ADDRESS_PASSWORD: passwordMode === 'password-only',
} : c.env;
return worker.fetch(c.req.raw, env, c.executionCtx);
});

export default { ...worker, fetch: app.fetch };
112 changes: 111 additions & 1 deletion e2e/tests/api/address-credential.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';

import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers';
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail, hashPassword } from '../../fixtures/test-helpers';

function signToken(payload: Record<string, unknown>) {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
Expand All @@ -11,6 +11,116 @@ function signToken(payload: Record<string, unknown>) {
return `${header}.${body}.${signature}`;
}

for (const mode of ['default', 'password-only', 'password-disabled']) {
test(`mailbox authentication policy: ${mode}`, async ({ request }) => {
const mailbox = await createTestAddress(request, 'password-policy');
const headers = mode === 'default' ? {} : { 'x-test-address-password-mode': mode };
const passwordOnly = mode === 'password-only';
const password = hashPassword('mailbox-policy-password');
try {
const reset = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` }, data: { new_password: password },
});
expect(reset.ok()).toBe(true);
const openSettings = await request.get(`${WORKER_URL}/open_api/settings`, { headers });
expect(await openSettings.json()).toMatchObject({
addressPasswordLoginOnly: passwordOnly, enableAddressPassword: mode !== 'password-disabled',
});
const credentialLogin = await request.post(`${WORKER_URL}/open_api/credential_login`, {
headers, data: { credential: mailbox.jwt },
});
expect(credentialLogin.status()).toBe(passwordOnly ? 403 : 200);
for (const path of ['/api/settings', '/api/mails?limit=10&offset=0']) {
const response = await request.get(`${WORKER_URL}${path}`, {
headers: { ...headers, Authorization: `Bearer ${mailbox.jwt}` },
});
expect(response.status()).toBe(passwordOnly ? 401 : 200);
}
const login = await request.post(`${WORKER_URL}/api/address_login`, {
headers, data: { email: mailbox.address, password },
});
if (mode === 'password-disabled') {
expect(login.status()).toBe(403);
return;
}
expect(login.ok()).toBe(true);
const { jwt } = await login.json();
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { ...headers, Authorization: `Bearer ${jwt}` },
});
expect(settings.ok()).toBe(true);
expect(await settings.json()).toMatchObject({ address: mailbox.address, type: 'address_password_login' });
const now = Math.floor(Date.now() / 1000);
const token = signToken({ address: mailbox.address, address_id: mailbox.address_id,
type: 'address_password_login', iat: now - 24 * 86400, exp: now + 6 * 86400 });
const renewal = await request.get(`${WORKER_URL}/api/settings`, {
headers: { ...headers, Authorization: `Bearer ${token}` },
});
expect(renewal.ok()).toBe(true);
const { new_address_token } = await renewal.json();
expect(new_address_token).toEqual(expect.any(String));
const renewed = await request.get(`${WORKER_URL}/api/settings`, {
headers: { ...headers, Authorization: `Bearer ${new_address_token}` },
});
expect(renewed.ok()).toBe(true);
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
}

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'],
Expand Down
44 changes: 44 additions & 0 deletions e2e/tests/api/address-password.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
});
});
83 changes: 83 additions & 0 deletions e2e/tests/browser/mailbox-login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test } from '@playwright/test';
import { sign } from 'hono/jwt';

import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';

test('historical mailbox cache keeps both login methods through renewal and password-only mode', async ({ page, request }) => {
const mailbox = await createTestAddress(request, 'cached-login');
const other = await createTestAddress(request, 'cached-other');
const now = Math.floor(Date.now() / 1000);
const passwordToken = await sign({
address: mailbox.address, address_id: mailbox.address_id, type: 'address_password_login',
iat: now - 24 * 86400, exp: now + 6 * 86400,
}, 'e2e-test-secret-key', 'HS256');
const readCache = () => page.evaluate(() => JSON.parse(localStorage.getItem('LocalAddressCache') || '[]'));
try {
await page.goto(`${FRONTEND_URL}/en/`);
await page.evaluate(cache => localStorage.setItem('LocalAddressCache', JSON.stringify(cache)), [
mailbox.jwt, passwordToken, other.jwt,
{ token: 'superseded-password-token', address: mailbox.address, type: 'address_password_login' },
]);
await page.goto(`${FRONTEND_URL}/en/?jwt=${mailbox.jwt}`);
await expect.poll(readCache).toEqual([
{ token: mailbox.jwt, address: mailbox.address, type: null },
{ token: passwordToken }, { token: other.jwt },
{ token: 'superseded-password-token', address: mailbox.address, type: 'address_password_login' },
]);

await page.locator('.address-select').click();
await page.locator('.n-base-select-menu:visible').getByText('Saved mailbox 2', { exact: true }).click();
await expect.poll(() => page.evaluate(() => localStorage.getItem('jwt'))).not.toBe(passwordToken);
await expect.poll(readCache).toEqual([
{ token: mailbox.jwt, address: mailbox.address, type: null },
{ token: expect.any(String), address: mailbox.address, type: 'address_password_login' },
{ token: other.jwt },
]);
const renewedToken = await page.evaluate(() => localStorage.getItem('jwt'));
expect(renewedToken).not.toBe(mailbox.jwt);
expect((await readCache())[1].token).toBe(renewedToken);
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${renewedToken}` },
});
expect(settings.ok()).toBe(true);
const renewedSettings = await settings.json();
expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * 86400);

await page.locator('.address-manage').click();
const rows = page.locator('.n-modal .n-data-table-tbody .n-data-table-tr');
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText(`${mailbox.address} (Credential Login)`);
await expect(rows.nth(1)).toContainText(`${mailbox.address} (Password Login)`);
await expect(rows.nth(2)).toContainText('Saved mailbox 3');
await page.keyboard.press('Escape');
await page.locator('.address-select').click();
const options = page.locator('.n-base-select-menu:visible');
await expect(options).toContainText('Credential Login');
await expect(options).toContainText('Password Login');
await options.getByText('Saved mailbox 3', { exact: true }).click();
await expect.poll(readCache).toEqual([
{ token: mailbox.jwt, address: mailbox.address, type: null },
{ token: renewedToken, address: mailbox.address, type: 'address_password_login' },
{ token: other.jwt, address: other.address, type: null },
]);

await page.setExtraHTTPHeaders({ 'x-test-address-password-mode': 'password-only' });
await page.goto(`${FRONTEND_URL}/en/?jwt=${renewedToken}`);
await page.locator('.address-select').click();
await expect(options).toContainText('Password Login');
await expect(options).not.toContainText('Credential Login');
await page.keyboard.press('Escape');
await page.locator('.address-manage').click();
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText('Password Login');
expect(await readCache()).toHaveLength(3);

await page.goto(`${FRONTEND_URL}/en/?jwt=`);
await expect(page.getByRole('button', { name: 'Log In to Mailbox', exact: true })).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Credential Login', exact: true })).toHaveCount(0);
} finally {
await deleteAddress(request, mailbox.jwt);
await deleteAddress(request, other.jwt);
}
});
Loading
Loading