Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
```

Expand Down Expand Up @@ -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 <id> --timeout 120 --json
Expand All @@ -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')

Expand Down
42 changes: 36 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 处理。
Expand All @@ -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)。
Expand Down Expand Up @@ -369,6 +395,7 @@ MoeMail 支持使用临时邮箱发送邮件,基于 [Resend](https://resend.co

- 📋 **Resend 限制**:请注意 Resend 服务的发送限制和定价政策
- 🔐 **域名验证**:使用自定义域名发件需要在 Resend 中验证域名
- 🔐 **子域名发件**:如果要从 `user@team.example.com` 发信,还需要确认当前发件服务是否允许该子域名作为发件身份;如服务商要求,请单独验证子域名
- 🚫 **反垃圾邮件**:请遵守邮件发送规范,避免发送垃圾邮件
- 📊 **配额监控**:系统会自动统计每日发件数量,达到限额后将无法继续发送
- 🔄 **配额重置**:每日发件配额在每天 00:00 自动重置
Expand Down Expand Up @@ -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"
}
```
响应字段说明:
Expand Down Expand Up @@ -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"
}'
```

Expand Down Expand Up @@ -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 <id> --timeout 120 --json
Expand All @@ -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')

Expand Down
30 changes: 25 additions & 5 deletions app/api/emails/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)) {
Expand All @@ -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())
})
Expand Down Expand Up @@ -102,4 +122,4 @@ export async function POST(request: Request) {
{ status: 500 }
)
}
}
}
59 changes: 47 additions & 12 deletions app/components/emails/create-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 () => {
Expand All @@ -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", {
Expand All @@ -56,6 +75,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
body: JSON.stringify({
name: emailName,
domain: currentDomain,
subdomain: normalizedSubdomain || undefined,
expiryTime: parseInt(expiryTime)
})
})
Expand All @@ -77,6 +97,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
onEmailCreated()
setOpen(false)
setEmailName("")
setSubdomain("")
} catch {
toast({
title: tList("error"),
Expand Down Expand Up @@ -114,6 +135,23 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
placeholder={t("namePlaceholder")}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={generateRandomName}
type="button"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>

<div className="flex gap-2">
<Input
value={subdomain}
onChange={(e) => setSubdomain(e.target.value)}
placeholder={t("subdomainPlaceholder")}
className="flex-1"
/>
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
<Select value={currentDomain} onValueChange={setCurrentDomain}>
<SelectTrigger className="w-[180px]">
Expand All @@ -126,14 +164,11 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
</SelectContent>
</Select>
)}
<Button
variant="outline"
size="icon"
onClick={generateRandomName}
type="button"
>
<RefreshCw className="w-4 h-4" />
</Button>
{(config?.emailDomainsArray?.length ?? 0) <= 1 && (
<div className="flex min-w-[180px] items-center rounded-md border px-3 text-sm text-muted-foreground">
@{currentDomain || t("domainPlaceholder")}
</div>
)}
</div>

<div className="flex items-center gap-4">
Expand All @@ -159,9 +194,9 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {

<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="shrink-0">{t("domain")}:</span>
{emailName ? (
{previewAddress ? (
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{`${emailName}@${currentDomain}`}</span>
<span className="truncate">{previewAddress}</span>
<div
className="shrink-0 cursor-pointer hover:text-primary transition-colors"
onClick={copyEmailAddress}
Expand All @@ -185,4 +220,4 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
</DialogContent>
</Dialog>
)
}
}
Loading