mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-05 07:27:27 +08:00
feat: add single-mail read status (#1125)
This commit is contained in:
@@ -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>) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -213,6 +213,7 @@ export type RawMailRow = {
|
||||
raw?: string;
|
||||
raw_blob?: unknown;
|
||||
metadata?: string;
|
||||
is_unread?: number | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user