feat: add SEND_MAIL delivery and quota controls (#986)

* feat: add SEND_MAIL delivery and quota controls

* test: cover -1 unlimited runtime for send mail quota

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: split send limit validation and save

* refactor: move send limit counters to settings

* fix: polish send mail limit review follow-ups

* docs: note SEND_MAIL breaking change

* test: align send mail limit e2e with new messages

* fix: address review follow-ups

* fix: harden admin send mail handlers

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2026-04-17 11:37:14 +08:00
committed by GitHub
parent a5aa475380
commit e772db8c3e
16 changed files with 1032 additions and 54 deletions

View File

@@ -12,7 +12,12 @@ import mail_webhook_settings from './mail_webhook_settings'
import oauth2_settings from './oauth2_settings'
import worker_config from './worker_config'
import admin_mail_api from './admin_mail_api'
import { sendMailbyAdmin } from './send_mail'
import { sendMailbyAdmin, sendMailByBindingAdmin } from './send_mail'
import {
getSendMailLimitConfig,
getSendMailLimitConfigToSave,
validateSendMailLimitConfig
} from '../mails_api/send_mail_limit_utils'
import db_api from './db_api'
import ip_blacklist_settings from './ip_blacklist_settings'
import ai_extract_settings from './ai_extract_settings'
@@ -341,6 +346,7 @@ api.get('/admin/account_settings', async (c) => {
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
const addressCreationSettings = await getAddressCreationSettings(c);
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
const sendMailLimitConfig = await getSendMailLimitConfig(c);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
@@ -352,6 +358,7 @@ api.get('/admin/account_settings', async (c) => {
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
sendMailLimitConfig,
})
} catch (error) {
console.error(error);
@@ -364,7 +371,8 @@ api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings,
sendMailLimitConfig
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text(msgs.InvalidInputMsg, 400)
@@ -380,6 +388,12 @@ api.post('/admin/account_settings', async (c) => {
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
if (sendMailLimitConfig && !validateSendMailLimitConfig(sendMailLimitConfig)) {
return c.text(msgs.InvalidInputMsg, 400)
}
const sendMailLimitConfigToSave = sendMailLimitConfig
? getSendMailLimitConfigToSave(sendMailLimitConfig)
: null;
await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
JSON.stringify(blockList)
@@ -417,6 +431,12 @@ api.post('/admin/account_settings', async (c) => {
)
}
}
if (sendMailLimitConfigToSave) {
await saveSetting(
c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY,
JSON.stringify(sendMailLimitConfigToSave)
)
}
return c.json({
success: true
})
@@ -459,6 +479,7 @@ api.get("/admin/worker/configs", worker_config.getConfig);
// send mail by admin
api.post("/admin/send_mail", sendMailbyAdmin);
api.post("/admin/send_mail_by_binding", sendMailByBindingAdmin);
// db api
api.get('admin/db_version', db_api.getVersion);

View File

@@ -1,21 +1,89 @@
import { Context } from "hono";
import i18n from "../i18n";
import { sendMail } from "../mails_api/send_mail_api";
import { ensureSendMailLimit, increaseSendMailLimitCount } from "../mails_api/send_mail_limit_utils";
const getAdminSendMailErrorMessage = (
msgs: ReturnType<typeof i18n.getMessagesbyContext>,
error: unknown
): string => {
const message = error instanceof Error ? error.message : "";
return Object.values(msgs).includes(message)
? message
: msgs.OperationFailedMsg;
}
export const sendMailbyAdmin = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
let reqJson;
try {
reqJson = await c.req.json();
} catch (e) {
console.error("Admin send_mail invalid json", e);
return c.text(msgs.InvalidInputMsg, 400)
}
const {
from_name, from_mail,
to_mail, to_name,
subject, content, is_html
} = await c.req.json();
await sendMail(c, from_mail, {
from_name: from_name,
to_name: to_name,
to_mail: to_mail,
subject: subject,
content: content,
is_html: is_html,
}, {
isAdmin: true
})
} = reqJson;
try {
await sendMail(c, from_mail, {
from_name: from_name,
to_name: to_name,
to_mail: to_mail,
subject: subject,
content: content,
is_html: is_html,
}, {
isAdmin: true
})
} catch (e) {
console.error("Admin send_mail failed", e);
return c.text(getAdminSendMailErrorMessage(msgs, e), 400)
}
return c.json({ status: "ok" });
}
export const sendMailByBindingAdmin = async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!c.env.SEND_MAIL) {
return c.text(msgs.EnableSendMailMsg, 400)
}
let reqJson;
try {
reqJson = await c.req.json();
} catch (e) {
console.error("Admin raw send_mail invalid json", e);
return c.text(msgs.InvalidInputMsg, 400)
}
const {
from, to, subject,
html, text,
cc, bcc, replyTo,
attachments, headers,
} = reqJson;
if (!from || !to || !subject || (!html && !text)) {
return c.text(msgs.InvalidInputMsg, 400)
}
try {
await ensureSendMailLimit(c);
await c.env.SEND_MAIL.send({
from,
to,
subject,
...(html ? { html } : {}),
...(text ? { text } : {}),
...(cc ? { cc } : {}),
...(bcc ? { bcc } : {}),
...(replyTo ? { replyTo } : {}),
...(attachments && attachments.length ? { attachments } : {}),
...(headers ? { headers } : {}),
});
await increaseSendMailLimitCount(c);
} catch (e) {
console.error("Admin raw send_mail failed", e);
return c.text(getAdminSendMailErrorMessage(msgs, e), 400)
}
return c.json({ status: "ok" });
}

View File

@@ -26,4 +26,6 @@ export const CONSTANTS = {
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
EMAIL_KV_BLACK_LIST: "temp-mail-email-black-list",
WEBHOOK_KV_ADMIN_MAIL_SETTINGS_KEY: "temp-mail-webhook-admin-mail-settings",
SEND_MAIL_LIMIT_COUNT_KEY_PREFIX: "send_mail_limit_count:",
SEND_MAIL_LIMIT_CONFIG_KEY: "send_mail_limit_config",
}

View File

@@ -71,7 +71,9 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "Content is empty",
AlreadyRequestedMsg: "Already requested",
EnableResendOrSmtpMsg: "Please enable resend or smtp for this domain",
EnableResendOrSmtpWithVerifiedMsg: "Please enable resend or smtp for this domain, or add recipient to verified address list",
EnableResendOrSmtpOrSendMailMsg: "Please enable resend, smtp or SEND_MAIL for this domain",
ServerSendMailDailyLimitMsg: "Server daily send quota has been reached",
ServerSendMailMonthlyLimitMsg: "Server monthly send quota has been reached",
InvalidToMailMsg: "Invalid recipient address",
// Admin related

View File

@@ -69,7 +69,9 @@ export type LocaleMessages = {
ContentEmptyMsg: string
AlreadyRequestedMsg: string
EnableResendOrSmtpMsg: string
EnableResendOrSmtpWithVerifiedMsg: string
EnableResendOrSmtpOrSendMailMsg: string
ServerSendMailDailyLimitMsg: string
ServerSendMailMonthlyLimitMsg: string
InvalidToMailMsg: string
// Admin related

View File

@@ -71,7 +71,9 @@ const messages: LocaleMessages = {
ContentEmptyMsg: "内容不能为空",
AlreadyRequestedMsg: "已经申请过了",
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resendsmtp,或将收件人添加到已验证地址列表",
EnableResendOrSmtpOrSendMailMsg: "请先为此域名启用 resendsmtp 或 SEND_MAIL",
ServerSendMailDailyLimitMsg: "服务器今日发信次数已达上限",
ServerSendMailMonthlyLimitMsg: "服务器本月发信次数已达上限",
InvalidToMailMsg: "收件人地址无效",
// Admin related

View File

@@ -6,9 +6,12 @@ import { WorkerMailer, WorkerMailerOptions } from 'worker-mailer';
import i18n from '../i18n';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue, getJsonObjectValue, getSplitStringListValue } from '../utils';
import {
getJsonSetting, getDomains, getIntValue, getBooleanValue, getJsonObjectValue, getSplitStringListValue
} from '../utils';
import { GeoData } from '../models'
import { handleListQuery, updateAddressUpdatedAt } from '../common'
import { ensureSendMailLimit, increaseSendMailLimitCount } from './send_mail_limit_utils';
export const api = new Hono<HonoCustomType>()
@@ -63,6 +66,25 @@ export const sendMailToVerifyAddress = async (
await c.env.SEND_MAIL.send(message);
}
export const sendMailByBinding = async (
c: Context<HonoCustomType>, address: string,
reqJson: {
from_name: string, to_mail: string, to_name: string,
subject: string, content: string, is_html: boolean
}
): Promise<void> => {
const {
from_name, to_mail, to_name,
subject, content, is_html
} = reqJson;
await c.env.SEND_MAIL.send({
from: from_name ? { email: address, name: from_name } : address,
to: to_name ? [`${to_name} <${to_mail}>`] : [to_mail],
subject,
...(is_html ? { html: content } : { text: content }),
});
}
const sendMailByResend = async (
c: Context<HonoCustomType>, address: string,
reqJson: {
@@ -173,6 +195,7 @@ export const sendMail = async (
if (!content) {
throw new Error(msgs.ContentEmptyMsg)
}
await ensureSendMailLimit(c);
// send to verified address list, do not update balance
const resendEnabled = c.env.RESEND_TOKEN || c.env[
@@ -202,12 +225,13 @@ export const sendMail = async (
else if (smtpConfig) {
await sendMailBySmtp(c, address, reqJson, smtpConfig);
}
else {
if (c.env.SEND_MAIL) {
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
}
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
else if (c.env.SEND_MAIL) {
await sendMailByBinding(c, address, reqJson);
}
else {
throw new Error(`${msgs.EnableResendOrSmtpOrSendMailMsg} (${mailDomain})`);
}
await increaseSendMailLimitCount(c);
// update balance
if (!sendByVerifiedAddressList && needCheckBalance) {

View File

@@ -0,0 +1,193 @@
import { Context } from "hono";
import i18n from "../i18n";
import { SendMailLimitConfig } from "../models";
import { CONSTANTS } from "../constants";
import { getJsonObjectValue, getSetting } from "../utils";
class SendMailLimitError extends Error {
constructor(message: string) {
super(message);
}
}
const parseLimitValue = (value: unknown): number | null => {
if (value === null || typeof value === "undefined") {
return null;
}
if (!Number.isInteger(value) || (value as number) < -1) {
return null;
}
return value as number;
}
const isValidLimitValue = (value: number | null): boolean => {
return value === -1 || (value !== null && value >= 0);
}
const parseSendMailLimitConfig = (value: unknown): SendMailLimitConfig | null => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const config = value as Record<string, unknown>;
if (typeof config.dailyEnabled !== "boolean" || typeof config.monthlyEnabled !== "boolean") {
return null;
}
const dailyLimit = parseLimitValue(config.dailyLimit);
const monthlyLimit = parseLimitValue(config.monthlyLimit);
const monthlyValid = config.monthlyEnabled
? isValidLimitValue(monthlyLimit)
: (config.monthlyLimit === null || typeof config.monthlyLimit === "undefined" || monthlyLimit !== null);
const dailyValid = config.dailyEnabled
? isValidLimitValue(dailyLimit)
: (config.dailyLimit === null || typeof config.dailyLimit === "undefined" || dailyLimit !== null);
if (!dailyValid || !monthlyValid) {
return null;
}
return {
dailyEnabled: config.dailyEnabled,
monthlyEnabled: config.monthlyEnabled,
dailyLimit,
monthlyLimit,
};
}
export const validateSendMailLimitConfig = (value: unknown): boolean => {
return !!parseSendMailLimitConfig(value);
}
export const getSendMailLimitConfigToSave = (
value: unknown
): SendMailLimitConfig | null => {
const sendMailLimitConfig = parseSendMailLimitConfig(value);
if (!sendMailLimitConfig) {
return null;
}
return {
dailyEnabled: sendMailLimitConfig.dailyEnabled,
monthlyEnabled: sendMailLimitConfig.monthlyEnabled,
dailyLimit: sendMailLimitConfig.dailyEnabled ? sendMailLimitConfig.dailyLimit : null,
monthlyLimit: sendMailLimitConfig.monthlyEnabled ? sendMailLimitConfig.monthlyLimit : null,
};
}
export const getSendMailLimitConfig = async (
c: Context<HonoCustomType>
): Promise<SendMailLimitConfig | null> => {
return getSendMailLimitConfigToSave(getJsonObjectValue<SendMailLimitConfig>(
await getSetting(c, CONSTANTS.SEND_MAIL_LIMIT_CONFIG_KEY)
));
}
const getDailyCountKey = (date: Date = new Date()): string => {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}daily:${yyyy}-${mm}-${dd}`;
}
const getMonthlyCountKey = (date: Date = new Date()): string => {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}monthly:${yyyy}-${mm}`;
}
const getCount = async (
c: Context<HonoCustomType>,
key: string
): Promise<number> => {
const value = await getSetting(c, key);
if (!value) {
return 0;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
return 0;
}
return parsed;
}
const cleanupSendMailLimitCount = async (
c: Context<HonoCustomType>,
currentDailyKey: string,
currentMonthlyKey: string
): Promise<void> => {
await c.env.DB.batch([
c.env.DB.prepare(
`DELETE FROM settings
WHERE key LIKE ?
AND key < ?`
).bind(`${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}daily:%`, currentDailyKey),
c.env.DB.prepare(
`DELETE FROM settings
WHERE key LIKE ?
AND key < ?`
).bind(`${CONSTANTS.SEND_MAIL_LIMIT_COUNT_KEY_PREFIX}monthly:%`, currentMonthlyKey),
]);
}
export const ensureSendMailLimit = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const msgs = i18n.getMessagesbyContext(c);
const config = await getSendMailLimitConfig(c);
if (!config || (!config.dailyEnabled && !config.monthlyEnabled)) {
return;
}
if (config.dailyEnabled && config.dailyLimit !== null && config.dailyLimit !== -1) {
const current = await getCount(c, getDailyCountKey());
if (current >= config.dailyLimit) {
throw new SendMailLimitError(msgs.ServerSendMailDailyLimitMsg);
}
}
if (config.monthlyEnabled && config.monthlyLimit !== null && config.monthlyLimit !== -1) {
const current = await getCount(c, getMonthlyCountKey());
if (current >= config.monthlyLimit) {
throw new SendMailLimitError(msgs.ServerSendMailMonthlyLimitMsg);
}
}
} catch (error) {
if (error instanceof SendMailLimitError) {
throw error;
}
console.warn("Failed to ensure send mail limit", error);
}
}
const increaseCount = async (
c: Context<HonoCustomType>,
key: string,
): Promise<void> => {
await c.env.DB.prepare(
`INSERT INTO settings (key, value)
VALUES (?, '1')
ON CONFLICT(key) DO UPDATE SET
value = CAST(COALESCE(value, '0') AS INTEGER) + 1,
updated_at = datetime('now')`
).bind(key).run();
}
export const increaseSendMailLimitCount = async (
c: Context<HonoCustomType>
): Promise<void> => {
try {
const config = await getSendMailLimitConfig(c);
if (!config || (!config.dailyEnabled && !config.monthlyEnabled)) {
return;
}
const dailyKey = getDailyCountKey();
const monthlyKey = getMonthlyCountKey();
if (config.dailyEnabled) {
await increaseCount(c, dailyKey);
}
if (config.monthlyEnabled) {
await increaseCount(c, monthlyKey);
}
await cleanupSendMailLimitCount(c, dailyKey, monthlyKey);
} catch (error) {
if (error instanceof SendMailLimitError) {
throw error;
}
console.warn(`Failed to increment send_mail_limit_count`, error);
}
}

View File

@@ -184,6 +184,13 @@ export type EmailRuleSettings = {
emailForwardingList: SubdomainForwardAddressList[]
}
export type SendMailLimitConfig = {
dailyEnabled: boolean;
monthlyEnabled: boolean;
dailyLimit: number | null;
monthlyLimit: number | null;
}
export type RoleConfig = {
maxAddressCount?: number;
// future configs can be added here

View File

@@ -8,8 +8,8 @@ type Bindings = {
// bindings
DB: D1Database
KV: KVNamespace
RATE_LIMITER: any
SEND_MAIL: any
RATE_LIMITER: RateLimit
SEND_MAIL: SendEmail
ASSETS: Fetcher
AI: Ai