feat: add user send mail and sent box (#1122)

* feat: add user send mail client

* fix: align user mail navigation

* fix: shorten address credential action

* test: cover user mail ownership boundaries

* fix: address user mail review feedback

* fix: disambiguate user mail e2e heading

* fix: minimize shared sent box changes

* refactor: isolate user send mail page

* refactor: reuse bound address lookup

* fix: clarify user sent box naming

* refactor: decouple user send API from roles

* fix: align user send mail behavior

* fix: align user send role and rate limits

* test: isolate user send rate limits

* test: initialize rate limit worker database

* refactor: simplify user send rate limit

* refactor: inline user send rate limit path

* refactor: simplify user send limiter key

* refactor: keep existing rate limit behavior

* style: simplify user send rate limit condition

* style: group user send rate limit condition

* fix: bind user role token to account

* fix: keep user sender selection available
This commit is contained in:
Dream Hunter
2026-08-25 14:14:42 +08:00
committed by GitHub
parent dccca92928
commit 5dbb6107dd
20 changed files with 1483 additions and 19 deletions
+4 -1
View File
@@ -650,5 +650,8 @@ 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.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
"views.user.UserSendBox.sendbox": "Gesendet"
}
+4 -1
View File
@@ -650,5 +650,8 @@ 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.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
"views.user.UserSendBox.sendbox": "Enviados"
}
+4 -1
View File
@@ -650,5 +650,8 @@ 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.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
"views.user.UserSendBox.sendbox": "送信済み"
}
+4 -1
View File
@@ -650,5 +650,8 @@ 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.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
"views.user.UserSendBox.sendbox": "Enviados"
}
+14
View File
@@ -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,16 @@ export const MESSAGE_REGISTRY = {
"zh": "用户设置"
}
},
"views.user.UserSendBox": {
"noAddress": {
"en": "Select a bound email address to continue",
"zh": "请选择一个已绑定的邮箱地址"
},
"sendbox": {
"en": "Sent",
"zh": "发件箱"
}
},
"views.user.UserLogin": {
"cannotForgotPassword": {
"en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator",
+9 -1
View File
@@ -8,12 +8,14 @@ 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 UserSendBox from './user/UserSendBox.vue';
const {
userTab, globalTabplacement, userSettings
userTab, globalTabplacement, userSettings, openSettings
} = useGlobalState()
const { t } = useScopedI18n('views.User')
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
</script>
@@ -27,6 +29,12 @@ 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_sendbox" :tab="userMailT('sendbox')">
<UserSendBox mode="sendbox" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
@@ -7,6 +7,7 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
import Login from '../common/Login.vue';
@@ -15,6 +16,7 @@ const message = useMessage()
const router = useRouter()
const { locale, t } = useScopedI18n('views.user.AddressManagement')
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
const data = ref([])
const count = ref(0)
@@ -24,6 +26,20 @@ const showTranferAddress = ref(false)
const currentAddress = ref("")
const currentAddressId = ref(0)
const targetUserEmail = ref('')
const showAddressCredential = ref(false)
const currentAddressCredential = ref('')
const credentialAddress = ref('')
const showCredential = async (row) => {
try {
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
currentAddressCredential.value = addressCredential
credentialAddress.value = row.name
showAddressCredential.value = true
} catch (error) {
message.error(error.message || "error")
}
}
const changeMailAddress = async (address_id) => {
try {
@@ -146,6 +162,14 @@ const columns = [
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => showCredential(row)
},
{ default: () => credentialT('addressCredential') }
),
h(NPopconfirm,
{
onPositiveClick: () => changeMailAddress(row.id)
@@ -204,6 +228,8 @@ watch([page, pageSize], async () => {
<template>
<div>
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
:jwt="currentAddressCredential" />
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
<span>
<p>{{ t("transferAddressTip") }}</p>
+491
View File
@@ -0,0 +1,491 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useScopedI18n } from '@/i18n/app'
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
import { SendRound } from '@vicons/material'
import AdminContact from '../common/AdminContact.vue'
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { blockRemoteContent } from '../../utils/remote-content-policy'
import { sanitizeHtml } from '../../utils/sanitize-html'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const sending = ref(false)
const settings = ref({
address: '',
send_balance: 0,
})
const props = defineProps({
addressId: {
type: Number,
default: 0,
},
addressOptions: {
type: Array,
default: () => [],
},
addressLoading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
const {
sendMailModel, userSettings, autoLoadRemoteImages, isDark,
} = useGlobalState()
const { t } = useScopedI18n('views.index.SendMail')
const getApiPath = (path) => `/user_api/address/${props.addressId}/${path}`
const refreshSettings = async () => {
settings.value = await api.fetch(getApiPath('settings'))
}
const contentTypes = computed(() => [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
])
const previewContent = computed(() => {
const content = `${sendMailModel.value.content ?? ''}`
return autoLoadRemoteImages.value
? sanitizeHtml(content)
: blockRemoteContent(content).html
})
const normalizeSendMailText = (content) => {
return content
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
const hasSendMailContent = (content, contentType) => {
if (typeof content !== 'string' || !content) {
return false
}
if (contentType === 'text') {
return normalizeSendMailText(content).length > 0
}
const container = document.createElement('div')
container.innerHTML = content
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
const plainContent = normalizeSendMailText(container.textContent ?? '')
if (plainContent.length > 0) {
return true
}
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
}
const send = async () => {
if (sending.value) {
return
}
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
const content = `${sendMailModel.value.content ?? ''}`
if (!subject) {
message.error(t('subjectEmpty'))
return
}
if (!toMail) {
message.error(t('toMailEmpty'))
return
}
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
message.error(t('contentEmpty'))
return
}
const payload = {
from_name: sendMailModel.value.fromName,
to_name: sendMailModel.value.toName,
to_mail: toMail,
subject,
is_html: sendMailModel.value.contentType != 'text',
content,
}
sending.value = true
try {
await api.fetch(getApiPath('send_mail'),
{
method: 'POST',
body: JSON.stringify(payload)
})
sendMailModel.value = {
fromName: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
}
isPreview.value = false
message.success(t("successSend"));
emit('sent')
} catch (error) {
message.error(error.message || "error");
} finally {
sending.value = false
}
}
const requestAccess = async () => {
try {
await api.fetch(getApiPath('request_send_mail_access'),
{
method: 'POST',
body: JSON.stringify({})
}
)
message.success(t("requestSuccess"))
await refreshSettings();
} catch (error) {
message.error(error.message || "error");
}
}
const toolbarConfig = {
excludeKeys: ["uploadVideo"]
}
const editorConfig = {
MENU_CONF: {
'uploadImage': {
async customUpload() {
message.error(t('tooLarge'))
},
maxFileSize: 1 * 1024 * 1024,
base64LimitSize: 1 * 1024 * 1024,
}
}
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor;
}
onMounted(async () => {
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
await refreshSettings();
})
</script>
<template>
<div class="composer-page" v-if="settings.address">
<n-card class="composer-card" :bordered="false" embedded>
<template #header>
<div class="composer-title">
<h2>{{ t('composeMail') }}</h2>
</div>
</template>
<template #header-extra>
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
{{ t('send_balance') }} · {{ settings.send_balance }}
</n-tag>
</template>
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
<n-select class="address-picker-select" :value="addressId"
:options="addressOptions" :loading="addressLoading" filterable
@scroll="emit('addressScroll', $event)"
@update:value="emit('update:addressId', $event)" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
<n-input v-model:value="sendMailModel.fromName"
:input-props="{ id: 'send-mail-sender-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<div class="access-state">
<div class="access-copy">
<h3>{{ t('balanceUnavailable') }}</h3>
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
</div>
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
</div>
<div class="admin-contact"><AdminContact /></div>
</div>
<template v-else>
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
<n-grid-item>
<n-form-item :label="t('recipientAddress')" required
:label-props="{ for: 'send-mail-recipient-address' }">
<n-input v-model:value="sendMailModel.toMail"
:input-props="{ id: 'send-mail-recipient-address' }" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
<n-input v-model:value="sendMailModel.toName"
:input-props="{ id: 'send-mail-recipient-name' }" />
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
<n-input v-model:value="sendMailModel.subject"
:input-props="{ id: 'send-mail-subject' }" />
</n-form-item>
<div class="editor-panel">
<div class="editor-panel-header">
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
class="required-mark">*</span></n-text>
<div class="editor-controls">
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
size="small" aria-labelledby="send-mail-content-label">
<n-radio-button v-for="option in contentTypes" :key="option.value"
:value="option.value" :label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
@click="isPreview = !isPreview">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</div>
</div>
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
</div>
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
@onCreated="handleCreated" />
</div>
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
:input-props="{ 'aria-label': t('content') }"
:autosize="{ minRows: 14, maxRows: 24 }" />
</div>
<div class="composer-actions">
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
<template #icon><n-icon :component="SendRound" /></template>
{{ t('send') }}
</n-button>
</div>
</template>
</n-form>
</n-card>
</div>
</template>
<style scoped>
.composer-page {
width: 100%;
padding: 14px 0 24px;
text-align: left;
}
.composer-card {
width: min(900px, 100%);
margin: 0 auto;
}
.composer-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px 12px;
}
.composer-title > h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.composer-title .n-text {
font-size: 13px;
word-break: break-all;
}
.access-state {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 6px 0 18px;
}
.access-copy h3 {
margin: 0;
font-size: 16px;
}
.access-copy p {
max-width: 640px;
margin: 8px 0 0;
line-height: 1.65;
opacity: 0.72;
}
.admin-contact {
margin-top: 4px;
}
.editor-panel {
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.24);
border-radius: 3px;
}
.editor-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 46px;
padding: 6px 10px 6px 14px;
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
}
.editor-controls {
display: flex;
align-items: center;
gap: 8px;
}
.required-mark {
color: #d03050;
}
.format-options {
display: flex;
}
.format-options :deep(.n-radio-button) {
min-width: 72px;
text-align: center;
}
.compose-preview {
min-height: 360px;
padding: 18px;
}
.rich-editor {
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
border-bottom: 1px solid #e5e7eb;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.rich-editor :deep(.w-e-scroll) {
min-height: 360px;
}
.compose-textarea :deep(.n-input__textarea-el),
.compose-textarea :deep(.n-input__placeholder) {
line-height: 1.7;
text-align: left;
}
.composer-form :deep(.n-input__input-el) {
text-align: left;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(128, 128, 128, 0.18);
}
.draft-status {
font-size: 13px;
}
@media (max-width: 640px) {
.composer-page {
padding-top: 8px;
}
.access-state {
align-items: stretch;
flex-direction: column;
gap: 16px;
}
.composer-card :deep(.n-card-header) {
flex-wrap: wrap;
gap: 8px 12px;
}
.composer-card :deep(.n-card-header__main) {
flex: 1 1 180px;
min-width: 0;
}
.composer-card :deep(.n-card-header__extra) {
margin-left: auto;
}
.editor-panel-header {
align-items: flex-start;
flex-wrap: wrap;
}
.editor-controls {
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.format-options {
max-width: 100%;
}
.format-options :deep(.n-radio-button) {
min-width: 0;
padding-right: 8px;
padding-left: 8px;
}
.rich-editor :deep(.w-e-toolbar) {
overflow-x: auto;
}
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup>
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { api } from '../../api'
import { useGlobalState } from '../../store'
import SendBox from '../../components/SendBox.vue'
const SendMail = defineAsyncComponent(() => import('./SendMail.vue'))
const ADDRESS_PAGE_SIZE = 100
const props = defineProps({
mode: {
type: String,
default: 'send_mail',
},
})
const emit = defineEmits(['sent'])
const message = useMessage()
const { openSettings } = useGlobalState()
const { t } = useScopedI18n('views.user.UserSendBox')
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
const selectedAddressId = ref(null)
const addressFilter = ref(null)
const addressOptions = ref([])
const addressCount = ref(0)
const addressLoading = ref(false)
const sendboxKey = ref(0)
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
label: address.label,
value: address.address,
})))
const fetchAddresses = async () => {
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
return
}
addressLoading.value = true
try {
const offset = addressOptions.value.length
const { results, count } = await api.fetch(
`/user_api/bind_address?limit=${ADDRESS_PAGE_SIZE}&offset=${offset}`
)
addressOptions.value.push(...results.map((address) => ({
label: address.name,
value: address.id,
address: address.name,
})))
if (offset === 0) {
addressCount.value = count
}
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
selectedAddressId.value = addressOptions.value[0].value
}
} catch (error) {
message.error(error.message || 'error')
} finally {
addressLoading.value = false
}
}
const handleAddressScroll = async (event) => {
const target = event.currentTarget
if (!target || target.scrollTop + target.clientHeight < target.scrollHeight - 24) {
return
}
await fetchAddresses()
}
const fetchSendbox = async (limit, offset) => {
return await api.fetch(
`/user_api/sendbox?limit=${limit}&offset=${offset}`
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
)
}
const deleteSendboxMail = async (mailId) => {
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
}
const querySendbox = () => {
sendboxKey.value = Date.now()
}
watch(addressFilter, querySendbox)
onMounted(fetchAddresses)
</script>
<template>
<div class="user-send-box">
<template v-if="mode === 'send_mail'">
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
<SendMail v-else :key="selectedAddressId" v-model:address-id="selectedAddressId"
:address-options="addressOptions" :address-loading="addressLoading"
@address-scroll="handleAddressScroll" @sent="emit('sent')" />
</template>
<template v-else>
<n-input-group>
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
@scroll="handleAddressScroll" />
<n-button @click="querySendbox" type="primary" tertiary>
{{ mailboxT('query') }}
</n-button>
</n-input-group>
<div class="filter-spacing"></div>
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
:delete-mail="deleteSendboxMail" />
</template>
</div>
</template>
<style scoped>
.user-send-box {
padding-top: 10px;
text-align: left;
}
.filter-spacing {
margin-top: 10px;
}
.address-empty {
padding: 72px 0;
}
</style>