diff --git a/CHANGELOG.md b/CHANGELOG.md
index 155744e..7455f33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
### Features
+- feat: |邮件状态| 新增可选 Mail Flags 功能,新邮件支持已读/未读状态、打开自动已读、手动切换状态、本页全部已读及按状态筛选,并为系统状态位及每地址自定义规则 Flag 预留扩展空间
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
@@ -25,6 +26,7 @@
### Testing
+- test: |E2E| 覆盖新邮件默认未读、地址隔离、标记已读及非法 Flag 掩码校验
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md
index 0d2eac3..f9398be 100644
--- a/CHANGELOG_EN.md
+++ b/CHANGELOG_EN.md
@@ -10,6 +10,7 @@
### 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: |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
+- 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
- 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
diff --git a/db/2026-08-25-mail-flags.sql b/db/2026-08-25-mail-flags.sql
new file mode 100644
index 0000000..7fcff9d
--- /dev/null
+++ b/db/2026-08-25-mail-flags.sql
@@ -0,0 +1 @@
+ALTER TABLE raw_mails ADD COLUMN flags INTEGER;
diff --git a/db/schema.sql b/db/schema.sql
index 321d9bf..b7e7d9c 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
+ flags INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
diff --git a/e2e/fixtures/wrangler.toml.e2e b/e2e/fixtures/wrangler.toml.e2e
index 0756436..4f042f0 100644
--- a/e2e/fixtures/wrangler.toml.e2e
+++ b/e2e/fixtures/wrangler.toml.e2e
@@ -18,6 +18,7 @@ JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
+ENABLE_MAIL_FLAGS = true
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
NO_LIMIT_SEND_ROLE = "case-role"
diff --git a/e2e/tests/api/mail-flags.spec.ts b/e2e/tests/api/mail-flags.spec.ts
new file mode 100644
index 0000000..eed09c7
--- /dev/null
+++ b/e2e/tests/api/mail-flags.spec.ts
@@ -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);
+ }
+ });
+});
diff --git a/frontend/src/components/MailBox.vue b/frontend/src/components/MailBox.vue
index ce16d66..341a96e 100644
--- a/frontend/src/components/MailBox.vue
+++ b/frontend/src/components/MailBox.vue
@@ -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(() => {
{{ t('refresh') }}
+
+ {{ t('markCurrentPageRead') }}
+
+
@@ -397,12 +470,15 @@ onBeforeUnmount(() => {
clickRow(row)"
- :class="mailItemClass(row)">
+ :class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
+
+ {{ t('unread') }}
+
ID: {{ row.id }}
@@ -461,6 +537,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
@@ -475,7 +552,7 @@ onBeforeUnmount(() => {
diff --git a/vitepress-docs/docs/en/guide/feature/mail-api.md b/vitepress-docs/docs/en/guide/feature/mail-api.md
index 8204dcd..400f2f5 100644
--- a/vitepress-docs/docs/en/guide/feature/mail-api.md
+++ b/vitepress-docs/docs/en/guide/feature/mail-api.md
@@ -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.
+## 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:///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
Supports `address` filter
diff --git a/vitepress-docs/docs/en/guide/worker-vars.md b/vitepress-docs/docs/en/guide/worker-vars.md
index 9fc89e8..8f59349 100644
--- a/vitepress-docs/docs/en/guide/worker-vars.md
+++ b/vitepress-docs/docs/en/guide/worker-vars.md
@@ -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_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_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` |
> [!NOTE]
diff --git a/vitepress-docs/docs/zh/guide/feature/mail-api.md b/vitepress-docs/docs/zh/guide/feature/mail-api.md
index 675a0fc..9534851 100644
--- a/vitepress-docs/docs/zh/guide/feature/mail-api.md
+++ b/vitepress-docs/docs/zh/guide/feature/mail-api.md
@@ -19,6 +19,30 @@ res = requests.get(
**注意**:`/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
支持 `address` 过滤
diff --git a/vitepress-docs/docs/zh/guide/worker-vars.md b/vitepress-docs/docs/zh/guide/worker-vars.md
index b909020..7cd4cfa 100644
--- a/vitepress-docs/docs/zh/guide/worker-vars.md
+++ b/vitepress-docs/docs/zh/guide/worker-vars.md
@@ -97,6 +97,7 @@
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `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_FLAGS` | 文本/JSON | 启用网页邮件状态功能。新邮件默认未读,支持打开自动已读、手动切换、按状态筛选及本页全部已读。历史邮件的 `NULL`/`0` 状态默认按已读处理。**启用前必须先执行数据库迁移,确保 `raw_mails.flags` 列已创建。** | `true` |
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
> [!NOTE]
diff --git a/worker/src/admin_api/admin_mail_api.ts b/worker/src/admin_api/admin_mail_api.ts
index 2ac4b22..e1a846d 100644
--- a/worker/src/admin_api/admin_mail_api.ts
+++ b/worker/src/admin_api/admin_mail_api.ts
@@ -1,6 +1,8 @@
import { Context } from "hono";
import { handleMailListQuery } from "../common";
import { resolveRawEmailRow } from "../gzip";
+import { serializeMailFlags } from "../mail_flags";
+import { getBooleanValue } from "../utils";
export default {
getMails: async (c: Context) => {
@@ -31,7 +33,8 @@ export default {
`SELECT * FROM raw_mails WHERE id = ?`
).bind(id).first();
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) => {
const { id } = c.req.param();
diff --git a/worker/src/admin_api/db_api.ts b/worker/src/admin_api/db_api.ts
index 2add400..38ff3cb 100644
--- a/worker/src/admin_api/db_api.ts
+++ b/worker/src/admin_api/db_api.ts
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
+ flags INTEGER NOT NULL DEFAULT 0,
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;`);
}
}
+ 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) {
// remove all \r and \n characters from the query string
// split by ; and join with a ;\n
diff --git a/worker/src/admin_api/worker_config.ts b/worker/src/admin_api/worker_config.ts
index cdba70b..05284b9 100644
--- a/worker/src/admin_api/worker_config.ts
+++ b/worker/src/admin_api/worker_config.ts
@@ -40,6 +40,7 @@ export default {
"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),
"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),
"COPYRIGHT": c.env.COPYRIGHT,
"ENABLE_WEBHOOK": utils.getBooleanValue(c.env.ENABLE_WEBHOOK),
diff --git a/worker/src/commom_api.ts b/worker/src/commom_api.ts
index c8a42c9..51da126 100644
--- a/worker/src/commom_api.ts
+++ b/worker/src/commom_api.ts
@@ -39,6 +39,7 @@ api.get('/open_api/settings', async (c) => {
"disableAnonymousUserCreateEmail": utils.getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL),
"disableCustomAddressName": utils.getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME),
"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),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
diff --git a/worker/src/common.ts b/worker/src/common.ts
index 0fd4a49..63b6a77 100644
--- a/worker/src/common.ts
+++ b/worker/src/common.ts
@@ -7,6 +7,7 @@ import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models';
import i18n from './i18n';
+import { serializeMailFlags } from './mail_flags';
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
@@ -720,7 +721,9 @@ export const handleMailListQuery = async (
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).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(
countQuery
).bind(...params).first("count") : 0;
diff --git a/worker/src/constants.ts b/worker/src/constants.ts
index 50a2a9a..ce1cf57 100644
--- a/worker/src/constants.ts
+++ b/worker/src/constants.ts
@@ -3,7 +3,7 @@ export const CONSTANTS = {
// DB Version
DB_VERSION_KEY: 'db_version',
- DB_VERSION: "v0.0.7",
+ DB_VERSION: "v0.0.8",
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
diff --git a/worker/src/email/index.ts b/worker/src/email/index.ts
index e83f6f8..e3b4156 100644
--- a/worker/src/email/index.ts
+++ b/worker/src/email/index.ts
@@ -12,6 +12,7 @@ import { forwardEmail } from "./forward";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
import { compressText } from "../gzip";
+import { insertRawMail, resolveInitialMailFlags } from "../mail_flags";
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");
// save email
try {
+ const initialFlags = await resolveInitialMailFlags(
+ getBooleanValue(env.ENABLE_MAIL_FLAGS), env, toAddress, parsedEmailContext
+ );
let success = false;
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
let compressed: ArrayBuffer | null = null;
@@ -77,38 +81,50 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
}
if (compressed) {
try {
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, compressed, message_id
- ).run());
+ ({ success } = await insertRawMail(env.DB, {
+ source: message.from,
+ address: toAddress,
+ content: compressed,
+ contentColumn: 'raw_blob',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
} catch (dbError) {
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
const errMsg = String(dbError);
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
console.error("raw_blob column missing, falling back to plaintext", dbError);
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, parsedEmailContext.rawEmail, message_id
- ).run());
+ ({ success } = await insertRawMail(env.DB, {
+ source: message.from,
+ address: toAddress,
+ content: parsedEmailContext.rawEmail,
+ contentColumn: 'raw',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
} else {
throw dbError;
}
}
} else {
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, parsedEmailContext.rawEmail, message_id
- ).run());
+ ({ success } = await insertRawMail(env.DB, {
+ source: message.from,
+ address: toAddress,
+ content: parsedEmailContext.rawEmail,
+ contentColumn: 'raw',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
}
} else {
- ({ success } = await env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind(
- message.from, toAddress, parsedEmailContext.rawEmail, message_id
- ).run());
+ ({ success } = await insertRawMail(env.DB, {
+ source: message.from,
+ address: toAddress,
+ content: parsedEmailContext.rawEmail,
+ contentColumn: 'raw',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
}
if (!success) {
message.setReject(`Failed save message to ${toAddress}`);
diff --git a/worker/src/mail_flags.ts b/worker/src/mail_flags.ts
new file mode 100644
index 0000000..5db5d94
--- /dev/null
+++ b/worker/src/mail_flags.ts
@@ -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 = >(
+ 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 => {
+ 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;
+ 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 };
+};
diff --git a/worker/src/mails_api/index.ts b/worker/src/mails_api/index.ts
index 5373864..53bb889 100644
--- a/worker/src/mails_api/index.ts
+++ b/worker/src/mails_api/index.ts
@@ -28,6 +28,7 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
// mail crud
api.get('/api/mails', mails_crud.listMails)
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)
// parsed mail (server-side parsed subject/text/html/attachments)
diff --git a/worker/src/mails_api/mails_crud.ts b/worker/src/mails_api/mails_crud.ts
index 34f6525..f646580 100644
--- a/worker/src/mails_api/mails_crud.ts
+++ b/worker/src/mails_api/mails_crud.ts
@@ -5,18 +5,32 @@ import { getBooleanValue } from '../utils';
import { handleMailListQuery, deleteAddressWithData, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
import { getSendBalanceState } from './send_balance';
+import { parseMailFlagFilter, parseMailFlagUpdate, serializeMailFlags } from '../mail_flags';
const listMails = async (c: Context) => {
const { address } = c.get("jwtPayload")
if (!address) {
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);
+ 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,
- `SELECT * FROM raw_mails where address = ?`,
- `SELECT count(*) as count FROM raw_mails where address = ?`,
- [address], limit, offset
+ `SELECT * FROM raw_mails WHERE ${whereClause}`,
+ `SELECT count(*) as count FROM raw_mails WHERE ${whereClause}`,
+ params, limit, offset
);
};
@@ -27,7 +41,8 @@ const getMail = async (c: Context) => {
`SELECT * FROM raw_mails where id = ? and address = ?`
).bind(mail_id, address).first();
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) => {
@@ -44,6 +59,23 @@ const deleteMail = async (c: Context) => {
return c.json({ success });
};
+const updateMailFlags = async (c: Context) => {
+ 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) => {
const { address, address_id } = c.get("jwtPayload")
const msgs = i18n.getMessagesbyContext(c);
@@ -117,4 +149,7 @@ const clearSentItems = async (c: Context) => {
return c.json({ success });
};
-export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
+export default {
+ listMails, getMail, deleteMail, updateMailFlags,
+ getSettings, deleteAddress, clearInbox, clearSentItems
+};
diff --git a/worker/src/mails_api/parsed_mail_api.ts b/worker/src/mails_api/parsed_mail_api.ts
index 65bd4c6..c071ea4 100644
--- a/worker/src/mails_api/parsed_mail_api.ts
+++ b/worker/src/mails_api/parsed_mail_api.ts
@@ -2,6 +2,8 @@ import { Context } from 'hono'
import { commonParseMail, handleMailListQuery, updateAddressUpdatedAt } from '../common'
import { resolveRawEmailRow } from '../gzip'
+import { serializeMailFlags } from '../mail_flags';
+import { getBooleanValue } from '../utils';
const toParsedMailRow = async (row: Record): Promise> => {
const raw = typeof row.raw === 'string' ? row.raw : '';
@@ -46,7 +48,8 @@ const getParsedMail = async (c: Context) => {
).bind(mail_id, address).first();
if (!row) return c.json(null);
const resolved = await resolveRawEmailRow(row);
- return c.json(await toParsedMailRow(resolved as Record));
+ const serialized = serializeMailFlags(resolved, getBooleanValue(c.env.ENABLE_MAIL_FLAGS));
+ return c.json(await toParsedMailRow(serialized));
};
export default { listParsedMails, getParsedMail };
diff --git a/worker/src/models/index.ts b/worker/src/models/index.ts
index f544393..463c499 100644
--- a/worker/src/models/index.ts
+++ b/worker/src/models/index.ts
@@ -213,6 +213,7 @@ export type RawMailRow = {
raw?: string;
raw_blob?: unknown;
metadata?: string;
+ flags?: number | null;
created_at?: string;
}
diff --git a/worker/src/types.d.ts b/worker/src/types.d.ts
index ce194e8..778fa47 100644
--- a/worker/src/types.d.ts
+++ b/worker/src/types.d.ts
@@ -117,6 +117,7 @@ type Bindings = {
// gzip compression for raw_mails
ENABLE_MAIL_GZIP: string | boolean | undefined
+ ENABLE_MAIL_FLAGS: string | boolean | undefined
CLEANUP_BATCH_SIZE: string | number | undefined
// E2E testing
diff --git a/worker/src/user_api/index.ts b/worker/src/user_api/index.ts
index 6e9c458..dc6f5a4 100644
--- a/worker/src/user_api/index.ts
+++ b/worker/src/user_api/index.ts
@@ -16,6 +16,7 @@ api.get('/user_api/settings', settings.settings);
// mail api
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);
// send mail api
diff --git a/worker/src/user_api/user_mail_api.ts b/worker/src/user_api/user_mail_api.ts
index 1cf5191..ab641e0 100644
--- a/worker/src/user_api/user_mail_api.ts
+++ b/worker/src/user_api/user_mail_api.ts
@@ -2,17 +2,27 @@ import { Context } from "hono";
import i18n from "../i18n";
import { handleMailListQuery } from "../common";
import { getBooleanValue } from "../utils";
+import { parseMailFlagFilter, parseMailFlagUpdate } from "../mail_flags";
export default {
getMails: async (c: Context) => {
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 filterParams = [String(user_id)];
if (address) {
filterQuerys.push(`rm.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`
+ ` JOIN address a ON a.id = ua.address_id`
+ ` JOIN raw_mails rm ON rm.address = a.name`
@@ -41,5 +51,26 @@ export default {
return c.json({
success: success
})
+ },
+ updateMailFlags: async (c: Context) => {
+ 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 });
}
}
diff --git a/worker/src/utils.ts b/worker/src/utils.ts
index 0c701d1..a3444fd 100644
--- a/worker/src/utils.ts
+++ b/worker/src/utils.ts
@@ -3,6 +3,7 @@ import { createMimeMessage } from "mimetext";
import { UserSettings, RoleAddressConfig } from "./models";
import { CONSTANTS } from "./constants";
import { compressText } from "./gzip";
+import { insertRawMail, resolveInitialMailFlags } from "./mail_flags";
export const getJsonObjectValue = (
value: string | any
@@ -371,6 +372,10 @@ export const sendAdminInternalMail = async (
});
const message_id = Math.random().toString(36).substring(2, 15);
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;
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
let compressed: ArrayBuffer | null = null;
@@ -381,29 +386,49 @@ export const sendAdminInternalMail = async (
}
if (compressed) {
try {
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, compressed, message_id).run());
+ ({ success } = await insertRawMail(c.env.DB, {
+ source: "admin@internal",
+ address: toMail,
+ content: compressed,
+ contentColumn: 'raw_blob',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
} catch (dbError) {
const errMsg = String(dbError);
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
console.error("raw_blob column missing, falling back to plaintext", dbError);
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, rawText, message_id).run());
+ ({ success } = await insertRawMail(c.env.DB, {
+ source: "admin@internal",
+ address: toMail,
+ content: rawText,
+ contentColumn: 'raw',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
} else {
throw dbError;
}
}
} else {
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, rawText, message_id).run());
+ ({ success } = await insertRawMail(c.env.DB, {
+ source: "admin@internal",
+ address: toMail,
+ content: rawText,
+ contentColumn: 'raw',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
}
} else {
- ({ success } = await c.env.DB.prepare(
- `INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
- ).bind("admin@internal", toMail, rawText, message_id).run());
+ ({ success } = await insertRawMail(c.env.DB, {
+ source: "admin@internal",
+ address: toMail,
+ content: rawText,
+ contentColumn: 'raw',
+ messageId: message_id,
+ flags: initialFlags,
+ }));
}
if (!success) {
console.log(`Failed save message from admin@internal to ${toMail}`);
diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template
index f57e44d..b4bc595 100644
--- a/worker/wrangler.toml.template
+++ b/worker/wrangler.toml.template
@@ -77,6 +77,8 @@ ENABLE_USER_CREATE_EMAIL = true
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
# Allow users to delete messages
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
ENABLE_AUTO_REPLY = false
# Allow webhook