feat: add extensible mail flags

This commit is contained in:
dreamhunter2333
2026-08-25 20:36:35 +08:00
parent 5dbb6107dd
commit 6fe31df05a
35 changed files with 640 additions and 61 deletions
+108 -13
View File
@@ -8,6 +8,7 @@ import { useIsMobile } from '../utils/composables'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
import { MAIL_FLAGS, hasMailFlag } from '../utils/mail-flags'
import MailContentRenderer from "./MailContentRenderer.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
@@ -55,9 +56,20 @@ const props = defineProps({
default: false,
required: false
},
enableMailFlags: {
type: Boolean,
default: false,
required: false
},
updateMailFlags: {
type: Function,
default: () => { },
required: false
},
})
const localFilterKeyword = ref('')
const mailFlagFilter = ref('all')
const {
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
@@ -94,6 +106,60 @@ const data = computed(() => {
});
})
const isMailUnread = (mail) => {
return props.enableMailFlags && hasMailFlag(mail?.flags, MAIL_FLAGS.UNREAD)
}
const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread))
const mailFlagFilterOptions = computed(() => [
{ label: t('allMail'), value: 'all' },
{ label: t('unread'), value: 'unread' },
{ label: t('read'), value: 'read' },
])
const setMailsUnread = async (mails, unread) => {
const changedMails = mails.filter(mail => isMailUnread(mail) !== unread)
if (changedMails.length === 0) return true
changedMails.forEach(mail => {
const flags = Number(mail.flags ?? 0)
mail.flags = unread ? flags | MAIL_FLAGS.UNREAD : flags & ~MAIL_FLAGS.UNREAD
})
try {
await props.updateMailFlags(
changedMails.map(mail => mail.id),
unread ? MAIL_FLAGS.UNREAD : 0,
unread ? 0 : MAIL_FLAGS.UNREAD
)
return true
} catch (error) {
changedMails.forEach(mail => {
const flags = Number(mail.flags ?? 0)
mail.flags = unread ? flags & ~MAIL_FLAGS.UNREAD : flags | MAIL_FLAGS.UNREAD
})
message.error(error.message || "error")
return false
}
}
const markMailsRead = async (mails) => setMailsUnread(mails, false)
const toggleCurrentMailUnread = async () => {
if (!curMail.value) return
await setMailsUnread([curMail.value], !isMailUnread(curMail.value))
}
const openMail = async (mail) => {
curMail.value = mail
await markMailsRead([mail])
}
const markCurrentPageRead = async () => {
if (!await markMailsRead(rawData.value)) return
message.success(t("success"))
if (mailFlagFilter.value === 'unread') await refresh()
}
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -111,12 +177,12 @@ const prevMail = async () => {
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
await 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]
await openMail(data.value[data.value.length - 1])
}
}
}
@@ -126,12 +192,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]
await 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]
await openMail(data.value[0])
}
}
}
@@ -175,22 +241,24 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
}
})
watch(mailFlagFilter, async () => {
await backFirstPageAndRefresh()
})
const refresh = async () => {
try {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
pageSize.value, (page.value - 1) * pageSize.value, mailFlagFilter.value
);
loading.value = true;
rawData.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (page.value === 1) count.value = totalCount;
curMail.value = null;
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
curMail.value = data.value[0];
await openMail(data.value[0]);
}
} catch (error) {
message.error(error.message || "error");
@@ -215,7 +283,7 @@ const clickRow = async (row) => {
curMail.value = null;
return;
}
curMail.value = row;
await openMail(row);
};
@@ -381,6 +449,11 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
<n-button v-if="enableMailFlags && currentPageHasUnread" @click="markCurrentPageRead" tertiary>
{{ t('markCurrentPageRead') }}
</n-button>
<n-select v-if="enableMailFlags" v-model:value="mailFlagFilter" :options="mailFlagFilterOptions"
style="width: 120px" />
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
clearable />
@@ -397,12 +470,15 @@ 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': isMailUnread(row) }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
<n-thing :title="row.subject">
<template #description>
<n-tag v-if="isMailUnread(row)" type="warning">
{{ t('unread') }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
@@ -461,6 +537,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailFlags="enableMailFlags" :onToggleUnread="toggleCurrentMailUnread"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
@@ -475,7 +552,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': isMailUnread(row) }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -487,6 +564,9 @@ onBeforeUnmount(() => {
</template>
<template #description>
<div class="mail-list-meta">
<n-tag v-if="isMailUnread(row)" type="warning">
{{ t('unread') }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
@@ -529,16 +609,26 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
{{ t('refresh') }}
</n-button>
<n-button v-if="enableMailFlags && currentPageHasUnread" @click="markCurrentPageRead" tertiary size="small">
{{ t('markCurrentPageRead') }}
</n-button>
</n-space>
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
<n-input v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" size="small" clearable />
</div>
<div v-if="enableMailFlags" style="padding: 0 10px; margin-bottom: 10px;">
<n-select v-model:value="mailFlagFilter" :options="mailFlagFilterOptions" size="small" />
</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': isMailUnread(row) }">
<n-thing :title="row.subject">
<template #description>
<n-tag v-if="isMailUnread(row)" type="warning">
{{ t('unread') }}
</n-tag>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
@@ -568,6 +658,7 @@ onBeforeUnmount(() => {
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:enableMailFlags="enableMailFlags" :onToggleUnread="toggleCurrentMailUnread"
:onSaveToS3="saveToS3Proxy" />
</n-card>
</n-drawer-content>
@@ -676,6 +767,10 @@ onBeforeUnmount(() => {
min-width: 0;
}
.mail-list-unread :deep(.n-thing-header__title) {
font-weight: 700;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
@@ -8,6 +8,7 @@ import { getDownloadEmlUrl } from '../utils/email-parser';
import { blockRemoteContent } from '../utils/remote-content-policy';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
import { MAIL_FLAGS, hasMailFlag } from '../utils/mail-flags';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
@@ -34,6 +35,10 @@ const props = defineProps({
type: Boolean,
default: false
},
enableMailFlags: {
type: Boolean,
default: false
},
// 回调函数 props
onDelete: {
type: Function,
@@ -50,6 +55,10 @@ const props = defineProps({
onSaveToS3: {
type: Function,
default: () => { }
},
onToggleUnread: {
type: Function,
default: () => { }
}
});
@@ -146,6 +155,10 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('downloadMail') }}
</n-button>
<n-button v-if="enableMailFlags" size="small" tertiary type="info" @click="onToggleUnread">
{{ hasMailFlag(mail.flags, MAIL_FLAGS.UNREAD) ? t('markRead') : t('markUnread') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
<template #icon>
<n-icon :component="ReplyFilled" />
+24
View File
@@ -34,6 +34,10 @@ export const MESSAGE_REGISTRY = {
}
},
"components.MailBox": {
"allMail": {
"en": "All Mail",
"zh": "全部邮件"
},
"attachments": {
"en": "Show Attachments",
"zh": "查看附件"
@@ -74,6 +78,10 @@ export const MESSAGE_REGISTRY = {
"en": "Filter current page",
"zh": "过滤当前页"
},
"markCurrentPageRead": {
"en": "Mark This Page as Read",
"zh": "本页全部已读"
},
"multiAction": {
"en": "Multi Action",
"zh": "多选"
@@ -94,6 +102,10 @@ export const MESSAGE_REGISTRY = {
"en": "Query",
"zh": "查询"
},
"read": {
"en": "Read",
"zh": "已读"
},
"refresh": {
"en": "Refresh",
"zh": "刷新"
@@ -129,6 +141,10 @@ export const MESSAGE_REGISTRY = {
"unselectAll": {
"en": "Unselect All",
"zh": "取消全选"
},
"unread": {
"en": "Unread",
"zh": "未读"
}
},
"components.AiExtractInfo": {
@@ -194,6 +210,14 @@ export const MESSAGE_REGISTRY = {
"en": "Load Images",
"zh": "加载图片"
},
"markRead": {
"en": "Mark as Read",
"zh": "标记已读"
},
"markUnread": {
"en": "Mark as Unread",
"zh": "标记未读"
},
"remoteImagesBlocked": {
"en": "{count} remote resources blocked to protect your privacy",
"zh": "已阻止 {count} 项外部资源以保护隐私"
+1
View File
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableMailFlags: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
+13
View File
@@ -0,0 +1,13 @@
export const MAIL_FLAGS = {
UNREAD: 1,
}
export const hasMailFlag = (flags, flag) => {
return (Number(flags ?? 0) & flag) !== 0
}
export const getMailFlagFilterQuery = (filter) => {
if (filter === 'unread') return '&flag=0&flag_state=set'
if (filter === 'read') return '&flag=0&flag_state=unset'
return ''
}
+14 -3
View File
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { useIsMobile } from '../utils/composables'
import { getMailFlagFilterQuery } from '../utils/mail-flags'
import { FullscreenExitOutlined } from '@vicons/material'
import AddressBar from './index/AddressBar.vue';
@@ -32,19 +33,28 @@ const SendMail = defineAsyncComponent(() => {
const { t } = useScopedI18n('views.Index')
const fetchMailData = async (limit, offset) => {
const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => {
if (mailIdQuery.value > 0) {
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
if (singleMail) return { results: [singleMail], count: 1 };
return { results: [], count: 0 };
}
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
return await api.fetch(
`/api/mails?limit=${limit}&offset=${offset}${getMailFlagFilterQuery(mailFlagFilter)}`
);
};
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
const updateMailFlags = async (ids, add, remove) => {
await api.fetch(`/api/mails/flags`, {
method: 'PATCH',
body: JSON.stringify({ ids, add, remove })
});
};
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
@@ -127,7 +137,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"
:enableMailFlags="openSettings.enableMailFlags" :updateMailFlags="updateMailFlags" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
+32
View File
@@ -18,6 +18,7 @@ import AccountSettings from './AccountSettings.vue'
import { processItem } from '../../utils/email-parser'
import MailContentRenderer from '../../components/MailContentRenderer.vue'
import AddressSelect from '../../components/AddressSelect.vue'
import { MAIL_FLAGS, hasMailFlag } from '../../utils/mail-flags'
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
const message = useMessage()
@@ -50,12 +51,42 @@ const fetchMails = async () => {
totalCount.value = count > 0 ? count : totalCount.value;
const rawMail = results && results.length > 0 ? results[0] : null
currentMail.value = rawMail ? await processItem(rawMail) : null
if (openSettings.value.enableMailFlags && hasMailFlag(rawMail?.flags, MAIL_FLAGS.UNREAD)) {
rawMail.flags = Number(rawMail.flags ?? 0) & ~MAIL_FLAGS.UNREAD
await api.fetch(`/api/mails/flags`, {
method: 'PATCH',
body: JSON.stringify({ ids: [rawMail.id], add: 0, remove: MAIL_FLAGS.UNREAD })
})
}
} catch (error) {
console.error('Failed to fetch mails:', error)
message.error('获取邮件失败')
}
}
const toggleCurrentMailUnread = async () => {
if (!currentMail.value || !openSettings.value.enableMailFlags) return
const wasUnread = hasMailFlag(currentMail.value.flags, MAIL_FLAGS.UNREAD)
currentMail.value.flags = wasUnread
? Number(currentMail.value.flags ?? 0) & ~MAIL_FLAGS.UNREAD
: Number(currentMail.value.flags ?? 0) | MAIL_FLAGS.UNREAD
try {
await api.fetch(`/api/mails/flags`, {
method: 'PATCH',
body: JSON.stringify({
ids: [currentMail.value.id],
add: wasUnread ? 0 : MAIL_FLAGS.UNREAD,
remove: wasUnread ? MAIL_FLAGS.UNREAD : 0,
})
})
} catch (error) {
currentMail.value.flags = wasUnread
? Number(currentMail.value.flags ?? 0) | MAIL_FLAGS.UNREAD
: Number(currentMail.value.flags ?? 0) & ~MAIL_FLAGS.UNREAD
message.error(error.message || 'error')
}
}
// 删除邮件
const deleteMail = async () => {
if (!currentMail.value) return;
@@ -220,6 +251,7 @@ onBeforeUnmount(() => {
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:enableMailFlags="openSettings.enableMailFlags" :onToggleUnread="toggleCurrentMailUnread"
:onDelete="deleteMail" />
</div>
</div>
+12 -2
View File
@@ -5,6 +5,7 @@ import { useScopedI18n } from '@/i18n/app'
import { api } from '../../api'
import { useGlobalState } from '../../store'
import MailBox from '../../components/MailBox.vue';
import { getMailFlagFilterQuery } from '../../utils/mail-flags';
const message = useMessage()
const { openSettings } = useGlobalState()
@@ -20,11 +21,12 @@ const queryMail = () => {
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => {
return await api.fetch(
`/user_api/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ getMailFlagFilterQuery(mailFlagFilter)
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
);
}
@@ -50,6 +52,13 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
};
const updateMailFlags = async (ids, add, remove) => {
await api.fetch(`/user_api/mails/flags`, {
method: 'PATCH',
body: JSON.stringify({ ids, add, remove })
});
};
watch(addressFilter, async (newValue) => {
queryMail();
});
@@ -70,6 +79,7 @@ onMounted(() => {
</n-input-group>
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" :showFilterInput="true" />
:deleteMail="deleteMail" :showFilterInput="true" :enableMailFlags="openSettings.enableMailFlags"
:updateMailFlags="updateMailFlags" />
</div>
</template>