diff --git a/README.md b/README.md index dc474f76..68ff7ab2 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,12 @@ This project supports automated deployment using GitHub Actions. It supports the In the MoeMail User Profile page, you can configure the site's email domains. Supports multiple domain configurations, separated by commas. ![Email Domain Configuration](https://pic.otaku.ren/20241227/AQAD88AxG67zeVd-.jpg "Email Domain Configuration") +### What changed after subdomain support + +- `EMAIL_DOMAINS` / site settings still store **base domains** only, for example: `example.com,moemail.app` +- When creating a mailbox, `domain` selects the base domain, and `subdomain` optionally expands the final address to `user@team.example.com` +- You do **not** need to save `team.example.com` into `EMAIL_DOMAINS` unless you intentionally want it to appear as a standalone selectable domain + ### Cloudflare Email Routing Configuration To make email domains effective, you also need to configure email routing in the Cloudflare console to forward received emails to the Email Worker. @@ -239,8 +245,28 @@ To make email domains effective, you also need to configure email routing in the ### Notes - Ensure domain DNS is hosted on Cloudflare. - Email Worker must be successfully deployed. +- If you enable mailbox subdomains (for example `user@team.example.com`), verify that your Cloudflare Email Routing rules actually cover those subdomain addresses. If your existing catch-all only matches the original domain setup, add the corresponding routing coverage for the subdomain and still point it to the same Email Worker. - If Catch-All status is unavailable (stuck loading), please click `Destination addresses` next to `Routing rules`, and bind an email address there. +### Subdomain Deployment Checklist + +When enabling mailbox subdomains, verify the following differences from the original deployment: + +1. **Base domain config** + - Keep `EMAIL_DOMAINS` as base domains only, such as `example.com` + - Do not replace it with `team.example.com` unless you want that subdomain shown as a standalone domain option +2. **Cloudflare Email Routing** + - Confirm inbound rules also match subdomain addresses like `*@team.example.com` + - Route those messages to the same Email Worker used before +3. **DNS / domain hosting** + - Ensure the parent domain and the target subdomain are both managed in Cloudflare as required by your routing setup +4. **Sender verification** + - If sending from subdomain addresses, verify whether your sender provider (for example Resend) requires separate subdomain verification +5. **Manual smoke test** + - Create `user@team.example.com` + - Send a test mail to it and confirm the message appears in MoeMail + - If outbound mail is enabled, send one mail from that address and confirm delivery succeeds + ## Permission System The project uses a Role-Based Access Control (RBAC) system. @@ -370,6 +396,7 @@ MoeMail supports sending emails using temporary addresses, based on [Resend](htt - 📋 **Resend Limits**: Please note Resend's sending limits and pricing - 🔐 **Domain Verification**: Using custom domains requires verification in Resend +- 🔐 **Subdomain Senders**: If you send from `user@team.example.com`, also confirm your mail provider allows that subdomain sender identity, or verify the subdomain separately when required - 🚫 **Anti-Spam**: Please follow email sending standards, avoid spamming - 📊 **Quota Monitoring**: System counts daily usage, stops sending when limit reached - 🔄 **Quota Reset**: Daily quota resets at 00:00 @@ -459,19 +486,21 @@ Content-Type: application/json { "name": "test", "expiryTime": 3600000, - "domain": "moemail.app" + "domain": "moemail.app", + "subdomain": "team" } ``` Params: - `name`: Prefix (optional) - `expiryTime`: Validity in ms. 3600000(1h), 86400000(24h), 604800000(7d), 0(Permanent) -- `domain`: From config +- `domain`: Base domain from config +- `subdomain`: Optional single-label subdomain. Final address becomes `name@subdomain.domain` Response: ```json { "id": "email-uuid-123", - "email": "test@moemail.app" + "email": "test@team.moemail.app" } ``` @@ -553,7 +582,7 @@ moemail config set api-url https://moemail.app moemail config set api-key YOUR_API_KEY # Create temporary email -moemail create --domain moemail.app --expiry 1h --json +moemail create --domain moemail.app --subdomain team --expiry 1h --json # Wait for new messages (polling) moemail wait --email-id --timeout 120 --json @@ -571,7 +600,7 @@ A typical AI agent verification flow in 3 tool calls: ```bash # 1. Create mailbox -EMAIL=$(moemail create --domain moemail.app --expiry 1h --json) +EMAIL=$(moemail create --domain moemail.app --subdomain team --expiry 1h --json) EMAIL_ID=$(echo $EMAIL | jq -r '.id') ADDRESS=$(echo $EMAIL | jq -r '.address') diff --git a/README.zh-CN.md b/README.zh-CN.md index a8edc375..c25c1d66 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -216,6 +216,12 @@ pnpm dlx tsx ./scripts/deploy/index.ts 在 MoeMail 个人中心页面,可以配置网站的邮箱域名,支持多域名配置,多个域名用逗号分隔 ![邮箱域名配置](https://pic.otaku.ren/20241227/AQAD88AxG67zeVd-.jpg "邮箱域名配置") +### 支持子域名后的变化 + +- `EMAIL_DOMAINS` / 站点配置中仍然只保存**基础域名**,例如:`example.com,moemail.app` +- 创建邮箱时,`domain` 用来选择基础域名,`subdomain` 为可选项,最终地址会变成 `user@team.example.com` +- 除非你希望 `team.example.com` 作为一个独立可选域名直接展示,否则不需要把它单独写进 `EMAIL_DOMAINS` + ### Cloudflare 邮件路由配置 为了使邮箱域名生效,还需要在 Cloudflare 控制台配置邮件路由,将收到的邮件转发给 Email Worker 处理。 @@ -238,8 +244,28 @@ pnpm dlx tsx ./scripts/deploy/index.ts ### 注意事项 - 确保域名的 DNS 托管在 Cloudflare - Email Worker 必须已经部署成功 +- 如果启用了子域名邮箱(例如 `user@team.example.com`),请确认 Cloudflare 的邮件路由规则已经覆盖这些子域名地址;如果现有 Catch-all 只覆盖修改前的基础域名场景,需要为对应子域名补充接收规则,但目标仍然指向同一个 Email Worker - 如果 Catch-All 状态不可用(一直 loading),请点击`路由规则`旁边的`目标地址`, 进去绑定一个邮箱 +### 子域名部署清单 + +启用邮箱子域名后,建议按下面清单逐项确认与修改前部署的差异: + +1. **基础域名配置** + - `EMAIL_DOMAINS` 里仍然填写基础域名,例如 `example.com` + - 除非你希望 `team.example.com` 作为独立可选域名展示,否则不要直接把它替换成子域名 +2. **Cloudflare 邮件路由** + - 确认收件规则也能匹配 `*@team.example.com` 这类子域名地址 + - 子域名邮件仍然转发到原来的同一个 Email Worker +3. **DNS / 域名托管** + - 确认父域名及目标子域名在当前路由方案下都已由 Cloudflare 正确托管 +4. **发件域验证** + - 如果需要从子域名地址发信,确认发件服务(例如 Resend)是否要求对子域名单独做验证 +5. **手动冒烟验证** + - 创建一个 `user@team.example.com` + - 向该地址发送一封测试邮件,确认 MoeMail 能正常收到 + - 如果启用了发信能力,再从该地址发送一封测试邮件,确认能成功投递 + ## 权限系统 本项目采用基于角色的权限控制系统(RBAC)。 @@ -369,6 +395,7 @@ MoeMail 支持使用临时邮箱发送邮件,基于 [Resend](https://resend.co - 📋 **Resend 限制**:请注意 Resend 服务的发送限制和定价政策 - 🔐 **域名验证**:使用自定义域名发件需要在 Resend 中验证域名 +- 🔐 **子域名发件**:如果要从 `user@team.example.com` 发信,还需要确认当前发件服务是否允许该子域名作为发件身份;如服务商要求,请单独验证子域名 - 🚫 **反垃圾邮件**:请遵守邮件发送规范,避免发送垃圾邮件 - 📊 **配额监控**:系统会自动统计每日发件数量,达到限额后将无法继续发送 - 🔄 **配额重置**:每日发件配额在每天 00:00 自动重置 @@ -463,19 +490,21 @@ Content-Type: application/json { "name": "test", "expiryTime": 3600000, - "domain": "moemail.app" + "domain": "moemail.app", + "subdomain": "team" } ``` 参数说明: - `name`: 邮箱前缀,可选 - `expiryTime`: 有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久) -- `domain`: 邮箱域名,可通过 `/api/config` 接口获取 +- `domain`: 基础域名,可通过 `/api/config` 接口获取 +- `subdomain`: 可选的单段子域名,最终地址格式为 `name@subdomain.domain` 返回响应: ```json { "id": "email-uuid-123", - "email": "test@moemail.app" + "email": "test@team.moemail.app" } ``` 响应字段说明: @@ -747,7 +776,8 @@ curl -X POST https://your-domain.com/api/emails/generate \ -d '{ "name": "test", "expiryTime": 3600000, - "domain": "moemail.app" + "domain": "moemail.app", + "subdomain": "team" }' ``` @@ -805,7 +835,7 @@ moemail config set api-url https://moemail.app moemail config set api-key YOUR_API_KEY # 创建临时邮箱 -moemail create --domain moemail.app --expiry 1h --json +moemail create --domain moemail.app --subdomain team --expiry 1h --json # 等待新邮件(轮询) moemail wait --email-id --timeout 120 --json @@ -823,7 +853,7 @@ AI Agent 仅需 3 次调用即可完成验证流程: ```bash # 1. 创建邮箱 -EMAIL=$(moemail create --domain moemail.app --expiry 1h --json) +EMAIL=$(moemail create --domain moemail.app --subdomain team --expiry 1h --json) EMAIL_ID=$(echo $EMAIL | jq -r '.id') ADDRESS=$(echo $EMAIL | jq -r '.address') diff --git a/app/api/emails/generate/route.ts b/app/api/emails/generate/route.ts index d0162dfc..d9ea24ac 100644 --- a/app/api/emails/generate/route.ts +++ b/app/api/emails/generate/route.ts @@ -9,6 +9,12 @@ import { getRequestContext } from "@cloudflare/next-on-pages" import { getUserId } from "@/lib/apiKey" import { getUserRole } from "@/lib/auth" import { ROLES } from "@/lib/permissions" +import { + buildMailboxAddress, + isValidSubdomainLabel, + normalizeDomainList, + normalizeSubdomain, +} from "@/lib/email-address" export const runtime = "edge" @@ -40,10 +46,11 @@ export async function POST(request: Request) { } } - const { name, expiryTime, domain } = await request.json<{ + const { name, expiryTime, domain, subdomain } = await request.json<{ name: string expiryTime: number domain: string + subdomain?: string }>() if (!EXPIRY_OPTIONS.some(option => option.value === expiryTime)) { @@ -54,16 +61,29 @@ export async function POST(request: Request) { } const domainString = await env.SITE_CONFIG.get("EMAIL_DOMAINS") - const domains = domainString ? domainString.split(',') : ["moemail.app"] + const domains = normalizeDomainList(domainString || "moemail.app") + const normalizedDomain = domain?.trim().toLowerCase() || "" + const normalizedSubdomain = normalizeSubdomain(subdomain) - if (!domains || !domains.includes(domain)) { + if (!domains.includes(normalizedDomain)) { return NextResponse.json( { error: "无效的域名" }, { status: 400 } ) } - const address = `${name || nanoid(8)}@${domain}` + if (normalizedSubdomain && !isValidSubdomainLabel(normalizedSubdomain)) { + return NextResponse.json( + { error: "无效的子域名" }, + { status: 400 } + ) + } + + const address = buildMailboxAddress( + name || nanoid(8), + normalizedDomain, + normalizedSubdomain + ) const existingEmail = await db.query.emails.findFirst({ where: eq(sql`LOWER(${emails.address})`, address.toLowerCase()) }) @@ -102,4 +122,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/components/emails/create-dialog.tsx b/app/components/emails/create-dialog.tsx index 84ef2078..1ad357d4 100644 --- a/app/components/emails/create-dialog.tsx +++ b/app/components/emails/create-dialog.tsx @@ -14,6 +14,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { EXPIRY_OPTIONS } from "@/types/email" import { useCopy } from "@/hooks/use-copy" import { useConfig } from "@/hooks/use-config" +import { + buildMailboxAddress, + isValidSubdomainLabel, + normalizeSubdomain, +} from "@/lib/email-address" interface CreateDialogProps { onEmailCreated: () => void @@ -27,15 +32,20 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const [emailName, setEmailName] = useState("") + const [subdomain, setSubdomain] = useState("") const [currentDomain, setCurrentDomain] = useState("") const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString()) const { toast } = useToast() const { copyToClipboard } = useCopy() + const normalizedSubdomain = normalizeSubdomain(subdomain) + const previewAddress = emailName && currentDomain + ? buildMailboxAddress(emailName, currentDomain, normalizedSubdomain) + : "" const generateRandomName = () => setEmailName(nanoid(8)) const copyEmailAddress = () => { - copyToClipboard(`${emailName}@${currentDomain}`) + copyToClipboard(previewAddress) } const createEmail = async () => { @@ -48,6 +58,15 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) { return } + if (normalizedSubdomain && !isValidSubdomainLabel(normalizedSubdomain)) { + toast({ + title: tList("error"), + description: t("invalidSubdomain"), + variant: "destructive" + }) + return + } + setLoading(true) try { const response = await fetch("/api/emails/generate", { @@ -56,6 +75,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) { body: JSON.stringify({ name: emailName, domain: currentDomain, + subdomain: normalizedSubdomain || undefined, expiryTime: parseInt(expiryTime) }) }) @@ -77,6 +97,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) { onEmailCreated() setOpen(false) setEmailName("") + setSubdomain("") } catch { toast({ title: tList("error"), @@ -114,6 +135,23 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) { placeholder={t("namePlaceholder")} className="flex-1" /> + + + +
+ setSubdomain(e.target.value)} + placeholder={t("subdomainPlaceholder")} + className="flex-1" + /> {(config?.emailDomainsArray?.length ?? 0) > 1 && ( )} - + {(config?.emailDomainsArray?.length ?? 0) <= 1 && ( +
+ @{currentDomain || t("domainPlaceholder")} +
+ )}
@@ -159,9 +194,9 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
{t("domain")}: - {emailName ? ( + {previewAddress ? (
- {`${emailName}@${currentDomain}`} + {previewAddress}
) -} \ No newline at end of file +} diff --git a/app/components/profile/api-key-panel.tsx b/app/components/profile/api-key-panel.tsx index e58b3443..4b27da02 100644 --- a/app/components/profile/api-key-panel.tsx +++ b/app/components/profile/api-key-panel.tsx @@ -355,7 +355,8 @@ export function ApiKeyPanel() { -d '{ "name": "test", "expiryTime": 3600000, - "domain": "moemail.app" + "domain": "moemail.app", + "subdomain": "team" }'` )} > @@ -369,7 +370,8 @@ export function ApiKeyPanel() { -d '{ "name": "test", "expiryTime": 3600000, - "domain": "moemail.app" + "domain": "moemail.app", + "subdomain": "team" }'`}
@@ -587,4 +589,4 @@ export function ApiKeyPanel() { }
) -} \ No newline at end of file +} diff --git a/app/hooks/use-config.ts b/app/hooks/use-config.ts index d2ade682..b082d4cb 100644 --- a/app/hooks/use-config.ts +++ b/app/hooks/use-config.ts @@ -4,6 +4,7 @@ import { create } from "zustand" import { Role, ROLES } from "@/lib/permissions" import { EMAIL_CONFIG } from "@/config" import { useEffect } from "react" +import { normalizeDomainList } from "@/lib/email-address" interface Config { defaultRole: Exclude @@ -34,7 +35,7 @@ const useConfigStore = create((set) => ({ config: { defaultRole: data.defaultRole || ROLES.CIVILIAN, emailDomains: data.emailDomains, - emailDomainsArray: data.emailDomains.split(','), + emailDomainsArray: normalizeDomainList(data.emailDomains), adminContact: data.adminContact || "", maxEmails: Number(data.maxEmails) || EMAIL_CONFIG.MAX_ACTIVE_EMAILS }, @@ -59,4 +60,4 @@ export function useConfig() { }, [store.config, store.loading]) return store -} \ No newline at end of file +} diff --git a/app/i18n/messages/en/emails.json b/app/i18n/messages/en/emails.json index 709e43c3..b0ddf5c7 100644 --- a/app/i18n/messages/en/emails.json +++ b/app/i18n/messages/en/emails.json @@ -31,10 +31,13 @@ "success": "Success" }, "create": { - "title": "Create Email", - "name": "Email Prefix", - "namePlaceholder": "Leave empty for random generation", - "domain": "Domain", + "title": "Create Email", + "name": "Email Prefix", + "namePlaceholder": "Leave empty for random generation", + "subdomain": "Subdomain", + "subdomainPlaceholder": "Optional, e.g. team", + "invalidSubdomain": "Invalid subdomain format", + "domain": "Domain", "domainPlaceholder": "Select a domain", "expiryTime": "Validity Period", "oneHour": "1 Hour", diff --git a/app/i18n/messages/ja/emails.json b/app/i18n/messages/ja/emails.json index 468730e4..59d46351 100644 --- a/app/i18n/messages/ja/emails.json +++ b/app/i18n/messages/ja/emails.json @@ -31,10 +31,13 @@ "success": "成功" }, "create": { - "title": "メールボックスを作成", - "name": "メールボックスのプレフィックス", - "namePlaceholder": "空白の場合はランダムに生成", - "domain": "ドメイン", + "title": "メールボックスを作成", + "name": "メールボックスのプレフィックス", + "namePlaceholder": "空白の場合はランダムに生成", + "subdomain": "サブドメイン", + "subdomainPlaceholder": "任意、例: team", + "invalidSubdomain": "サブドメインの形式が正しくありません", + "domain": "ドメイン", "domainPlaceholder": "ドメインを選択", "expiryTime": "有効期間", "oneHour": "1時間", diff --git a/app/i18n/messages/ko/emails.json b/app/i18n/messages/ko/emails.json index 54cbfcfe..525b7964 100644 --- a/app/i18n/messages/ko/emails.json +++ b/app/i18n/messages/ko/emails.json @@ -31,10 +31,13 @@ "success": "성공" }, "create": { - "title": "이메일 생성", - "name": "이메일 접두사", - "namePlaceholder": "비워두면 무작위로 생성됩니다", - "domain": "도메인", + "title": "이메일 생성", + "name": "이메일 접두사", + "namePlaceholder": "비워두면 무작위로 생성됩니다", + "subdomain": "서브도메인", + "subdomainPlaceholder": "선택 사항, 예: team", + "invalidSubdomain": "서브도메인 형식이 올바르지 않습니다", + "domain": "도메인", "domainPlaceholder": "도메인 선택", "expiryTime": "유효 기간", "oneHour": "1시간", diff --git a/app/i18n/messages/zh-CN/emails.json b/app/i18n/messages/zh-CN/emails.json index fc2eace8..fc1218bf 100644 --- a/app/i18n/messages/zh-CN/emails.json +++ b/app/i18n/messages/zh-CN/emails.json @@ -31,10 +31,13 @@ "success": "成功" }, "create": { - "title": "创建邮箱", - "name": "邮箱前缀", - "namePlaceholder": "留空则随机生成", - "domain": "域名", + "title": "创建邮箱", + "name": "邮箱前缀", + "namePlaceholder": "留空则随机生成", + "subdomain": "子域名", + "subdomainPlaceholder": "可选,例如 team", + "invalidSubdomain": "子域名格式无效", + "domain": "域名", "domainPlaceholder": "选择域名", "expiryTime": "有效期", "oneHour": "1 小时", diff --git a/app/i18n/messages/zh-TW/emails.json b/app/i18n/messages/zh-TW/emails.json index 88a7c697..0c6a847e 100644 --- a/app/i18n/messages/zh-TW/emails.json +++ b/app/i18n/messages/zh-TW/emails.json @@ -31,10 +31,13 @@ "success": "成功" }, "create": { - "title": "建立郵箱", - "name": "郵箱前綴", - "namePlaceholder": "留空則隨機生成", - "domain": "網域", + "title": "建立郵箱", + "name": "郵箱前綴", + "namePlaceholder": "留空則隨機生成", + "subdomain": "子網域", + "subdomainPlaceholder": "選填,例如 team", + "invalidSubdomain": "子網域格式無效", + "domain": "網域", "domainPlaceholder": "選擇網域", "expiryTime": "有效期", "oneHour": "1 小時", diff --git a/app/lib/email-address.ts b/app/lib/email-address.ts new file mode 100644 index 00000000..13ace301 --- /dev/null +++ b/app/lib/email-address.ts @@ -0,0 +1,27 @@ +export const SUBDOMAIN_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + +export function normalizeDomainList(domains?: string | null): string[] { + return (domains ?? "") + .split(",") + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean) +} + +export function normalizeSubdomain(subdomain?: string | null): string { + return (subdomain ?? "").trim().toLowerCase() +} + +export function isValidSubdomainLabel(subdomain: string): boolean { + return SUBDOMAIN_LABEL_REGEX.test(normalizeSubdomain(subdomain)) +} + +export function buildMailboxAddress( + localPart: string, + domain: string, + subdomain?: string | null +): string { + const normalizedSubdomain = normalizeSubdomain(subdomain) + const hostname = normalizedSubdomain ? `${normalizedSubdomain}.${domain}` : domain + + return `${localPart}@${hostname}` +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f8e097c..2e2f04e5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,7 +17,7 @@ moemail config --domain moemail.app ### 2. Create a temporary email ```bash -moemail create --expiry 1h +moemail create --domain moemail.app --subdomain team --expiry 1h ``` ### 3. Wait for messages @@ -30,7 +30,7 @@ moemail wait --email-id --timeout 120 | Command | Description | Key Flags | |---------|-------------|-----------| | `config` | Set default domain and options | `--domain `, `--expiry ` | -| `create` | Create a temporary email address | `--domain `, `--expiry `, `--json` | +| `create` | Create a temporary email address | `--domain `, `--subdomain