feat: add single-mail read status (#1125)

This commit is contained in:
Dream Hunter
2026-08-31 13:11:07 +08:00
committed by GitHub
parent f92b059aac
commit 70206c61ef
32 changed files with 496 additions and 128 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { Context } from 'hono'
import i18n from '../i18n'
import { sendAdminInternalMail } from '../utils'
import { sendAdminInternalMail } from '../email/storage'
import { handleListQuery } from '../common'
const list = async (c: Context<HonoCustomType>) => {
+9
View File
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
is_unread INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -197,6 +198,14 @@ 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();
if (!tableInfo.results?.some((col: any) => col.name === 'is_unread')) {
await c.env.DB.exec(
`ALTER TABLE raw_mails ADD COLUMN is_unread 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_READ_STATUS": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS),
"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),
"enableMailReadStatus": utils.getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS),
"enableAutoReply": utils.getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": utils.getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
+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',
+5 -45
View File
@@ -1,6 +1,6 @@
import { Context } from "hono";
import { getBooleanValue, getJsonSetting, normalizeAddressDomain } from "../utils";
import { getJsonSetting, normalizeAddressDomain } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
@@ -11,7 +11,7 @@ import { extractEmailInfo } from "./ai_extract";
import { forwardEmail } from "./forward";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
import { compressText } from "../gzip";
import { storeRawMail } from "./storage";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
@@ -67,49 +67,9 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
const message_id = message.headers.get("Message-ID");
// save email
try {
let success = false;
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
let compressed: ArrayBuffer | null = null;
try {
compressed = await compressText(parsedEmailContext.rawEmail);
} catch (gzipError) {
console.error("gzip compression failed, falling back to plaintext", gzipError);
}
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());
} 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());
} 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());
}
} 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());
}
const { success } = await storeRawMail(
env, message.from, toAddress, message_id, parsedEmailContext.rawEmail
);
if (!success) {
message.setReject(`Failed save message to ${toAddress}`);
console.error(`Failed save message from ${message.from} to ${toAddress}`);
+99
View File
@@ -0,0 +1,99 @@
import { Context } from "hono";
import { createMimeMessage } from "mimetext";
import { compressText } from "../gzip";
import { getBooleanValue } from "../utils";
let rawMailTableColumns: Set<string> | undefined;
const getRawMailTableColumns = async (
env: Bindings, requiredColumns: string[]
): Promise<Set<string>> => {
const cachedColumns = rawMailTableColumns;
if (cachedColumns && requiredColumns.every(column => cachedColumns.has(column))) {
return cachedColumns;
}
const tableInfo = await env.DB.prepare(`PRAGMA table_info(raw_mails)`).all<{ name: string }>();
const columns = new Set(tableInfo.results.map(column => column.name));
if (requiredColumns.every(column => columns.has(column))) {
rawMailTableColumns = columns;
}
return columns;
}
export const storeRawMail = async (
env: Bindings,
source: string,
address: string,
messageId: string | null,
raw: string,
): Promise<D1Result> => {
const gzipEnabled = getBooleanValue(env.ENABLE_MAIL_GZIP);
const readStatusEnabled = getBooleanValue(env.ENABLE_MAIL_READ_STATUS);
const requiredColumns: string[] = [];
if (gzipEnabled) requiredColumns.push('raw_blob');
if (readStatusEnabled) requiredColumns.push('is_unread');
let tableColumns = new Set<string>();
if (requiredColumns.length > 0) {
tableColumns = await getRawMailTableColumns(env, requiredColumns);
}
let rawBlob: ArrayBuffer | undefined;
if (gzipEnabled && tableColumns.has('raw_blob')) {
try {
rawBlob = await compressText(raw);
} catch (error) {
console.error("gzip compression failed, falling back to plaintext", error);
}
}
const storeUnreadStatus = readStatusEnabled && tableColumns.has('is_unread');
if (rawBlob) {
if (!storeUnreadStatus) {
return env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
).bind(source, address, rawBlob, messageId).run();
}
return env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw_blob, message_id, is_unread) VALUES (?, ?, ?, ?, 1)`
).bind(source, address, rawBlob, messageId).run();
}
if (!storeUnreadStatus) {
return env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(source, address, raw, messageId).run();
}
return env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id, is_unread) VALUES (?, ?, ?, ?, 1)`
).bind(source, address, raw, messageId).run();
}
export const sendAdminInternalMail = async (
c: Context<HonoCustomType>, toMail: string, subject: string, text: string
): Promise<boolean> => {
try {
const msg = createMimeMessage();
msg.setSender({
name: "Admin",
addr: "admin@internal"
});
msg.setRecipient(toMail);
msg.setSubject(subject);
msg.addMessage({
contentType: 'text/plain',
data: text
});
const messageId = Math.random().toString(36).substring(2, 15);
const { success } = await storeRawMail(
c.env, "admin@internal", toMail, messageId, msg.asRaw()
);
if (!success) {
console.log(`Failed save message from admin@internal to ${toMail}`);
}
return success;
} catch (error) {
console.log("sendAdminInternalMail error", error);
return false;
}
};
+1
View File
@@ -29,6 +29,7 @@ api.post('/api/attachment/get_url', s3_attachment.getSignedGetUrl)
api.get('/api/mails', mails_crud.listMails)
api.get('/api/mail/:mail_id', mails_crud.getMail)
api.delete('/api/mails/:id', mails_crud.deleteMail)
api.patch('/api/mails/:id/read', mails_crud.updateMailReadStatus)
// parsed mail (server-side parsed subject/text/html/attachments)
api.get('/api/parsed_mails', parsed_mail_api.listParsedMails)
+18 -1
View File
@@ -20,6 +20,23 @@ const listMails = async (c: Context<HonoCustomType>) => {
);
};
const updateMailReadStatus = async (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.ENABLE_MAIL_READ_STATUS)) {
return c.json({ error: 'Mail read status is disabled' }, 403);
}
const { address } = c.get("jwtPayload");
const { id } = c.req.param();
const { isUnread } = await c.req.json<{ isUnread?: boolean }>().catch(() => ({ isUnread: undefined }));
if (typeof isUnread !== 'boolean') {
return c.json({ error: 'isUnread must be a boolean' }, 400);
}
const value = isUnread ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE raw_mails SET is_unread = ? WHERE id = ? AND address = ? AND COALESCE(is_unread, 0) != ?`
).bind(value, id, address, value).run();
return c.json({ success });
};
const getMail = async (c: Context<HonoCustomType>) => {
const { address } = c.get("jwtPayload")
const { mail_id } = c.req.param();
@@ -117,4 +134,4 @@ const clearSentItems = async (c: Context<HonoCustomType>) => {
return c.json({ success });
};
export default { listMails, getMail, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
export default { listMails, getMail, updateMailReadStatus, deleteMail, getSettings, deleteAddress, clearInbox, clearSentItems };
+1
View File
@@ -213,6 +213,7 @@ export type RawMailRow = {
raw?: string;
raw_blob?: unknown;
metadata?: string;
is_unread?: 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_READ_STATUS: string | boolean | undefined
CLEANUP_BATCH_SIZE: string | number | undefined
// E2E testing
-65
View File
@@ -1,8 +1,6 @@
import { Context } from "hono";
import { createMimeMessage } from "mimetext";
import { UserSettings, RoleAddressConfig } from "./models";
import { CONSTANTS } from "./constants";
import { compressText } from "./gzip";
export const getJsonObjectValue = <T = any>(
value: string | any
@@ -353,68 +351,6 @@ export const getEnvStringList = (value: string | string[] | undefined): string[]
return value.filter((item) => item.length > 0);
}
export const sendAdminInternalMail = async (
c: Context<HonoCustomType>, toMail: string, subject: string, text: string
): Promise<boolean> => {
try {
const msg = createMimeMessage();
msg.setSender({
name: "Admin",
addr: "admin@internal"
});
msg.setRecipient(toMail);
msg.setSubject(subject);
msg.addMessage({
contentType: 'text/plain',
data: text
});
const message_id = Math.random().toString(36).substring(2, 15);
const rawText = msg.asRaw();
let success = false;
if (getBooleanValue(c.env.ENABLE_MAIL_GZIP)) {
let compressed: ArrayBuffer | null = null;
try {
compressed = await compressText(rawText);
} catch (gzipError) {
console.error("gzip compression failed, falling back to plaintext", gzipError);
}
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());
} 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());
} 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());
}
} 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());
}
if (!success) {
console.log(`Failed save message from admin@internal to ${toMail}`);
}
return success;
} catch (error) {
console.log("sendAdminInternalMail error", error);
return false;
}
};
export const isGlobalTurnstileEnabled = (c: Context<HonoCustomType>): boolean => {
return getBooleanValue(c.env.ENABLE_GLOBAL_TURNSTILE_CHECK)
&& !!c.env.CF_TURNSTILE_SITE_KEY
@@ -525,7 +461,6 @@ export default {
getAdminPasswords,
checkIsAdmin,
getEnvStringList,
sendAdminInternalMail,
isGlobalTurnstileEnabled,
checkCfTurnstile,
checkUserPassword,
+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
# Track read and unread mail. Run the database migration before enabling.
# ENABLE_MAIL_READ_STATUS = true
# Allow automatic replies to emails
ENABLE_AUTO_REPLY = false
# Allow webhook