mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-04 23:17:34 +08:00
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:
co-authored by
Claude Opus 4.7
parent
a5aa475380
commit
e772db8c3e
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user