mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 19:48:01 +08:00
feat: add user send mail client
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||
- feat: |用户系统| 用户中心新增绑定邮箱选择、发送邮件和发件箱,提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||
- test: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心选择绑定邮箱后发信的完整流程
|
||||
|
||||
## v1.11.0
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
### 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: |User| Add bound-address selection, mail composition, and sent items to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
- 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
|
||||
- test: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus the complete user-center address selection and send flow
|
||||
|
||||
## v1.11.0
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
hashPassword,
|
||||
onMailpitMessage,
|
||||
} 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, 'user-send-outsider-');
|
||||
addresses.push(bound, accessRequest, outsider);
|
||||
await bindAddress(request, user.jwt, bound.jwt);
|
||||
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||
|
||||
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 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 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 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 sendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(sendboxRes.ok()).toBe(true);
|
||||
const sendbox = await sendboxRes.json();
|
||||
expect(sendbox.count).toBe(1);
|
||||
expect(sendbox.results).toHaveLength(1);
|
||||
expect(JSON.parse(sendbox.results[0].raw).subject).toBe(subject);
|
||||
|
||||
const outsiderSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderSendboxRes.status()).toBe(400);
|
||||
|
||||
const deleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/sendbox/${sendbox.results[0].id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
|
||||
const emptySendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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();
|
||||
let address: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||
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;
|
||||
address = await createTestAddress(request, 'user-send-browser-address-');
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${address.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 });
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
const addressSelect = page.locator('.address-picker-select');
|
||||
await addressSelect.click();
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address!.address_id}/settings`
|
||||
));
|
||||
await page.locator('.n-base-select-menu:visible')
|
||||
.getByText(address.address, { exact: true })
|
||||
.click();
|
||||
expect((await settingsResponse).ok()).toBe(true);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.composer-title')).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`
|
||||
));
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
expect((await sendResponse).ok()).toBe(true);
|
||||
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
try {
|
||||
if (address) await 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,7 @@ const refresh = async () => {
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (totalCount > 0) {
|
||||
if (page.value === 1) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (!isMobile.value && !curMail.value && data.value.length > 0) {
|
||||
|
||||
@@ -650,5 +650,11 @@ 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"
|
||||
"components.AddressCredentialModal.username": "Benutzername",
|
||||
"views.User.send_mail": "E-Mail senden",
|
||||
"views.user.UserMailClient.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||
"views.user.UserMailClient.selectAddress": "Absenderadresse",
|
||||
"views.user.UserMailClient.selectAddressTip": "Wähle eine verknüpfte Adresse, um E-Mails zu schreiben und gesendete Nachrichten anzuzeigen",
|
||||
"views.user.UserMailClient.sendbox": "Gesendet",
|
||||
"views.user.UserMailClient.sendMail": "Verfassen"
|
||||
}
|
||||
|
||||
@@ -650,5 +650,11 @@ 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"
|
||||
"components.AddressCredentialModal.username": "Usuario",
|
||||
"views.User.send_mail": "Enviar correo",
|
||||
"views.user.UserMailClient.noAddress": "Selecciona una dirección de correo vinculada",
|
||||
"views.user.UserMailClient.selectAddress": "Dirección del remitente",
|
||||
"views.user.UserMailClient.selectAddressTip": "Elige una dirección vinculada para redactar correos y ver los enviados",
|
||||
"views.user.UserMailClient.sendbox": "Enviados",
|
||||
"views.user.UserMailClient.sendMail": "Redactar"
|
||||
}
|
||||
|
||||
@@ -650,5 +650,11 @@ export const jaMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||
"components.AddressCredentialModal.username": "ユーザー名"
|
||||
"components.AddressCredentialModal.username": "ユーザー名",
|
||||
"views.User.send_mail": "メール送信",
|
||||
"views.user.UserMailClient.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||
"views.user.UserMailClient.selectAddress": "送信元アドレス",
|
||||
"views.user.UserMailClient.selectAddressTip": "紐付け済みアドレスを選択して、メール作成と送信済みメールの確認ができます",
|
||||
"views.user.UserMailClient.sendbox": "送信済み",
|
||||
"views.user.UserMailClient.sendMail": "作成"
|
||||
}
|
||||
|
||||
@@ -650,5 +650,11 @@ 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"
|
||||
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||
"views.User.send_mail": "Enviar e-mail",
|
||||
"views.user.UserMailClient.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||
"views.user.UserMailClient.selectAddress": "Endereço do remetente",
|
||||
"views.user.UserMailClient.selectAddressTip": "Escolha um endereço vinculado para escrever e ver e-mails enviados",
|
||||
"views.user.UserMailClient.sendbox": "Enviados",
|
||||
"views.user.UserMailClient.sendMail": "Escrever"
|
||||
}
|
||||
|
||||
@@ -738,6 +738,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Bind Mail Address",
|
||||
"zh": "绑定邮箱地址"
|
||||
},
|
||||
"send_mail": {
|
||||
"en": "Send Mail",
|
||||
"zh": "发送邮件"
|
||||
},
|
||||
"user_mail_box_tab": {
|
||||
"en": "Mail Box",
|
||||
"zh": "收件箱"
|
||||
@@ -747,6 +751,28 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "用户设置"
|
||||
}
|
||||
},
|
||||
"views.user.UserMailClient": {
|
||||
"noAddress": {
|
||||
"en": "Select a bound email address to continue",
|
||||
"zh": "请选择一个已绑定的邮箱地址"
|
||||
},
|
||||
"selectAddress": {
|
||||
"en": "Sender address",
|
||||
"zh": "发件邮箱"
|
||||
},
|
||||
"selectAddressTip": {
|
||||
"en": "Choose a bound address to compose mail and view its sent items",
|
||||
"zh": "选择已绑定邮箱后,可发送邮件并查看该邮箱的发件箱"
|
||||
},
|
||||
"sendbox": {
|
||||
"en": "Sent",
|
||||
"zh": "发件箱"
|
||||
},
|
||||
"sendMail": {
|
||||
"en": "Compose",
|
||||
"zh": "写邮件"
|
||||
}
|
||||
},
|
||||
"views.user.UserLogin": {
|
||||
"cannotForgotPassword": {
|
||||
"en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator",
|
||||
|
||||
@@ -8,9 +8,10 @@ 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 UserMailClient from './user/UserMailClient.vue';
|
||||
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings
|
||||
userTab, globalTabplacement, userSettings, openSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.User')
|
||||
@@ -27,6 +28,9 @@ const { t } = useScopedI18n('views.User')
|
||||
<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_send_mail" :tab="t('send_mail')">
|
||||
<UserMailClient />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -16,6 +16,19 @@ const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const userAddressSettings = ref({
|
||||
address: '',
|
||||
send_balance: 0,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
addressId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sent'])
|
||||
|
||||
|
||||
const {
|
||||
@@ -25,6 +38,23 @@ const {
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const isUserAddressMode = computed(() => props.addressId > 0)
|
||||
const mailSettings = computed(() => (
|
||||
isUserAddressMode.value ? userAddressSettings.value : settings.value
|
||||
))
|
||||
|
||||
const getApiPath = (path) => isUserAddressMode.value
|
||||
? `/user_api/address/${props.addressId}/${path}`
|
||||
: `/api/${path}`
|
||||
|
||||
const refreshSettings = async () => {
|
||||
if (!isUserAddressMode.value) {
|
||||
await api.getSettings()
|
||||
return
|
||||
}
|
||||
userAddressSettings.value = await api.fetch(getApiPath('settings'))
|
||||
}
|
||||
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
@@ -99,7 +129,7 @@ const send = async () => {
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(`/api/send_mail`,
|
||||
await api.fetch(getApiPath('send_mail'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
@@ -114,7 +144,11 @@ const send = async () => {
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
indexTab.value = 'sendbox'
|
||||
if (isUserAddressMode.value) {
|
||||
emit('sent')
|
||||
} else {
|
||||
indexTab.value = 'sendbox'
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
@@ -124,14 +158,14 @@ const send = async () => {
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/request_send_mail_access`,
|
||||
await api.fetch(getApiPath('request_send_mail_access'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("requestSuccess"))
|
||||
await api.getSettings();
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
@@ -166,30 +200,30 @@ const handleCreated = (editor) => {
|
||||
onMounted(async () => {
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
await api.getSettings();
|
||||
await refreshSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<div class="composer-page" v-if="mailSettings.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>
|
||||
<n-text depth="3">{{ mailSettings.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 v-if="mailSettings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ mailSettings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div v-if="!mailSettings.send_balance || mailSettings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
<p>{{ t('requestAccessTip', { address: mailSettings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
@@ -201,7 +235,7 @@ onMounted(async () => {
|
||||
<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
|
||||
<n-input :value="mailSettings.address" readonly
|
||||
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref } 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('../index/SendMail.vue'))
|
||||
|
||||
const ADDRESS_PAGE_SIZE = 100
|
||||
|
||||
const message = useMessage()
|
||||
const { openSettings } = useGlobalState()
|
||||
const { t } = useScopedI18n('views.user.UserMailClient')
|
||||
|
||||
const addressId = ref(null)
|
||||
const addressOptions = ref([])
|
||||
const addressCount = ref(0)
|
||||
const addressLoading = ref(false)
|
||||
const mailTab = ref('send_mail')
|
||||
|
||||
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
||||
|
||||
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,
|
||||
})))
|
||||
if (offset === 0) {
|
||||
addressCount.value = count
|
||||
}
|
||||
} 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/address/${addressId.value}/sendbox?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
const deleteSendboxMail = async (mailId) => {
|
||||
await api.fetch(
|
||||
`/user_api/address/${addressId.value}/sendbox/${mailId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(fetchAddresses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-mail-client">
|
||||
<n-card class="address-picker" :bordered="false" embedded size="small">
|
||||
<n-flex align="center" justify="space-between" :wrap="true">
|
||||
<div class="address-picker-copy">
|
||||
<n-text strong>{{ t('selectAddress') }}</n-text>
|
||||
<n-text depth="3">{{ t('selectAddressTip') }}</n-text>
|
||||
</div>
|
||||
<n-select v-model:value="addressId" class="address-picker-select" :options="addressOptions"
|
||||
:loading="addressLoading" :placeholder="t('selectAddress')" filterable clearable
|
||||
@scroll="handleAddressScroll" />
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<n-empty v-if="!addressId" class="address-empty" :description="t('noAddress')" />
|
||||
|
||||
<n-tabs v-else v-model:value="mailTab" type="line" animated>
|
||||
<n-tab-pane name="send_mail" :tab="t('sendMail')" display-directive="show:lazy">
|
||||
<SendMail :key="addressId" :address-id="addressId" @sent="mailTab = 'sendbox'" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')" display-directive="show:lazy">
|
||||
<SendBox :key="addressId" :fetch-mail-data="fetchSendbox"
|
||||
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
||||
:delete-mail="deleteSendboxMail" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-mail-client {
|
||||
padding-top: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.address-picker {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.address-picker-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.address-picker-select {
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.address-empty {
|
||||
padding: 72px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.address-picker-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## 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 |
|
||||
|----------|---------------|----------|
|
||||
| `/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`.
|
||||
@@ -59,6 +60,41 @@ 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.
|
||||
|
||||
```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>",
|
||||
"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/address/:address_id/sendbox?limit=20&offset=0` | List sent items for the address with pagination |
|
||||
| `DELETE` | `/user_api/address/:address_id/sendbox/:mail_id` | Delete one sent item for the address |
|
||||
|
||||
All endpoints require a User JWT and verify that `address_id` is bound to the current user before performing the operation.
|
||||
|
||||
## Send Email via SMTP
|
||||
|
||||
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## 通过 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` 字段。
|
||||
@@ -59,6 +60,41 @@ res = requests.post(
|
||||
)
|
||||
```
|
||||
|
||||
### 方式三:使用用户 JWT(`/user_api/address/:address_id/send_mail`)
|
||||
|
||||
`address_id` 可从分页接口 `GET /user_api/bind_address` 的结果中获取。后端会验证该地址属于当前用户,客户端不能自行指定发件邮箱。
|
||||
|
||||
```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>",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
同一组用户地址接口还包括:
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/user_api/address/:address_id/settings` | 获取地址和剩余发信额度 |
|
||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | 为该地址申请发信权限 |
|
||||
| `GET` | `/user_api/address/:address_id/sendbox?limit=20&offset=0` | 分页获取该地址的发件箱 |
|
||||
| `DELETE` | `/user_api/address/:address_id/sendbox/:mail_id` | 删除该地址的一条发件记录 |
|
||||
|
||||
以上接口都只接受用户 JWT,并在执行操作前验证 `address_id` 是否绑定到当前用户。
|
||||
|
||||
## 通过 SMTP 发送邮件
|
||||
|
||||
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
|
||||
|
||||
@@ -297,17 +297,23 @@ api.get('/api/sendbox', async (c) => {
|
||||
return getSendbox(c, address, limit, offset);
|
||||
})
|
||||
|
||||
api.delete('/api/sendbox/:id', async (c) => {
|
||||
export const deleteSendbox = async (
|
||||
c: Context<HonoCustomType>, address: string, id: string | number
|
||||
): Promise<Response> => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address = ? and id = ? `
|
||||
).bind(address, id).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
}
|
||||
|
||||
api.delete('/api/sendbox/:id', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { id } = c.req.param();
|
||||
return deleteSendbox(c, address, id);
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ 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>();
|
||||
|
||||
@@ -17,6 +18,13 @@ api.get('/user_api/settings', settings.settings);
|
||||
api.get('/user_api/mails', user_mail_api.getMails);
|
||||
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/address/:address_id/sendbox', user_send_mail_api.listSendbox);
|
||||
api.delete('/user_api/address/:address_id/sendbox/:mail_id', user_send_mail_api.removeSendboxMail);
|
||||
|
||||
// user api
|
||||
api.post('/user_api/login', user.login);
|
||||
api.post('/user_api/verify_code', user.verifyCode);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import { commonGetUserRole } from "../common";
|
||||
import i18n from "../i18n";
|
||||
import {
|
||||
deleteSendbox,
|
||||
getSendbox,
|
||||
sendMail,
|
||||
} from "../mails_api/send_mail_api";
|
||||
import {
|
||||
getSendBalanceState,
|
||||
requestSendMailAccess,
|
||||
} from "../mails_api/send_balance";
|
||||
|
||||
const getBindedAddress = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<string | null> => {
|
||||
const addressId = Number(c.req.param("address_id"));
|
||||
if (!Number.isInteger(addressId) || addressId <= 0) {
|
||||
return null;
|
||||
}
|
||||
const { user_id } = c.get("userPayload");
|
||||
return 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 a.id = ?`
|
||||
).bind(user_id, addressId).first<string>("name");
|
||||
}
|
||||
|
||||
const getAddressOrError = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<string | Response> => {
|
||||
const address = await getBindedAddress(c);
|
||||
if (address) {
|
||||
return address;
|
||||
}
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
return c.text(msgs.AddressNotBindedMsg, 400);
|
||||
}
|
||||
|
||||
const setUserRole = async (c: Context<HonoCustomType>): Promise<void> => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const userRole = await commonGetUserRole(c, user_id);
|
||||
c.set("userRolePayload", userRole?.role);
|
||||
}
|
||||
|
||||
const settings = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const address = await getAddressOrError(c);
|
||||
if (address instanceof Response) {
|
||||
return address;
|
||||
}
|
||||
await setUserRole(c);
|
||||
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;
|
||||
}
|
||||
await setUserRole(c);
|
||||
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 listSendbox = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const address = await getAddressOrError(c);
|
||||
if (address instanceof Response) {
|
||||
return address;
|
||||
}
|
||||
const { limit, offset } = c.req.query();
|
||||
return getSendbox(c, address, limit, offset);
|
||||
}
|
||||
|
||||
const removeSendboxMail = async (c: Context<HonoCustomType>): Promise<Response> => {
|
||||
const address = await getAddressOrError(c);
|
||||
if (address instanceof Response) {
|
||||
return address;
|
||||
}
|
||||
return deleteSendbox(c, address, c.req.param("mail_id"));
|
||||
}
|
||||
|
||||
export default {
|
||||
settings,
|
||||
requestAccess,
|
||||
send,
|
||||
listSendbox,
|
||||
removeSendboxMail,
|
||||
};
|
||||
Reference in New Issue
Block a user