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
co-authored by Claude Opus 4.7
parent a5aa475380
commit e772db8c3e
16 changed files with 1032 additions and 54 deletions
+30 -6
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) {
@@ -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);
}
}