feat: add user send mail client

This commit is contained in:
dreamhunter2333
2026-08-23 17:44:21 +08:00
parent dccca92928
commit 43d9a9dfe9
18 changed files with 728 additions and 23 deletions
+1 -1
View File
@@ -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) {
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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": "作成"
}
+7 -1
View File
@@ -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"
}
+26
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,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",
+5 -1
View File
@@ -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>
+46 -12
View File
@@ -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>
+131
View File
@@ -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>