mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-07 00:17:12 +08:00
feat: add extensible mail flags
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |邮件状态| 新增可选 Mail Flags 功能,新邮件支持已读/未读状态、打开自动已读、手动切换状态、本页全部已读及按状态筛选,并为系统状态位及每地址自定义规则 Flag 预留扩展空间
|
||||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||||
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法 Flag 掩码校验
|
||||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||||
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Mail State| Add optional Mail Flags with unread state for new mail, automatic read-on-open, manual state toggling, mark-current-page-read and state filters, plus reserved system/custom bits for future per-address rule assignment
|
||||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||||
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
- test: |E2E| Cover unread state on new mail, mailbox isolation, marking mail read, and invalid flag-mask validation
|
||||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
- 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
|
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||||
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE raw_mails ADD COLUMN flags INTEGER;
|
||||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
|||||||
raw TEXT,
|
raw TEXT,
|
||||||
raw_blob BLOB,
|
raw_blob BLOB,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
|
flags INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ JWT_SECRET = "e2e-test-secret-key"
|
|||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
ENABLE_USER_DELETE_EMAIL = true
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
ENABLE_MAIL_FLAGS = true
|
||||||
ENABLE_AUTO_REPLY = true
|
ENABLE_AUTO_REPLY = true
|
||||||
DEFAULT_SEND_BALANCE = 10
|
DEFAULT_SEND_BALANCE = 10
|
||||||
NO_LIMIT_SEND_ROLE = "case-role"
|
NO_LIMIT_SEND_ROLE = "case-role"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
seedTestMail,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test.describe('Mail Flags', () => {
|
||||||
|
test('new mail is unread and can be marked as read without changing other mailboxes', async ({ request }) => {
|
||||||
|
const first = await createTestAddress(request, 'mail-flags-first');
|
||||||
|
const second = await createTestAddress(request, 'mail-flags-second');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedTestMail(request, first.address, { subject: 'Unread mail' });
|
||||||
|
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||||
|
});
|
||||||
|
expect(listRes.ok()).toBe(true);
|
||||||
|
const { results } = await listRes.json();
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].flags).toBe(1);
|
||||||
|
|
||||||
|
const unreadRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/mails?limit=10&offset=0&flag=0&flag_state=set`,
|
||||||
|
{ headers: { Authorization: `Bearer ${first.jwt}` } },
|
||||||
|
);
|
||||||
|
expect((await unreadRes.json()).results).toHaveLength(1);
|
||||||
|
|
||||||
|
const deniedRes = await request.patch(`${WORKER_URL}/api/mails/flags`, {
|
||||||
|
headers: { Authorization: `Bearer ${second.jwt}` },
|
||||||
|
data: { ids: [results[0].id], add: 0, remove: 1 },
|
||||||
|
});
|
||||||
|
expect(deniedRes.ok()).toBe(true);
|
||||||
|
expect((await deniedRes.json()).changes).toBe(0);
|
||||||
|
|
||||||
|
const updateRes = await request.patch(`${WORKER_URL}/api/mails/flags`, {
|
||||||
|
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||||
|
data: { ids: [results[0].id], add: 0, remove: 1 },
|
||||||
|
});
|
||||||
|
expect(updateRes.ok()).toBe(true);
|
||||||
|
expect((await updateRes.json()).changes).toBe(1);
|
||||||
|
|
||||||
|
const updatedListRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${first.jwt}` },
|
||||||
|
});
|
||||||
|
expect((await updatedListRes.json()).results[0].flags).toBe(0);
|
||||||
|
|
||||||
|
const unreadAfterUpdateRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/mails?limit=10&offset=0&flag=0&flag_state=set`,
|
||||||
|
{ headers: { Authorization: `Bearer ${first.jwt}` } },
|
||||||
|
);
|
||||||
|
expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, first.jwt);
|
||||||
|
await deleteAddress(request, second.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unsupported and overlapping flag masks', async ({ request }) => {
|
||||||
|
const { jwt } = await createTestAddress(request, 'mail-flags-invalid');
|
||||||
|
try {
|
||||||
|
for (const data of [
|
||||||
|
{ ids: [1], add: 4, remove: 0 },
|
||||||
|
{ ids: [1], add: 1, remove: 1 },
|
||||||
|
]) {
|
||||||
|
const res = await request.patch(`${WORKER_URL}/api/mails/flags`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { useIsMobile } from '../utils/composables'
|
|||||||
import { processItem } from '../utils/email-parser'
|
import { processItem } from '../utils/email-parser'
|
||||||
import { utcToLocalDate } from '../utils';
|
import { utcToLocalDate } from '../utils';
|
||||||
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
|
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
|
||||||
|
import { MAIL_FLAGS, hasMailFlag } from '../utils/mail-flags'
|
||||||
import MailContentRenderer from "./MailContentRenderer.vue";
|
import MailContentRenderer from "./MailContentRenderer.vue";
|
||||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||||
|
|
||||||
@@ -55,9 +56,20 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
|
enableMailFlags: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
updateMailFlags: {
|
||||||
|
type: Function,
|
||||||
|
default: () => { },
|
||||||
|
required: false
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const localFilterKeyword = ref('')
|
const localFilterKeyword = ref('')
|
||||||
|
const mailFlagFilter = ref('all')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
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(() => {
|
const canGoPrevMail = computed(() => {
|
||||||
if (!curMail.value) return false
|
if (!curMail.value) return false
|
||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
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)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
|
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
curMail.value = data.value[currentIndex - 1]
|
await openMail(data.value[currentIndex - 1])
|
||||||
} else if (page.value > 1) {
|
} else if (page.value > 1) {
|
||||||
page.value--
|
page.value--
|
||||||
await refresh()
|
await refresh()
|
||||||
if (data.value.length > 0) {
|
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)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
|
|
||||||
if (currentIndex < data.value.length - 1) {
|
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) {
|
} else if (count.value > page.value * pageSize.value) {
|
||||||
page.value++
|
page.value++
|
||||||
await refresh()
|
await refresh()
|
||||||
if (data.value.length > 0) {
|
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 () => {
|
const refresh = async () => {
|
||||||
try {
|
try {
|
||||||
const { results, count: totalCount } = await props.fetchMailData(
|
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;
|
loading.value = true;
|
||||||
rawData.value = await Promise.all(results.map(async (item) => {
|
rawData.value = await Promise.all(results.map(async (item) => {
|
||||||
item.checked = false;
|
item.checked = false;
|
||||||
return await processItem(item);
|
return await processItem(item);
|
||||||
}));
|
}));
|
||||||
if (totalCount > 0) {
|
if (page.value === 1) count.value = totalCount;
|
||||||
count.value = totalCount;
|
|
||||||
}
|
|
||||||
curMail.value = null;
|
curMail.value = null;
|
||||||
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||||
curMail.value = data.value[0];
|
await openMail(data.value[0]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || "error");
|
message.error(error.message || "error");
|
||||||
@@ -215,7 +283,7 @@ const clickRow = async (row) => {
|
|||||||
curMail.value = null;
|
curMail.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
curMail.value = row;
|
await openMail(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -381,6 +449,11 @@ onBeforeUnmount(() => {
|
|||||||
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
||||||
{{ t('refresh') }}
|
{{ t('refresh') }}
|
||||||
</n-button>
|
</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"
|
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
|
||||||
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
|
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
|
||||||
clearable />
|
clearable />
|
||||||
@@ -397,12 +470,15 @@ onBeforeUnmount(() => {
|
|||||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<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="mailItemClass(row)">
|
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||||
<template #prefix v-if="multiActionMode">
|
<template #prefix v-if="multiActionMode">
|
||||||
<n-checkbox v-model:checked="row.checked" />
|
<n-checkbox v-model:checked="row.checked" />
|
||||||
</template>
|
</template>
|
||||||
<n-thing :title="row.subject">
|
<n-thing :title="row.subject">
|
||||||
<template #description>
|
<template #description>
|
||||||
|
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||||
|
{{ t('unread') }}
|
||||||
|
</n-tag>
|
||||||
<n-tag type="info">
|
<n-tag type="info">
|
||||||
ID: {{ row.id }}
|
ID: {{ row.id }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
@@ -461,6 +537,7 @@ onBeforeUnmount(() => {
|
|||||||
style="overflow: auto; max-height: 100vh;">
|
style="overflow: auto; max-height: 100vh;">
|
||||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||||
|
:enableMailFlags="enableMailFlags" :onToggleUnread="toggleCurrentMailUnread"
|
||||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||||
@@ -475,7 +552,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-else class="mail-list-scroll">
|
<div v-else class="mail-list-scroll">
|
||||||
<n-list hoverable clickable>
|
<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="mailItemClass(row)">
|
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||||
<template #prefix v-if="multiActionMode">
|
<template #prefix v-if="multiActionMode">
|
||||||
<n-checkbox v-model:checked="row.checked" />
|
<n-checkbox v-model:checked="row.checked" />
|
||||||
</template>
|
</template>
|
||||||
@@ -487,6 +564,9 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
<div class="mail-list-meta">
|
<div class="mail-list-meta">
|
||||||
|
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||||
|
{{ t('unread') }}
|
||||||
|
</n-tag>
|
||||||
<n-tag type="info">
|
<n-tag type="info">
|
||||||
ID: {{ row.id }}
|
ID: {{ row.id }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
@@ -529,16 +609,26 @@ onBeforeUnmount(() => {
|
|||||||
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
||||||
{{ t('refresh') }}
|
{{ t('refresh') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button v-if="enableMailFlags && currentPageHasUnread" @click="markCurrentPageRead" tertiary size="small">
|
||||||
|
{{ t('markCurrentPageRead') }}
|
||||||
|
</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
|
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
|
||||||
<n-input v-model:value="localFilterKeyword"
|
<n-input v-model:value="localFilterKeyword"
|
||||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||||
</div>
|
</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;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<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">
|
<n-thing :title="row.subject">
|
||||||
<template #description>
|
<template #description>
|
||||||
|
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||||
|
{{ t('unread') }}
|
||||||
|
</n-tag>
|
||||||
<n-tag type="info">
|
<n-tag type="info">
|
||||||
ID: {{ row.id }}
|
ID: {{ row.id }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
@@ -568,6 +658,7 @@ onBeforeUnmount(() => {
|
|||||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||||
|
:enableMailFlags="enableMailFlags" :onToggleUnread="toggleCurrentMailUnread"
|
||||||
:onSaveToS3="saveToS3Proxy" />
|
:onSaveToS3="saveToS3Proxy" />
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-drawer-content>
|
</n-drawer-content>
|
||||||
@@ -676,6 +767,10 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-list-unread :deep(.n-thing-header__title) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getDownloadEmlUrl } from '../utils/email-parser';
|
|||||||
import { blockRemoteContent } from '../utils/remote-content-policy';
|
import { blockRemoteContent } from '../utils/remote-content-policy';
|
||||||
import { utcToLocalDate } from '../utils';
|
import { utcToLocalDate } from '../utils';
|
||||||
import { useGlobalState } from '../store';
|
import { useGlobalState } from '../store';
|
||||||
|
import { MAIL_FLAGS, hasMailFlag } from '../utils/mail-flags';
|
||||||
|
|
||||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
|
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
enableMailFlags: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
// 回调函数 props
|
// 回调函数 props
|
||||||
onDelete: {
|
onDelete: {
|
||||||
type: Function,
|
type: Function,
|
||||||
@@ -50,6 +55,10 @@ const props = defineProps({
|
|||||||
onSaveToS3: {
|
onSaveToS3: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => { }
|
default: () => { }
|
||||||
|
},
|
||||||
|
onToggleUnread: {
|
||||||
|
type: Function,
|
||||||
|
default: () => { }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,6 +155,10 @@ const handleSaveToS3 = async (filename, blob) => {
|
|||||||
{{ t('downloadMail') }}
|
{{ t('downloadMail') }}
|
||||||
</n-button>
|
</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">
|
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="ReplyFilled" />
|
<n-icon :component="ReplyFilled" />
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components.MailBox": {
|
"components.MailBox": {
|
||||||
|
"allMail": {
|
||||||
|
"en": "All Mail",
|
||||||
|
"zh": "全部邮件"
|
||||||
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"en": "Show Attachments",
|
"en": "Show Attachments",
|
||||||
"zh": "查看附件"
|
"zh": "查看附件"
|
||||||
@@ -74,6 +78,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"en": "Filter current page",
|
"en": "Filter current page",
|
||||||
"zh": "过滤当前页"
|
"zh": "过滤当前页"
|
||||||
},
|
},
|
||||||
|
"markCurrentPageRead": {
|
||||||
|
"en": "Mark This Page as Read",
|
||||||
|
"zh": "本页全部已读"
|
||||||
|
},
|
||||||
"multiAction": {
|
"multiAction": {
|
||||||
"en": "Multi Action",
|
"en": "Multi Action",
|
||||||
"zh": "多选"
|
"zh": "多选"
|
||||||
@@ -94,6 +102,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"en": "Query",
|
"en": "Query",
|
||||||
"zh": "查询"
|
"zh": "查询"
|
||||||
},
|
},
|
||||||
|
"read": {
|
||||||
|
"en": "Read",
|
||||||
|
"zh": "已读"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"en": "Refresh",
|
"en": "Refresh",
|
||||||
"zh": "刷新"
|
"zh": "刷新"
|
||||||
@@ -129,6 +141,10 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"unselectAll": {
|
"unselectAll": {
|
||||||
"en": "Unselect All",
|
"en": "Unselect All",
|
||||||
"zh": "取消全选"
|
"zh": "取消全选"
|
||||||
|
},
|
||||||
|
"unread": {
|
||||||
|
"en": "Unread",
|
||||||
|
"zh": "未读"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components.AiExtractInfo": {
|
"components.AiExtractInfo": {
|
||||||
@@ -194,6 +210,14 @@ export const MESSAGE_REGISTRY = {
|
|||||||
"en": "Load Images",
|
"en": "Load Images",
|
||||||
"zh": "加载图片"
|
"zh": "加载图片"
|
||||||
},
|
},
|
||||||
|
"markRead": {
|
||||||
|
"en": "Mark as Read",
|
||||||
|
"zh": "标记已读"
|
||||||
|
},
|
||||||
|
"markUnread": {
|
||||||
|
"en": "Mark as Unread",
|
||||||
|
"zh": "标记未读"
|
||||||
|
},
|
||||||
"remoteImagesBlocked": {
|
"remoteImagesBlocked": {
|
||||||
"en": "{count} remote resources blocked to protect your privacy",
|
"en": "{count} remote resources blocked to protect your privacy",
|
||||||
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
|
|||||||
disableAnonymousUserCreateEmail: false,
|
disableAnonymousUserCreateEmail: false,
|
||||||
disableCustomAddressName: false,
|
disableCustomAddressName: false,
|
||||||
enableUserDeleteEmail: false,
|
enableUserDeleteEmail: false,
|
||||||
|
enableMailFlags: false,
|
||||||
enableAutoReply: false,
|
enableAutoReply: false,
|
||||||
enableIndexAbout: false,
|
enableIndexAbout: false,
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { useIsMobile } from '../utils/composables'
|
import { useIsMobile } from '../utils/composables'
|
||||||
|
import { getMailFlagFilterQuery } from '../utils/mail-flags'
|
||||||
import { FullscreenExitOutlined } from '@vicons/material'
|
import { FullscreenExitOutlined } from '@vicons/material'
|
||||||
|
|
||||||
import AddressBar from './index/AddressBar.vue';
|
import AddressBar from './index/AddressBar.vue';
|
||||||
@@ -32,19 +33,28 @@ const SendMail = defineAsyncComponent(() => {
|
|||||||
|
|
||||||
const { t } = useScopedI18n('views.Index')
|
const { t } = useScopedI18n('views.Index')
|
||||||
|
|
||||||
const fetchMailData = async (limit, offset) => {
|
const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => {
|
||||||
if (mailIdQuery.value > 0) {
|
if (mailIdQuery.value > 0) {
|
||||||
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
||||||
if (singleMail) return { results: [singleMail], count: 1 };
|
if (singleMail) return { results: [singleMail], count: 1 };
|
||||||
return { results: [], count: 0 };
|
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) => {
|
const deleteMail = async (curMailId) => {
|
||||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
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) => {
|
const deleteSenboxMail = async (curMailId) => {
|
||||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||||
};
|
};
|
||||||
@@ -127,7 +137,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
: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>
|
||||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import AccountSettings from './AccountSettings.vue'
|
|||||||
import { processItem } from '../../utils/email-parser'
|
import { processItem } from '../../utils/email-parser'
|
||||||
import MailContentRenderer from '../../components/MailContentRenderer.vue'
|
import MailContentRenderer from '../../components/MailContentRenderer.vue'
|
||||||
import AddressSelect from '../../components/AddressSelect.vue'
|
import AddressSelect from '../../components/AddressSelect.vue'
|
||||||
|
import { MAIL_FLAGS, hasMailFlag } from '../../utils/mail-flags'
|
||||||
|
|
||||||
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
|
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -50,12 +51,42 @@ const fetchMails = async () => {
|
|||||||
totalCount.value = count > 0 ? count : totalCount.value;
|
totalCount.value = count > 0 ? count : totalCount.value;
|
||||||
const rawMail = results && results.length > 0 ? results[0] : null
|
const rawMail = results && results.length > 0 ? results[0] : null
|
||||||
currentMail.value = rawMail ? await processItem(rawMail) : 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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch mails:', error)
|
console.error('Failed to fetch mails:', error)
|
||||||
message.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 () => {
|
const deleteMail = async () => {
|
||||||
if (!currentMail.value) return;
|
if (!currentMail.value) return;
|
||||||
@@ -220,6 +251,7 @@ onBeforeUnmount(() => {
|
|||||||
<div style="margin-top: 16px;">
|
<div style="margin-top: 16px;">
|
||||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||||
|
:enableMailFlags="openSettings.enableMailFlags" :onToggleUnread="toggleCurrentMailUnread"
|
||||||
:onDelete="deleteMail" />
|
:onDelete="deleteMail" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useScopedI18n } from '@/i18n/app'
|
|||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
import MailBox from '../../components/MailBox.vue';
|
import MailBox from '../../components/MailBox.vue';
|
||||||
|
import { getMailFlagFilterQuery } from '../../utils/mail-flags';
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { openSettings } = useGlobalState()
|
const { openSettings } = useGlobalState()
|
||||||
@@ -20,11 +21,12 @@ const queryMail = () => {
|
|||||||
mailBoxKey.value = Date.now();
|
mailBoxKey.value = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchMailData = async (limit, offset) => {
|
const fetchMailData = async (limit, offset, mailFlagFilter = 'all') => {
|
||||||
return await api.fetch(
|
return await api.fetch(
|
||||||
`/user_api/mails`
|
`/user_api/mails`
|
||||||
+ `?limit=${limit}`
|
+ `?limit=${limit}`
|
||||||
+ `&offset=${offset}`
|
+ `&offset=${offset}`
|
||||||
|
+ getMailFlagFilterQuery(mailFlagFilter)
|
||||||
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
|
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,13 @@ const deleteMail = async (curMailId) => {
|
|||||||
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
|
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) => {
|
watch(addressFilter, async (newValue) => {
|
||||||
queryMail();
|
queryMail();
|
||||||
});
|
});
|
||||||
@@ -70,6 +79,7 @@ onMounted(() => {
|
|||||||
</n-input-group>
|
</n-input-group>
|
||||||
<div style="margin-top: 10px;"></div>
|
<div style="margin-top: 10px;"></div>
|
||||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||||
:deleteMail="deleteMail" :showFilterInput="true" />
|
:deleteMail="deleteMail" :showFilterInput="true" :enableMailFlags="openSettings.enableMailFlags"
|
||||||
|
:updateMailFlags="updateMailFlags" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ res = requests.get(
|
|||||||
|
|
||||||
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
|
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
|
||||||
|
|
||||||
|
## Mail Flags API
|
||||||
|
|
||||||
|
After enabling `ENABLE_MAIL_FLAGS` and running the database migration, each mail response includes an integer `flags` bitmask. Bit 0 currently means `UNREAD`: `1` is unread, while `NULL` or `0` is treated as read.
|
||||||
|
|
||||||
|
With an Address JWT, use `PATCH /api/mails/flags` to add or remove flags for up to 100 mail IDs. Only the `UNREAD` bit is currently mutable.
|
||||||
|
|
||||||
|
```python
|
||||||
|
requests.patch(
|
||||||
|
"https://<your-worker-address>/api/mails/flags",
|
||||||
|
headers={"Authorization": f"Bearer {your-JWT-password}"},
|
||||||
|
json={"ids": [1, 2], "add": 0, "remove": 1}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
With a User JWT, send the same body to `PATCH /user_api/mails/flags`. Only mail belonging to addresses bound to that user can be changed. Other flag bits are always preserved.
|
||||||
|
|
||||||
|
Mail-list endpoints accept generic flag filters: `flag` is the bit position (`0-30`), and `flag_state` is either `set` or `unset`. For example, list unread mail with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/mails?limit=20&offset=0&flag=0&flag_state=set
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `flag=0&flag_state=unset` for read mail. `/user_api/mails` accepts the same parameters, and future custom flags can use bits 10 through 19 directly.
|
||||||
|
|
||||||
## Admin Mail API
|
## Admin Mail API
|
||||||
|
|
||||||
Supports `address` filter
|
Supports `address` filter
|
||||||
|
|||||||
@@ -102,6 +102,7 @@
|
|||||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
||||||
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
||||||
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
||||||
|
| `ENABLE_MAIL_FLAGS` | Text/JSON | Enables per-message state in the web inbox. New mail starts unread and can be opened to mark read, toggled manually, filtered by state, or marked read for the current page. Historical `NULL`/`0` values are treated as read. **Run the database migration first so `raw_mails.flags` exists before enabling this option.** | `true` |
|
||||||
| `CLEANUP_BATCH_SIZE` | Number | Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to `3000`, valid range `1-5000`. Smaller values reduce per-run D1 pressure; larger values clear backlogs faster | `3000` |
|
| `CLEANUP_BATCH_SIZE` | Number | Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to `3000`, valid range `1-5000`. Smaller values reduce per-run D1 pressure; larger values clear backlogs faster | `3000` |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ res = requests.get(
|
|||||||
|
|
||||||
**注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。
|
**注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。
|
||||||
|
|
||||||
|
## 邮件 Flag API
|
||||||
|
|
||||||
|
启用 `ENABLE_MAIL_FLAGS` 并完成数据库迁移后,邮件响应中的 `flags` 为整数位掩码。当前 bit 0 表示 `UNREAD`:值为 `1` 时未读,`NULL` 或 `0` 按已读处理。
|
||||||
|
|
||||||
|
地址 JWT 使用 `PATCH /api/mails/flags` 批量增删状态位。每次最多传入 100 个邮件 ID;当前仅允许修改 `UNREAD` 位。
|
||||||
|
|
||||||
|
```python
|
||||||
|
requests.patch(
|
||||||
|
"https://<你的worker地址>/api/mails/flags",
|
||||||
|
headers={"Authorization": f"Bearer {你的JWT密码}"},
|
||||||
|
json={"ids": [1, 2], "add": 0, "remove": 1}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
用户 JWT 使用相同请求体访问 `PATCH /user_api/mails/flags`,只能修改该用户已绑定地址的邮件。服务端始终保留请求未涉及的其他 Flag 位。
|
||||||
|
|
||||||
|
邮件列表接口支持通用 Flag 查询参数:`flag` 为 bit 位置(`0-30`),`flag_state` 为 `set` 或 `unset`。例如查询未读邮件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/mails?limit=20&offset=0&flag=0&flag_state=set
|
||||||
|
```
|
||||||
|
|
||||||
|
查询已读邮件则使用 `flag=0&flag_state=unset`。`/user_api/mails` 支持相同参数,后续自定义 Flag 可以直接使用 bit 10~19。
|
||||||
|
|
||||||
## admin 邮件 API
|
## admin 邮件 API
|
||||||
|
|
||||||
支持 `address` 过滤
|
支持 `address` 过滤
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||||
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||||
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
||||||
|
| `ENABLE_MAIL_FLAGS` | 文本/JSON | 启用网页邮件状态功能。新邮件默认未读,支持打开自动已读、手动切换、按状态筛选及本页全部已读。历史邮件的 `NULL`/`0` 状态默认按已读处理。**启用前必须先执行数据库迁移,确保 `raw_mails.flags` 列已创建。** | `true` |
|
||||||
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
|
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { handleMailListQuery } from "../common";
|
import { handleMailListQuery } from "../common";
|
||||||
import { resolveRawEmailRow } from "../gzip";
|
import { resolveRawEmailRow } from "../gzip";
|
||||||
|
import { serializeMailFlags } from "../mail_flags";
|
||||||
|
import { getBooleanValue } from "../utils";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getMails: async (c: Context<HonoCustomType>) => {
|
getMails: async (c: Context<HonoCustomType>) => {
|
||||||
@@ -31,7 +33,8 @@ export default {
|
|||||||
`SELECT * FROM raw_mails WHERE id = ?`
|
`SELECT * FROM raw_mails WHERE id = ?`
|
||||||
).bind(id).first();
|
).bind(id).first();
|
||||||
if (!result) return c.json(null);
|
if (!result) return c.json(null);
|
||||||
return c.json(await resolveRawEmailRow(result));
|
const resolved = await resolveRawEmailRow(result);
|
||||||
|
return c.json(serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS)));
|
||||||
},
|
},
|
||||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||||
const { id } = c.req.param();
|
const { id } = c.req.param();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
|||||||
raw TEXT,
|
raw TEXT,
|
||||||
raw_blob BLOB,
|
raw_blob BLOB,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
|
flags INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -197,6 +198,17 @@ export default {
|
|||||||
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;`);
|
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (version && version <= "v0.0.7") {
|
||||||
|
const tableInfo = await c.env.DB.prepare(
|
||||||
|
`PRAGMA table_info(raw_mails)`
|
||||||
|
).all();
|
||||||
|
const hasFlags = tableInfo.results?.some(
|
||||||
|
(col: any) => col.name === 'flags'
|
||||||
|
);
|
||||||
|
if (!hasFlags) {
|
||||||
|
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN flags INTEGER;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (version != CONSTANTS.DB_VERSION) {
|
if (version != CONSTANTS.DB_VERSION) {
|
||||||
// remove all \r and \n characters from the query string
|
// remove all \r and \n characters from the query string
|
||||||
// split by ; and join with a ;\n
|
// split by ; and join with a ;\n
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default {
|
|||||||
"ENABLE_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
|
"ENABLE_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
|
||||||
"DISABLE_ANONYMOUS_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
"DISABLE_ANONYMOUS_USER_CREATE_EMAIL": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
||||||
"ENABLE_USER_DELETE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
"ENABLE_USER_DELETE_EMAIL": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||||
|
"ENABLE_MAIL_FLAGS": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS),
|
||||||
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
"ENABLE_AUTO_REPLY": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||||
"COPYRIGHT": c.env.COPYRIGHT,
|
"COPYRIGHT": c.env.COPYRIGHT,
|
||||||
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
|
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ api.get('/open_api/settings', async (c) => {
|
|||||||
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
|
||||||
"disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME),
|
"disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME),
|
||||||
"enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
"enableUserDeleteEmail": utils.getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||||
|
"enableMailFlags": utils.getBooleanValue(c.env.ENABLE_MAIL_FLAGS),
|
||||||
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||||
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
|
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
|
||||||
"copyright": c.env.COPYRIGHT,
|
"copyright": c.env.COPYRIGHT,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { unbindTelegramByAddress } from './telegram_api/common';
|
|||||||
import { CONSTANTS } from './constants';
|
import { CONSTANTS } from './constants';
|
||||||
import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models';
|
import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
import { serializeMailFlags } from './mail_flags';
|
||||||
|
|
||||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||||
@@ -720,7 +721,9 @@ export const handleMailListQuery = async (
|
|||||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||||
...params, limit, offset
|
...params, limit, offset
|
||||||
).all();
|
).all();
|
||||||
const resolvedResults = await resolveRawEmailList(results);
|
const resolvedResults = (await resolveRawEmailList(results)).map(row =>
|
||||||
|
serializeMailFlags(row, getBooleanValue(c.env.ENABLE_MAIL_FLAGS))
|
||||||
|
);
|
||||||
const count = offset == 0 ? await c.env.DB.prepare(
|
const count = offset == 0 ? await c.env.DB.prepare(
|
||||||
countQuery
|
countQuery
|
||||||
).bind(...params).first("count") : 0;
|
).bind(...params).first("count") : 0;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const CONSTANTS = {
|
|||||||
|
|
||||||
// DB Version
|
// DB Version
|
||||||
DB_VERSION_KEY: 'db_version',
|
DB_VERSION_KEY: 'db_version',
|
||||||
DB_VERSION: "v0.0.7",
|
DB_VERSION: "v0.0.8",
|
||||||
|
|
||||||
// DB settings
|
// DB settings
|
||||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||||
|
|||||||
+36
-20
@@ -12,6 +12,7 @@ import { forwardEmail } from "./forward";
|
|||||||
import { EmailRuleSettings } from "../models";
|
import { EmailRuleSettings } from "../models";
|
||||||
import { CONSTANTS } from "../constants";
|
import { CONSTANTS } from "../constants";
|
||||||
import { compressText } from "../gzip";
|
import { compressText } from "../gzip";
|
||||||
|
import { insertRawMail, resolveInitialMailFlags } from "../mail_flags";
|
||||||
|
|
||||||
|
|
||||||
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
||||||
@@ -67,6 +68,9 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
|||||||
const message_id = message.headers.get("Message-ID");
|
const message_id = message.headers.get("Message-ID");
|
||||||
// save email
|
// save email
|
||||||
try {
|
try {
|
||||||
|
const initialFlags = await resolveInitialMailFlags(
|
||||||
|
getBooleanValue(env.ENABLE_MAIL_FLAGS), env, toAddress, parsedEmailContext
|
||||||
|
);
|
||||||
let success = false;
|
let success = false;
|
||||||
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
|
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
|
||||||
let compressed: ArrayBuffer | null = null;
|
let compressed: ArrayBuffer | null = null;
|
||||||
@@ -77,38 +81,50 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
|||||||
}
|
}
|
||||||
if (compressed) {
|
if (compressed) {
|
||||||
try {
|
try {
|
||||||
({ success } = await env.DB.prepare(
|
({ success } = await insertRawMail(env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
source: message.from,
|
||||||
).bind(
|
address: toAddress,
|
||||||
message.from, toAddress, compressed, message_id
|
content: compressed,
|
||||||
).run());
|
contentColumn: 'raw_blob',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
|
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
|
||||||
const errMsg = String(dbError);
|
const errMsg = String(dbError);
|
||||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||||
({ success } = await env.DB.prepare(
|
({ success } = await insertRawMail(env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
source: message.from,
|
||||||
).bind(
|
address: toAddress,
|
||||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
content: parsedEmailContext.rawEmail,
|
||||||
).run());
|
contentColumn: 'raw',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
throw dbError;
|
throw dbError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
({ success } = await env.DB.prepare(
|
({ success } = await insertRawMail(env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
source: message.from,
|
||||||
).bind(
|
address: toAddress,
|
||||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
content: parsedEmailContext.rawEmail,
|
||||||
).run());
|
contentColumn: 'raw',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
({ success } = await env.DB.prepare(
|
({ success } = await insertRawMail(env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
source: message.from,
|
||||||
).bind(
|
address: toAddress,
|
||||||
message.from, toAddress, parsedEmailContext.rawEmail, message_id
|
content: parsedEmailContext.rawEmail,
|
||||||
).run());
|
contentColumn: 'raw',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (!success) {
|
if (!success) {
|
||||||
message.setReject(`Failed save message to ${toAddress}`);
|
message.setReject(`Failed save message to ${toAddress}`);
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
export const MAIL_FLAGS = {
|
||||||
|
UNREAD: 1 << 0,
|
||||||
|
ANSWERED: 1 << 1,
|
||||||
|
FLAGGED: 1 << 2,
|
||||||
|
DELETED: 1 << 3,
|
||||||
|
DRAFT: 1 << 4,
|
||||||
|
JUNK: 1 << 5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const CUSTOM_MAIL_FLAG_OFFSET = 10;
|
||||||
|
export const CUSTOM_MAIL_FLAG_COUNT = 10;
|
||||||
|
export const MUTABLE_MAIL_FLAGS = MAIL_FLAGS.UNREAD;
|
||||||
|
|
||||||
|
export const getCustomMailFlag = (slot: number): number => {
|
||||||
|
if (!Number.isInteger(slot) || slot < 0 || slot >= CUSTOM_MAIL_FLAG_COUNT) {
|
||||||
|
throw new Error("Invalid custom mail flag slot");
|
||||||
|
}
|
||||||
|
return 1 << (CUSTOM_MAIL_FLAG_OFFSET + slot);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const serializeMailFlags = <T extends Record<string, unknown>>(
|
||||||
|
row: T,
|
||||||
|
enabled: boolean,
|
||||||
|
): T => {
|
||||||
|
const result = { ...row };
|
||||||
|
if (!enabled) {
|
||||||
|
delete result.flags;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.flags = Number(result.flags ?? 0);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInitialMailFlags = async (
|
||||||
|
enabled: boolean,
|
||||||
|
_env: Bindings,
|
||||||
|
_address: string,
|
||||||
|
_parsedEmailContext: ParsedEmailContext,
|
||||||
|
): Promise<number | null> => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
return MAIL_FLAGS.UNREAD;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InsertRawMailParams = {
|
||||||
|
source: string;
|
||||||
|
address: string;
|
||||||
|
content: string | ArrayBuffer;
|
||||||
|
contentColumn: 'raw' | 'raw_blob';
|
||||||
|
messageId: string | null;
|
||||||
|
flags: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertRawMail = async (
|
||||||
|
db: D1Database,
|
||||||
|
params: InsertRawMailParams,
|
||||||
|
) => {
|
||||||
|
const { source, address, content, contentColumn, messageId, flags } = params;
|
||||||
|
if (flags === null) {
|
||||||
|
return db.prepare(
|
||||||
|
`INSERT INTO raw_mails (source, address, ${contentColumn}, message_id) VALUES (?, ?, ?, ?)`
|
||||||
|
).bind(source, address, content, messageId).run();
|
||||||
|
}
|
||||||
|
return db.prepare(
|
||||||
|
`INSERT INTO raw_mails (source, address, ${contentColumn}, message_id, flags) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
).bind(source, address, content, messageId, flags).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailFlagUpdate = {
|
||||||
|
ids: number[];
|
||||||
|
add: number;
|
||||||
|
remove: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailFlagFilter = {
|
||||||
|
mask: number;
|
||||||
|
state: 'set' | 'unset';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseMailFlagFilter = (
|
||||||
|
bitValue: string | undefined,
|
||||||
|
stateValue: string | undefined,
|
||||||
|
): MailFlagFilter | undefined | null => {
|
||||||
|
if (bitValue === undefined && stateValue === undefined) return undefined;
|
||||||
|
if (!bitValue || !/^\d+$/.test(bitValue)) return null;
|
||||||
|
const bit = Number(bitValue);
|
||||||
|
if (!Number.isInteger(bit) || bit < 0 || bit > 30) return null;
|
||||||
|
if (stateValue !== 'set' && stateValue !== 'unset') return null;
|
||||||
|
return { mask: 1 << bit, state: stateValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | null => {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const body = value as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(body.ids) || body.ids.length === 0 || body.ids.length > 100) return null;
|
||||||
|
if (body.ids.some(id => typeof id !== 'number')) return null;
|
||||||
|
|
||||||
|
const ids = [...new Set(body.ids.map(Number))];
|
||||||
|
if (ids.some(id => !Number.isInteger(id) || id <= 0)) return null;
|
||||||
|
|
||||||
|
if (body.add !== undefined && typeof body.add !== 'number') return null;
|
||||||
|
if (body.remove !== undefined && typeof body.remove !== 'number') return null;
|
||||||
|
const add = Number(body.add ?? 0);
|
||||||
|
const remove = Number(body.remove ?? 0);
|
||||||
|
if (!Number.isInteger(add) || !Number.isInteger(remove) || add < 0 || remove < 0) return null;
|
||||||
|
if (add > MUTABLE_MAIL_FLAGS || remove > MUTABLE_MAIL_FLAGS) return null;
|
||||||
|
if (((add | remove) & ~MUTABLE_MAIL_FLAGS) !== 0 || (add & remove) !== 0) return null;
|
||||||
|
if (add === 0 && remove === 0) return null;
|
||||||
|
|
||||||
|
return { ids, add, remove };
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
|
|||||||
// mail crud
|
// mail crud
|
||||||
api.get('/api/mails', mails_crud.listMails)
|
api.get('/api/mails', mails_crud.listMails)
|
||||||
api.get('/api/mail/:mail_id', mails_crud.getMail)
|
api.get('/api/mail/:mail_id', mails_crud.getMail)
|
||||||
|
api.patch('/api/mails/flags', mails_crud.updateMailFlags)
|
||||||
api.delete('/api/mails/:id', mails_crud.deleteMail)
|
api.delete('/api/mails/:id', mails_crud.deleteMail)
|
||||||
|
|
||||||
// parsed mail (server-side parsed subject/text/html/attachments)
|
// parsed mail (server-side parsed subject/text/html/attachments)
|
||||||
|
|||||||
@@ -5,18 +5,32 @@ import { getBooleanValue } from '../utils';
|
|||||||
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
|
||||||
import { resolveRawEmailRow } from '../gzip'
|
import { resolveRawEmailRow } from '../gzip'
|
||||||
import { getSendBalanceState } from './send_balance';
|
import { getSendBalanceState } from './send_balance';
|
||||||
|
import { parseMailFlagFilter, parseMailFlagUpdate, serializeMailFlags } from '../mail_flags';
|
||||||
|
|
||||||
const listMails = async (c: Context<HonoCustomType>) => {
|
const listMails = async (c: Context<HonoCustomType>) => {
|
||||||
const { address } = c.get("jwtPayload")
|
const { address } = c.get("jwtPayload")
|
||||||
if (!address) {
|
if (!address) {
|
||||||
return c.json({ "error": "No address" }, 400)
|
return c.json({ "error": "No address" }, 400)
|
||||||
}
|
}
|
||||||
const { limit, offset } = c.req.query();
|
const { limit, offset, flag, flag_state } = c.req.query();
|
||||||
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
|
||||||
|
const flagFilter = parseMailFlagFilter(flag, flag_state);
|
||||||
|
if (flagFilter === null) return c.json({ error: "Invalid mail flag filter" }, 400);
|
||||||
|
if (flagFilter && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||||
|
return c.json({ error: "Mail flags are disabled" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = [`address = ?`];
|
||||||
|
const params = [address];
|
||||||
|
if (flagFilter) {
|
||||||
|
filters.push(`(COALESCE(flags, 0) & ?) ${flagFilter.state === 'set' ? '!=' : '='} 0`);
|
||||||
|
params.push(String(flagFilter.mask));
|
||||||
|
}
|
||||||
|
const whereClause = filters.join(' AND ');
|
||||||
return await handleMailListQuery(c,
|
return await handleMailListQuery(c,
|
||||||
`SELECT * FROM raw_mails where address = ?`,
|
`SELECT * FROM raw_mails WHERE ${whereClause}`,
|
||||||
`SELECT count(*) as count FROM raw_mails where address = ?`,
|
`SELECT count(*) as count FROM raw_mails WHERE ${whereClause}`,
|
||||||
[address], limit, offset
|
params, limit, offset
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +41,8 @@ const getMail = async (c: Context<HonoCustomType>) => {
|
|||||||
`SELECT * FROM raw_mails where id = ? and address = ?`
|
`SELECT * FROM raw_mails where id = ? and address = ?`
|
||||||
).bind(mail_id, address).first();
|
).bind(mail_id, address).first();
|
||||||
if (!result) return c.json(null);
|
if (!result) return c.json(null);
|
||||||
return c.json(await resolveRawEmailRow(result));
|
const resolved = await resolveRawEmailRow(result);
|
||||||
|
return c.json(serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMail = async (c: Context<HonoCustomType>) => {
|
const deleteMail = async (c: Context<HonoCustomType>) => {
|
||||||
@@ -44,6 +59,23 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
|
|||||||
return c.json({ success });
|
return c.json({ success });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateMailFlags = async (c: Context<HonoCustomType>) => {
|
||||||
|
if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||||
|
return c.json({ error: "Mail flags are disabled" }, 403);
|
||||||
|
}
|
||||||
|
const update = parseMailFlagUpdate(await c.req.json().catch(() => null));
|
||||||
|
if (!update) return c.json({ error: "Invalid mail flags request" }, 400);
|
||||||
|
|
||||||
|
const { address } = c.get("jwtPayload");
|
||||||
|
const placeholders = update.ids.map(() => '?').join(',');
|
||||||
|
const result = await c.env.DB.prepare(
|
||||||
|
`UPDATE raw_mails`
|
||||||
|
+ ` SET flags = (COALESCE(flags, 0) | ?) & ~?`
|
||||||
|
+ ` WHERE address = ? AND id IN (${placeholders})`
|
||||||
|
).bind(update.add, update.remove, address, ...update.ids).run();
|
||||||
|
return c.json({ success: result.success, changes: result.meta.changes ?? 0 });
|
||||||
|
};
|
||||||
|
|
||||||
const getSettings = async (c: Context<HonoCustomType>) => {
|
const getSettings = async (c: Context<HonoCustomType>) => {
|
||||||
const { address, address_id } = c.get("jwtPayload")
|
const { address, address_id } = c.get("jwtPayload")
|
||||||
const msgs = i18n.getMessagesbyContext(c);
|
const msgs = i18n.getMessagesbyContext(c);
|
||||||
@@ -117,4 +149,7 @@ const clearSentItems = async (c: Context<HonoCustomType>) => {
|
|||||||
return c.json({ success });
|
return c.json({ success });
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
|
export default {
|
||||||
|
listMails, getMail, deleteMail, updateMailFlags,
|
||||||
|
getSettings, deleteAddress, clearInbox, clearSentItems
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Context } from 'hono'
|
|||||||
|
|
||||||
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
|
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
|
||||||
import { resolveRawEmailRow } from '../gzip'
|
import { resolveRawEmailRow } from '../gzip'
|
||||||
|
import { serializeMailFlags } from '../mail_flags';
|
||||||
|
import { getBooleanValue } from '../utils';
|
||||||
|
|
||||||
const toParsedMailRow = async (row: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
const toParsedMailRow = async (row: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||||
const raw = typeof row.raw === 'string' ? row.raw : '';
|
const raw = typeof row.raw === 'string' ? row.raw : '';
|
||||||
@@ -46,7 +48,8 @@ const getParsedMail = async (c: Context<HonoCustomType>) => {
|
|||||||
).bind(mail_id, address).first();
|
).bind(mail_id, address).first();
|
||||||
if (!row) return c.json(null);
|
if (!row) return c.json(null);
|
||||||
const resolved = await resolveRawEmailRow(row);
|
const resolved = await resolveRawEmailRow(row);
|
||||||
return c.json(await toParsedMailRow(resolved as Record<string, unknown>));
|
const serialized = serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS));
|
||||||
|
return c.json(await toParsedMailRow(serialized));
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { listParsedMails, getParsedMail };
|
export default { listParsedMails, getParsedMail };
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export type RawMailRow = {
|
|||||||
raw?: string;
|
raw?: string;
|
||||||
raw_blob?: unknown;
|
raw_blob?: unknown;
|
||||||
metadata?: string;
|
metadata?: string;
|
||||||
|
flags?: number | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -117,6 +117,7 @@ type Bindings = {
|
|||||||
|
|
||||||
// gzip compression for raw_mails
|
// gzip compression for raw_mails
|
||||||
ENABLE_MAIL_GZIP: string | boolean | undefined
|
ENABLE_MAIL_GZIP: string | boolean | undefined
|
||||||
|
ENABLE_MAIL_FLAGS: string | boolean | undefined
|
||||||
CLEANUP_BATCH_SIZE: string | number | undefined
|
CLEANUP_BATCH_SIZE: string | number | undefined
|
||||||
|
|
||||||
// E2E testing
|
// E2E testing
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ api.get('/user_api/settings', settings.settings);
|
|||||||
|
|
||||||
// mail api
|
// mail api
|
||||||
api.get('/user_api/mails', user_mail_api.getMails);
|
api.get('/user_api/mails', user_mail_api.getMails);
|
||||||
|
api.patch('/user_api/mails/flags', user_mail_api.updateMailFlags);
|
||||||
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
|
api.delete('/user_api/mails/:id', user_mail_api.deleteMail);
|
||||||
|
|
||||||
// send mail api
|
// send mail api
|
||||||
|
|||||||
@@ -2,17 +2,27 @@ import { Context } from "hono";
|
|||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
import { handleMailListQuery } from "../common";
|
import { handleMailListQuery } from "../common";
|
||||||
import { getBooleanValue } from "../utils";
|
import { getBooleanValue } from "../utils";
|
||||||
|
import { parseMailFlagFilter, parseMailFlagUpdate } from "../mail_flags";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getMails: async (c: Context<HonoCustomType>) => {
|
getMails: async (c: Context<HonoCustomType>) => {
|
||||||
const { user_id } = c.get("userPayload");
|
const { user_id } = c.get("userPayload");
|
||||||
const { address, limit, offset } = c.req.query();
|
const { address, limit, offset, flag, flag_state } = c.req.query();
|
||||||
const filterQuerys = [`ua.user_id = ?`];
|
const filterQuerys = [`ua.user_id = ?`];
|
||||||
const filterParams = [String(user_id)];
|
const filterParams = [String(user_id)];
|
||||||
if (address) {
|
if (address) {
|
||||||
filterQuerys.push(`rm.address = ?`);
|
filterQuerys.push(`rm.address = ?`);
|
||||||
filterParams.push(address);
|
filterParams.push(address);
|
||||||
}
|
}
|
||||||
|
const flagFilter = parseMailFlagFilter(flag, flag_state);
|
||||||
|
if (flagFilter === null) return c.json({ error: "Invalid mail flag filter" }, 400);
|
||||||
|
if (flagFilter && !getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||||
|
return c.json({ error: "Mail flags are disabled" }, 403);
|
||||||
|
}
|
||||||
|
if (flagFilter) {
|
||||||
|
filterQuerys.push(`(COALESCE(rm.flags, 0) & ?) ${flagFilter.state === 'set' ? '!=' : '='} 0`);
|
||||||
|
filterParams.push(String(flagFilter.mask));
|
||||||
|
}
|
||||||
const fromQuery = ` FROM users_address ua`
|
const fromQuery = ` FROM users_address ua`
|
||||||
+ ` JOIN address a ON a.id = ua.address_id`
|
+ ` JOIN address a ON a.id = ua.address_id`
|
||||||
+ ` JOIN raw_mails rm ON rm.address = a.name`
|
+ ` JOIN raw_mails rm ON rm.address = a.name`
|
||||||
@@ -41,5 +51,26 @@ export default {
|
|||||||
return c.json({
|
return c.json({
|
||||||
success: success
|
success: success
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
updateMailFlags: async (c: Context<HonoCustomType>) => {
|
||||||
|
if (!getBooleanValue(c.env.ENABLE_MAIL_FLAGS)) {
|
||||||
|
return c.json({ error: "Mail flags are disabled" }, 403);
|
||||||
|
}
|
||||||
|
const update = parseMailFlagUpdate(await c.req.json().catch(() => null));
|
||||||
|
if (!update) return c.json({ error: "Invalid mail flags request" }, 400);
|
||||||
|
|
||||||
|
const { user_id } = c.get("userPayload");
|
||||||
|
const placeholders = update.ids.map(() => '?').join(',');
|
||||||
|
const result = await c.env.DB.prepare(
|
||||||
|
`UPDATE raw_mails`
|
||||||
|
+ ` SET flags = (COALESCE(flags, 0) | ?) & ~?`
|
||||||
|
+ ` WHERE id IN (${placeholders})`
|
||||||
|
+ ` AND EXISTS (`
|
||||||
|
+ `SELECT 1 FROM users_address ua`
|
||||||
|
+ ` JOIN address a ON a.id = ua.address_id`
|
||||||
|
+ ` WHERE ua.user_id = ? AND a.name = raw_mails.address`
|
||||||
|
+ `)`
|
||||||
|
).bind(update.add, update.remove, ...update.ids, user_id).run();
|
||||||
|
return c.json({ success: result.success, changes: result.meta.changes ?? 0 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-12
@@ -3,6 +3,7 @@ import { createMimeMessage } from "mimetext";
|
|||||||
import { UserSettings, RoleAddressConfig } from "./models";
|
import { UserSettings, RoleAddressConfig } from "./models";
|
||||||
import { CONSTANTS } from "./constants";
|
import { CONSTANTS } from "./constants";
|
||||||
import { compressText } from "./gzip";
|
import { compressText } from "./gzip";
|
||||||
|
import { insertRawMail, resolveInitialMailFlags } from "./mail_flags";
|
||||||
|
|
||||||
export const getJsonObjectValue = <T = any>(
|
export const getJsonObjectValue = <T = any>(
|
||||||
value: string | any
|
value: string | any
|
||||||
@@ -371,6 +372,10 @@ export const sendAdminInternalMail = async (
|
|||||||
});
|
});
|
||||||
const message_id = Math.random().toString(36).substring(2, 15);
|
const message_id = Math.random().toString(36).substring(2, 15);
|
||||||
const rawText = msg.asRaw();
|
const rawText = msg.asRaw();
|
||||||
|
const parsedEmailContext: ParsedEmailContext = { rawEmail: rawText };
|
||||||
|
const initialFlags = await resolveInitialMailFlags(
|
||||||
|
getBooleanValue(c.env.ENABLE_MAIL_FLAGS), c.env, toMail, parsedEmailContext
|
||||||
|
);
|
||||||
let success = false;
|
let success = false;
|
||||||
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
|
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
|
||||||
let compressed: ArrayBuffer | null = null;
|
let compressed: ArrayBuffer | null = null;
|
||||||
@@ -381,29 +386,49 @@ export const sendAdminInternalMail = async (
|
|||||||
}
|
}
|
||||||
if (compressed) {
|
if (compressed) {
|
||||||
try {
|
try {
|
||||||
({ success } = await c.env.DB.prepare(
|
({ success } = await insertRawMail(c.env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
source: "admin@internal",
|
||||||
).bind("admin@internal", toMail, compressed, message_id).run());
|
address: toMail,
|
||||||
|
content: compressed,
|
||||||
|
contentColumn: 'raw_blob',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
const errMsg = String(dbError);
|
const errMsg = String(dbError);
|
||||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||||
({ success } = await c.env.DB.prepare(
|
({ success } = await insertRawMail(c.env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
source: "admin@internal",
|
||||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
address: toMail,
|
||||||
|
content: rawText,
|
||||||
|
contentColumn: 'raw',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
throw dbError;
|
throw dbError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
({ success } = await c.env.DB.prepare(
|
({ success } = await insertRawMail(c.env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
source: "admin@internal",
|
||||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
address: toMail,
|
||||||
|
content: rawText,
|
||||||
|
contentColumn: 'raw',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
({ success } = await c.env.DB.prepare(
|
({ success } = await insertRawMail(c.env.DB, {
|
||||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
source: "admin@internal",
|
||||||
).bind("admin@internal", toMail, rawText, message_id).run());
|
address: toMail,
|
||||||
|
content: rawText,
|
||||||
|
contentColumn: 'raw',
|
||||||
|
messageId: message_id,
|
||||||
|
flags: initialFlags,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`Failed save message from admin@internal to ${toMail}`);
|
console.log(`Failed save message from admin@internal to ${toMail}`);
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ ENABLE_USER_CREATE_EMAIL = true
|
|||||||
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
|
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
|
||||||
# Allow users to delete messages
|
# Allow users to delete messages
|
||||||
ENABLE_USER_DELETE_EMAIL = true
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
# Enable per-message mail flags such as unread state. Run the database migration before enabling.
|
||||||
|
# ENABLE_MAIL_FLAGS = true
|
||||||
# Allow automatic replies to emails
|
# Allow automatic replies to emails
|
||||||
ENABLE_AUTO_REPLY = false
|
ENABLE_AUTO_REPLY = false
|
||||||
# Allow webhook
|
# Allow webhook
|
||||||
|
|||||||
Reference in New Issue
Block a user