mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-07 00:17:12 +08:00
feat: add user send mail and sent box (#1122)
* feat: add user send mail client * fix: align user mail navigation * fix: shorten address credential action * test: cover user mail ownership boundaries * fix: address user mail review feedback * fix: disambiguate user mail e2e heading * fix: minimize shared sent box changes * refactor: isolate user send mail page * refactor: reuse bound address lookup * fix: clarify user sent box naming * refactor: decouple user send API from roles * fix: align user send mail behavior * fix: align user send role and rate limits * test: isolate user send rate limits * test: initialize rate limit worker database * refactor: simplify user send rate limit * refactor: inline user send rate limit path * refactor: simplify user send limiter key * refactor: keep existing rate limit behavior * style: simplify user send rate limit condition * style: group user send rate limit condition * fix: bind user role token to account * fix: keep user sender selection available
This commit is contained in:
@@ -11,11 +11,13 @@
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||||
|
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||||
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||||
|
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
|
|
||||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||||
|
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||||
|
|
||||||
## v1.11.0
|
## v1.11.0
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- 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 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: |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
|
### Bug Fixes
|
||||||
|
|
||||||
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
- 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: |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
|
### Improvements
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
|
|
||||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
- 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 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
|
## v1.11.0
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ ENABLE_USER_CREATE_EMAIL = true
|
|||||||
ENABLE_USER_DELETE_EMAIL = true
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
ENABLE_AUTO_REPLY = true
|
ENABLE_AUTO_REPLY = true
|
||||||
DEFAULT_SEND_BALANCE = 10
|
DEFAULT_SEND_BALANCE = 10
|
||||||
|
NO_LIMIT_SEND_ROLE = "case-role"
|
||||||
ENABLE_ADDRESS_PASSWORD = true
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -650,5 +650,8 @@ export const deMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||||
"components.AddressCredentialModal.username": "Benutzername"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,5 +650,8 @@ export const esMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
"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.title": "Credenciales de dirección y métodos de conexión",
|
||||||
"components.AddressCredentialModal.username": "Usuario"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,5 +650,8 @@ export const jaMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||||
"components.AddressCredentialModal.username": "ユーザー名"
|
"components.AddressCredentialModal.username": "ユーザー名",
|
||||||
|
"views.User.send_mail": "メール送信",
|
||||||
|
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||||
|
"views.user.UserSendBox.sendbox": "送信済み"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,5 +650,8 @@ export const ptBRMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
"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.title": "Credenciais do endereço e métodos de conexão",
|
||||||
"components.AddressCredentialModal.username": "Nome de usuário"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -738,6 +738,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"en": "Bind Mail Address",
|
"en": "Bind Mail Address",
|
||||||
"zh": "绑定邮箱地址"
|
"zh": "绑定邮箱地址"
|
||||||
},
|
},
|
||||||
|
"send_mail": {
|
||||||
|
"en": "Send Mail",
|
||||||
|
"zh": "发送邮件"
|
||||||
|
},
|
||||||
"user_mail_box_tab": {
|
"user_mail_box_tab": {
|
||||||
"en": "Mail Box",
|
"en": "Mail Box",
|
||||||
"zh": "收件箱"
|
"zh": "收件箱"
|
||||||
@@ -747,6 +751,16 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"zh": "用户设置"
|
"zh": "用户设置"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"views.user.UserSendBox": {
|
||||||
|
"noAddress": {
|
||||||
|
"en": "Select a bound email address to continue",
|
||||||
|
"zh": "请选择一个已绑定的邮箱地址"
|
||||||
|
},
|
||||||
|
"sendbox": {
|
||||||
|
"en": "Sent",
|
||||||
|
"zh": "发件箱"
|
||||||
|
}
|
||||||
|
},
|
||||||
"views.user.UserLogin": {
|
"views.user.UserLogin": {
|
||||||
"cannotForgotPassword": {
|
"cannotForgotPassword": {
|
||||||
"en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator",
|
"en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator",
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue';
|
|||||||
import UserBar from './user/UserBar.vue';
|
import UserBar from './user/UserBar.vue';
|
||||||
import BindAddress from './user/BindAddress.vue';
|
import BindAddress from './user/BindAddress.vue';
|
||||||
import UserMailBox from './user/UserMailBox.vue';
|
import UserMailBox from './user/UserMailBox.vue';
|
||||||
|
import UserSendBox from './user/UserSendBox.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userTab, globalTabplacement, userSettings
|
userTab, globalTabplacement, userSettings, openSettings
|
||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useScopedI18n('views.User')
|
const { t } = useScopedI18n('views.User')
|
||||||
|
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User')
|
|||||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||||
<UserMailBox />
|
<UserMailBox />
|
||||||
</n-tab-pane>
|
</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')">
|
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||||
<UserSettingsPage />
|
<UserSettingsPage />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
|||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
import { getRouterPathWithLang } from '../../utils'
|
import { getRouterPathWithLang } from '../../utils'
|
||||||
|
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||||
|
|
||||||
import Login from '../common/Login.vue';
|
import Login from '../common/Login.vue';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const message = useMessage()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||||
|
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
|
||||||
|
|
||||||
const data = ref([])
|
const data = ref([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
@@ -24,6 +26,20 @@ const showTranferAddress = ref(false)
|
|||||||
const currentAddress = ref("")
|
const currentAddress = ref("")
|
||||||
const currentAddressId = ref(0)
|
const currentAddressId = ref(0)
|
||||||
const targetUserEmail = ref('')
|
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) => {
|
const changeMailAddress = async (address_id) => {
|
||||||
try {
|
try {
|
||||||
@@ -146,6 +162,14 @@ const columns = [
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render(row) {
|
render(row) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
|
h(NButton,
|
||||||
|
{
|
||||||
|
tertiary: true,
|
||||||
|
type: "primary",
|
||||||
|
onClick: () => showCredential(row)
|
||||||
|
},
|
||||||
|
{ default: () => credentialT('addressCredential') }
|
||||||
|
),
|
||||||
h(NPopconfirm,
|
h(NPopconfirm,
|
||||||
{
|
{
|
||||||
onPositiveClick: () => changeMailAddress(row.id)
|
onPositiveClick: () => changeMailAddress(row.id)
|
||||||
@@ -204,6 +228,8 @@ watch([page, pageSize], async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||||
|
:jwt="currentAddressCredential" />
|
||||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||||
<span>
|
<span>
|
||||||
<p>{{ t("transferAddressTip") }}</p>
|
<p>{{ t("transferAddressTip") }}</p>
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<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>
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
## Send Email via HTTP API
|
## Send Email via HTTP API
|
||||||
|
|
||||||
There are two HTTP API endpoints for sending emails:
|
There are three HTTP API endpoints for sending emails:
|
||||||
|
|
||||||
| Endpoint | Authentication | Use Case |
|
| Endpoint | Authentication | Use Case |
|
||||||
|----------|---------------|----------|
|
|----------|---------------|----------|
|
||||||
| `/api/send_mail` | `Authorization: Bearer <address_JWT>` header | Internal calls, requires cookie / header auth |
|
| `/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 |
|
| `/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"?
|
::: 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`.
|
The Address JWT is the `jwt` field returned when creating an email address via `/api/new_address` or `/admin/new_address`.
|
||||||
@@ -59,6 +60,44 @@ 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
|
## Send Email via SMTP
|
||||||
|
|
||||||
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
|
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
## 通过 HTTP API 发送邮件
|
## 通过 HTTP API 发送邮件
|
||||||
|
|
||||||
有两种 HTTP API 端点可以发送邮件,区别如下:
|
有三种 HTTP API 端点可以发送邮件,区别如下:
|
||||||
|
|
||||||
| 端点 | 认证方式 | 适用场景 |
|
| 端点 | 认证方式 | 适用场景 |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| `/api/send_mail` | `Authorization: Bearer <地址JWT>` header | 内部调用,需要先通过 cookie / header 鉴权 |
|
| `/api/send_mail` | `Authorization: Bearer <地址JWT>` header | 内部调用,需要先通过 cookie / header 鉴权 |
|
||||||
| `/external/api/send_mail` | 请求体中的 `token` 字段 | 外部系统集成,无需 header 鉴权 |
|
| `/external/api/send_mail` | 请求体中的 `token` 字段 | 外部系统集成,无需 header 鉴权 |
|
||||||
|
| `/user_api/address/:address_id/send_mail` | `x-user-token: <用户JWT>` header | 已登录用户使用自己的绑定邮箱发信 |
|
||||||
|
|
||||||
::: tip 什么是"地址 JWT"?
|
::: tip 什么是"地址 JWT"?
|
||||||
地址 JWT 是通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱地址时返回的 `jwt` 字段。
|
地址 JWT 是通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱地址时返回的 `jwt` 字段。
|
||||||
@@ -59,6 +60,44 @@ 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 发送邮件
|
||||||
|
|
||||||
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
|
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ import { unbindTelegramByAddress } from '../telegram_api/common';
|
|||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
|
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 = {
|
const UserBindAddressModule = {
|
||||||
bind: async (c: Context<HonoCustomType>) => {
|
bind: async (c: Context<HonoCustomType>) => {
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
@@ -158,17 +174,10 @@ const UserBindAddressModule = {
|
|||||||
if (!address_id || !user_id) {
|
if (!address_id || !user_id) {
|
||||||
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400)
|
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400)
|
||||||
}
|
}
|
||||||
// check users_address if address binded
|
const name = await getBindedAddressById(c, user_id, address_id);
|
||||||
const db_user_id = await c.env.DB.prepare(
|
if (!name) {
|
||||||
`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)
|
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({
|
const jwt = await Jwt.sign({
|
||||||
address: name,
|
address: name,
|
||||||
address_id: address_id
|
address_id: address_id
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import bind_address from './bind_address';
|
|||||||
import passkey from './passkey';
|
import passkey from './passkey';
|
||||||
import oauth2 from './oauth2';
|
import oauth2 from './oauth2';
|
||||||
import user_mail_api from './user_mail_api';
|
import user_mail_api from './user_mail_api';
|
||||||
|
import user_send_mail_api from './user_send_mail_api';
|
||||||
|
|
||||||
export const api = new Hono<HonoCustomType>();
|
export const api = new Hono<HonoCustomType>();
|
||||||
|
|
||||||
@@ -17,6 +18,13 @@ api.get('/user_api/settings', settings.settings);
|
|||||||
api.get('/user_api/mails', user_mail_api.getMails);
|
api.get('/user_api/mails', user_mail_api.getMails);
|
||||||
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
|
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
|
// user api
|
||||||
api.post('/user_api/login', user.login);
|
api.post('/user_api/login', user.login);
|
||||||
api.post('/user_api/verify_code', user.verifyCode);
|
api.post('/user_api/verify_code', user.verifyCode);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
+10
-3
@@ -65,6 +65,7 @@ app.use('/*', async (c, next) => {
|
|||||||
c.req.path.startsWith("/api/new_address")
|
c.req.path.startsWith("/api/new_address")
|
||||||
|| c.req.path.startsWith("/api/send_mail")
|
|| c.req.path.startsWith("/api/send_mail")
|
||||||
|| c.req.path.startsWith("/external/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/register")
|
||||||
|| c.req.path.startsWith("/user_api/verify_code")
|
|| c.req.path.startsWith("/user_api/verify_code")
|
||||||
) {
|
) {
|
||||||
@@ -125,7 +126,8 @@ const checkUserPayload = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkoutUserRolePayload = async (
|
const checkoutUserRolePayload = async (
|
||||||
c: Context<HonoCustomType>
|
c: Context<HonoCustomType>,
|
||||||
|
userId?: number
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const token = c.req.raw.headers.get("x-user-access-token");
|
const token = c.req.raw.headers.get("x-user-access-token");
|
||||||
@@ -138,6 +140,7 @@ const checkoutUserRolePayload = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof payload?.user_role !== "string") return;
|
if (typeof payload?.user_role !== "string") return;
|
||||||
|
if (userId !== undefined && payload.user_id !== userId) return;
|
||||||
c.set("userRolePayload", payload.user_role);
|
c.set("userRolePayload", payload.user_role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -202,8 +205,12 @@ app.use('/user_api/*', async (c, next) => {
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
return c.text(msgs.UserTokenExpiredMsg, 401)
|
return c.text(msgs.UserTokenExpiredMsg, 401)
|
||||||
}
|
}
|
||||||
if (c.req.path.startsWith("/user_api/bind_address")) {
|
if (
|
||||||
await checkoutUserRolePayload(c);
|
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')
|
if (c.req.path.startsWith('/user_api/bind_address')
|
||||||
&& c.req.method === 'POST'
|
&& c.req.method === 'POST'
|
||||||
|
|||||||
Reference in New Issue
Block a user