mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-06 06:41:32 +08:00
feat(subdomain): add random second-level mailbox support (#924)
Summary: add random second-level subdomain mailbox creation for web, admin, and Telegram. Scope: worker config, UI toggle, and README/VitePress documentation. Co-authored-by: wufei <fwu@creams.io>
This commit is contained in:
@@ -45,7 +45,7 @@ api.get('/admin/address', async (c) => {
|
||||
})
|
||||
|
||||
api.post('/admin/new_address', async (c) => {
|
||||
const { name, domain, enablePrefix } = await c.req.json();
|
||||
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!name) {
|
||||
return c.text(msgs.RequiredFieldMsg, 400)
|
||||
@@ -53,6 +53,7 @@ api.post('/admin/new_address', async (c) => {
|
||||
try {
|
||||
const res = await newAddress(c, {
|
||||
name, domain, enablePrefix,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: false,
|
||||
addressPrefix: null,
|
||||
checkAllowDomains: false,
|
||||
|
||||
@@ -24,6 +24,8 @@ export default {
|
||||
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
|
||||
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
|
||||
"DOMAINS": utils.getDomains(c),
|
||||
"RANDOM_SUBDOMAIN_DOMAINS": utils.getRandomSubdomainDomains(c),
|
||||
"RANDOM_SUBDOMAIN_LENGTH": utils.getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, 8),
|
||||
"DOMAIN_LABELS": utils.getStringArray(c.env.DOMAIN_LABELS),
|
||||
|
||||
"HAS_JWT_SECRET": !!utils.getStringValue(c.env.JWT_SECRET),
|
||||
|
||||
@@ -26,6 +26,7 @@ api.get('/open_api/settings', async (c) => {
|
||||
"maxAddressLen": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30),
|
||||
"defaultDomains": utils.getDefaultDomains(c),
|
||||
"domains": utils.getDomains(c),
|
||||
"randomSubdomainDomains": utils.getRandomSubdomainDomains(c),
|
||||
"domainLabels": utils.getStringArray(c.env.DOMAIN_LABELS),
|
||||
"needAuth": needAuth,
|
||||
"adminContact": c.env.ADMIN_CONTACT,
|
||||
|
||||
@@ -2,13 +2,15 @@ import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { WorkerMailerOptions } from 'worker-mailer';
|
||||
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue } from './utils';
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
import i18n from './i18n';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
const MAX_RANDOM_SUBDOMAIN_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Check if send mail is enabled for a specific domain
|
||||
@@ -66,6 +68,26 @@ export const generateRandomName = (c: Context<HonoCustomType>): string => {
|
||||
return fullName.substring(0, Math.min(fullName.length, maxLength));
|
||||
};
|
||||
|
||||
const generateRandomSubdomain = (c: Context<HonoCustomType>): string => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const length = Math.min(
|
||||
Math.max(getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, DEFAULT_RANDOM_SUBDOMAIN_LENGTH), 1),
|
||||
63
|
||||
);
|
||||
let subdomain = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
subdomain += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
const allowRandomSubdomainForDomain = (
|
||||
c: Context<HonoCustomType>,
|
||||
domain: string
|
||||
): boolean => {
|
||||
return getRandomSubdomainDomains(c).includes(domain);
|
||||
}
|
||||
|
||||
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
|
||||
let error = null;
|
||||
try {
|
||||
@@ -148,12 +170,42 @@ const generatePasswordForAddress = async (
|
||||
return plainPassword;
|
||||
}
|
||||
|
||||
const insertAddressRecord = async (
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
sourceMeta: string | undefined | null,
|
||||
msgs: ReturnType<typeof i18n.getMessagesbyContext>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
|
||||
).bind(address, sourceMeta).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
// Fallback: source_meta field may not exist, try without it
|
||||
if (message && message.includes("source_meta")) {
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(address).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export const newAddress = async (
|
||||
c: Context<HonoCustomType>,
|
||||
{
|
||||
name,
|
||||
domain,
|
||||
enablePrefix,
|
||||
enableRandomSubdomain = false,
|
||||
checkLengthByConfig = true,
|
||||
addressPrefix = null,
|
||||
checkAllowDomains = true,
|
||||
@@ -162,6 +214,7 @@ export const newAddress = async (
|
||||
}: {
|
||||
name: string, domain: string | undefined | null,
|
||||
enablePrefix: boolean,
|
||||
enableRandomSubdomain?: boolean,
|
||||
checkLengthByConfig?: boolean,
|
||||
addressPrefix?: string | undefined | null,
|
||||
checkAllowDomains?: boolean,
|
||||
@@ -215,56 +268,56 @@ export const newAddress = async (
|
||||
if (!domain || !allowDomains.includes(domain)) {
|
||||
throw new Error(msgs.InvalidDomainMsg)
|
||||
}
|
||||
// create address
|
||||
name = name + "@" + domain;
|
||||
try {
|
||||
// Try insert with source_meta field first
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
|
||||
).bind(name, sourceMeta).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
await updateAddressUpdatedAt(c, name);
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
// Fallback: source_meta field may not exist, try without it
|
||||
if (message && message.includes("source_meta")) {
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) {
|
||||
throw new Error(msgs.RandomSubdomainNotAllowedMsg)
|
||||
}
|
||||
|
||||
const maxAttempts = enableRandomSubdomain ? MAX_RANDOM_SUBDOMAIN_ATTEMPTS : 1;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const addressDomain = enableRandomSubdomain
|
||||
? `${generateRandomSubdomain(c)}.${domain}`
|
||||
: domain;
|
||||
const address = `${name}@${addressDomain}`;
|
||||
|
||||
try {
|
||||
await insertAddressRecord(c, address, sourceMeta, msgs);
|
||||
await updateAddressUpdatedAt(c, address);
|
||||
|
||||
const address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(address).first<number>("id");
|
||||
|
||||
if (!address_id) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg);
|
||||
}
|
||||
|
||||
// 如果启用地址密码功能,自动生成密码
|
||||
const generatedPassword = await generatePasswordForAddress(c, address);
|
||||
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: address,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: address,
|
||||
password: generatedPassword,
|
||||
address_id: address_id,
|
||||
}
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
if (message && message.includes("UNIQUE")) {
|
||||
if (enableRandomSubdomain && attempt < maxAttempts - 1) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(msgs.AddressAlreadyExistsMsg)
|
||||
}
|
||||
await updateAddressUpdatedAt(c, name);
|
||||
} else if (message && message.includes("UNIQUE")) {
|
||||
throw new Error(msgs.AddressAlreadyExistsMsg)
|
||||
} else {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
}
|
||||
const address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first<number>("id");
|
||||
|
||||
if (!address_id) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg);
|
||||
}
|
||||
|
||||
// 如果启用地址密码功能,自动生成密码
|
||||
const generatedPassword = await generatePasswordForAddress(c, name);
|
||||
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: name,
|
||||
password: generatedPassword,
|
||||
address_id: address_id,
|
||||
}
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
|
||||
const checkNameBlockList = async (
|
||||
|
||||
@@ -57,6 +57,7 @@ const messages: LocaleMessages = {
|
||||
NameTooShortMsg: "Name is too short",
|
||||
NameTooLongMsg: "Name is too long",
|
||||
InvalidDomainMsg: "Invalid domain",
|
||||
RandomSubdomainNotAllowedMsg: "Random subdomain is not enabled for this domain",
|
||||
AddressAlreadyExistsMsg: "Address already exists",
|
||||
MaxAddressCountReachedMsg: "Max address count reached",
|
||||
AddressNotBindedMsg: "Address is not binded",
|
||||
|
||||
@@ -55,6 +55,7 @@ export type LocaleMessages = {
|
||||
NameTooShortMsg: string
|
||||
NameTooLongMsg: string
|
||||
InvalidDomainMsg: string
|
||||
RandomSubdomainNotAllowedMsg: string
|
||||
AddressAlreadyExistsMsg: string
|
||||
MaxAddressCountReachedMsg: string
|
||||
AddressNotBindedMsg: string
|
||||
|
||||
@@ -57,6 +57,7 @@ const messages: LocaleMessages = {
|
||||
NameTooShortMsg: "名称太短",
|
||||
NameTooLongMsg: "名称太长",
|
||||
InvalidDomainMsg: "无效的域名",
|
||||
RandomSubdomainNotAllowedMsg: "当前域名未启用随机子域名",
|
||||
AddressAlreadyExistsMsg: "邮箱地址已存在",
|
||||
MaxAddressCountReachedMsg: "已达到最大地址数量限制",
|
||||
AddressNotBindedMsg: "邮箱地址未绑定",
|
||||
|
||||
@@ -125,7 +125,7 @@ api.post('/api/new_address', async (c) => {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { name, domain, cf_token } = await c.req.json();
|
||||
let { name, domain, cf_token, enableRandomSubdomain } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
@@ -160,6 +160,7 @@ api.post('/api/new_address', async (c) => {
|
||||
const res = await newAddress(c, {
|
||||
name, domain,
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: true,
|
||||
addressPrefix,
|
||||
sourceMeta
|
||||
|
||||
@@ -7,7 +7,8 @@ import { LocaleMessages } from "../i18n/type";
|
||||
|
||||
export const tgUserNewAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, address: string,
|
||||
msgs: LocaleMessages
|
||||
msgs: LocaleMessages,
|
||||
enableRandomSubdomain: boolean = false
|
||||
): Promise<{ address: string, jwt: string, password?: string | null }> => {
|
||||
if (c.env.RATE_LIMITER) {
|
||||
const { success } = await c.env.RATE_LIMITER.limit(
|
||||
@@ -41,6 +42,7 @@ export const tgUserNewAddress = async (
|
||||
name: finalName,
|
||||
domain,
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain,
|
||||
sourceMeta: `tg:${userId}`
|
||||
});
|
||||
// for mail push to telegram
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Context } from "hono";
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { bindTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress } from "./common";
|
||||
import { checkCfTurnstile, checkIsAdmin } from "../utils";
|
||||
import { checkCfTurnstile, checkIsAdmin, getBooleanValue } from "../utils";
|
||||
import { TelegramSettings } from "./settings";
|
||||
import i18n from "../i18n";
|
||||
|
||||
@@ -83,7 +83,7 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
|
||||
}
|
||||
|
||||
async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const { initData, address, cf_token } = await c.req.json();
|
||||
const { initData, address, cf_token, enableRandomSubdomain } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
// check cf turnstile
|
||||
try {
|
||||
@@ -94,7 +94,13 @@ async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response>
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
// get the address list from the KV
|
||||
const res = await tgUserNewAddress(c, userId, address, msgs)
|
||||
const res = await tgUserNewAddress(
|
||||
c,
|
||||
userId,
|
||||
address,
|
||||
msgs,
|
||||
getBooleanValue(enableRandomSubdomain)
|
||||
)
|
||||
return c.json(res);
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
2
worker/src/types.d.ts
vendored
2
worker/src/types.d.ts
vendored
@@ -25,6 +25,8 @@ type Bindings = {
|
||||
MAX_ADDRESS_LEN: string | number | undefined
|
||||
DEFAULT_DOMAINS: string | string[] | undefined
|
||||
DOMAINS: string | string[] | undefined
|
||||
RANDOM_SUBDOMAIN_DOMAINS: string | string[] | undefined
|
||||
RANDOM_SUBDOMAIN_LENGTH: string | number | undefined
|
||||
DISABLE_CUSTOM_ADDRESS_NAME: string | boolean | undefined
|
||||
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST: string | boolean | undefined
|
||||
ADMIN_USER_ROLE: string | undefined
|
||||
|
||||
@@ -150,6 +150,13 @@ export const getDomains = (c: Context<HonoCustomType>): string[] => {
|
||||
return c.env.DOMAINS;
|
||||
}
|
||||
|
||||
export const getRandomSubdomainDomains = (c: Context<HonoCustomType>): string[] => {
|
||||
if (!c.env.RANDOM_SUBDOMAIN_DOMAINS) {
|
||||
return [];
|
||||
}
|
||||
return getStringArray(c.env.RANDOM_SUBDOMAIN_DOMAINS);
|
||||
}
|
||||
|
||||
export const getUserRoles = (c: Context<HonoCustomType>): UserRole[] => {
|
||||
if (!c.env.USER_ROLES) {
|
||||
return [];
|
||||
@@ -368,6 +375,7 @@ export default {
|
||||
getStringArray,
|
||||
getDefaultDomains,
|
||||
getDomains,
|
||||
getRandomSubdomainDomains,
|
||||
getUserRoles,
|
||||
getAnotherWorkerList,
|
||||
getPasswords,
|
||||
|
||||
Reference in New Issue
Block a user