feat: add single-mail read status (#1125)

This commit is contained in:
Dream Hunter
2026-08-31 13:11:07 +08:00
committed by GitHub
parent f92b059aac
commit 70206c61ef
32 changed files with 496 additions and 128 deletions
+4 -2
View File
@@ -21,7 +21,8 @@ const instance = axios.create({
});
const apiFetch = async (path, options = {}) => {
loading.value = true;
const showLoading = options.showLoading !== false;
if (showLoading) loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
@@ -67,7 +68,7 @@ const apiFetch = async (path, options = {}) => {
}
throw error;
} finally {
loading.value = false;
if (showLoading) loading.value = false;
}
}
@@ -99,6 +100,7 @@ const getOpenSettings = async (message, notification) => {
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableMailReadStatus: res["enableMailReadStatus"] === true,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
+58 -9
View File
@@ -55,6 +55,14 @@ const props = defineProps({
default: false,
required: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
updateMailReadStatus: {
type: Function,
default: () => { }
},
})
const localFilterKeyword = ref('')
@@ -94,6 +102,29 @@ const data = computed(() => {
});
})
const openMail = (mail) => {
curMail.value = mail
if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return
mail.is_unread = 0
void props.updateMailReadStatus(mail.id, false).catch(() => {
mail.is_unread = 1
})
}
const toggleCurrentMailUnread = async () => {
if (!curMail.value || !props.enableMailReadStatus) return
const mail = curMail.value
const previousValue = mail.is_unread
const isUnread = previousValue !== 1
mail.is_unread = isUnread ? 1 : 0
try {
await props.updateMailReadStatus(mail.id, isUnread)
message.success(t("success"))
} catch {
mail.is_unread = previousValue
}
}
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -111,12 +142,12 @@ const prevMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
openMail(data.value[currentIndex - 1])
} else if (page.value > 1) {
page.value--
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[data.value.length - 1]
openMail(data.value[data.value.length - 1])
}
}
}
@@ -126,12 +157,12 @@ const nextMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex < data.value.length - 1) {
curMail.value = data.value[currentIndex + 1]
openMail(data.value[currentIndex + 1])
} else if (count.value > page.value * pageSize.value) {
page.value++
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[0]
openMail(data.value[0])
}
}
}
@@ -205,7 +236,7 @@ const backFirstPageAndRefresh = async () => {
await refresh();
}
const clickRow = async (row) => {
const clickRow = (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
curMail.value = row;
@@ -215,7 +246,7 @@ const clickRow = async (row) => {
curMail.value = null;
return;
}
curMail.value = row;
openMail(row);
};
@@ -397,7 +428,7 @@ onBeforeUnmount(() => {
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -461,6 +492,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
@@ -475,7 +507,7 @@ onBeforeUnmount(() => {
<div v-else class="mail-list-scroll">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -536,7 +568,8 @@ onBeforeUnmount(() => {
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="{ 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
@@ -567,6 +600,7 @@ onBeforeUnmount(() => {
<n-card :bordered="false" embedded style="overflow: auto;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
@@ -676,6 +710,21 @@ onBeforeUnmount(() => {
min-width: 0;
}
.mail-list-unread :deep(.n-thing-header__title) {
font-weight: 700;
}
.mail-list-unread :deep(.n-thing-header__title)::before {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: #2080f0;
content: '';
vertical-align: middle;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
@@ -34,6 +34,10 @@ const props = defineProps({
type: Boolean,
default: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
// 回调函数 props
onDelete: {
type: Function,
@@ -50,6 +54,10 @@ const props = defineProps({
onSaveToS3: {
type: Function,
default: () => { }
},
onUpdateMailReadStatus: {
type: Function,
default: () => { }
}
});
@@ -57,6 +65,7 @@ const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
const readStatusUpdating = ref(false);
const showFullscreen = ref(false);
// Per-mail consent, deliberately independent of the global setting: it only
@@ -96,6 +105,15 @@ const handleForward = () => {
props.onForward();
};
const handleUpdateMailReadStatus = async () => {
readStatusUpdating.value = true;
try {
await props.onUpdateMailReadStatus();
} finally {
readStatusUpdating.value = false;
}
};
const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true;
@@ -138,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('attachments') }}
</n-button>
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" :loading="readStatusUpdating"
@click="handleUpdateMailReadStatus">
{{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
:href="getDownloadEmlUrl(mail.raw)">
<template #icon>
+12
View File
@@ -190,6 +190,14 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen",
"zh": "全屏"
},
"markAsRead": {
"en": "Mark as Read",
"zh": "标为已读"
},
"markAsUnread": {
"en": "Mark as Unread",
"zh": "标为未读"
},
"loadRemoteImages": {
"en": "Load Images",
"zh": "加载图片"
@@ -1198,6 +1206,10 @@ export const MESSAGE_REGISTRY = {
"en": "Previous",
"zh": "上一页"
},
"readStatusUpdated": {
"en": "Read status updated",
"zh": "已读状态已更新"
},
"refreshAfter": {
"en": "Refresh After {msg} Seconds",
"zh": "{msg}秒后刷新"
+1
View File
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableMailReadStatus: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
+10 -1
View File
@@ -45,6 +45,14 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
const updateMailReadStatus = async (id, isUnread) => {
await api.fetch(`/api/mails/${id}/read`, {
method: 'PATCH',
body: JSON.stringify({ isUnread }),
showLoading: false
})
}
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
@@ -127,7 +135,8 @@ onMounted(() => {
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
:enableMailReadStatus="openSettings.enableMailReadStatus" :updateMailReadStatus="updateMailReadStatus" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
+39 -1
View File
@@ -56,6 +56,24 @@ const fetchMails = async () => {
}
}
const toggleCurrentMailUnread = async () => {
if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
const mail = currentMail.value
const previousValue = mail.is_unread
const isUnread = previousValue !== 1
mail.is_unread = isUnread ? 1 : 0
try {
await api.fetch(`/api/mails/${mail.id}/read`, {
method: 'PATCH',
body: JSON.stringify({ isUnread }),
showLoading: false
})
message.success(t('readStatusUpdated'))
} catch {
mail.is_unread = previousValue
}
}
// 删除邮件
const deleteMail = async () => {
if (!currentMail.value) return;
@@ -216,10 +234,15 @@ onBeforeUnmount(() => {
<n-empty :description="t('noMails')" />
</div>
<div v-else>
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
<h3 v-if="currentMail.subject"
:class="{ 'mail-title-unread': openSettings.enableMailReadStatus && currentMail.is_unread === 1 }">
{{ currentMail.subject }}
</h3>
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:enableMailReadStatus="openSettings.enableMailReadStatus"
:onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" />
</div>
</div>
@@ -246,4 +269,19 @@ onBeforeUnmount(() => {
margin-top: 20px;
width: 100%;
}
.mail-title-unread {
font-weight: 700;
}
.mail-title-unread::before {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 8px;
border-radius: 50%;
background: #2080f0;
content: '';
vertical-align: middle;
}
</style>