diff --git a/CHANGELOG.md b/CHANGELOG.md
index c782737..49cba70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,9 +18,12 @@
### Improvements
+- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
+
### Testing
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
+- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
## v1.11.0
diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md
index 3482e55..5bf0c42 100644
--- a/CHANGELOG_EN.md
+++ b/CHANGELOG_EN.md
@@ -18,9 +18,12 @@
### Improvements
+- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
+
### Testing
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
+- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
## v1.11.0
diff --git a/e2e/tests/browser/send-mail-compose.spec.ts b/e2e/tests/browser/send-mail-compose.spec.ts
new file mode 100644
index 0000000..89430f4
--- /dev/null
+++ b/e2e/tests/browser/send-mail-compose.spec.ts
@@ -0,0 +1,78 @@
+import { test, expect, request as apiRequest } from '@playwright/test';
+import {
+ FRONTEND_URL,
+ createTestAddress,
+ deleteAddress,
+ requestSendAccess,
+} from '../../fixtures/test-helpers';
+
+test.describe('Send mail composer', () => {
+ test('edits a draft, changes format, and previews HTML', async ({ page }) => {
+ const api = await apiRequest.newContext();
+ let jwt: string | undefined;
+
+ try {
+ const created = await createTestAddress(api, 'compose-ui');
+ jwt = created.jwt;
+ await requestSendAccess(api, jwt);
+
+ await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
+ await page.getByText('Send Mail', { exact: true }).click();
+
+ await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
+ await expect(page.locator('.composer-title')).toContainText(created.address);
+
+ const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
+ const subject = page.getByRole('textbox', { name: /^Subject/ });
+ await recipient.fill('recipient@test.example.com');
+ await subject.fill('Composer preview');
+
+ await page.getByText('HTML', { exact: true }).click();
+ const htmlContent = [
+ '
Preview heading
Preview body
',
+ '
',
+ ].join('');
+ const textarea = page.locator('.compose-textarea textarea');
+ let previewDialogAppeared = false;
+ page.on('dialog', async (dialog) => {
+ previewDialogAppeared = true;
+ await dialog.dismiss();
+ });
+ await textarea.fill(htmlContent);
+ await page.getByRole('button', { name: 'Preview' }).click();
+
+ const preview = page.locator('.compose-preview');
+ await expect(preview).toBeVisible();
+ await expect(preview).toContainText('Preview heading');
+ await expect(preview.locator('script, [onerror]')).toHaveCount(0);
+ expect(previewDialogAppeared).toBe(false);
+
+ await page.getByRole('button', { name: 'Edit' }).click();
+ await expect(preview).toBeHidden();
+ await expect(recipient).toHaveValue('recipient@test.example.com');
+ await expect(subject).toHaveValue('Composer preview');
+ await expect(textarea).toHaveValue(htmlContent);
+
+ await page.setViewportSize({ width: 320, height: 800 });
+ await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
+ await page.getByText('Enviar correo', { exact: true }).click();
+
+ await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
+ await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
+ await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
+
+ await page.getByText('HTML', { exact: true }).click();
+ const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
+ await expect(previewButton).toBeVisible();
+ const previewBox = await previewButton.boundingBox();
+ expect(previewBox).not.toBeNull();
+ expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
+ } finally {
+ try {
+ if (jwt) await deleteAddress(api, jwt);
+ } finally {
+ await api.dispose();
+ }
+ }
+ });
+});
diff --git a/frontend/src/i18n/locales/source/de.ts b/frontend/src/i18n/locales/source/de.ts
index 89fe4e7..8c88449 100644
--- a/frontend/src/i18n/locales/source/de.ts
+++ b/frontend/src/i18n/locales/source/de.ts
@@ -1,4 +1,20 @@
export const deMessages = {
+ "views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
+ "views.index.SendMail.composeMail": "E-Mail verfassen",
+ "views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
+ "views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
+ "views.index.SendMail.recipientAddress": "Empfängeradresse",
+ "views.index.SendMail.recipientName": "Empfängername (optional)",
+ "views.index.SendMail.senderAddress": "Absenderadresse",
+ "views.index.SendMail.senderName": "Absendername (optional)",
+ "views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
+ "views.admin.SendMail.composeMail": "E-Mail verfassen",
+ "views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
+ "views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
+ "views.admin.SendMail.recipientAddress": "Empfängeradresse",
+ "views.admin.SendMail.recipientName": "Empfängername (optional)",
+ "views.admin.SendMail.senderAddress": "Absenderadresse",
+ "views.admin.SendMail.senderName": "Absendername (optional)",
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
diff --git a/frontend/src/i18n/locales/source/es.ts b/frontend/src/i18n/locales/source/es.ts
index e3f27e1..c2b8e35 100644
--- a/frontend/src/i18n/locales/source/es.ts
+++ b/frontend/src/i18n/locales/source/es.ts
@@ -1,4 +1,20 @@
export const esMessages = {
+ "views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
+ "views.index.SendMail.composeMail": "Redactar correo",
+ "views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
+ "views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
+ "views.index.SendMail.recipientAddress": "Dirección del destinatario",
+ "views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
+ "views.index.SendMail.senderAddress": "Dirección del remitente",
+ "views.index.SendMail.senderName": "Nombre del remitente (opcional)",
+ "views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
+ "views.admin.SendMail.composeMail": "Redactar correo",
+ "views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
+ "views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
+ "views.admin.SendMail.recipientAddress": "Dirección del destinatario",
+ "views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
+ "views.admin.SendMail.senderAddress": "Dirección del remitente",
+ "views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
diff --git a/frontend/src/i18n/locales/source/ja.ts b/frontend/src/i18n/locales/source/ja.ts
index 26bb941..46bb77e 100644
--- a/frontend/src/i18n/locales/source/ja.ts
+++ b/frontend/src/i18n/locales/source/ja.ts
@@ -1,4 +1,20 @@
export const jaMessages = {
+ "views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
+ "views.index.SendMail.composeMail": "メールを作成",
+ "views.index.SendMail.contentPlaceholder": "メッセージを入力...",
+ "views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
+ "views.index.SendMail.recipientAddress": "宛先メールアドレス",
+ "views.index.SendMail.recipientName": "宛先名(任意)",
+ "views.index.SendMail.senderAddress": "送信元メールアドレス",
+ "views.index.SendMail.senderName": "送信者名(任意)",
+ "views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
+ "views.admin.SendMail.composeMail": "メールを作成",
+ "views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
+ "views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
+ "views.admin.SendMail.recipientAddress": "宛先メールアドレス",
+ "views.admin.SendMail.recipientName": "宛先名(任意)",
+ "views.admin.SendMail.senderAddress": "送信元メールアドレス",
+ "views.admin.SendMail.senderName": "送信者名(任意)",
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
diff --git a/frontend/src/i18n/locales/source/ptBR.ts b/frontend/src/i18n/locales/source/ptBR.ts
index 66dc8f6..3954523 100644
--- a/frontend/src/i18n/locales/source/ptBR.ts
+++ b/frontend/src/i18n/locales/source/ptBR.ts
@@ -1,4 +1,20 @@
export const ptBRMessages = {
+ "views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
+ "views.index.SendMail.composeMail": "Escrever e-mail",
+ "views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
+ "views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
+ "views.index.SendMail.recipientAddress": "Endereço do destinatário",
+ "views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
+ "views.index.SendMail.senderAddress": "Endereço do remetente",
+ "views.index.SendMail.senderName": "Nome do remetente (opcional)",
+ "views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
+ "views.admin.SendMail.composeMail": "Escrever e-mail",
+ "views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
+ "views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
+ "views.admin.SendMail.recipientAddress": "Endereço do destinatário",
+ "views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
+ "views.admin.SendMail.senderAddress": "Endereço do remetente",
+ "views.admin.SendMail.senderName": "Nome do remetente (opcional)",
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
diff --git a/frontend/src/i18n/message-registry.ts b/frontend/src/i18n/message-registry.ts
index b1d6c1e..e5d6246 100644
--- a/frontend/src/i18n/message-registry.ts
+++ b/frontend/src/i18n/message-registry.ts
@@ -1026,6 +1026,14 @@ export const MESSAGE_REGISTRY = {
}
},
"views.index.SendMail": {
+ "balanceUnavailable": {
+ "en": "No send balance for this address",
+ "zh": "当前地址暂无发信额度"
+ },
+ "composeMail": {
+ "en": "Compose email",
+ "zh": "写邮件"
+ },
"content": {
"en": "Content",
"zh": "内容"
@@ -1034,6 +1042,14 @@ export const MESSAGE_REGISTRY = {
"en": "Content is empty",
"zh": "内容不能为空"
},
+ "contentPlaceholder": {
+ "en": "Write your message...",
+ "zh": "输入邮件正文..."
+ },
+ "draftSaved": {
+ "en": "Draft saved in this browser",
+ "zh": "草稿已保存在当前浏览器"
+ },
"edit": {
"en": "Edit",
"zh": "编辑"
@@ -1055,8 +1071,8 @@ export const MESSAGE_REGISTRY = {
"zh": "预览"
},
"requestAccess": {
- "en": "Request Access",
- "zh": "申请权限"
+ "en": "Request send access",
+ "zh": "申请发信权限"
},
"requestAccessTip": {
"en": "Send permission and balance are managed separately for each email address. The current address, {address}, has no available balance. Request permission for this address or contact the admin.",
@@ -1066,6 +1082,14 @@ export const MESSAGE_REGISTRY = {
"en": "Send permission requested for the current address",
"zh": "已为当前地址提交发信权限申请"
},
+ "recipientAddress": {
+ "en": "Recipient address",
+ "zh": "收件人邮箱"
+ },
+ "recipientName": {
+ "en": "Recipient name (optional)",
+ "zh": "收件人名称(可选)"
+ },
"rich text": {
"en": "Rich Text",
"zh": "富文本"
@@ -1078,6 +1102,14 @@ export const MESSAGE_REGISTRY = {
"en": "Current Address Send Balance",
"zh": "当前地址剩余发信额度"
},
+ "senderAddress": {
+ "en": "Sender address",
+ "zh": "发件邮箱"
+ },
+ "senderName": {
+ "en": "Sender name (optional)",
+ "zh": "发件人名称(可选)"
+ },
"subject": {
"en": "Subject",
"zh": "主题"
@@ -1092,7 +1124,7 @@ export const MESSAGE_REGISTRY = {
},
"text": {
"en": "Text",
- "zh": "文本"
+ "zh": "纯文本"
},
"toMailEmpty": {
"en": "Recipient address is empty",
@@ -1278,6 +1310,14 @@ export const MESSAGE_REGISTRY = {
}
},
"views.admin.SendMail": {
+ "adminComposeTip": {
+ "en": "Send from a configured email address",
+ "zh": "使用已配置的邮箱地址发信"
+ },
+ "composeMail": {
+ "en": "Compose email",
+ "zh": "写邮件"
+ },
"content": {
"en": "Content",
"zh": "内容"
@@ -1286,6 +1326,14 @@ export const MESSAGE_REGISTRY = {
"en": "Content is empty",
"zh": "内容不能为空"
},
+ "contentPlaceholder": {
+ "en": "Write your message...",
+ "zh": "输入邮件正文..."
+ },
+ "draftSaved": {
+ "en": "Draft saved in this browser",
+ "zh": "草稿已保存在当前浏览器"
+ },
"edit": {
"en": "Edit",
"zh": "编辑"
@@ -1310,6 +1358,14 @@ export const MESSAGE_REGISTRY = {
"en": "Preview",
"zh": "预览"
},
+ "recipientAddress": {
+ "en": "Recipient address",
+ "zh": "收件人邮箱"
+ },
+ "recipientName": {
+ "en": "Recipient name (optional)",
+ "zh": "收件人名称(可选)"
+ },
"rich text": {
"en": "Rich Text",
"zh": "富文本"
@@ -1318,6 +1374,14 @@ export const MESSAGE_REGISTRY = {
"en": "Send",
"zh": "发送"
},
+ "senderAddress": {
+ "en": "Sender address",
+ "zh": "发件邮箱"
+ },
+ "senderName": {
+ "en": "Sender name (optional)",
+ "zh": "发件人名称(可选)"
+ },
"subject": {
"en": "Subject",
"zh": "主题"
@@ -1332,7 +1396,7 @@ export const MESSAGE_REGISTRY = {
},
"text": {
"en": "Text",
- "zh": "文本"
+ "zh": "纯文本"
},
"toMailEmpty": {
"en": "Recipient address is empty",
diff --git a/frontend/src/views/admin/SendMail.vue b/frontend/src/views/admin/SendMail.vue
index 6c2ca25..b621213 100644
--- a/frontend/src/views/admin/SendMail.vue
+++ b/frontend/src/views/admin/SendMail.vue
@@ -2,14 +2,20 @@
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app'
-import { onBeforeUnmount, ref, shallowRef } from 'vue'
+import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { useSessionStorage } from '@vueuse/core'
+import { SendRound } from '@vicons/material'
import { api } from '../../api'
+import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
+import { useGlobalState } from '../../store'
+import { blockRemoteContent } from '../../utils/remote-content-policy'
+import { sanitizeHtml } from '../../utils/sanitize-html'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const sending = ref(false)
+const { autoLoadRemoteImages, isDark } = useGlobalState()
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
fromName: "",
@@ -23,11 +29,18 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
const { t } = useScopedI18n('views.admin.SendMail')
-const contentTypes = [
+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
@@ -110,6 +123,7 @@ const send = async () => {
contentType: 'text',
content: "",
}
+ isPreview.value = false
message.success(t("successSend"));
} catch (error) {
message.error(error.message || "error");
@@ -146,78 +160,230 @@ const handleCreated = (editor) => {
-
-
-
- {{ t('send') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ isPreview ? t('edit') : t('preview') }}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
{{ t('composeMail') }}
+ {{ t('adminComposeTip') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('draftSaved') }}
+
+
+ {{ t('send') }}
+
+
+
diff --git a/frontend/src/views/index/SendMail.vue b/frontend/src/views/index/SendMail.vue
index 2bd7913..bbd2d5f 100644
--- a/frontend/src/views/index/SendMail.vue
+++ b/frontend/src/views/index/SendMail.vue
@@ -2,11 +2,15 @@
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app'
-import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
+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)
@@ -14,15 +18,25 @@ const editorRef = shallowRef()
const sending = ref(false)
-const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
+const {
+ settings, sendMailModel, indexTab, userSettings,
+ autoLoadRemoteImages, isDark,
+} = useGlobalState()
const { t } = useScopedI18n('views.index.SendMail')
-const contentTypes = [
+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
@@ -157,95 +171,292 @@ onMounted(async () => {
-
-
-
-
- {{ t('requestAccessTip', { address: settings.address }) }}
- {{ t('requestAccess')
- }}
-
-
-
-
-
- {{ t('send_balance') }}: {{ settings.send_balance }}
-
-
- {{ t('send') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ isPreview ? t('edit') : t('preview') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
{{ t('composeMail') }}
+ {{ settings.address }}
+
+
+
+ {{ t('send_balance') }} · {{ settings.send_balance }}
+
+
+
+
+
+
+
{{ t('balanceUnavailable') }}
+
{{ t('requestAccessTip', { address: settings.address }) }}
+
+
{{ t('requestAccess') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('draftSaved') }}
+
+
+ {{ t('send') }}
+
+
+
+