feat: improve send mail composer (#1120)

feat(frontend): improve send mail composer
This commit is contained in:
Dream Hunter
2026-08-22 19:08:09 +08:00
committed by GitHub
parent aeb2ac687d
commit 005d74bfde
10 changed files with 733 additions and 144 deletions
+3
View File
@@ -18,9 +18,12 @@
### Improvements
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
### Testing
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
## v1.11.0
+3
View File
@@ -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
@@ -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 = [
'<h1>Preview heading</h1><p>Preview body</p>',
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
].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();
}
}
});
});
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+68 -4
View File
@@ -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",
+229 -63
View File
@@ -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) => {
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-flex justify="end">
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input v-model:value="sendMailModel.fromMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
<div class="composer-page">
<n-card class="composer-card" :bordered="false" embedded>
<template #header>
<div class="composer-title">
<h2>{{ t('composeMail') }}</h2>
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
</div>
</template>
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('senderAddress')" required
:label-props="{ for: 'admin-send-mail-sender-address' }">
<n-input v-model:value="sendMailModel.fromMail"
:input-props="{ id: 'admin-send-mail-sender-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('senderName')"
:label-props="{ for: 'admin-send-mail-sender-name' }">
<n-input v-model:value="sendMailModel.fromName"
:input-props="{ id: 'admin-send-mail-sender-name' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientAddress')" required
:label-props="{ for: 'admin-send-mail-recipient-address' }">
<n-input v-model:value="sendMailModel.toMail"
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientName')"
:label-props="{ for: 'admin-send-mail-recipient-name' }">
<n-input v-model:value="sendMailModel.toName"
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item :label="t('subject')" required
:label-props="{ for: 'admin-send-mail-subject' }">
<n-input v-model:value="sendMailModel.subject"
:input-props="{ id: 'admin-send-mail-subject' }" />
</n-form-item>
<div class="editor-panel">
<div class="editor-panel-header">
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
class="required-mark">*</span></n-text>
<div class="editor-controls">
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
size="small" aria-labelledby="admin-send-mail-content-label">
<n-radio-button v-for="option in contentTypes" :key="option.value"
:value="option.value" :label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
@click="isPreview = !isPreview">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
</div>
</div>
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
</div>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
@onCreated="handleCreated" />
</div>
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
:input-props="{ 'aria-label': t('content') }"
:autosize="{ minRows: 14, maxRows: 24 }" />
</div>
<div class="composer-actions">
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
<template #icon><n-icon :component="SendRound" /></template>
{{ t('send') }}
</n-button>
</div>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
.composer-page {
width: 100%;
padding: 14px 0 24px;
text-align: left;
margin-right: 10px;
}
.center {
.composer-card {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
}
.left {
.composer-title > h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.composer-title .n-text {
font-size: 13px;
}
.editor-panel {
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.24);
border-radius: 3px;
}
.editor-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 6px 10px 6px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
}
.editor-controls {
display: flex;
align-items: center;
gap: 8px;
}
.required-mark {
color: #d03050;
}
.format-options {
display: flex;
}
.format-options :deep(.n-radio-button) {
min-width: 72px;
text-align: center;
}
.compose-preview {
min-height: 360px;
padding: 18px;
}
.rich-editor {
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
border-bottom: 1px solid #e5e7eb;
}
.rich-editor :deep(.w-e-text-container),
.rich-editor :deep(.w-e-scroll) {
min-height: 360px;
}
.compose-textarea :deep(.n-input__textarea-el) {
padding: 18px;
line-height: 1.7;
text-align: left;
place-items: left;
justify-content: left;
}
.composer-form :deep(.n-input__input-el) {
text-align: left;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.18);
}
.draft-status {
font-size: 13px;
}
@media (max-width: 640px) {
.composer-page {
padding-top: 8px;
}
.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>
+288 -77
View File
@@ -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 () => {
</script>
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" :show-icon="false" :bordered="false">
{{ t('requestAccessTip', { address: settings.address }) }}
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>
<AdminContact />
</div>
<div v-else>
<n-alert type="info" :show-icon="false" :bordered="false" closable>
{{ t('send_balance') }}: {{ settings.send_balance }}
</n-alert>
<n-flex justify="end">
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input :value="settings.address" disabled />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
<div class="composer-page" v-if="settings.address">
<n-card class="composer-card" :bordered="false" embedded>
<template #header>
<div class="composer-title">
<h2>{{ t('composeMail') }}</h2>
<n-text depth="3">{{ settings.address }}</n-text>
</div>
</template>
<template #header-extra>
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
{{ t('send_balance') }} · {{ settings.send_balance }}
</n-tag>
</template>
<div 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-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('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
<n-input v-model:value="sendMailModel.fromName"
:input-props="{ id: 'send-mail-sender-name' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
<n-input :value="settings.address" readonly
:input-props="{ id: 'send-mail-sender-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('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>
</n-form>
</template>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
.composer-page {
width: 100%;
padding: 14px 0 24px;
text-align: left;
margin-right: 10px;
}
.center {
.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;
place-items: center;
justify-content: center;
}
.left {
.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) {
padding: 18px;
line-height: 1.7;
text-align: left;
place-items: left;
justify-content: left;
}
.n-alert {
margin-bottom: 10px;
.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>