mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 11:37:42 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1572f4cd0f |
+1
-10
@@ -6,30 +6,21 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.12.0(main)
|
||||
## v1.11.1(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |邮件状态| 新增基于独立稀疏关联表的可选邮件状态功能,不修改原邮件表且历史邮件默认已读、未星标;已读状态与低写入量的星标功能使用独立开关,支持索引化组合筛选
|
||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126)
|
||||
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||
|
||||
## v1.11.0
|
||||
|
||||
|
||||
+1
-10
@@ -6,30 +6,21 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.12.0(main)
|
||||
## v1.11.1(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Mail State| Add optional mail states backed by a separate sparse relation table without changing the raw-mail table; read status and low-write Flagged mail use independent switches and support indexed combined filters
|
||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
|
||||
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
||||
- fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
|
||||
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
||||
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||
|
||||
## v1.11.0
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
@@ -15,15 +15,6 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
|
||||
@@ -18,11 +18,8 @@ JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_MAIL_READ_STATUS = true
|
||||
ENABLE_MAIL_FLAGGED = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
NO_LIMIT_SEND_ROLE = "case-role"
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
|
||||
@@ -20,7 +20,6 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_GZIP = true
|
||||
ENABLE_MAIL_READ_STATUS = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
@@ -24,7 +24,6 @@ DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_FLAGGED = true
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_ENV_OFF,
|
||||
WORKER_GZIP_URL,
|
||||
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const addressHeaders = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||
|
||||
async function createAddressAt(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
name: string,
|
||||
domain: string,
|
||||
) {
|
||||
const response = await request.post(`${baseUrl}/api/new_address`, {
|
||||
data: { name: `${name}${Date.now()}`, domain },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function receiveMailAt(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
address: string,
|
||||
) {
|
||||
const raw = [
|
||||
'From: sender@example.com',
|
||||
`To: ${address}`,
|
||||
`Subject: Split flags ${Date.now()}`,
|
||||
'',
|
||||
'Split flags body',
|
||||
].join('\r\n');
|
||||
const response = await request.post(`${baseUrl}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@example.com', to: address, raw },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function listMails(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
state = 'all',
|
||||
baseUrl = WORKER_URL,
|
||||
) {
|
||||
const response = await request.get(
|
||||
`${baseUrl}/api/mails?limit=100&offset=0&mail_state=${state}`,
|
||||
{ headers: addressHeaders(jwt) },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function updateState(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
ids: number[],
|
||||
state: string,
|
||||
) {
|
||||
const response = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: addressHeaders(jwt),
|
||||
data: { ids, state },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function updateFlagged(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
ids: number[],
|
||||
flagged: boolean,
|
||||
) {
|
||||
const response = await request.patch(`${WORKER_URL}/api/mails/flagged`, {
|
||||
headers: addressHeaders(jwt),
|
||||
data: { ids, flagged },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
test.describe('Mail states', () => {
|
||||
test('read status and Flagged switches are independent', async ({ request }) => {
|
||||
test.skip(
|
||||
!WORKER_GZIP_URL || !WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
'Mixed feature workers are not configured',
|
||||
);
|
||||
|
||||
const readOnly = await createAddressAt(
|
||||
request, WORKER_GZIP_URL, 'mail-read-only', 'test.example.com',
|
||||
);
|
||||
const flaggedOnly = await createAddressAt(
|
||||
request, WORKER_URL_SEND_MAIL_DOMAIN, 'mail-flagged-only', 'TEST.EXAMPLE.COM',
|
||||
);
|
||||
|
||||
try {
|
||||
await receiveMailAt(request, WORKER_GZIP_URL, readOnly.address);
|
||||
await receiveMailAt(request, WORKER_URL_SEND_MAIL_DOMAIN, flaggedOnly.address);
|
||||
|
||||
const readSettings = await (await request.get(
|
||||
`${WORKER_GZIP_URL}/open_api/settings`,
|
||||
)).json();
|
||||
expect(readSettings.enableMailReadStatus).toBe(true);
|
||||
expect(readSettings).not.toHaveProperty('enableMailFlagged');
|
||||
|
||||
const flaggedSettings = await (await request.get(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/open_api/settings`,
|
||||
)).json();
|
||||
expect(flaggedSettings).not.toHaveProperty('enableMailReadStatus');
|
||||
expect(flaggedSettings.enableMailFlagged).toBe(true);
|
||||
|
||||
const readList = await request.get(
|
||||
`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`,
|
||||
{ headers: addressHeaders(readOnly.jwt) },
|
||||
);
|
||||
const readMail = (await readList.json()).results[0];
|
||||
expect(readMail.unread).toBe(true);
|
||||
expect(readMail).not.toHaveProperty('flagged');
|
||||
expect((await request.get(`${WORKER_GZIP_URL}/api/mail-states`, {
|
||||
headers: addressHeaders(readOnly.jwt),
|
||||
})).ok()).toBe(true);
|
||||
expect((await request.patch(`${WORKER_GZIP_URL}/api/mails/flagged`, {
|
||||
headers: addressHeaders(readOnly.jwt),
|
||||
data: { ids: [readMail.id], flagged: true },
|
||||
})).status()).toBe(403);
|
||||
|
||||
const flaggedList = await request.get(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails?limit=10&offset=0`,
|
||||
{ headers: addressHeaders(flaggedOnly.jwt) },
|
||||
);
|
||||
const flaggedMail = (await flaggedList.json()).results[0];
|
||||
expect(flaggedMail).not.toHaveProperty('unread');
|
||||
expect(flaggedMail.flagged).toBe(false);
|
||||
expect((await request.get(`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mail-states`, {
|
||||
headers: addressHeaders(flaggedOnly.jwt),
|
||||
})).status()).toBe(403);
|
||||
const addedStar = await request.patch(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails/flagged`,
|
||||
{
|
||||
headers: addressHeaders(flaggedOnly.jwt),
|
||||
data: { ids: [flaggedMail.id], flagged: true },
|
||||
},
|
||||
);
|
||||
expect((await addedStar.json()).results).toEqual([{ id: flaggedMail.id, flagged: true }]);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
request.delete(`${WORKER_GZIP_URL}/admin/delete_address/${readOnly.address_id}`),
|
||||
request.delete(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/delete_address/${flaggedOnly.address_id}`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports unread lifecycle, historical mail, filtering and mailbox isolation', async ({ request }) => {
|
||||
const first = await createTestAddress(request, 'mail-state-first');
|
||||
const second = await createTestAddress(request, 'mail-state-second');
|
||||
|
||||
try {
|
||||
const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: { address: first.address, raw: 'Historical mail' },
|
||||
});
|
||||
expect(historical.ok()).toBe(true);
|
||||
|
||||
await seedTestMail(request, first.address, { subject: 'Unread one' });
|
||||
await seedTestMail(request, first.address, { subject: 'Unread two' });
|
||||
await seedTestMail(request, second.address, { subject: 'Other mailbox' });
|
||||
|
||||
const states = await request.get(`${WORKER_URL}/api/mail-states`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
});
|
||||
expect(states.ok()).toBe(true);
|
||||
expect((await states.json()).results.map((state: { value: string }) => state.value))
|
||||
.toEqual(['all', 'unread', 'read']);
|
||||
|
||||
const initial = await listMails(request, first.jwt);
|
||||
expect(initial.count).toBe(3);
|
||||
expect(initial.results.filter((mail: { unread: boolean }) => mail.unread)).toHaveLength(2);
|
||||
expect(initial.results.filter((mail: { unread: boolean }) => !mail.unread)).toHaveLength(1);
|
||||
expect(initial.results.every((mail: { flagged: boolean }) => !mail.flagged)).toBe(true);
|
||||
|
||||
const unreadIds = initial.results
|
||||
.filter((mail: { unread: boolean }) => mail.unread)
|
||||
.map((mail: { id: number }) => mail.id);
|
||||
const denied = await updateState(request, second.jwt, [unreadIds[0]], 'read');
|
||||
expect(denied.changes).toBe(0);
|
||||
const deniedStar = await updateFlagged(request, second.jwt, [unreadIds[0]], true);
|
||||
expect(deniedStar.changes).toBe(0);
|
||||
|
||||
const addedStar = await updateFlagged(request, first.jwt, [unreadIds[0]], true);
|
||||
expect(addedStar.results).toEqual([{ id: unreadIds[0], flagged: true }]);
|
||||
|
||||
const markedRead = await updateState(request, first.jwt, unreadIds, 'read');
|
||||
expect(markedRead.changes).toBe(2);
|
||||
expect(markedRead.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true);
|
||||
expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(0);
|
||||
expect((await listMails(request, first.jwt, 'read')).results).toHaveLength(3);
|
||||
|
||||
const flagged = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=100&offset=0&mail_state=read&flagged=true`,
|
||||
{ headers: addressHeaders(first.jwt) },
|
||||
);
|
||||
const flaggedMails = (await flagged.json()).results;
|
||||
expect(flaggedMails).toHaveLength(1);
|
||||
expect(flaggedMails[0]).toMatchObject({ id: unreadIds[0], unread: false, flagged: true });
|
||||
|
||||
const markedUnread = await updateState(request, first.jwt, [unreadIds[0]], 'unread');
|
||||
expect(markedUnread.results).toEqual([{ id: unreadIds[0], unread: true }]);
|
||||
expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(1);
|
||||
|
||||
const detail = await request.get(`${WORKER_URL}/api/mail/${unreadIds[0]}`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
});
|
||||
expect(await detail.json()).toMatchObject({ unread: true, flagged: true });
|
||||
|
||||
await updateFlagged(request, first.jwt, [unreadIds[0]], false);
|
||||
const noFlagged = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=100&offset=0&flagged=true`,
|
||||
{ headers: addressHeaders(first.jwt) },
|
||||
);
|
||||
expect((await noFlagged.json()).results).toHaveLength(0);
|
||||
|
||||
const invalid = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
data: { ids: [unreadIds[0]], state: 'unknown' },
|
||||
});
|
||||
expect(invalid.status()).toBe(400);
|
||||
} finally {
|
||||
await deleteAddress(request, first.jwt);
|
||||
await deleteAddress(request, second.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('user APIs only expose and update bound-address mail', async ({ request }) => {
|
||||
let originalSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
const mailboxes: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
|
||||
try {
|
||||
const settings = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
originalSettings = await settings.json();
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { ...originalSettings, enable: true, enableMailVerify: false, maxAddressCount: 0 },
|
||||
});
|
||||
|
||||
const email = `mail-state-user-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('mail-state-password');
|
||||
expect((await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
})).ok()).toBe(true);
|
||||
const login = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
const { jwt: userJwt } = await login.json();
|
||||
userId = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString()).user_id;
|
||||
|
||||
const bound = await createTestAddress(request, 'mail-state-bound');
|
||||
const outsider = await createTestAddress(request, 'mail-state-outsider');
|
||||
mailboxes.push(bound, outsider);
|
||||
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { ...addressHeaders(bound.jwt), 'x-user-token': userJwt },
|
||||
});
|
||||
expect(bind.ok()).toBe(true);
|
||||
|
||||
await seedTestMail(request, bound.address, { subject: 'Bound unread' });
|
||||
await seedTestMail(request, outsider.address, { subject: 'Outsider unread' });
|
||||
|
||||
const userList = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&mail_state=unread`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
const userMails = await userList.json();
|
||||
expect(userMails.results).toHaveLength(1);
|
||||
expect(userMails.results[0].address).toBe(bound.address);
|
||||
|
||||
const outsiderMail = (await listMails(request, outsider.jwt)).results[0];
|
||||
const denied = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [outsiderMail.id], state: 'read' },
|
||||
});
|
||||
expect((await denied.json()).changes).toBe(0);
|
||||
|
||||
const update = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [userMails.results[0].id], state: 'read' },
|
||||
});
|
||||
expect((await update.json()).results[0].unread).toBe(false);
|
||||
|
||||
const addStar = await request.patch(`${WORKER_URL}/user_api/mails/flagged`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [userMails.results[0].id], flagged: true },
|
||||
});
|
||||
expect((await addStar.json()).results[0].flagged).toBe(true);
|
||||
|
||||
const flagged = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&flagged=true`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect((await flagged.json()).results).toHaveLength(1);
|
||||
} finally {
|
||||
await Promise.allSettled(mailboxes.map(mailbox => deleteAddress(request, mailbox.jwt)));
|
||||
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
if (originalSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, { data: originalSettings });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled feature keeps existing responses unchanged', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
const created = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, {
|
||||
data: { name: `mail-state-off-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
const mailbox = await created.json();
|
||||
|
||||
try {
|
||||
const raw = [
|
||||
'From: sender@example.com',
|
||||
`To: ${mailbox.address}`,
|
||||
'Subject: States disabled',
|
||||
'',
|
||||
'Disabled body',
|
||||
].join('\r\n');
|
||||
await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@example.com', to: mailbox.address, raw },
|
||||
});
|
||||
|
||||
const list = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
const body = await list.json();
|
||||
expect(body.results).toHaveLength(1);
|
||||
expect(body.results[0]).not.toHaveProperty('unread');
|
||||
expect(body.results[0]).not.toHaveProperty('flagged');
|
||||
|
||||
const states = await request.get(`${WORKER_URL_ENV_OFF}/api/mail-states`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
expect(states.status()).toBe(403);
|
||||
const update = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/state`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
data: { ids: [body.results[0].id], state: 'read' },
|
||||
});
|
||||
expect(update.status()).toBe(403);
|
||||
const disabledFlagUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/flagged`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
data: { ids: [body.results[0].id], flagged: true },
|
||||
});
|
||||
expect(disabledFlagUpdate.status()).toBe(403);
|
||||
} finally {
|
||||
await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${mailbox.address_id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,415 +0,0 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
getAddressSender,
|
||||
hashPassword,
|
||||
onMailpitMessage,
|
||||
updateAddressSender,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(
|
||||
request: APIRequestContext,
|
||||
userJwt: string,
|
||||
addressJwt: string,
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User send mail API', () => {
|
||||
test('sends and manages sent items for a bound address only', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const bound = await createTestAddress(request, 'user-send-bound-');
|
||||
const accessRequest = await createTestAddress(request, 'user-send-access-');
|
||||
const outsider = await createTestAddress(request, 'usr-outsider-');
|
||||
addresses.push(bound, accessRequest, outsider);
|
||||
await bindAddress(request, user.jwt, bound.jwt);
|
||||
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||
|
||||
const invalidAddressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/0/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(invalidAddressSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderCredentialRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderCredentialRes.status()).toBe(400);
|
||||
|
||||
const outsiderAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderAccessRes.status()).toBe(400);
|
||||
|
||||
const outsiderUserSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'Forbidden user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(outsiderUserSendRes.status()).toBe(400);
|
||||
|
||||
const unauthenticatedSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
);
|
||||
expect(unauthenticatedSendboxRes.status()).toBe(401);
|
||||
|
||||
const requestAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(requestAccessRes.ok()).toBe(true);
|
||||
|
||||
const accessSender = await getAddressSender(request, accessRequest.address);
|
||||
await updateAddressSender(request, {
|
||||
address: accessRequest.address,
|
||||
address_id: accessSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const duplicateAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(duplicateAccessRes.status()).toBe(400);
|
||||
expect(await duplicateAccessRes.text()).toContain('Already');
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
const addressSettings = await addressSettingsRes.json();
|
||||
expect(addressSettings.address).toBe(bound.address);
|
||||
expect(addressSettings.send_balance).toBe(10);
|
||||
|
||||
await deleteAllMailpitMessages(request);
|
||||
const subject = `User API send ${Date.now()}`;
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
from_name: 'User Sender',
|
||||
from_mail: outsider.address,
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: 'Sent through the user API',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.From.Address).toBe(bound.address);
|
||||
|
||||
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: outsiderSubject,
|
||||
content: 'This sent item must remain inaccessible to the user',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(outsiderSendRes.ok()).toBe(true);
|
||||
const outsiderAddressSendboxRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||
JSON.parse(item.raw).subject === outsiderSubject
|
||||
));
|
||||
expect(outsiderMail).toBeTruthy();
|
||||
|
||||
const unauthorizedDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||
|
||||
const updatedSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
|
||||
|
||||
const sender = await getAddressSender(request, bound.address);
|
||||
await updateAddressSender(request, {
|
||||
address: bound.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const noBalanceRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'No balance user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(noBalanceRes.status()).toBe(400);
|
||||
expect(await noBalanceRes.text()).toContain('No balance');
|
||||
|
||||
const userSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(userSendboxRes.ok()).toBe(true);
|
||||
const userSendbox = await userSendboxRes.json();
|
||||
expect(userSendbox.count).toBe(1);
|
||||
expect(userSendbox.results).toHaveLength(1);
|
||||
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
|
||||
|
||||
const filteredSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(filteredSendboxRes.ok()).toBe(true);
|
||||
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||
|
||||
const outsiderFilterRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderFilterRes.ok()).toBe(true);
|
||||
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||
|
||||
const deleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
|
||||
const emptySendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
const emptySendbox = await emptySendboxRes.json();
|
||||
expect(emptySendbox.count).toBe(0);
|
||||
expect(emptySendbox.results).toHaveLength(0);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('applies unlimited balance from the user role access token', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
const userIds: number[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userIds.push(user.userId);
|
||||
const address = await createTestAddress(request, 'user-send-role-');
|
||||
addresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.userId, role_text: 'case-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const accessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(accessRes.ok()).toBe(true);
|
||||
const sender = await getAddressSender(request, address.address);
|
||||
await updateAddressSender(request, {
|
||||
address: address.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
});
|
||||
expect(userSettingsRes.ok()).toBe(true);
|
||||
const { access_token: accessToken } = await userSettingsRes.json();
|
||||
expect(accessToken).toBeTruthy();
|
||||
const userHeaders = {
|
||||
'x-user-token': user.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
|
||||
{ headers: userHeaders },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
|
||||
{
|
||||
headers: userHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Unlimited role send ${Date.now()}`,
|
||||
content: 'Sent without consuming address balance',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(0);
|
||||
|
||||
const otherUser = await createUser(request);
|
||||
userIds.push(otherUser.userId);
|
||||
const otherAddress = await createTestAddress(request, 'user-send-other-');
|
||||
addresses.push(otherAddress);
|
||||
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
|
||||
const otherAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': otherUser.jwt } },
|
||||
);
|
||||
expect(otherAccessRes.ok()).toBe(true);
|
||||
const otherSender = await getAddressSender(request, otherAddress.address);
|
||||
await updateAddressSender(request, {
|
||||
address: otherAddress.address,
|
||||
address_id: otherSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const mixedHeaders = {
|
||||
'x-user-token': otherUser.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
const otherSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
|
||||
{ headers: mixedHeaders },
|
||||
);
|
||||
expect(otherSettingsRes.ok()).toBe(true);
|
||||
expect((await otherSettingsRes.json()).send_balance).toBe(0);
|
||||
|
||||
const otherSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
|
||||
{
|
||||
headers: mixedHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Mismatched role token ${Date.now()}`,
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(otherSendRes.status()).toBe(400);
|
||||
expect(await otherSendRes.text()).toContain('No balance');
|
||||
} finally {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
await Promise.allSettled(userIds.map((userId) => (
|
||||
request.delete(`${WORKER_URL}/admin/users/${userId}`)
|
||||
)));
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail state browser flow', () => {
|
||||
test('does not mark the initial desktop preview as read before a click', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-preview-unread');
|
||||
jwt = mailbox.jwt;
|
||||
const subject = `Preview unread ${Date.now()}`;
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'false'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await expect(page.getByText(subject, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const beforeClick = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await beforeClick.json()).results).toHaveLength(1);
|
||||
|
||||
const readResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByText(subject, { exact: true }).first().click();
|
||||
expect((await readResponse).ok()).toBe(true);
|
||||
|
||||
const afterClick = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await afterClick.json()).results).toHaveLength(0);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('opens, toggles, filters and marks the current page read', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-flags-browser');
|
||||
jwt = mailbox.jwt;
|
||||
const subjects = [`Unread A ${Date.now()}`, `Unread B ${Date.now()}`];
|
||||
for (const subject of subjects) {
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
await expect(page.getByText('Unread', { exact: true })).toHaveCount(2);
|
||||
|
||||
const openStateResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByText(subjects[0], { exact: true }).click();
|
||||
expect((await openStateResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toBeVisible();
|
||||
|
||||
const flaggedResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/flagged'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.locator('.mail-content-renderer').getByRole('button', { name: 'Add Star' }).click();
|
||||
expect((await flaggedResponse).ok()).toBe(true);
|
||||
await expect(
|
||||
page.locator('.mail-content-renderer').getByRole('button', { name: 'Remove Star' }),
|
||||
).toBeVisible();
|
||||
|
||||
const unreadAfterOpen = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterOpen.json()).results).toHaveLength(1);
|
||||
|
||||
const toggleResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||
expect((await toggleResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Read' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to List' }).click();
|
||||
|
||||
const flaggedFilterResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && url.searchParams.get('flagged') === 'true';
|
||||
});
|
||||
await page.getByRole('checkbox', { name: 'Flagged' }).check();
|
||||
expect((await flaggedFilterResponse).ok()).toBe(true);
|
||||
await expect(page.getByText(subjects[0], { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(subjects[1], { exact: true })).toHaveCount(0);
|
||||
const allMailResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && !url.searchParams.has('flagged');
|
||||
});
|
||||
await page.getByRole('checkbox', { name: 'Flagged' }).uncheck();
|
||||
expect((await allMailResponse).ok()).toBe(true);
|
||||
|
||||
const pageReadResponse = page.waitForResponse((response) => {
|
||||
if (new URL(response.url()).pathname !== '/api/mails/state') return false;
|
||||
if (response.request().method() !== 'PATCH') return false;
|
||||
const body = response.request().postDataJSON();
|
||||
return body.state === 'read' && body.ids.length === 2;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark This Page as Read' }).click();
|
||||
expect((await pageReadResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark This Page as Read' })).toBeHidden();
|
||||
|
||||
const unreadAfterPage = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterPage.json()).results).toHaveLength(0);
|
||||
|
||||
const unreadFilterResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && url.searchParams.get('mail_state') === 'unread';
|
||||
});
|
||||
const stateSelect = page.locator('.n-select').filter({ hasText: 'All Mail' }).first();
|
||||
await stateSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: /^Unread$/ }).click();
|
||||
expect((await unreadFilterResponse).ok()).toBe(true);
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
requestSendAccess,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const expectEditorOriginsToAlign = async (page: Page) => {
|
||||
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
|
||||
const textareaElement = editor.querySelector('textarea');
|
||||
const placeholder = editor.querySelector('.n-input__placeholder');
|
||||
if (!textareaElement) throw new Error('compose textarea element not found');
|
||||
if (!placeholder) throw new Error('compose placeholder element not found');
|
||||
const textareaBox = textareaElement.getBoundingClientRect();
|
||||
const placeholderBox = placeholder.getBoundingClientRect();
|
||||
const textareaStyle = getComputedStyle(textareaElement);
|
||||
const placeholderStyle = getComputedStyle(placeholder);
|
||||
return {
|
||||
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
|
||||
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
|
||||
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
|
||||
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
|
||||
};
|
||||
});
|
||||
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
|
||||
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
|
||||
};
|
||||
|
||||
test.describe('Send mail composer', () => {
|
||||
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'compose-ui');
|
||||
jwt = created.jwt;
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.composer-title')).toContainText(created.address);
|
||||
|
||||
const expectedFieldOrder = [
|
||||
'send-mail-sender-address',
|
||||
'send-mail-sender-name',
|
||||
'send-mail-recipient-address',
|
||||
'send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
|
||||
const textarea = page.locator('.compose-textarea textarea');
|
||||
await expectEditorOriginsToAlign(page);
|
||||
|
||||
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
|
||||
const subject = page.getByRole('textbox', { name: /^Subject/ });
|
||||
await recipient.fill('recipient@test.example.com');
|
||||
await subject.fill('Composer preview');
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const htmlContent = [
|
||||
'<h1>Preview heading</h1><p>Preview body</p>',
|
||||
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
|
||||
].join('');
|
||||
let previewDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
previewDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await textarea.fill(htmlContent);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
|
||||
const preview = page.locator('.compose-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText('Preview heading');
|
||||
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
|
||||
expect(previewDialogAppeared).toBe(false);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
await expect(recipient).toHaveValue('recipient@test.example.com');
|
||||
await expect(subject).toHaveValue('Composer preview');
|
||||
await expect(textarea).toHaveValue(htmlContent);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
|
||||
await page.getByText('Enviar correo', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
|
||||
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
|
||||
await expect(previewButton).toBeVisible();
|
||||
const previewBox = await previewButton.boundingBox();
|
||||
expect(previewBox).not.toBeNull();
|
||||
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'mails');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
const expectedFieldOrder = [
|
||||
'admin-send-mail-sender-address',
|
||||
'admin-send-mail-sender-name',
|
||||
'admin-send-mail-recipient-address',
|
||||
'admin-send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
await expectEditorOriginsToAlign(page);
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User send mail page', () => {
|
||||
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const address = await createTestAddress(request, 'usr-browser-');
|
||||
const secondAddress = await createTestAddress(request, 'usr-second-');
|
||||
addresses.push(address, secondAddress);
|
||||
for (const boundAddress of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${boundAddress.jwt}`,
|
||||
'x-user-token': user.jwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const credentialResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/bind_address_jwt/${address.address_id}`
|
||||
));
|
||||
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
|
||||
expect((await credentialResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const initialSettingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${secondAddress.address_id}/settings`
|
||||
));
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
expect((await initialSettingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
|
||||
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/settings`
|
||||
));
|
||||
await page.locator('.address-picker-select').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
|
||||
expect((await settingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||
|
||||
const subject = `Browser user send ${Date.now()}`;
|
||||
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||
.fill('recipient@test.example.com');
|
||||
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
|
||||
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
|
||||
|
||||
const sendResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/send_mail`
|
||||
));
|
||||
const sendboxResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||
));
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
expect((await sendResponse).ok()).toBe(true);
|
||||
expect((await sendboxResponse).ok()).toBe(true);
|
||||
|
||||
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
|
||||
.toHaveText(subject, { timeout: 15_000 });
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.12.0",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -99,8 +99,6 @@ const getOpenSettings = async (message, notification) => {
|
||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableMailReadStatus: res["enableMailReadStatus"] === true,
|
||||
enableMailFlagged: res["enableMailFlagged"] === true,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
|
||||
@@ -3,10 +3,7 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useGlobalState } from '../store'
|
||||
import {
|
||||
CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound,
|
||||
StarBorderRound, StarRound
|
||||
} from '@vicons/material'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
@@ -58,37 +55,9 @@ const props = defineProps({
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
enableMailReadStatus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
enableMailFlagged: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
updateMailState: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
updateMailFlagged: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
fetchMailStates: {
|
||||
type: Function,
|
||||
default: () => ({ results: [] }),
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const localFilterKeyword = ref('')
|
||||
const mailStateFilter = ref(null)
|
||||
const flaggedOnly = ref(false)
|
||||
const mailStates = ref([])
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
||||
@@ -125,73 +94,6 @@ const data = computed(() => {
|
||||
});
|
||||
})
|
||||
|
||||
const isMailUnread = (mail) => {
|
||||
return props.enableMailReadStatus && mail?.unread === true
|
||||
}
|
||||
|
||||
const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread))
|
||||
const mailStateFilterOptions = computed(() => mailStates.value.map(state => ({
|
||||
label: state.label || t(state.label_key),
|
||||
value: state.value,
|
||||
})))
|
||||
|
||||
const getReadStateValue = (unread) => {
|
||||
return mailStates.value.find(state => state.unread === unread)?.value
|
||||
}
|
||||
|
||||
const updateUnreadState = async (mails, state) => {
|
||||
if (mails.length === 0 || !state) return true
|
||||
try {
|
||||
const response = await props.updateMailState(mails.map(mail => mail.id), state)
|
||||
const results = response?.results ?? []
|
||||
const resultById = new Map(results.map(result => [result.id, result]))
|
||||
mails.forEach(mail => {
|
||||
const result = resultById.get(mail.id)
|
||||
if (result) mail.unread = result.unread
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const markMailsRead = async (mails) => {
|
||||
return await updateUnreadState(mails.filter(isMailUnread), getReadStateValue(false))
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!curMail.value) return
|
||||
await updateUnreadState([curMail.value], getReadStateValue(!curMail.value.unread))
|
||||
}
|
||||
|
||||
const toggleMailFlagged = async (mail) => {
|
||||
if (!mail) return
|
||||
try {
|
||||
const response = await props.updateMailFlagged([mail.id], !mail.flagged)
|
||||
const result = response?.results?.[0]
|
||||
if (result) mail.flagged = result.flagged
|
||||
if (flaggedOnly.value && !mail.flagged) await backFirstPageAndRefresh()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailFlagged = async () => {
|
||||
await toggleMailFlagged(curMail.value)
|
||||
}
|
||||
|
||||
const openMail = async (mail) => {
|
||||
curMail.value = mail
|
||||
await markMailsRead([mail])
|
||||
}
|
||||
|
||||
const markCurrentPageRead = async () => {
|
||||
if (!await markMailsRead(rawData.value)) return
|
||||
message.success(t("success"))
|
||||
if (mailStateFilter.value === getReadStateValue(true)) await backFirstPageAndRefresh()
|
||||
}
|
||||
|
||||
const canGoPrevMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
@@ -209,12 +111,12 @@ const prevMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
await openMail(data.value[currentIndex - 1])
|
||||
curMail.value = data.value[currentIndex - 1]
|
||||
} else if (page.value > 1) {
|
||||
page.value--
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
await openMail(data.value[data.value.length - 1])
|
||||
curMail.value = data.value[data.value.length - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,12 +126,12 @@ const nextMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex < data.value.length - 1) {
|
||||
await openMail(data.value[currentIndex + 1])
|
||||
curMail.value = data.value[currentIndex + 1]
|
||||
} else if (count.value > page.value * pageSize.value) {
|
||||
page.value++
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
await openMail(data.value[0])
|
||||
curMail.value = data.value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,22 +165,6 @@ const setupAutoRefresh = async (autoRefresh) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadMailStates = async () => {
|
||||
if (!props.enableMailReadStatus) {
|
||||
mailStates.value = []
|
||||
mailStateFilter.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { results = [] } = await props.fetchMailStates()
|
||||
mailStates.value = results
|
||||
mailStateFilter.value = results.find(state => state.default)?.value ?? results[0]?.value ?? null
|
||||
} catch (error) {
|
||||
mailStates.value = []
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
}, { immediate: true })
|
||||
@@ -289,32 +175,19 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(mailStateFilter, async (_value, oldValue) => {
|
||||
if (oldValue === null) return
|
||||
await backFirstPageAndRefresh()
|
||||
})
|
||||
|
||||
watch(flaggedOnly, async () => {
|
||||
await backFirstPageAndRefresh()
|
||||
})
|
||||
|
||||
watch(() => props.enableMailReadStatus, async (enabled, oldValue) => {
|
||||
if (enabled === oldValue) return
|
||||
await loadMailStates()
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await props.fetchMailData(
|
||||
pageSize.value, (page.value - 1) * pageSize.value, mailStateFilter.value,
|
||||
flaggedOnly.value
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
loading.value = true;
|
||||
rawData.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (page.value === 1) count.value = totalCount;
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
@@ -342,7 +215,7 @@ const clickRow = async (row) => {
|
||||
curMail.value = null;
|
||||
return;
|
||||
}
|
||||
await openMail(row);
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
|
||||
@@ -456,7 +329,6 @@ const multiActionDownload = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMailStates()
|
||||
await refresh();
|
||||
});
|
||||
|
||||
@@ -509,14 +381,6 @@ onBeforeUnmount(() => {
|
||||
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-button v-if="enableMailReadStatus && currentPageHasUnread" @click="markCurrentPageRead" tertiary>
|
||||
{{ t('markCurrentPageRead') }}
|
||||
</n-button>
|
||||
<n-select v-if="enableMailReadStatus" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
|
||||
style="width: 120px" />
|
||||
<n-checkbox v-if="enableMailFlagged" v-model:checked="flaggedOnly">
|
||||
{{ t('flagged') }}
|
||||
</n-checkbox>
|
||||
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
|
||||
clearable />
|
||||
@@ -533,21 +397,12 @@ onBeforeUnmount(() => {
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||
<template #prefix>
|
||||
<n-checkbox v-if="multiActionMode" v-model:checked="row.checked" />
|
||||
<n-button v-else-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
|
||||
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
|
||||
<template #icon>
|
||||
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
|
||||
</template>
|
||||
</n-button>
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -606,9 +461,6 @@ onBeforeUnmount(() => {
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:enableMailReadStatus="enableMailReadStatus" :enableMailFlagged="enableMailFlagged"
|
||||
:onToggleUnread="toggleCurrentMailUnread"
|
||||
:onToggleFlagged="toggleCurrentMailFlagged"
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
@@ -623,15 +475,9 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="mail-list-scroll">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||
<template #prefix>
|
||||
<n-checkbox v-if="multiActionMode" v-model:checked="row.checked" />
|
||||
<n-button v-else-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
|
||||
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
|
||||
<template #icon>
|
||||
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
|
||||
</template>
|
||||
</n-button>
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
<n-thing class="mail-list-thing">
|
||||
<template #header>
|
||||
@@ -641,9 +487,6 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="mail-list-meta">
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -686,38 +529,16 @@ onBeforeUnmount(() => {
|
||||
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-button v-if="enableMailReadStatus && currentPageHasUnread" @click="markCurrentPageRead" tertiary size="small">
|
||||
{{ t('markCurrentPageRead') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
|
||||
<n-input v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||
</div>
|
||||
<div v-if="enableMailReadStatus || enableMailFlagged" style="padding: 0 10px; margin-bottom: 10px;">
|
||||
<n-select v-if="enableMailReadStatus" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
|
||||
size="small" />
|
||||
<n-checkbox v-if="enableMailFlagged" v-model:checked="flaggedOnly" style="margin-top: 8px;">
|
||||
{{ t('flagged') }}
|
||||
</n-checkbox>
|
||||
</div>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="{ 'mail-list-unread': isMailUnread(row) }">
|
||||
<template #prefix>
|
||||
<n-button v-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
|
||||
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
|
||||
<template #icon>
|
||||
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -747,9 +568,6 @@ onBeforeUnmount(() => {
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||
:enableMailReadStatus="enableMailReadStatus" :enableMailFlagged="enableMailFlagged"
|
||||
:onToggleUnread="toggleCurrentMailUnread"
|
||||
:onToggleFlagged="toggleCurrentMailFlagged"
|
||||
:onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
@@ -858,10 +676,6 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-unread :deep(.n-thing-header__title) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -34,14 +34,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableMailReadStatus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableMailFlagged: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 回调函数 props
|
||||
onDelete: {
|
||||
type: Function,
|
||||
@@ -58,14 +50,6 @@ const props = defineProps({
|
||||
onSaveToS3: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onToggleUnread: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onToggleFlagged: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -162,14 +146,6 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" @click="onToggleUnread">
|
||||
{{ mail.unread ? t('markRead') : t('markUnread') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="enableMailFlagged" size="small" tertiary type="warning" @click="onToggleFlagged">
|
||||
{{ mail.flagged ? t('removeFlagged') : t('addFlagged') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
export const deMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.index.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.index.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.index.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.index.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
|
||||
"views.admin.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.admin.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.admin.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.admin.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -402,7 +386,6 @@ export const deMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
||||
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
|
||||
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
||||
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
||||
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
||||
@@ -651,8 +634,5 @@ export const deMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||
"components.AddressCredentialModal.username": "Benutzername",
|
||||
"views.User.send_mail": "E-Mail senden",
|
||||
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||
"views.user.UserSendBox.sendbox": "Gesendet"
|
||||
"components.AddressCredentialModal.username": "Benutzername"
|
||||
}
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
export const esMessages = {
|
||||
"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...",
|
||||
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
|
||||
"views.admin.SendMail.composeMail": "Redactar correo",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -402,7 +386,6 @@ export const esMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
|
||||
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
|
||||
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
|
||||
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
|
||||
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
|
||||
@@ -651,8 +634,5 @@ export const esMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
||||
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
|
||||
"components.AddressCredentialModal.username": "Usuario",
|
||||
"views.User.send_mail": "Enviar correo",
|
||||
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
|
||||
"views.user.UserSendBox.sendbox": "Enviados"
|
||||
"components.AddressCredentialModal.username": "Usuario"
|
||||
}
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
export const jaMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
|
||||
"views.index.SendMail.composeMail": "メールを作成",
|
||||
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.index.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.index.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.index.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
|
||||
"views.admin.SendMail.composeMail": "メールを作成",
|
||||
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.admin.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.admin.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -402,7 +386,6 @@ export const jaMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
|
||||
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
|
||||
"views.admin.CreateAccount.generateName": "ランダム名を生成",
|
||||
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
|
||||
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
|
||||
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
|
||||
@@ -651,8 +634,5 @@ export const jaMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||
"components.AddressCredentialModal.username": "ユーザー名",
|
||||
"views.User.send_mail": "メール送信",
|
||||
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||
"views.user.UserSendBox.sendbox": "送信済み"
|
||||
"components.AddressCredentialModal.username": "ユーザー名"
|
||||
}
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
export const ptBRMessages = {
|
||||
"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...",
|
||||
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
|
||||
"views.admin.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
@@ -402,7 +386,6 @@ export const ptBRMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
|
||||
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
|
||||
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
|
||||
"views.user.UserLogin.pleaseInputCode": "Informe o código",
|
||||
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
|
||||
@@ -651,8 +634,5 @@ export const ptBRMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
||||
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
|
||||
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||
"views.User.send_mail": "Enviar e-mail",
|
||||
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||
"views.user.UserSendBox.sendbox": "Enviados"
|
||||
"components.AddressCredentialModal.username": "Nome de usuário"
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"components.MailBox": {
|
||||
"allMail": {
|
||||
"en": "All Mail",
|
||||
"zh": "全部邮件"
|
||||
},
|
||||
"attachments": {
|
||||
"en": "Show Attachments",
|
||||
"zh": "查看附件"
|
||||
@@ -74,18 +70,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Forward",
|
||||
"zh": "转发"
|
||||
},
|
||||
"flagged": {
|
||||
"en": "Flagged",
|
||||
"zh": "星标邮件"
|
||||
},
|
||||
"keywordQueryTip": {
|
||||
"en": "Filter current page",
|
||||
"zh": "过滤当前页"
|
||||
},
|
||||
"markCurrentPageRead": {
|
||||
"en": "Mark This Page as Read",
|
||||
"zh": "本页全部已读"
|
||||
},
|
||||
"multiAction": {
|
||||
"en": "Multi Action",
|
||||
"zh": "多选"
|
||||
@@ -106,10 +94,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Query",
|
||||
"zh": "查询"
|
||||
},
|
||||
"read": {
|
||||
"en": "Read",
|
||||
"zh": "已读"
|
||||
},
|
||||
"refresh": {
|
||||
"en": "Refresh",
|
||||
"zh": "刷新"
|
||||
@@ -145,18 +129,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"unselectAll": {
|
||||
"en": "Unselect All",
|
||||
"zh": "取消全选"
|
||||
},
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"zh": "未读"
|
||||
},
|
||||
"addFlagged": {
|
||||
"en": "Add Star",
|
||||
"zh": "添加星标"
|
||||
},
|
||||
"removeFlagged": {
|
||||
"en": "Remove Star",
|
||||
"zh": "取消星标"
|
||||
}
|
||||
},
|
||||
"components.AiExtractInfo": {
|
||||
@@ -198,10 +170,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "View Attachments",
|
||||
"zh": "查看附件"
|
||||
},
|
||||
"addFlagged": {
|
||||
"en": "Add Star",
|
||||
"zh": "添加星标"
|
||||
},
|
||||
"delete": {
|
||||
"en": "Delete",
|
||||
"zh": "删除"
|
||||
@@ -226,22 +194,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Load Images",
|
||||
"zh": "加载图片"
|
||||
},
|
||||
"markRead": {
|
||||
"en": "Mark as Read",
|
||||
"zh": "标记已读"
|
||||
},
|
||||
"markUnread": {
|
||||
"en": "Mark as Unread",
|
||||
"zh": "标记未读"
|
||||
},
|
||||
"remoteImagesBlocked": {
|
||||
"en": "{count} remote resources blocked to protect your privacy",
|
||||
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
||||
},
|
||||
"removeFlagged": {
|
||||
"en": "Remove Star",
|
||||
"zh": "取消星标"
|
||||
},
|
||||
"reply": {
|
||||
"en": "Reply",
|
||||
"zh": "回复"
|
||||
@@ -782,10 +738,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Bind Mail Address",
|
||||
"zh": "绑定邮箱地址"
|
||||
},
|
||||
"send_mail": {
|
||||
"en": "Send Mail",
|
||||
"zh": "发送邮件"
|
||||
},
|
||||
"user_mail_box_tab": {
|
||||
"en": "Mail Box",
|
||||
"zh": "收件箱"
|
||||
@@ -795,16 +747,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "用户设置"
|
||||
}
|
||||
},
|
||||
"views.user.UserSendBox": {
|
||||
"noAddress": {
|
||||
"en": "Select a bound email address to continue",
|
||||
"zh": "请选择一个已绑定的邮箱地址"
|
||||
},
|
||||
"sendbox": {
|
||||
"en": "Sent",
|
||||
"zh": "发件箱"
|
||||
}
|
||||
},
|
||||
"views.user.UserLogin": {
|
||||
"cannotForgotPassword": {
|
||||
"en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator",
|
||||
@@ -1084,14 +1026,6 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.index.SendMail": {
|
||||
"balanceUnavailable": {
|
||||
"en": "No send balance for this address",
|
||||
"zh": "当前地址暂无发信额度"
|
||||
},
|
||||
"composeMail": {
|
||||
"en": "Compose email",
|
||||
"zh": "写邮件"
|
||||
},
|
||||
"content": {
|
||||
"en": "Content",
|
||||
"zh": "内容"
|
||||
@@ -1100,14 +1034,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Content is empty",
|
||||
"zh": "内容不能为空"
|
||||
},
|
||||
"contentPlaceholder": {
|
||||
"en": "Write your message...",
|
||||
"zh": "输入邮件正文..."
|
||||
},
|
||||
"draftSaved": {
|
||||
"en": "Draft saved in this browser",
|
||||
"zh": "草稿已保存在当前浏览器"
|
||||
},
|
||||
"edit": {
|
||||
"en": "Edit",
|
||||
"zh": "编辑"
|
||||
@@ -1129,8 +1055,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "预览"
|
||||
},
|
||||
"requestAccess": {
|
||||
"en": "Request send access",
|
||||
"zh": "申请发信权限"
|
||||
"en": "Request Access",
|
||||
"zh": "申请权限"
|
||||
},
|
||||
"requestAccessTip": {
|
||||
"en": "Send permission and balance are managed separately for each email address. The current address, {address}, has no available balance. Request permission for this address or contact the admin.",
|
||||
@@ -1140,14 +1066,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Send permission requested for the current address",
|
||||
"zh": "已为当前地址提交发信权限申请"
|
||||
},
|
||||
"recipientAddress": {
|
||||
"en": "Recipient address",
|
||||
"zh": "收件人邮箱"
|
||||
},
|
||||
"recipientName": {
|
||||
"en": "Recipient name (optional)",
|
||||
"zh": "收件人名称(可选)"
|
||||
},
|
||||
"rich text": {
|
||||
"en": "Rich Text",
|
||||
"zh": "富文本"
|
||||
@@ -1160,14 +1078,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Current Address Send Balance",
|
||||
"zh": "当前地址剩余发信额度"
|
||||
},
|
||||
"senderAddress": {
|
||||
"en": "Sender address",
|
||||
"zh": "发件邮箱"
|
||||
},
|
||||
"senderName": {
|
||||
"en": "Sender name (optional)",
|
||||
"zh": "发件人名称(可选)"
|
||||
},
|
||||
"subject": {
|
||||
"en": "Subject",
|
||||
"zh": "主题"
|
||||
@@ -1182,7 +1092,7 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"text": {
|
||||
"en": "Text",
|
||||
"zh": "纯文本"
|
||||
"zh": "文本"
|
||||
},
|
||||
"toMailEmpty": {
|
||||
"en": "Recipient address is empty",
|
||||
@@ -1368,14 +1278,6 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.admin.SendMail": {
|
||||
"adminComposeTip": {
|
||||
"en": "Send from a configured email address",
|
||||
"zh": "使用已配置的邮箱地址发信"
|
||||
},
|
||||
"composeMail": {
|
||||
"en": "Compose email",
|
||||
"zh": "写邮件"
|
||||
},
|
||||
"content": {
|
||||
"en": "Content",
|
||||
"zh": "内容"
|
||||
@@ -1384,14 +1286,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Content is empty",
|
||||
"zh": "内容不能为空"
|
||||
},
|
||||
"contentPlaceholder": {
|
||||
"en": "Write your message...",
|
||||
"zh": "输入邮件正文..."
|
||||
},
|
||||
"draftSaved": {
|
||||
"en": "Draft saved in this browser",
|
||||
"zh": "草稿已保存在当前浏览器"
|
||||
},
|
||||
"edit": {
|
||||
"en": "Edit",
|
||||
"zh": "编辑"
|
||||
@@ -1416,14 +1310,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Preview",
|
||||
"zh": "预览"
|
||||
},
|
||||
"recipientAddress": {
|
||||
"en": "Recipient address",
|
||||
"zh": "收件人邮箱"
|
||||
},
|
||||
"recipientName": {
|
||||
"en": "Recipient name (optional)",
|
||||
"zh": "收件人名称(可选)"
|
||||
},
|
||||
"rich text": {
|
||||
"en": "Rich Text",
|
||||
"zh": "富文本"
|
||||
@@ -1432,14 +1318,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Send",
|
||||
"zh": "发送"
|
||||
},
|
||||
"senderAddress": {
|
||||
"en": "Sender address",
|
||||
"zh": "发件邮箱"
|
||||
},
|
||||
"senderName": {
|
||||
"en": "Sender name (optional)",
|
||||
"zh": "发件人名称(可选)"
|
||||
},
|
||||
"subject": {
|
||||
"en": "Subject",
|
||||
"zh": "主题"
|
||||
@@ -1454,7 +1332,7 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"text": {
|
||||
"en": "Text",
|
||||
"zh": "纯文本"
|
||||
"zh": "文本"
|
||||
},
|
||||
"toMailEmpty": {
|
||||
"en": "Recipient address is empty",
|
||||
@@ -2190,10 +2068,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Please fill in all fields",
|
||||
"zh": "请填写完整信息"
|
||||
},
|
||||
"generateName": {
|
||||
"en": "Generate Fake Name",
|
||||
"zh": "生成随机名字"
|
||||
},
|
||||
"linkWithAddressCredential": {
|
||||
"en": "Open to auto login email link",
|
||||
"zh": "打开即可自动登录邮箱的链接"
|
||||
|
||||
@@ -24,8 +24,6 @@ export const useGlobalState = createGlobalState(
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableMailReadStatus: false,
|
||||
enableMailFlagged: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
|
||||
@@ -32,41 +32,19 @@ const SendMail = defineAsyncComponent(() => {
|
||||
|
||||
const { t } = useScopedI18n('views.Index')
|
||||
|
||||
const fetchMailData = async (limit, offset, mailState, flaggedOnly) => {
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
if (mailIdQuery.value > 0) {
|
||||
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
||||
if (singleMail) return { results: [singleMail], count: 1 };
|
||||
return { results: [], count: 0 };
|
||||
}
|
||||
const mailStateQuery = mailState ? `&mail_state=${encodeURIComponent(mailState)}` : ''
|
||||
const flaggedQuery = flaggedOnly ? '&flagged=true' : ''
|
||||
return await api.fetch(
|
||||
`/api/mails?limit=${limit}&offset=${offset}${mailStateQuery}${flaggedQuery}`
|
||||
);
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
};
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailState = async (ids, state) => {
|
||||
return await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, state })
|
||||
});
|
||||
};
|
||||
|
||||
const updateMailFlagged = async (ids, flagged) => {
|
||||
return await api.fetch(`/api/mails/flagged`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, flagged })
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMailStates = async () => {
|
||||
return await api.fetch(`/api/mail-states`)
|
||||
}
|
||||
|
||||
const deleteSenboxMail = async (curMailId) => {
|
||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
@@ -149,10 +127,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:enableMailFlagged="openSettings.enableMailFlagged" :updateMailState="updateMailState"
|
||||
:updateMailFlagged="updateMailFlagged" :fetchMailStates="fetchMailStates" />
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
|
||||
@@ -8,14 +8,12 @@ import UserSettingsPage from './user/UserSettings.vue';
|
||||
import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
import UserMailBox from './user/UserMailBox.vue';
|
||||
import UserSendBox from './user/UserSendBox.vue';
|
||||
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings, openSettings
|
||||
userTab, globalTabplacement, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.User')
|
||||
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
|
||||
|
||||
</script>
|
||||
|
||||
@@ -29,12 +27,6 @@ const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
|
||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||
<UserMailBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
|
||||
<UserSendBox mode="sendbox" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
|
||||
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -23,40 +23,6 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const addressRegex = computed(() => {
|
||||
try {
|
||||
if (openSettings.value.addressRegex) {
|
||||
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||
}
|
||||
return /[^a-z0-9]/g;
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(addressRegex.value, '')
|
||||
.toLowerCase();
|
||||
// support maxAddressLen
|
||||
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
@@ -115,22 +81,15 @@ onMounted(async () => {
|
||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-spin :show="generateNameLoading" style="width: 100%;">
|
||||
<div>
|
||||
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-spin>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<div style="width: 100%;">
|
||||
|
||||
@@ -2,20 +2,14 @@
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import { api } from '../../api'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const { autoLoadRemoteImages, isDark } = useGlobalState()
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
@@ -29,18 +23,11 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
|
||||
const { t } = useScopedI18n('views.admin.SendMail')
|
||||
|
||||
const contentTypes = computed(() => [
|
||||
const contentTypes = [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
]
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
@@ -123,7 +110,6 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -160,230 +146,78 @@ const handleCreated = (editor) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-page">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-sender-address' }">
|
||||
<n-input v-model:value="sendMailModel.fromMail"
|
||||
:input-props="{ id: 'admin-send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')"
|
||||
:label-props="{ for: 'admin-send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'admin-send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')"
|
||||
:label-props="{ for: 'admin-send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required
|
||||
:label-props="{ for: 'admin-send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'admin-send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="admin-send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input v-model:value="sendMailModel.fromMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container),
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
.left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
@@ -18,25 +14,15 @@ const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
|
||||
const {
|
||||
settings, sendMailModel, indexTab, userSettings,
|
||||
autoLoadRemoteImages, isDark,
|
||||
} = useGlobalState()
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const contentTypes = computed(() => [
|
||||
const contentTypes = [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
]
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
@@ -171,292 +157,95 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ settings.address }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('requestAccessTip', { address: settings.address }) }}
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-input :value="settings.address" readonly
|
||||
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
<div v-else>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input :value="settings.address" disabled />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</template>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
.left {
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,17 +26,12 @@ const message = useMessage()
|
||||
const currentPage = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const currentMail = ref(null)
|
||||
const mailStates = ref([])
|
||||
const showAccountSettingsCard = ref(false)
|
||||
const currentAutoRefreshInterval = ref(60)
|
||||
const timer = ref(null)
|
||||
|
||||
const { t } = useScopedI18n('views.index.SimpleIndex')
|
||||
|
||||
const getReadStateValue = (unread) => {
|
||||
return mailStates.value.find(state => state.unread === unread)?.value
|
||||
}
|
||||
|
||||
// 复制地址
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
@@ -55,58 +50,12 @@ const fetchMails = async () => {
|
||||
totalCount.value = count > 0 ? count : totalCount.value;
|
||||
const rawMail = results && results.length > 0 ? results[0] : null
|
||||
currentMail.value = rawMail ? await processItem(rawMail) : null
|
||||
if (openSettings.value.enableMailReadStatus && rawMail?.unread) {
|
||||
const state = getReadStateValue(false)
|
||||
if (!state) return
|
||||
const response = await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids: [rawMail.id], state })
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.unread = updatedMail.unread
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mails:', error)
|
||||
message.error('获取邮件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
|
||||
try {
|
||||
const state = getReadStateValue(!currentMail.value.unread)
|
||||
if (!state) return
|
||||
const response = await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [currentMail.value.id],
|
||||
state,
|
||||
})
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.unread = updatedMail.unread
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailFlagged = async () => {
|
||||
if (!currentMail.value || !openSettings.value.enableMailFlagged) return
|
||||
try {
|
||||
const response = await api.fetch(`/api/mails/flagged`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [currentMail.value.id],
|
||||
flagged: !currentMail.value.flagged,
|
||||
})
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.flagged = updatedMail.flagged
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除邮件
|
||||
const deleteMail = async () => {
|
||||
if (!currentMail.value) return;
|
||||
@@ -157,15 +106,6 @@ watch(currentPage, () => {
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings()
|
||||
if (openSettings.value.enableMailReadStatus) {
|
||||
try {
|
||||
const { results = [] } = await api.fetch(`/api/mail-states`)
|
||||
mailStates.value = results
|
||||
} catch (error) {
|
||||
mailStates.value = []
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
await fetchMails()
|
||||
|
||||
// 启动自动刷新
|
||||
@@ -280,10 +220,6 @@ onBeforeUnmount(() => {
|
||||
<div style="margin-top: 16px;">
|
||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:enableMailFlagged="openSettings.enableMailFlagged"
|
||||
:onToggleUnread="toggleCurrentMailUnread"
|
||||
:onToggleFlagged="toggleCurrentMailFlagged"
|
||||
:onDelete="deleteMail" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
@@ -16,7 +15,6 @@ const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
@@ -26,20 +24,6 @@ const showTranferAddress = ref(false)
|
||||
const currentAddress = ref("")
|
||||
const currentAddressId = ref(0)
|
||||
const targetUserEmail = ref('')
|
||||
const showAddressCredential = ref(false)
|
||||
const currentAddressCredential = ref('')
|
||||
const credentialAddress = ref('')
|
||||
|
||||
const showCredential = async (row) => {
|
||||
try {
|
||||
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
|
||||
currentAddressCredential.value = addressCredential
|
||||
credentialAddress.value = row.name
|
||||
showAddressCredential.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
const changeMailAddress = async (address_id) => {
|
||||
try {
|
||||
@@ -162,14 +146,6 @@ const columns = [
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => showCredential(row)
|
||||
},
|
||||
{ default: () => credentialT('addressCredential') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => changeMailAddress(row.id)
|
||||
@@ -228,8 +204,6 @@ watch([page, pageSize], async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||
:jwt="currentAddressCredential" />
|
||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||
<span>
|
||||
<p>{{ t("transferAddressTip") }}</p>
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const settings = ref({
|
||||
address: '',
|
||||
send_balance: 0,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
addressId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
addressOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
addressLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
|
||||
|
||||
const {
|
||||
sendMailModel, userSettings, autoLoadRemoteImages, isDark,
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const getApiPath = (path) => `/user_api/address/${props.addressId}/${path}`
|
||||
|
||||
const refreshSettings = async () => {
|
||||
settings.value = await api.fetch(getApiPath('settings'))
|
||||
}
|
||||
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(getApiPath('send_mail'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
emit('sent')
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(getApiPath('request_send_mail_access'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("requestSuccess"))
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ["uploadVideo"]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
MENU_CONF: {
|
||||
'uploadImage': {
|
||||
async customUpload() {
|
||||
message.error(t('tooLarge'))
|
||||
},
|
||||
maxFileSize: 1 * 1024 * 1024,
|
||||
base64LimitSize: 1 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
await refreshSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-select class="address-picker-select" :value="addressId"
|
||||
:options="addressOptions" :loading="addressLoading" filterable
|
||||
@scroll="emit('addressScroll', $event)"
|
||||
@update:value="emit('update:addressId', $event)" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,13 +20,11 @@ const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset, mailState, flaggedOnly) => {
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/user_api/mails`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (mailState ? `&mail_state=${encodeURIComponent(mailState)}` : '')
|
||||
+ (flaggedOnly ? '&flagged=true' : '')
|
||||
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
|
||||
);
|
||||
}
|
||||
@@ -52,24 +50,6 @@ const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailState = async (ids, state) => {
|
||||
return await api.fetch(`/user_api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, state })
|
||||
});
|
||||
};
|
||||
|
||||
const updateMailFlagged = async (ids, flagged) => {
|
||||
return await api.fetch(`/user_api/mails/flagged`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, flagged })
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMailStates = async () => {
|
||||
return await api.fetch(`/user_api/mail-states`)
|
||||
}
|
||||
|
||||
watch(addressFilter, async (newValue) => {
|
||||
queryMail();
|
||||
});
|
||||
@@ -90,10 +70,6 @@ onMounted(() => {
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" :showFilterInput="true"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:enableMailFlagged="openSettings.enableMailFlagged"
|
||||
:updateMailState="updateMailState" :updateMailFlagged="updateMailFlagged"
|
||||
:fetchMailStates="fetchMailStates" />
|
||||
:deleteMail="deleteMail" :showFilterInput="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
import SendBox from '../../components/SendBox.vue'
|
||||
|
||||
const SendMail = defineAsyncComponent(() => import('./SendMail.vue'))
|
||||
|
||||
const ADDRESS_PAGE_SIZE = 100
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'send_mail',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sent'])
|
||||
|
||||
const message = useMessage()
|
||||
const { openSettings } = useGlobalState()
|
||||
const { t } = useScopedI18n('views.user.UserSendBox')
|
||||
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
|
||||
|
||||
const selectedAddressId = ref(null)
|
||||
const addressFilter = ref(null)
|
||||
const addressOptions = ref([])
|
||||
const addressCount = ref(0)
|
||||
const addressLoading = ref(false)
|
||||
const sendboxKey = ref(0)
|
||||
|
||||
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
||||
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
|
||||
label: address.label,
|
||||
value: address.address,
|
||||
})))
|
||||
|
||||
const fetchAddresses = async () => {
|
||||
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const offset = addressOptions.value.length
|
||||
const { results, count } = await api.fetch(
|
||||
`/user_api/bind_address?limit=${ADDRESS_PAGE_SIZE}&offset=${offset}`
|
||||
)
|
||||
addressOptions.value.push(...results.map((address) => ({
|
||||
label: address.name,
|
||||
value: address.id,
|
||||
address: address.name,
|
||||
})))
|
||||
if (offset === 0) {
|
||||
addressCount.value = count
|
||||
}
|
||||
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
|
||||
selectedAddressId.value = addressOptions.value[0].value
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
} finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddressScroll = async (event) => {
|
||||
const target = event.currentTarget
|
||||
if (!target || target.scrollTop + target.clientHeight < target.scrollHeight - 24) {
|
||||
return
|
||||
}
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
const fetchSendbox = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/user_api/sendbox?limit=${limit}&offset=${offset}`
|
||||
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
const deleteSendboxMail = async (mailId) => {
|
||||
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const querySendbox = () => {
|
||||
sendboxKey.value = Date.now()
|
||||
}
|
||||
|
||||
watch(addressFilter, querySendbox)
|
||||
|
||||
onMounted(fetchAddresses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-send-box">
|
||||
<template v-if="mode === 'send_mail'">
|
||||
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
|
||||
<SendMail v-else :key="selectedAddressId" v-model:address-id="selectedAddressId"
|
||||
:address-options="addressOptions" :address-loading="addressLoading"
|
||||
@address-scroll="handleAddressScroll" @sent="emit('sent')" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-input-group>
|
||||
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
|
||||
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
|
||||
@scroll="handleAddressScroll" />
|
||||
<n-button @click="querySendbox" type="primary" tertiary>
|
||||
{{ mailboxT('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div class="filter-spacing"></div>
|
||||
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
|
||||
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
||||
:delete-mail="deleteSendboxMail" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-send-box {
|
||||
padding-top: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-spacing {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.address-empty {
|
||||
padding: 72px 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.12.0",
|
||||
"version": "1.11.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,52 +19,6 @@ res = requests.get(
|
||||
|
||||
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
|
||||
|
||||
## Mail State API
|
||||
|
||||
After running the database migration, `ENABLE_MAIL_READ_STATUS` and `ENABLE_MAIL_FLAGGED` can be enabled independently. The former adds `unread` to mail responses and the latter adds `flagged`. State lives in a separate sparse relation table without changing `raw_mails`; historical mail without a state record is read and unstarred by default, and the backend handles state calculation and updates.
|
||||
|
||||
Read status is the high-write feature: it inserts one unread row for every new mail and deletes that row when the mail becomes read. Enabling only Flagged performs none of those writes; the database changes only when a user adds or removes a star.
|
||||
|
||||
With an Address JWT, use `GET /api/mail-states` to retrieve the available read states. The frontend uses each returned `value` directly for filtering and updates, and displays its `label_key`.
|
||||
|
||||
Use `PATCH /api/mails/state` to move the state of up to 100 mail IDs:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<your-worker-address>/api/mails/state",
|
||||
headers={"Authorization": "Bearer <your-JWT-password>"},
|
||||
json={"ids": [1, 2], "state": "read"}
|
||||
)
|
||||
```
|
||||
|
||||
With a User JWT, use `GET /user_api/mail-states` and `PATCH /user_api/mails/state`. Only mail belonging to addresses bound to that user can be changed. The response contains the updated `unread` state.
|
||||
|
||||
Flagged is independent of read state. Use `PATCH /api/mails/flagged` to add or remove stars:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<your-worker-address>/api/mails/flagged",
|
||||
headers={"Authorization": "Bearer <your-JWT-password>"},
|
||||
json={"ids": [1, 2], "flagged": True}
|
||||
)
|
||||
```
|
||||
|
||||
The User JWT equivalent is `PATCH /user_api/mails/flagged`.
|
||||
|
||||
Mail-list endpoints accept a state `value` returned by the backend. For example, list unread mail with:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread
|
||||
```
|
||||
|
||||
Use `flagged=true` to list starred mail. It can be combined with `mail_state`:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread&flagged=true
|
||||
```
|
||||
|
||||
`/user_api/mails` accepts the same parameter.
|
||||
|
||||
## Admin Mail API
|
||||
|
||||
Supports `address` filter
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
## Send Email via HTTP API
|
||||
|
||||
There are three HTTP API endpoints for sending emails:
|
||||
There are two HTTP API endpoints for sending emails:
|
||||
|
||||
| Endpoint | Authentication | Use Case |
|
||||
|----------|---------------|----------|
|
||||
| `/api/send_mail` | `Authorization: Bearer <address_JWT>` header | Internal calls, requires cookie / header auth |
|
||||
| `/external/api/send_mail` | `token` field in request body | External system integration, no header auth needed |
|
||||
| `/user_api/address/:address_id/send_mail` | `x-user-token: <user_JWT>` header | Signed-in users sending from one of their bound addresses |
|
||||
|
||||
::: tip What is "Address JWT"?
|
||||
The Address JWT is the `jwt` field returned when creating an email address via `/api/new_address` or `/admin/new_address`.
|
||||
@@ -60,44 +59,6 @@ res = requests.post(
|
||||
)
|
||||
```
|
||||
|
||||
### Method 3: User JWT (`/user_api/address/:address_id/send_mail`)
|
||||
|
||||
Obtain `address_id` from the paginated `GET /user_api/bind_address` response. The backend verifies that the address belongs to the current user; clients cannot choose an arbitrary sender address.
|
||||
|
||||
If the site grants unlimited sending to the current user's role through `NO_LIMIT_SEND_ROLE`, also send the `access_token` returned by `GET /user_api/settings`. The frontend handles this token automatically.
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"from_name": "Sender Name",
|
||||
"to_name": "Recipient Name",
|
||||
"to_mail": "Recipient Address",
|
||||
"subject": "Email Subject",
|
||||
"is_html": False,
|
||||
"content": "Email content",
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"https://your_worker_domain/user_api/address/123/send_mail",
|
||||
json=send_body,
|
||||
headers={
|
||||
"x-user-token": "<user_JWT>",
|
||||
# "x-user-access-token": "<user_access_token>", # Required for role permissions
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The same user-address API group also provides:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/user_api/address/:address_id/settings` | Get the address and remaining send balance |
|
||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | Request send access for the address |
|
||||
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=optional-address` | List the current user's sent items, optionally filtered by a bound address |
|
||||
| `DELETE` | `/user_api/sendbox/:mail_id` | Delete one sent item owned by the current user |
|
||||
|
||||
All endpoints require a User JWT. Address-scoped endpoints verify that `address_id` is bound to the current user, while user-level sent-item endpoints only return or delete records for the user's bound addresses. The user access token is only used to apply optional role permissions.
|
||||
|
||||
## Send Email via SMTP
|
||||
|
||||
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
|
||||
|
||||
@@ -102,8 +102,6 @@
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
||||
| `ENABLE_MAIL_READ_STATUS` | Text/JSON | Enables web read/unread state. It writes one unread row for every new mail and deletes that row when read, so this is the high-write feature. Historical mail without a row is read. **Run the database migration before enabling.** | `true` |
|
||||
| `ENABLE_MAIL_FLAGGED` | Text/JSON | Independently enables Flagged/starred mail. It writes only when a user adds or removes a star and does not add per-new-mail writes. Historical mail is unstarred. **Run the database migration before enabling.** | `true` |
|
||||
| `CLEANUP_BATCH_SIZE` | Number | Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to `3000`, valid range `1-5000`. Smaller values reduce per-run D1 pressure; larger values clear backlogs faster | `3000` |
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -19,52 +19,6 @@ res = requests.get(
|
||||
|
||||
**注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。
|
||||
|
||||
## 邮件状态 API
|
||||
|
||||
完成数据库迁移后,可分别启用 `ENABLE_MAIL_READ_STATUS` 和 `ENABLE_MAIL_FLAGGED`。前者让邮件响应包含 `unread`,后者包含 `flagged`;两个开关互不依赖。邮件状态保存在独立的稀疏关联表中,不修改 `raw_mails`;没有状态记录的历史邮件默认已读且未星标,状态计算和更新全部由后端处理。
|
||||
|
||||
已读状态是高写入量功能:启用后每封新邮件会新增一条未读记录,邮件变为已读时再删除。仅启用星标不会执行这些写入,只有用户添加或取消星标时才修改数据库。
|
||||
|
||||
地址 JWT 使用 `GET /api/mail-states` 获取当前可用的已读状态。前端直接使用其中的 `value` 作为筛选和更新参数,并使用 `label_key` 显示名称。
|
||||
|
||||
使用 `PATCH /api/mails/state` 批量移动邮件状态,每次最多传入 100 个邮件 ID:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<你的worker地址>/api/mails/state",
|
||||
headers={"Authorization": "Bearer <你的JWT密码>"},
|
||||
json={"ids": [1, 2], "state": "read"}
|
||||
)
|
||||
```
|
||||
|
||||
用户 JWT 使用 `GET /user_api/mail-states` 和 `PATCH /user_api/mails/state`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `unread` 状态。
|
||||
|
||||
星标与已读状态相互独立。使用 `PATCH /api/mails/flagged` 添加或取消星标:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<你的worker地址>/api/mails/flagged",
|
||||
headers={"Authorization": "Bearer <你的JWT密码>"},
|
||||
json={"ids": [1, 2], "flagged": True}
|
||||
)
|
||||
```
|
||||
|
||||
用户 JWT 对应接口为 `PATCH /user_api/mails/flagged`。
|
||||
|
||||
邮件列表使用后端返回的状态 `value` 查询。例如查询未读邮件:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread
|
||||
```
|
||||
|
||||
使用 `flagged=true` 查询星标邮件,并可与 `mail_state` 组合:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread&flagged=true
|
||||
```
|
||||
|
||||
`/user_api/mails` 支持相同参数。
|
||||
|
||||
## admin 邮件 API
|
||||
|
||||
支持 `address` 过滤
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
## 通过 HTTP API 发送邮件
|
||||
|
||||
有三种 HTTP API 端点可以发送邮件,区别如下:
|
||||
有两种 HTTP API 端点可以发送邮件,区别如下:
|
||||
|
||||
| 端点 | 认证方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `/api/send_mail` | `Authorization: Bearer <地址JWT>` header | 内部调用,需要先通过 cookie / header 鉴权 |
|
||||
| `/external/api/send_mail` | 请求体中的 `token` 字段 | 外部系统集成,无需 header 鉴权 |
|
||||
| `/user_api/address/:address_id/send_mail` | `x-user-token: <用户JWT>` header | 已登录用户使用自己的绑定邮箱发信 |
|
||||
|
||||
::: tip 什么是"地址 JWT"?
|
||||
地址 JWT 是通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱地址时返回的 `jwt` 字段。
|
||||
@@ -60,44 +59,6 @@ res = requests.post(
|
||||
)
|
||||
```
|
||||
|
||||
### 方式三:使用用户 JWT(`/user_api/address/:address_id/send_mail`)
|
||||
|
||||
`address_id` 可从分页接口 `GET /user_api/bind_address` 的结果中获取。后端会验证该地址属于当前用户,客户端不能自行指定发件邮箱。
|
||||
|
||||
如果站点通过 `NO_LIMIT_SEND_ROLE` 为当前用户角色配置了无限发信额度,还需要传入 `GET /user_api/settings` 返回的 `access_token`。前端会自动处理该令牌。
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"from_name": "发件人名字",
|
||||
"to_name": "收件人名字",
|
||||
"to_mail": "收件人地址",
|
||||
"subject": "邮件主题",
|
||||
"is_html": False,
|
||||
"content": "邮件内容",
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"https://你的worker域名/user_api/address/123/send_mail",
|
||||
json=send_body,
|
||||
headers={
|
||||
"x-user-token": "<用户JWT>",
|
||||
# "x-user-access-token": "<用户访问令牌>", # 使用角色权限时需要
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
同一组用户地址接口还包括:
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/user_api/address/:address_id/settings` | 获取地址和剩余发信额度 |
|
||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | 为该地址申请发信权限 |
|
||||
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=可选地址` | 分页获取当前用户的发件箱,可按绑定地址过滤 |
|
||||
| `DELETE` | `/user_api/sendbox/:mail_id` | 删除当前用户的一条发件记录 |
|
||||
|
||||
以上接口都需要用户 JWT。地址级接口验证 `address_id` 是否绑定到当前用户,用户级发件箱接口只返回或删除当前用户绑定地址的记录;用户访问令牌仅用于应用可选的角色权限。
|
||||
|
||||
## 通过 SMTP 发送邮件
|
||||
|
||||
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
|
||||
|
||||
@@ -97,8 +97,6 @@
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
||||
| `ENABLE_MAIL_READ_STATUS` | 文本/JSON | 启用网页已读/未读功能。每封新邮件写入一条未读记录,变为已读时删除,因此属于高写入量功能。无记录的历史邮件默认已读。**启用前必须先执行数据库迁移。** | `true` |
|
||||
| `ENABLE_MAIL_FLAGGED` | 文本/JSON | 独立启用星标邮件功能。只有添加或取消星标时才写数据库,不会产生逐封新邮件写入;历史邮件默认未星标。**启用前必须先执行数据库迁移。** | `true` |
|
||||
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.11.1",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.12.0",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
import i18n from '../i18n'
|
||||
import { getBooleanValue } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { deleteRawMails, prepareRawMailDeleteStatements } from '../mail_flags'
|
||||
|
||||
const listAddresses = async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
@@ -75,12 +74,10 @@ const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
// deleted first and the address row last, so the name subqueries still
|
||||
// resolve and a failed statement rolls back the whole deletion
|
||||
const results = await c.env.DB.batch([
|
||||
...prepareRawMailDeleteStatements(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (select name from address where id = ?)`,
|
||||
[id],
|
||||
),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
@@ -110,12 +107,10 @@ const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: mailSuccess } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (select name from address where id = ?)`,
|
||||
[id],
|
||||
);
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Context } from "hono";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import { resolveRawEmailRow } from "../gzip";
|
||||
import { deleteRawMails } from "../mail_flags";
|
||||
|
||||
export default {
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
@@ -36,7 +35,9 @@ export default {
|
||||
},
|
||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await deleteRawMails(c.env.DB, c.env, `id = ?`, [id]);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE id = ? `
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getJsonSetting, saveSetting } from '../utils';
|
||||
import { CleanupSettings, CustomSqlCleanup } from '../models';
|
||||
import i18n from '../i18n';
|
||||
import { LocaleMessages } from '../i18n/type';
|
||||
import { cleanupOrphanMailFlags } from '../mail_flags';
|
||||
|
||||
// SQL validation error types
|
||||
type SqlValidationError = 'empty' | 'too_long' | 'not_delete' | 'multiple_statements' | 'has_comments';
|
||||
@@ -85,7 +84,6 @@ export const executeCustomSqlCleanup = async (
|
||||
console.log(`Executing custom SQL cleanup [${customSql.name}]: ${sql}`);
|
||||
const result = await c.env.DB.prepare(sql).run();
|
||||
const rowsAffected = result.meta?.changes ?? 0;
|
||||
await cleanupOrphanMailFlags(c.env.DB, c.env);
|
||||
console.log(`Custom SQL cleanup [${customSql.name}] completed, rows affected: ${rowsAffected}`);
|
||||
return { success: true, rowsAffected };
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,15 +20,6 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
|
||||
@@ -40,8 +40,6 @@ export default {
|
||||
"ENABLE_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
|
||||
"DISABLE_ANONYMOUS_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
||||
"ENABLE_USER_DELETE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||
"ENABLE_MAIL_READ_STATUS": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS),
|
||||
"ENABLE_MAIL_FLAGGED": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGGED),
|
||||
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||
"COPYRIGHT": c.env.COPYRIGHT,
|
||||
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
|
||||
|
||||
@@ -20,8 +20,6 @@ api.get('/open_api/settings', async (c) => {
|
||||
) || {};
|
||||
const smtpProxyConfig = smtpImapProxyConfig.smtp || {};
|
||||
const imapProxyConfig = smtpImapProxyConfig.imap || {};
|
||||
const enableMailReadStatus = utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS);
|
||||
const enableMailFlagged = utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGGED);
|
||||
|
||||
return c.json({
|
||||
"title": c.env.TITLE,
|
||||
@@ -41,8 +39,6 @@ api.get('/open_api/settings', async (c) => {
|
||||
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
||||
"disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME),
|
||||
"enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||
...(enableMailReadStatus ? { "enableMailReadStatus": true } : {}),
|
||||
...(enableMailFlagged ? { "enableMailFlagged": true } : {}),
|
||||
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
|
||||
"copyright": c.env.COPYRIGHT,
|
||||
|
||||
+18
-30
@@ -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 { deleteRawMails, serializeMailStates } from './mail_flags';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
@@ -528,25 +527,20 @@ export const cleanup = async (
|
||||
)
|
||||
break;
|
||||
case "mails":
|
||||
await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`id IN (
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE id IN (
|
||||
SELECT id FROM raw_mails
|
||||
WHERE created_at < datetime('now', ?)
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?)`,
|
||||
[`-${cleanDays} day`, cleanupBatchSize],
|
||||
);
|
||||
LIMIT ?
|
||||
)`
|
||||
).bind(`-${cleanDays} day`, cleanupBatchSize).run();
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address NOT IN (select name from address)`
|
||||
+ ` AND created_at < datetime('now', '-${cleanDays} day')`,
|
||||
[],
|
||||
);
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE address NOT IN
|
||||
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
@@ -575,12 +569,10 @@ const batchDeleteAddressWithData = async (
|
||||
c: Context<HonoCustomType>,
|
||||
addressQueryCondition: string,
|
||||
): Promise<boolean> => {
|
||||
await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (SELECT name FROM address WHERE ${addressQueryCondition})`,
|
||||
[],
|
||||
);
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN ( ` +
|
||||
`SELECT name FROM address WHERE ${addressQueryCondition})`
|
||||
).run();
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address IN ( ` +
|
||||
`SELECT name FROM address WHERE ${addressQueryCondition})`
|
||||
@@ -634,12 +626,9 @@ export const deleteAddressWithData = async (
|
||||
// unbind telegram
|
||||
await unbindTelegramByAddress(c, address);
|
||||
// delete address and related data
|
||||
const { success: mailSuccess } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address = ?`,
|
||||
[address],
|
||||
);
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? `
|
||||
).bind(address).run();
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address = ? `
|
||||
).bind(address).run();
|
||||
@@ -715,7 +704,7 @@ export const hideObjectFields = <T extends Record<string, unknown>>(
|
||||
*/
|
||||
export const handleMailListQuery = async (
|
||||
c: Context<HonoCustomType>,
|
||||
query: string, countQuery: string, params: (string | number)[],
|
||||
query: string, countQuery: string, params: string[],
|
||||
limit: string | number | undefined | null,
|
||||
offset: string | number | undefined | null,
|
||||
orderBy?: string
|
||||
@@ -732,11 +721,10 @@ export const handleMailListQuery = async (
|
||||
...params, limit, offset
|
||||
).all();
|
||||
const resolvedResults = await resolveRawEmailList(results);
|
||||
const serializedResults = await serializeMailStates(c.env.DB, resolvedResults, c.env);
|
||||
const count = offset == 0 ? await c.env.DB.prepare(
|
||||
countQuery
|
||||
).bind(...params).first("count") : 0;
|
||||
return c.json({ results: serializedResults, count });
|
||||
return c.json({ results: resolvedResults, count });
|
||||
}
|
||||
|
||||
export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): Promise<{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v' + '1.12.0',
|
||||
VERSION: 'v' + '1.11.1',
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
DB_VERSION: "v0.0.8",
|
||||
DB_VERSION: "v0.0.7",
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
|
||||
+10
-18
@@ -12,7 +12,6 @@ import { forwardEmail } from "./forward";
|
||||
import { EmailRuleSettings } from "../models";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { compressText } from "../gzip";
|
||||
import { initializeMailFlagsAfterInsert } from "../mail_flags";
|
||||
|
||||
|
||||
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
||||
@@ -68,7 +67,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
try {
|
||||
let insertResult: D1Result | null = null;
|
||||
let success = false;
|
||||
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
|
||||
let compressed: ArrayBuffer | null = null;
|
||||
try {
|
||||
@@ -78,49 +77,42 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
}
|
||||
if (compressed) {
|
||||
try {
|
||||
insertResult = await env.DB.prepare(
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, compressed, message_id
|
||||
).run();
|
||||
).run());
|
||||
} catch (dbError) {
|
||||
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
|
||||
const errMsg = String(dbError);
|
||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||
insertResult = await env.DB.prepare(
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
||||
).run();
|
||||
).run());
|
||||
} else {
|
||||
throw dbError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
insertResult = await env.DB.prepare(
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
||||
).run();
|
||||
).run());
|
||||
}
|
||||
} else {
|
||||
insertResult = await env.DB.prepare(
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
||||
).run();
|
||||
).run());
|
||||
}
|
||||
if (!insertResult?.success) {
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${toAddress}`);
|
||||
console.error(`Failed save message from ${message.from} to ${toAddress}`);
|
||||
} else {
|
||||
await initializeMailFlagsAfterInsert(
|
||||
env.DB,
|
||||
env,
|
||||
insertResult?.meta.last_row_id ?? 0,
|
||||
toAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
export enum MailFlag {
|
||||
UNREAD = 0,
|
||||
FLAGGED = 1,
|
||||
}
|
||||
|
||||
export enum MailState {
|
||||
ALL = 'all',
|
||||
UNREAD = 'unread',
|
||||
READ = 'read',
|
||||
}
|
||||
|
||||
export type MailStateOption = {
|
||||
value: string;
|
||||
label_key: string;
|
||||
unread?: boolean;
|
||||
default?: boolean;
|
||||
};
|
||||
|
||||
type MailStateDefinition = MailStateOption & {
|
||||
filter?: { flag: MailFlag; present: boolean };
|
||||
};
|
||||
|
||||
const MAIL_STATES: MailStateDefinition[] = [
|
||||
{ value: MailState.ALL, label_key: 'allMail', default: true },
|
||||
{
|
||||
value: MailState.UNREAD,
|
||||
label_key: 'unread',
|
||||
unread: true,
|
||||
filter: { flag: MailFlag.UNREAD, present: true },
|
||||
},
|
||||
{
|
||||
value: MailState.READ,
|
||||
label_key: 'read',
|
||||
unread: false,
|
||||
filter: { flag: MailFlag.UNREAD, present: false },
|
||||
},
|
||||
];
|
||||
|
||||
export const getMailStateOptions = (): MailStateOption[] => {
|
||||
return MAIL_STATES.map(({ filter: _filter, ...option }) => option);
|
||||
};
|
||||
|
||||
const getMailStateDefinition = (value: unknown): MailStateDefinition | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return MAIL_STATES.find(state => state.value === value);
|
||||
};
|
||||
|
||||
const isEnabled = (value: boolean | string | undefined): boolean => {
|
||||
return value === true || value === 'true';
|
||||
};
|
||||
|
||||
export const isMailReadStatusEnabled = (env: Bindings): boolean => {
|
||||
return isEnabled(env.ENABLE_MAIL_READ_STATUS);
|
||||
};
|
||||
|
||||
export const isMailFlaggedEnabled = (env: Bindings): boolean => {
|
||||
return isEnabled(env.ENABLE_MAIL_FLAGGED);
|
||||
};
|
||||
|
||||
const isAnyMailFlagEnabled = (env: Bindings): boolean => {
|
||||
return isMailReadStatusEnabled(env) || isMailFlaggedEnabled(env);
|
||||
};
|
||||
|
||||
export const serializeMailStates = async <T extends Record<string, unknown>>(
|
||||
db: D1Database,
|
||||
rows: T[],
|
||||
env: Bindings,
|
||||
): Promise<T[]> => {
|
||||
const readStatusEnabled = isMailReadStatusEnabled(env);
|
||||
const flaggedEnabled = isMailFlaggedEnabled(env);
|
||||
if ((!readStatusEnabled && !flaggedEnabled) || rows.length === 0) return rows;
|
||||
|
||||
const hasBoolean = (row: T, field: string) => {
|
||||
return [true, false, 0, 1].includes(row[field] as boolean | number);
|
||||
};
|
||||
const ids = [...new Set(rows
|
||||
.filter(row => (readStatusEnabled && !hasBoolean(row, 'unread'))
|
||||
|| (flaggedEnabled && !hasBoolean(row, 'flagged')))
|
||||
.map(row => Number(row.id)))]
|
||||
.filter(id => Number.isInteger(id) && id > 0);
|
||||
if (ids.length === 0) {
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
...(readStatusEnabled ? { unread: Boolean(row.unread) } : {}),
|
||||
...(flaggedEnabled ? { flagged: Boolean(row.flagged) } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
const flags = [
|
||||
...(readStatusEnabled ? [MailFlag.UNREAD] : []),
|
||||
...(flaggedEnabled ? [MailFlag.FLAGGED] : []),
|
||||
];
|
||||
const flagPlaceholders = flags.map(() => '?').join(',');
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const { results } = await db.prepare(
|
||||
`SELECT mf.mail_id, mf.flag, a.name AS address FROM mail_flags mf`
|
||||
+ ` JOIN address a ON a.id = mf.address_id`
|
||||
+ ` WHERE mf.flag IN (${flagPlaceholders}) AND mf.mail_id IN (${placeholders})`
|
||||
).bind(...flags, ...ids).all<{
|
||||
mail_id: number;
|
||||
flag: number;
|
||||
address: string;
|
||||
}>();
|
||||
const mailKey = (id: unknown, address: unknown) => `${Number(id)}\0${String(address)}`;
|
||||
const unreadKeys = new Set(results
|
||||
.filter(row => row.flag === MailFlag.UNREAD)
|
||||
.map(row => mailKey(row.mail_id, row.address)));
|
||||
const flaggedKeys = new Set(results
|
||||
.filter(row => row.flag === MailFlag.FLAGGED)
|
||||
.map(row => mailKey(row.mail_id, row.address)));
|
||||
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
...(readStatusEnabled ? {
|
||||
unread: hasBoolean(row, 'unread')
|
||||
? Boolean(row.unread)
|
||||
: unreadKeys.has(mailKey(row.id, row.address)),
|
||||
} : {}),
|
||||
...(flaggedEnabled ? {
|
||||
flagged: hasBoolean(row, 'flagged')
|
||||
? Boolean(row.flagged)
|
||||
: flaggedKeys.has(mailKey(row.id, row.address)),
|
||||
} : {}),
|
||||
}));
|
||||
};
|
||||
|
||||
export const serializeMailState = async <T extends Record<string, unknown>>(
|
||||
db: D1Database,
|
||||
row: T,
|
||||
env: Bindings,
|
||||
): Promise<T> => {
|
||||
const [result] = await serializeMailStates(db, [row], env);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const initializeMailFlagsAfterInsert = async (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
mailId: number,
|
||||
address: string,
|
||||
): Promise<void> => {
|
||||
if (!isMailReadStatusEnabled(env) || !Number.isInteger(mailId) || mailId <= 0) return;
|
||||
|
||||
try {
|
||||
await db.prepare(
|
||||
`INSERT OR IGNORE INTO mail_flags (mail_id, address_id, flag)`
|
||||
+ ` SELECT ?, id, ? FROM address WHERE name = ?`
|
||||
).bind(mailId, MailFlag.UNREAD, address).run();
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize mail flags for mail ${mailId}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export type MailStateQuery = {
|
||||
join: string;
|
||||
clause?: string;
|
||||
orderBy?: string;
|
||||
unread?: boolean;
|
||||
flagged?: boolean;
|
||||
params: number[];
|
||||
};
|
||||
|
||||
export const getMailFlaggedQuery = (
|
||||
value: string | undefined,
|
||||
mailAlias: string,
|
||||
addressIdColumn: string,
|
||||
): MailStateQuery | undefined | null => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value !== 'true' && value !== 'false') return null;
|
||||
|
||||
const present = value === 'true';
|
||||
return {
|
||||
join: ` ${present ? 'JOIN' : 'LEFT JOIN'} mail_flags mail_flagged_flags`
|
||||
+ ` ON mail_flagged_flags.mail_id = ${mailAlias}.id`
|
||||
+ ` AND mail_flagged_flags.address_id = ${addressIdColumn}`
|
||||
+ ` AND mail_flagged_flags.flag = ?`,
|
||||
clause: present ? undefined : 'mail_flagged_flags.mail_id IS NULL',
|
||||
orderBy: present ? 'mail_flagged_flags.mail_id desc' : undefined,
|
||||
flagged: present,
|
||||
params: [MailFlag.FLAGGED],
|
||||
};
|
||||
};
|
||||
|
||||
export const getMailStateQuery = (
|
||||
value: string | undefined,
|
||||
mailAlias: string,
|
||||
addressIdColumn: string,
|
||||
): MailStateQuery | undefined | null => {
|
||||
if (value === undefined) return undefined;
|
||||
|
||||
const definition = getMailStateDefinition(value);
|
||||
if (!definition) return null;
|
||||
if (!definition.filter) return undefined;
|
||||
|
||||
const { flag, present } = definition.filter;
|
||||
return {
|
||||
join: ` ${present ? 'JOIN' : 'LEFT JOIN'} mail_flags mail_state_flags`
|
||||
+ ` ON mail_state_flags.mail_id = ${mailAlias}.id`
|
||||
+ ` AND mail_state_flags.address_id = ${addressIdColumn}`
|
||||
+ ` AND mail_state_flags.flag = ?`,
|
||||
clause: present ? undefined : 'mail_state_flags.mail_id IS NULL',
|
||||
orderBy: present ? 'mail_state_flags.mail_id desc' : undefined,
|
||||
unread: definition.unread,
|
||||
params: [flag],
|
||||
};
|
||||
};
|
||||
|
||||
type MailFlagUpdate = {
|
||||
ids: number[];
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const body = value as Record<string, unknown>;
|
||||
if (!Array.isArray(body.ids) || body.ids.length === 0 || body.ids.length > 100) return null;
|
||||
if (body.ids.some(id => typeof id !== 'number')) return null;
|
||||
|
||||
const ids = [...new Set(body.ids.map(Number))];
|
||||
if (ids.some(id => !Number.isInteger(id) || id <= 0)) return null;
|
||||
|
||||
return { ids, body };
|
||||
};
|
||||
|
||||
type MailScope = {
|
||||
clause: string;
|
||||
params: (string | number)[];
|
||||
};
|
||||
|
||||
const applyMailFlagUpdate = async (
|
||||
db: D1Database,
|
||||
scope: MailScope,
|
||||
ids: number[],
|
||||
flag: MailFlag,
|
||||
present: boolean,
|
||||
resultField: 'unread' | 'flagged',
|
||||
) => {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const targetWhere = `rm.id IN (${placeholders}) AND (${scope.clause})`;
|
||||
const mutation = present
|
||||
? db.prepare(
|
||||
`INSERT OR IGNORE INTO mail_flags (mail_id, address_id, flag)`
|
||||
+ ` SELECT rm.id, a.id, ? FROM raw_mails rm`
|
||||
+ ` JOIN address a ON a.name = rm.address WHERE ${targetWhere}`
|
||||
).bind(flag, ...ids, ...scope.params)
|
||||
: db.prepare(
|
||||
`DELETE FROM mail_flags WHERE flag = ? AND mail_id IN (`
|
||||
+ `SELECT rm.id FROM raw_mails rm JOIN address a ON a.name = rm.address`
|
||||
+ ` WHERE ${targetWhere})`
|
||||
).bind(flag, ...ids, ...scope.params);
|
||||
|
||||
const mutationResult = await mutation.run();
|
||||
if (!mutationResult.success) {
|
||||
return { success: false, changes: 0, results: [] };
|
||||
}
|
||||
|
||||
const { results } = await db.prepare(
|
||||
`SELECT rm.id FROM raw_mails rm JOIN address a ON a.name = rm.address`
|
||||
+ ` WHERE ${targetWhere}`
|
||||
).bind(...ids, ...scope.params).all<{ id: number }>();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
changes: mutationResult.meta.changes ?? 0,
|
||||
results: results.map(row => ({ id: row.id, [resultField]: present })),
|
||||
};
|
||||
};
|
||||
|
||||
export const applyMailStateUpdate = async (
|
||||
db: D1Database,
|
||||
scope: MailScope,
|
||||
value: unknown,
|
||||
) => {
|
||||
const update = parseMailFlagUpdate(value);
|
||||
if (!update) return null;
|
||||
|
||||
const definition = getMailStateDefinition(update.body.state);
|
||||
if (definition?.unread === undefined) return null;
|
||||
return await applyMailFlagUpdate(
|
||||
db, scope, update.ids, MailFlag.UNREAD, definition.unread, 'unread'
|
||||
);
|
||||
};
|
||||
|
||||
export const applyMailFlaggedUpdate = async (
|
||||
db: D1Database,
|
||||
scope: MailScope,
|
||||
value: unknown,
|
||||
) => {
|
||||
const update = parseMailFlagUpdate(value);
|
||||
if (!update || typeof update.body.flagged !== 'boolean') return null;
|
||||
return await applyMailFlagUpdate(
|
||||
db, scope, update.ids, MailFlag.FLAGGED, update.body.flagged, 'flagged'
|
||||
);
|
||||
};
|
||||
|
||||
export const prepareRawMailDeleteStatements = (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
whereClause: string,
|
||||
params: (string | number)[],
|
||||
): D1PreparedStatement[] => {
|
||||
const deleteMail = db.prepare(`DELETE FROM raw_mails WHERE ${whereClause}`).bind(...params);
|
||||
if (!isAnyMailFlagEnabled(env)) return [deleteMail];
|
||||
|
||||
return [
|
||||
db.prepare(
|
||||
`DELETE FROM mail_flags WHERE mail_id IN (`
|
||||
+ `SELECT id FROM raw_mails WHERE ${whereClause})`
|
||||
).bind(...params),
|
||||
deleteMail,
|
||||
];
|
||||
};
|
||||
|
||||
export const deleteRawMails = async (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
whereClause: string,
|
||||
params: (string | number)[],
|
||||
): Promise<D1Result> => {
|
||||
const statements = prepareRawMailDeleteStatements(db, env, whereClause, params);
|
||||
if (statements.length === 1) return await statements[0].run();
|
||||
|
||||
const results = await db.batch(statements);
|
||||
return results[results.length - 1];
|
||||
};
|
||||
|
||||
export const cleanupOrphanMailFlags = async (
|
||||
db: D1Database,
|
||||
env: Bindings,
|
||||
limit = 1000,
|
||||
): Promise<number> => {
|
||||
if (!isAnyMailFlagEnabled(env) || !Number.isInteger(limit) || limit <= 0) return 0;
|
||||
|
||||
const result = await db.prepare(
|
||||
`DELETE FROM mail_flags WHERE (mail_id, flag) IN (`
|
||||
+ `SELECT mf.mail_id, mf.flag FROM mail_flags mf`
|
||||
+ ` LEFT JOIN raw_mails rm ON rm.id = mf.mail_id`
|
||||
+ ` LEFT JOIN address a ON a.id = mf.address_id AND a.name = rm.address`
|
||||
+ ` WHERE rm.id IS NULL OR a.id IS NULL LIMIT ?)`
|
||||
).bind(limit).run();
|
||||
return result.meta.changes ?? 0;
|
||||
};
|
||||
@@ -27,10 +27,7 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
|
||||
|
||||
// mail crud
|
||||
api.get('/api/mails', mails_crud.listMails)
|
||||
api.get('/api/mail-states', mails_crud.getMailStates)
|
||||
api.get('/api/mail/:mail_id', mails_crud.getMail)
|
||||
api.patch('/api/mails/state', mails_crud.updateMailState)
|
||||
api.patch('/api/mails/flagged', mails_crud.updateMailFlagged)
|
||||
api.delete('/api/mails/:id', mails_crud.deleteMail)
|
||||
|
||||
// parsed mail (server-side parsed subject/text/html/attachments)
|
||||
|
||||
@@ -5,63 +5,18 @@ import { getBooleanValue } from '../utils';
|
||||
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
||||
import { resolveRawEmailRow } from '../gzip'
|
||||
import { getSendBalanceState } from './send_balance';
|
||||
import {
|
||||
getMailStateQuery,
|
||||
getMailFlaggedQuery,
|
||||
getMailStateOptions,
|
||||
applyMailStateUpdate,
|
||||
applyMailFlaggedUpdate,
|
||||
serializeMailState,
|
||||
deleteRawMails,
|
||||
isMailReadStatusEnabled,
|
||||
isMailFlaggedEnabled,
|
||||
} from '../mail_flags';
|
||||
|
||||
const listMails = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
if (!address) {
|
||||
return c.json({ "error": "No address" }, 400)
|
||||
}
|
||||
const { limit, offset, mail_state, flagged } = c.req.query();
|
||||
const { limit, offset } = c.req.query();
|
||||
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
||||
const stateQuery = getMailStateQuery(mail_state, 'rm', 'a.id');
|
||||
const flaggedQuery = getMailFlaggedQuery(flagged, 'rm', 'a.id');
|
||||
if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400);
|
||||
if (flaggedQuery === null) return c.json({ error: "Invalid flagged filter" }, 400);
|
||||
if (stateQuery && !isMailReadStatusEnabled(c.env)) {
|
||||
return c.json({ error: "Mail read status is disabled" }, 403);
|
||||
}
|
||||
if (flaggedQuery && !isMailFlaggedEnabled(c.env)) {
|
||||
return c.json({ error: "Flagged mail is disabled" }, 403);
|
||||
}
|
||||
|
||||
if (!stateQuery && !flaggedQuery) {
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails WHERE address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails WHERE address = ?`,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
|
||||
const filters = [`rm.address = ?`];
|
||||
if (stateQuery?.clause) filters.push(stateQuery.clause);
|
||||
if (flaggedQuery?.clause) filters.push(flaggedQuery.clause);
|
||||
const fromQuery = ` FROM raw_mails rm`
|
||||
+ ` JOIN address a ON a.name = rm.address`
|
||||
+ (stateQuery?.join ?? '')
|
||||
+ (flaggedQuery?.join ?? '')
|
||||
+ ` WHERE ${filters.join(' AND ')}`;
|
||||
const unreadSelect = stateQuery?.unread === undefined
|
||||
? ''
|
||||
: `, ${stateQuery.unread ? 1 : 0} AS unread`;
|
||||
const flaggedSelect = flaggedQuery?.flagged === undefined
|
||||
? ''
|
||||
: `, ${flaggedQuery.flagged ? 1 : 0} AS flagged`;
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT rm.*${unreadSelect}${flaggedSelect}${fromQuery}`,
|
||||
`SELECT count(*) as count${fromQuery}`,
|
||||
[...(stateQuery?.params ?? []), ...(flaggedQuery?.params ?? []), address], limit, offset,
|
||||
flaggedQuery?.orderBy ?? stateQuery?.orderBy ?? 'rm.id desc'
|
||||
`SELECT * FROM raw_mails where address = ?`,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ?`,
|
||||
[address], limit, offset
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,11 +27,7 @@ const getMail = async (c: Context<HonoCustomType>) => {
|
||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||
).bind(mail_id, address).first();
|
||||
if (!result) return c.json(null);
|
||||
return c.json(await serializeMailState(
|
||||
c.env.DB,
|
||||
await resolveRawEmailRow(result),
|
||||
c.env,
|
||||
));
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
};
|
||||
|
||||
const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||
@@ -87,52 +38,12 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { id } = c.req.param();
|
||||
// TODO: add toLowerCase() to handle old data
|
||||
const { success } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address = ? and id = ?`,
|
||||
[address.toLowerCase(), id],
|
||||
);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ? and id = ? `
|
||||
).bind(address.toLowerCase(), id).run();
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
const updateMailState = async (c: Context<HonoCustomType>) => {
|
||||
if (!isMailReadStatusEnabled(c.env)) {
|
||||
return c.json({ error: "Mail read status is disabled" }, 403);
|
||||
}
|
||||
const { address } = c.get("jwtPayload");
|
||||
const result = await applyMailStateUpdate(
|
||||
c.env.DB,
|
||||
{ clause: 'rm.address = ?', params: [address] },
|
||||
await c.req.json().catch(() => null),
|
||||
);
|
||||
if (!result) return c.json({ error: "Invalid mail state request" }, 400);
|
||||
if (!result.success) return c.json(result, 500);
|
||||
return c.json(result);
|
||||
};
|
||||
|
||||
const updateMailFlagged = async (c: Context<HonoCustomType>) => {
|
||||
if (!isMailFlaggedEnabled(c.env)) {
|
||||
return c.json({ error: "Flagged mail is disabled" }, 403);
|
||||
}
|
||||
const { address } = c.get("jwtPayload");
|
||||
const result = await applyMailFlaggedUpdate(
|
||||
c.env.DB,
|
||||
{ clause: 'rm.address = ?', params: [address] },
|
||||
await c.req.json().catch(() => null),
|
||||
);
|
||||
if (!result) return c.json({ error: "Invalid flagged request" }, 400);
|
||||
if (!result.success) return c.json(result, 500);
|
||||
return c.json(result);
|
||||
};
|
||||
|
||||
const getMailStates = (c: Context<HonoCustomType>) => {
|
||||
if (!isMailReadStatusEnabled(c.env)) {
|
||||
return c.json({ error: "Mail read status is disabled" }, 403);
|
||||
}
|
||||
return c.json({ results: getMailStateOptions() });
|
||||
};
|
||||
|
||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
@@ -182,12 +93,9 @@ const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { success } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address = ?`,
|
||||
[address],
|
||||
);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address = ?`
|
||||
).bind(address).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedClearInboxMsg, 500)
|
||||
}
|
||||
@@ -209,7 +117,4 @@ const clearSentItems = async (c: Context<HonoCustomType>) => {
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
export default {
|
||||
listMails, getMail, deleteMail, updateMailState, updateMailFlagged, getMailStates,
|
||||
getSettings, deleteAddress, clearInbox, clearSentItems
|
||||
};
|
||||
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
|
||||
|
||||
@@ -213,8 +213,6 @@ export type RawMailRow = {
|
||||
raw?: string;
|
||||
raw_blob?: unknown;
|
||||
metadata?: string;
|
||||
unread?: boolean;
|
||||
flagged?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
Vendored
-2
@@ -117,8 +117,6 @@ type Bindings = {
|
||||
|
||||
// gzip compression for raw_mails
|
||||
ENABLE_MAIL_GZIP: string | boolean | undefined
|
||||
ENABLE_MAIL_READ_STATUS: string | boolean | undefined
|
||||
ENABLE_MAIL_FLAGGED: string | boolean | undefined
|
||||
CLEANUP_BATCH_SIZE: string | number | undefined
|
||||
|
||||
// E2E testing
|
||||
|
||||
@@ -6,22 +6,6 @@ import { unbindTelegramByAddress } from '../telegram_api/common';
|
||||
import i18n from '../i18n';
|
||||
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
|
||||
|
||||
export const getBindedAddressById = async (
|
||||
c: Context<HonoCustomType>,
|
||||
user_id: number | string,
|
||||
address_id: number | string
|
||||
): Promise<string | null> => {
|
||||
if (!user_id || !address_id) {
|
||||
return null;
|
||||
}
|
||||
const address = await c.env.DB.prepare(
|
||||
`SELECT a.name FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` WHERE ua.user_id = ? AND ua.address_id = ?`
|
||||
).bind(user_id, address_id).first<string>('name');
|
||||
return address ?? null;
|
||||
}
|
||||
|
||||
const UserBindAddressModule = {
|
||||
bind: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
@@ -174,10 +158,17 @@ const UserBindAddressModule = {
|
||||
if (!address_id || !user_id) {
|
||||
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400)
|
||||
}
|
||||
const name = await getBindedAddressById(c, user_id, address_id);
|
||||
if (!name) {
|
||||
// check users_address if address binded
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
`SELECT user_id FROM users_address WHERE address_id = ? and user_id = ?`
|
||||
).bind(address_id, user_id).first("user_id");
|
||||
if (!db_user_id) {
|
||||
return c.text(msgs.AddressNotBindedMsg, 400)
|
||||
}
|
||||
// generate jwt
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(address_id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
|
||||
@@ -6,7 +6,6 @@ import bind_address from './bind_address';
|
||||
import passkey from './passkey';
|
||||
import oauth2 from './oauth2';
|
||||
import user_mail_api from './user_mail_api';
|
||||
import user_send_mail_api from './user_send_mail_api';
|
||||
|
||||
export const api = new Hono<HonoCustomType>();
|
||||
|
||||
@@ -16,18 +15,8 @@ api.get('/user_api/settings', settings.settings);
|
||||
|
||||
// mail api
|
||||
api.get('/user_api/mails', user_mail_api.getMails);
|
||||
api.get('/user_api/mail-states', user_mail_api.getMailStates);
|
||||
api.patch('/user_api/mails/state', user_mail_api.updateMailState);
|
||||
api.patch('/user_api/mails/flagged', user_mail_api.updateMailFlagged);
|
||||
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
|
||||
|
||||
// send mail api
|
||||
api.get('/user_api/address/:address_id/settings', user_send_mail_api.settings);
|
||||
api.post('/user_api/address/:address_id/request_send_mail_access', user_send_mail_api.requestAccess);
|
||||
api.post('/user_api/address/:address_id/send_mail', user_send_mail_api.send);
|
||||
api.get('/user_api/sendbox', user_send_mail_api.listUserSendbox);
|
||||
api.delete('/user_api/sendbox/:mail_id', user_send_mail_api.removeUserSendboxMail);
|
||||
|
||||
// user api
|
||||
api.post('/user_api/login', user.login);
|
||||
api.post('/user_api/verify_code', user.verifyCode);
|
||||
|
||||
@@ -2,62 +2,25 @@ import { Context } from "hono";
|
||||
import i18n from "../i18n";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import {
|
||||
getMailStateQuery,
|
||||
getMailFlaggedQuery,
|
||||
getMailStateOptions,
|
||||
applyMailStateUpdate,
|
||||
applyMailFlaggedUpdate,
|
||||
deleteRawMails,
|
||||
isMailReadStatusEnabled,
|
||||
isMailFlaggedEnabled,
|
||||
} from "../mail_flags";
|
||||
|
||||
export default {
|
||||
getMailStates: (c: Context<HonoCustomType>) => {
|
||||
if (!isMailReadStatusEnabled(c.env)) {
|
||||
return c.json({ error: "Mail read status is disabled" }, 403);
|
||||
}
|
||||
return c.json({ results: getMailStateOptions() });
|
||||
},
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address, limit, offset, mail_state, flagged } = c.req.query();
|
||||
const { address, limit, offset } = c.req.query();
|
||||
const filterQuerys = [`ua.user_id = ?`];
|
||||
const filterParams = [String(user_id)];
|
||||
if (address) {
|
||||
filterQuerys.push(`rm.address = ?`);
|
||||
filterParams.push(address);
|
||||
}
|
||||
const stateQuery = getMailStateQuery(mail_state, 'rm', 'a.id');
|
||||
const flaggedQuery = getMailFlaggedQuery(flagged, 'rm', 'a.id');
|
||||
if (stateQuery === null) return c.json({ error: "Invalid mail state filter" }, 400);
|
||||
if (flaggedQuery === null) return c.json({ error: "Invalid flagged filter" }, 400);
|
||||
if (stateQuery && !isMailReadStatusEnabled(c.env)) {
|
||||
return c.json({ error: "Mail read status is disabled" }, 403);
|
||||
}
|
||||
if (flaggedQuery && !isMailFlaggedEnabled(c.env)) {
|
||||
return c.json({ error: "Flagged mail is disabled" }, 403);
|
||||
}
|
||||
if (stateQuery?.clause) filterQuerys.push(stateQuery.clause);
|
||||
if (flaggedQuery?.clause) filterQuerys.push(flaggedQuery.clause);
|
||||
const fromQuery = ` FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` JOIN raw_mails rm ON rm.address = a.name`
|
||||
+ (stateQuery?.join ?? '')
|
||||
+ (flaggedQuery?.join ?? '')
|
||||
+ ` WHERE ${filterQuerys.join(" AND ")}`;
|
||||
const unreadSelect = stateQuery?.unread === undefined
|
||||
? ''
|
||||
: `, ${stateQuery.unread ? 1 : 0} AS unread`;
|
||||
const flaggedSelect = flaggedQuery?.flagged === undefined
|
||||
? ''
|
||||
: `, ${flaggedQuery.flagged ? 1 : 0} AS flagged`;
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT rm.*${unreadSelect}${flaggedSelect}${fromQuery}`,
|
||||
`SELECT rm.*${fromQuery}`,
|
||||
`SELECT count(*) as count${fromQuery}`,
|
||||
[...(stateQuery?.params ?? []), ...(flaggedQuery?.params ?? []), ...filterParams],
|
||||
limit, offset, flaggedQuery?.orderBy ?? stateQuery?.orderBy ?? 'rm.id desc'
|
||||
filterParams, limit, offset, 'rm.id desc'
|
||||
);
|
||||
},
|
||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||
@@ -67,57 +30,16 @@ export default {
|
||||
}
|
||||
const { id } = c.req.param();
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { success } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`id = ?`
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE id = ?`
|
||||
+ ` AND EXISTS (`
|
||||
+ `SELECT 1 FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` WHERE ua.user_id = ? AND a.name = raw_mails.address`
|
||||
+ `)`,
|
||||
[id, user_id],
|
||||
);
|
||||
+ `)`
|
||||
).bind(id, user_id).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
},
|
||||
updateMailState: async (c: Context<HonoCustomType>) => {
|
||||
if (!isMailReadStatusEnabled(c.env)) {
|
||||
return c.json({ error: "Mail read status is disabled" }, 403);
|
||||
}
|
||||
const { user_id } = c.get("userPayload");
|
||||
const result = await applyMailStateUpdate(
|
||||
c.env.DB,
|
||||
{
|
||||
clause: `a.id IN (`
|
||||
+ `SELECT address_id FROM users_address WHERE user_id = ?`
|
||||
+ `)`,
|
||||
params: [user_id],
|
||||
},
|
||||
await c.req.json().catch(() => null),
|
||||
);
|
||||
if (!result) return c.json({ error: "Invalid mail state request" }, 400);
|
||||
if (!result.success) return c.json(result, 500);
|
||||
return c.json(result);
|
||||
},
|
||||
updateMailFlagged: async (c: Context<HonoCustomType>) => {
|
||||
if (!isMailFlaggedEnabled(c.env)) {
|
||||
return c.json({ error: "Flagged mail is disabled" }, 403);
|
||||
}
|
||||
const { user_id } = c.get("userPayload");
|
||||
const result = await applyMailFlaggedUpdate(
|
||||
c.env.DB,
|
||||
{
|
||||
clause: `a.id IN (`
|
||||
+ `SELECT address_id FROM users_address WHERE user_id = ?`
|
||||
+ `)`,
|
||||
params: [user_id],
|
||||
},
|
||||
await c.req.json().catch(() => null),
|
||||
);
|
||||
if (!result) return c.json({ error: "Invalid flagged request" }, 400);
|
||||
if (!result.success) return c.json(result, 500);
|
||||
return c.json(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { handleListQuery } from "../common";
|
||||
import i18n from "../i18n";
|
||||
import { sendMail } from "../mails_api/send_mail_api";
|
||||
import {
|
||||
getSendBalanceState,
|
||||
requestSendMailAccess,
|
||||
} from "../mails_api/send_balance";
|
||||
import { getBooleanValue } from "../utils";
|
||||
import { getBindedAddressById } from "./bind_address";
|
||||
|
||||
const getAddressOrError = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<string | Response> => {
|
||||
const addressId = Number(c.req.param("address_id"));
|
||||
if (!Number.isInteger(addressId) || addressId <= 0) {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
return c.text(msgs.AddressNotBindedMsg, 400);
|
||||
}
|
||||
const { user_id } = c.get("userPayload");
|
||||
const address = await getBindedAddressById(c, user_id, addressId);
|
||||
if (address) {
|
||||
return address;
|
||||
}
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
return c.text(msgs.AddressNotBindedMsg, 400);
|
||||
}
|
||||
|
||||
const settings = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const address = await getAddressOrError(c);
|
||||
if (address instanceof Response) {
|
||||
return address;
|
||||
}
|
||||
const { balance } = await getSendBalanceState(c, address);
|
||||
return c.json({
|
||||
address,
|
||||
send_balance: balance || 0,
|
||||
});
|
||||
}
|
||||
|
||||
const requestAccess = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const address = await getAddressOrError(c);
|
||||
if (address instanceof Response) {
|
||||
return address;
|
||||
}
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const result = await requestSendMailAccess(c, address);
|
||||
if (result.status === "ok") {
|
||||
return c.json({ status: "ok" });
|
||||
}
|
||||
if (result.status === "already_requested") {
|
||||
return c.text(msgs.AlreadyRequestedMsg, 400);
|
||||
}
|
||||
return c.text(msgs.OperationFailedMsg, 500);
|
||||
}
|
||||
|
||||
const send = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const address = await getAddressOrError(c);
|
||||
if (address instanceof Response) {
|
||||
return address;
|
||||
}
|
||||
try {
|
||||
const reqJson = await c.req.json();
|
||||
await sendMail(c, address, reqJson);
|
||||
} catch (error) {
|
||||
console.error("Failed to send user mail", error);
|
||||
return c.text(`Failed to send mail ${(error as Error).message}`, 400);
|
||||
}
|
||||
return c.json({ status: "ok" });
|
||||
}
|
||||
|
||||
const listUserSendbox = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address, limit, offset } = c.req.query();
|
||||
const filters = ["ua.user_id = ?"];
|
||||
const params = [String(user_id)];
|
||||
if (address) {
|
||||
filters.push("sb.address = ?");
|
||||
params.push(address);
|
||||
}
|
||||
const fromQuery = ` FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` JOIN sendbox sb ON sb.address = a.name`
|
||||
+ ` WHERE ${filters.join(" AND ")}`;
|
||||
return await handleListQuery(c,
|
||||
`SELECT sb.*${fromQuery}`,
|
||||
`SELECT count(*) as count${fromQuery}`,
|
||||
params, limit, offset, "sb.id desc"
|
||||
);
|
||||
}
|
||||
|
||||
const removeUserSendboxMail = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403);
|
||||
}
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { mail_id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE id = ?`
|
||||
+ ` AND EXISTS (`
|
||||
+ `SELECT 1 FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` WHERE ua.user_id = ? AND a.name = sendbox.address`
|
||||
+ `)`
|
||||
).bind(mail_id, user_id).run();
|
||||
return c.json({ success });
|
||||
}
|
||||
|
||||
export default {
|
||||
settings,
|
||||
requestAccess,
|
||||
send,
|
||||
listUserSendbox,
|
||||
removeUserSendboxMail,
|
||||
};
|
||||
+11
-19
@@ -3,7 +3,6 @@ import { createMimeMessage } from "mimetext";
|
||||
import { UserSettings, RoleAddressConfig } from "./models";
|
||||
import { CONSTANTS } from "./constants";
|
||||
import { compressText } from "./gzip";
|
||||
import { initializeMailFlagsAfterInsert } from "./mail_flags";
|
||||
|
||||
export const getJsonObjectValue = <T = any>(
|
||||
value: string | any
|
||||
@@ -372,7 +371,7 @@ export const sendAdminInternalMail = async (
|
||||
});
|
||||
const message_id = Math.random().toString(36).substring(2, 15);
|
||||
const rawText = msg.asRaw();
|
||||
let insertResult: D1Result | null = null;
|
||||
let success = false;
|
||||
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
|
||||
let compressed: ArrayBuffer | null = null;
|
||||
try {
|
||||
@@ -382,41 +381,34 @@ export const sendAdminInternalMail = async (
|
||||
}
|
||||
if (compressed) {
|
||||
try {
|
||||
insertResult = await c.env.DB.prepare(
|
||||
({ success } = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, compressed, message_id).run();
|
||||
).bind("admin@internal", toMail, compressed, message_id).run());
|
||||
} catch (dbError) {
|
||||
const errMsg = String(dbError);
|
||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||
insertResult = await c.env.DB.prepare(
|
||||
({ success } = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, rawText, message_id).run();
|
||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
||||
} else {
|
||||
throw dbError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
insertResult = await c.env.DB.prepare(
|
||||
({ success } = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, rawText, message_id).run();
|
||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
||||
}
|
||||
} else {
|
||||
insertResult = await c.env.DB.prepare(
|
||||
({ success } = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind("admin@internal", toMail, rawText, message_id).run();
|
||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
||||
}
|
||||
if (!insertResult?.success) {
|
||||
if (!success) {
|
||||
console.log(`Failed save message from admin@internal to ${toMail}`);
|
||||
} else {
|
||||
await initializeMailFlagsAfterInsert(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
insertResult?.meta.last_row_id ?? 0,
|
||||
toMail,
|
||||
);
|
||||
}
|
||||
return insertResult?.success ?? false;
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.log("sendAdminInternalMail error", error);
|
||||
return false;
|
||||
|
||||
+3
-10
@@ -65,7 +65,6 @@ app.use('/*', async (c, next) => {
|
||||
c.req.path.startsWith("/api/new_address")
|
||||
|| c.req.path.startsWith("/api/send_mail")
|
||||
|| c.req.path.startsWith("/external/api/send_mail")
|
||||
|| (c.req.path.startsWith("/user_api/address/") && c.req.path.endsWith("/send_mail"))
|
||||
|| c.req.path.startsWith("/user_api/register")
|
||||
|| c.req.path.startsWith("/user_api/verify_code")
|
||||
) {
|
||||
@@ -126,8 +125,7 @@ const checkUserPayload = async (
|
||||
}
|
||||
|
||||
const checkoutUserRolePayload = async (
|
||||
c: Context<HonoCustomType>,
|
||||
userId?: number
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const token = c.req.raw.headers.get("x-user-access-token");
|
||||
@@ -140,7 +138,6 @@ const checkoutUserRolePayload = async (
|
||||
return;
|
||||
}
|
||||
if (typeof payload?.user_role !== "string") return;
|
||||
if (userId !== undefined && payload.user_id !== userId) return;
|
||||
c.set("userRolePayload", payload.user_role);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -205,12 +202,8 @@ app.use('/user_api/*', async (c, next) => {
|
||||
console.error(e);
|
||||
return c.text(msgs.UserTokenExpiredMsg, 401)
|
||||
}
|
||||
if (
|
||||
c.req.path.startsWith("/user_api/bind_address")
|
||||
|| c.req.path.startsWith("/user_api/address/")
|
||||
) {
|
||||
const { user_id } = c.get("userPayload");
|
||||
await checkoutUserRolePayload(c, user_id);
|
||||
if (c.req.path.startsWith("/user_api/bind_address")) {
|
||||
await checkoutUserRolePayload(c);
|
||||
}
|
||||
if (c.req.path.startsWith('/user_api/bind_address')
|
||||
&& c.req.method === 'POST'
|
||||
|
||||
@@ -77,10 +77,6 @@ ENABLE_USER_CREATE_EMAIL = true
|
||||
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
|
||||
# Allow users to delete messages
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# Enable per-message read status. This adds one write for each new mail and another when it is read.
|
||||
# ENABLE_MAIL_READ_STATUS = true
|
||||
# Enable low-write Flagged/starred mail independently from read status.
|
||||
# ENABLE_MAIL_FLAGGED = true
|
||||
# Allow automatic replies to emails
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# Allow webhook
|
||||
|
||||
Reference in New Issue
Block a user