feat: add extensible mail flags

This commit is contained in:
dreamhunter2333
2026-08-25 20:36:35 +08:00
parent 5dbb6107dd
commit 6fe31df05a
35 changed files with 640 additions and 61 deletions
+4 -1
View File
@@ -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<HonoCustomType>) => {
@@ -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<HonoCustomType>) => {
const { id } = c.req.param();
+12
View File
@@ -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
+1
View File
@@ -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),
+1
View File
@@ -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,
+4 -1
View File
@@ -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;
+1 -1
View File
@@ -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',
+36 -20
View File
@@ -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}`);
+110
View File
@@ -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 };
};
+1
View File
@@ -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)
+41 -6
View File
@@ -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<HonoCustomType>) => {
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<HonoCustomType>) => {
`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<HonoCustomType>) => {
@@ -44,6 +59,23 @@ const deleteMail = async (c: Context<HonoCustomType>) => {
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 { address, address_id } = c.get("jwtPayload")
const msgs = i18n.getMessagesbyContext(c);
@@ -117,4 +149,7 @@ const clearSentItems = async (c: Context<HonoCustomType>) => {
return c.json({ success });
};
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
export default {
listMails, getMail, deleteMail, updateMailFlags,
getSettings, deleteAddress, clearInbox, clearSentItems
};
+4 -1
View File
@@ -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<string, unknown>): Promise<Record<string, unknown>> => {
const raw = typeof row.raw === 'string' ? row.raw : '';
@@ -46,7 +48,8 @@ const getParsedMail = async (c: Context<HonoCustomType>) => {
).bind(mail_id, address).first();
if (!row) return c.json(null);
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 };
+1
View File
@@ -213,6 +213,7 @@ export type RawMailRow = {
raw?: string;
raw_blob?: unknown;
metadata?: string;
flags?: number | null;
created_at?: string;
}
+1
View File
@@ -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
+1
View File
@@ -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
+32 -1
View File
@@ -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<HonoCustomType>) => {
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<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
View File
@@ -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 = <T = any>(
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}`);
+2
View File
@@ -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