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) => { 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 () => {