refactor: move mail flag logic to backend

This commit is contained in:
dreamhunter2333
2026-08-25 20:47:09 +08:00
parent 6fe31df05a
commit de8920ccfd
14 changed files with 206 additions and 122 deletions
+63 -20
View File
@@ -9,7 +9,18 @@ export const MAIL_FLAGS = {
export const CUSTOM_MAIL_FLAG_OFFSET = 10;
export const CUSTOM_MAIL_FLAG_COUNT = 10;
export const MUTABLE_MAIL_FLAGS = MAIL_FLAGS.UNREAD;
const MAIL_FLAG_MASKS = {
unread: MAIL_FLAGS.UNREAD,
} as const;
type MailFlagName = keyof typeof MAIL_FLAG_MASKS;
type MailFlagAction = 'set' | 'clear' | 'toggle';
const isMailFlagName = (value: unknown): value is MailFlagName => {
return typeof value === 'string'
&& Object.prototype.hasOwnProperty.call(MAIL_FLAG_MASKS, value);
};
export const getCustomMailFlag = (slot: number): number => {
if (!Number.isInteger(slot) || slot < 0 || slot >= CUSTOM_MAIL_FLAG_COUNT) {
@@ -23,11 +34,14 @@ export const serializeMailFlags = <T extends Record<string, unknown>>(
enabled: boolean,
): T => {
const result = { ...row };
const flags = Number(result.flags ?? 0);
delete result.flags;
if (!enabled) {
delete result.flags;
return result;
}
result.flags = Number(result.flags ?? 0);
result.mail_flags = {
unread: (flags & MAIL_FLAGS.UNREAD) !== 0,
};
return result;
};
@@ -67,8 +81,9 @@ export const insertRawMail = async (
export type MailFlagUpdate = {
ids: number[];
add: number;
remove: number;
flag: MailFlagName;
mask: number;
action: MailFlagAction;
};
export type MailFlagFilter = {
@@ -77,15 +92,22 @@ export type MailFlagFilter = {
};
export const parseMailFlagFilter = (
bitValue: string | undefined,
flagValue: 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 (flagValue === undefined && stateValue === undefined) return undefined;
if (!isMailFlagName(flagValue)) return null;
if (stateValue !== 'set' && stateValue !== 'unset') return null;
return { mask: 1 << bit, state: stateValue };
return { mask: MAIL_FLAG_MASKS[flagValue], state: stateValue };
};
export const parseReadStatusFilter = (
value: string | undefined,
): MailFlagFilter | undefined | null => {
if (value === undefined || value === 'all') return undefined;
if (value === 'unread') return { mask: MAIL_FLAGS.UNREAD, state: 'set' };
if (value === 'read') return { mask: MAIL_FLAGS.UNREAD, state: 'unset' };
return null;
};
export const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | null => {
@@ -97,14 +119,35 @@ export const parseMailFlagUpdate = (value: unknown): MailFlagUpdate | 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;
if (!isMailFlagName(body.flag)) return null;
if (body.action !== 'set' && body.action !== 'clear' && body.action !== 'toggle') return null;
return { ids, add, remove };
const flag = body.flag;
return { ids, flag, mask: MAIL_FLAG_MASKS[flag], action: body.action };
};
export const getMailFlagUpdateExpression = (
update: MailFlagUpdate,
column = 'flags',
): { expression: string; params: number[]; condition?: string; conditionParams?: number[] } => {
if (update.action === 'set') {
return {
expression: `(COALESCE(${column}, 0) | ?)`,
params: [update.mask],
condition: `(COALESCE(${column}, 0) & ?) = 0`,
conditionParams: [update.mask],
};
}
if (update.action === 'clear') {
return {
expression: `(COALESCE(${column}, 0) & ~?)`,
params: [update.mask],
condition: `(COALESCE(${column}, 0) & ?) != 0`,
conditionParams: [update.mask],
};
}
return {
expression: `((COALESCE(${column}, 0) | ?) - (COALESCE(${column}, 0) & ?))`,
params: [update.mask, update.mask],
};
};
+34 -7
View File
@@ -5,16 +5,27 @@ 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';
import {
getMailFlagUpdateExpression,
parseMailFlagFilter,
parseMailFlagUpdate,
parseReadStatusFilter,
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, flag, flag_state } = c.req.query();
const { limit, offset, flag, flag_state, read_status } = c.req.query();
if (Number.parseInt(offset) <= 0) updateAddressUpdatedAt(c, address);
const flagFilter = parseMailFlagFilter(flag, flag_state);
if (read_status !== undefined && (flag !== undefined || flag_state !== undefined)) {
return c.json({ error: "Conflicting mail flag filters" }, 400);
}
const flagFilter = read_status === undefined
? parseMailFlagFilter(flag, flag_state)
: parseReadStatusFilter(read_status);
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);
@@ -68,12 +79,28 @@ const updateMailFlags = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload");
const placeholders = update.ids.map(() => '?').join(',');
const flagUpdate = getMailFlagUpdateExpression(update);
const condition = flagUpdate.condition ? ` AND ${flagUpdate.condition}` : '';
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 });
+ ` SET flags = ${flagUpdate.expression}`
+ ` WHERE address = ? AND id IN (${placeholders})${condition}`
).bind(
...flagUpdate.params,
address,
...update.ids,
...(flagUpdate.conditionParams ?? []),
).run();
if (!result.success) return c.json({ success: false, changes: 0, results: [] }, 500);
const { results } = await c.env.DB.prepare(
`SELECT id, flags FROM raw_mails WHERE address = ? AND id IN (${placeholders})`
).bind(address, ...update.ids).all();
return c.json({
success: true,
changes: result.meta.changes ?? 0,
results: results.map(row => serializeMailFlags(row, true)),
});
};
const getSettings = async (c: Context<HonoCustomType>) => {
+39 -6
View File
@@ -2,19 +2,30 @@ import { Context } from "hono";
import i18n from "../i18n";
import { handleMailListQuery } from "../common";
import { getBooleanValue } from "../utils";
import { parseMailFlagFilter, parseMailFlagUpdate } from "../mail_flags";
import {
getMailFlagUpdateExpression,
parseMailFlagFilter,
parseMailFlagUpdate,
parseReadStatusFilter,
serializeMailFlags,
} from "../mail_flags";
export default {
getMails: async (c: Context<HonoCustomType>) => {
const { user_id } = c.get("userPayload");
const { address, limit, offset, flag, flag_state } = c.req.query();
const { address, limit, offset, flag, flag_state, read_status } = 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 (read_status !== undefined && (flag !== undefined || flag_state !== undefined)) {
return c.json({ error: "Conflicting mail flag filters" }, 400);
}
const flagFilter = read_status === undefined
? parseMailFlagFilter(flag, flag_state)
: parseReadStatusFilter(read_status);
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);
@@ -61,16 +72,38 @@ export default {
const { user_id } = c.get("userPayload");
const placeholders = update.ids.map(() => '?').join(',');
const flagUpdate = getMailFlagUpdateExpression(update);
const condition = flagUpdate.condition ? ` AND ${flagUpdate.condition}` : '';
const result = await c.env.DB.prepare(
`UPDATE raw_mails`
+ ` SET flags = (COALESCE(flags, 0) | ?) & ~?`
+ ` SET flags = ${flagUpdate.expression}`
+ ` 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`
+ `)${condition}`
).bind(
...flagUpdate.params,
...update.ids,
user_id,
...(flagUpdate.conditionParams ?? []),
).run();
if (!result.success) return c.json({ success: false, changes: 0, results: [] }, 500);
const { results } = await c.env.DB.prepare(
`SELECT id, flags FROM raw_mails`
+ ` 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 });
).bind(...update.ids, user_id).all();
return c.json({
success: true,
changes: result.meta.changes ?? 0,
results: results.map(row => serializeMailFlags(row, true)),
});
}
}