29 Commits

Author SHA1 Message Date
ty
f5d49790a7 feat: integrate theme support in message view for improved styling 2025-03-16 15:43:04 +08:00
ty
6ddd5bdf4e chore: enhance test data generation with user identification 2025-03-16 15:34:50 +08:00
ty
e8e2349a97 fix: Github Action failed to build pages 2025-03-16 15:25:29 +08:00
BeilunYang
fd46bf2661 Merge pull request #38 from jieyitang/master
feat: Add delete function for single message
2025-03-16 15:17:36 +08:00
jieyitang
21d09a2cb0 feat: add delete function for single email message 2025-03-14 16:52:38 +08:00
jieyitang
eb8023280b fix: prevent unnecessary requests when switching emails 2025-03-14 16:48:54 +08:00
beilunyang
58e6d06bed docs: Add GitHub OAuth configuration variables to README 2025-03-09 12:40:03 +08:00
beilunyang
436666a88b fix: Breaking update due to new deployment scripts 2025-03-09 12:03:35 +08:00
beilunyang
dae8122231 feat: Update API keys index to enforce unique name per user 2025-03-09 11:28:43 +08:00
beilunyang
7210c68fbd feat: Add optional database and KV namespace IDs to deployment workflow 2025-03-09 11:17:21 +08:00
beilunyang
994ab8acc3 feat: Modify apikeys schema and README configuration files 2025-03-09 11:07:21 +08:00
beilunyang
c405c02a34 docs: Update deployment instructions in README 2025-03-09 10:57:07 +08:00
beilunyang
d9fb486104 feat: Add optional environment variables for database and KV namespace configuration 2025-03-09 10:22:06 +08:00
BeilunYang
77cb52e608 Merge pull request #34 from sdrpsps/chore/deploy-script
chore: Update deploy script
2025-03-09 10:08:07 +08:00
sunny
16bc357973 chore: Remove environment variable DATABASE_ID and KV_NAMESPACE_ID, auto-pagination kvNamespaces 2025-03-06 14:33:56 +08:00
sunny
b75d9ada43 chore: Update checking kv namespace function to avoid failed to create namespace 2025-03-05 13:28:45 +08:00
sunny
da979d2a51 chore: Update and simplify deployment workflow 2025-03-05 11:58:15 +08:00
sunny
200d82f874 chore: Extract and push only runtime-required environment variables 2025-03-05 11:16:00 +08:00
sunny
ed8885a2d8 chore: Change environment variable PROJECT_URL to CUSTOM_DOMAIN 2025-03-05 11:12:19 +08:00
sunny
cd429b96d8 fix: Database migration failed 2025-03-04 17:31:54 +08:00
sunny
5173cbf9d3 chore: Update deploy script 2025-03-04 16:38:15 +08:00
beilunyang
9b7ed0b031 docs: Add system settings section to README with configuration details 2025-03-01 10:40:59 +08:00
beilunyang
ea7fd5490c refactor: Consolidate configuration management with Zustand store 2025-03-01 10:29:50 +08:00
beilunyang
b1d898e298 feat: Add configurable maximum email limit for users 2025-02-28 00:30:37 +08:00
beilunyang
f86d944c25 feat: Add role-based email limit exemption for emperors 2025-02-27 23:59:36 +08:00
BeilunYang
59671091b6 docs: Update Contract QRCode 2025-02-21 17:08:58 +08:00
BeilunYang
19d805de57 Merge pull request #26 from Ktovoz/updateReadme
docs:补充 Catch-All 状态不可用时的解决方案- 在 README.md 文件中添加了新的注意事项
2025-02-21 16:13:53 +08:00
ktoWYY
2566a8a105 docs:补充 Catch-All 状态不可用时的解决方案- 在 README.md 文件中添加了新的注意事项
- 提供了当 Catch-All 状态不可用时的替代方案,即绑定一个邮箱
2025-02-21 15:41:48 +08:00
beilunyang
821a32aa4b feat: Add GitHub link float menu button 2025-02-17 23:22:39 +08:00
40 changed files with 2238 additions and 431 deletions

View File

@@ -1,3 +1,10 @@
AUTH_GITHUB_ID = ""
AUTH_GITHUB_SECRET = ""
AUTH_SECRET = ""
AUTH_SECRET = ""
CLOUDFLARE_API_TOKEN = ""
CLOUDFLARE_ACCOUNT_ID = ""
DATABASE_NAME = ""
KV_NAMESPACE_NAME = ""
CUSTOM_DOMAIN = ""

View File

@@ -5,22 +5,6 @@ on:
tags:
- 'v*'
workflow_dispatch:
inputs:
run_migrations:
description: 'Run database migrations'
type: boolean
required: true
default: false
deploy_email_worker:
description: 'Deploy email Worker'
type: boolean
required: true
default: false
deploy_cleanup_worker:
description: 'Deploy cleanup Worker'
type: boolean
required: true
default: false
jobs:
deploy:
@@ -51,112 +35,23 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile
# Check if database migrations have changes
- name: Check migrations changes
id: check_migrations
if: github.event_name == 'push'
- name: Run deploy script
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PROJECT_NAME: ${{ secrets.PROJECT_NAME }}
DATABASE_NAME: ${{ secrets.DATABASE_NAME }}
DATABASE_ID: ${{ secrets.DATABASE_ID }}
KV_NAMESPACE_NAME: ${{ secrets.KV_NAMESPACE_NAME }}
KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID }}
CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN }}
AUTH_GITHUB_ID: ${{ secrets.AUTH_GITHUB_ID }}
AUTH_GITHUB_SECRET: ${{ secrets.AUTH_GITHUB_SECRET }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
run: pnpm dlx tsx scripts/deploy/index.ts
# Clean up
- name: Post deployment cleanup
run: |
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
echo "migrations_changed=true" >> $GITHUB_OUTPUT
else
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "^drizzle/"; then
echo "migrations_changed=true" >> $GITHUB_OUTPUT
else
echo "migrations_changed=false" >> $GITHUB_OUTPUT
fi
fi
# Process configuration files
- name: Process configuration files
run: |
# Process wrangler.example.toml
if [ -f wrangler.example.toml ]; then
cp wrangler.example.toml wrangler.toml
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.toml
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.toml
sed -i "s/id = \"\"/id = \"${{ secrets.KV_NAMESPACE_ID }}\"/" wrangler.toml
fi
# Process wrangler.email.example.toml
if [ -f wrangler.email.example.toml ]; then
cp wrangler.email.example.toml wrangler.email.toml
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.email.toml
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.email.toml
fi
# Process wrangler.cleanup.example.toml
if [ -f wrangler.cleanup.example.toml ]; then
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
sed -i "s/database_name = \".*\"/database_name = \"${{ secrets.DATABASE_NAME }}\"/" wrangler.cleanup.toml
sed -i "s/database_id = \".*\"/database_id = \"${{ secrets.DATABASE_ID }}\"/" wrangler.cleanup.toml
fi
# Run database migrations if needed
- name: Run database migrations
if: |
github.event_name == 'push' && steps.check_migrations.outputs.migrations_changed == 'true' ||
github.event_name == 'workflow_dispatch' && github.event.inputs.run_migrations == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm db:migrate-remote
# Check if workers have changes
- name: Check workers changes
id: check_changes
if: github.event_name == 'push'
run: |
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
# Check email worker and its dependencies
if git ls-files | grep -q -E "workers/email-receiver.ts|app/lib/schema.ts|app/lib/webhook.ts|app/config/webhook.ts"; then
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
else
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
fi
# Check cleanup worker
if git ls-files | grep -q "workers/cleanup.ts"; then
echo "cleanup_worker_changed=true" >> $GITHUB_OUTPUT
else
echo "cleanup_worker_changed=false" >> $GITHUB_OUTPUT
fi
else
# Check email worker and its dependencies changes
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q -E "workers/email-receiver.ts|app/lib/schema.ts|app/lib/webhook.ts|app/config/webhook.ts"; then
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
else
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
fi
# Check cleanup worker changes
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "workers/cleanup.ts"; then
echo "cleanup_worker_changed=true" >> $GITHUB_OUTPUT
else
echo "cleanup_worker_changed=false" >> $GITHUB_OUTPUT
fi
fi
# Deploy Pages application
- name: Deploy Pages
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm run deploy:pages
# Deploy email worker if changed or manually triggered
- name: Deploy Email Worker
if: |
github.event_name == 'push' && steps.check_changes.outputs.email_worker_changed == 'true' ||
github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_email_worker == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm run deploy:email
# Deploy cleanup worker if changed or manually triggered
- name: Deploy Cleanup Worker
if: |
github.event_name == 'push' && steps.check_changes.outputs.cleanup_worker_changed == 'true' ||
github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_cleanup_worker == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm run deploy:cleanup
rm -f .env*.*
rm -f wrangler*.json

6
.gitignore vendored
View File

@@ -46,4 +46,8 @@ wrangler.email.toml
wrangler.cleanup.toml
public/workbox-*.js
public/sw.js
public/sw.js
wrangler.json
wrangler.cleanup.json
wrangler.email.json

135
README.md
View File

@@ -15,6 +15,7 @@
<a href="#部署">部署</a> •
<a href="#邮箱域名配置">邮箱域名配置</a> •
<a href="#权限系统">权限系统</a> •
<a href="#系统设置">系统设置</a> •
<a href="#Webhook 集成">Webhook 集成</a> •
<a href="#OpenAPI">OpenAPI</a> •
<a href="#环境变量">环境变量</a> •
@@ -86,9 +87,9 @@ pnpm install
3. 设置 wrangler
```bash
cp wrangler.example.toml wrangler.toml
cp wrangler.email.example.toml wrangler.email.toml
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
cp wrangler.example.json wrangler.json
cp wrangler.email.example.json wrangler.email.json
cp wrangler.cleanup.example.json wrangler.cleanup.json
```
设置 Cloudflare D1 数据库名以及数据库 ID
@@ -128,67 +129,16 @@ pnpm generate-test-data
```
## 部署
### 视频版部署教程
https://www.bilibili.com/video/BV19wrXY2ESM/
### 部署前准备
在开始部署之前,需要在 Cloudflare 控制台完成以下准备工作:
1. **创建 D1 数据库**
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)
- 选择 “存储与数据库” -> “D1 SQL 数据库”
- 创建一个数据库例如moemail
- 记录下数据库名称和数据库 ID后续配置需要用到
2. **创建 KV 命名空间**
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)
- 选择 “存储与数据库” -> “KV”
- 创建一个 KV 命名空间例如moemail
- 记录下命名空间 ID后续配置需要用到
3. **创建 Pages 项目**
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/)
- 选择 “Workers 和 Pages”
- 点击 “创建” 并选择 “Pages” 标签
- 选择 “使用直接上传创建”
- 点击 “上传资产”
- 输入项目名称
- 注意:项目名称必须为 moemail否则无法正常部署
- 输入项目名称后,点击 “创建项目” 即可,不需要上传任何文件以及点击“部署站点”,之后我们会通过 本地运行Wrangler 或者通过 Github Actions 自动部署
4. **为 Pages 项目添加 AUTH 认证相关的 SECRETS**
- 在 Overview 中选择刚刚创建的 Pages 项目
- 在 Settings 中选择变量和机密
- 添加 AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_SECRET
### 本地 Wrangler 部署
1. 设置 wrangler
1. 创建 .env 文件
```bash
cp wrangler.example.toml wrangler.toml
cp wrangler.email.example.toml wrangler.email.toml
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
cp .env.example .env
```
设置 Cloudflare D1 数据库名以及数据库 ID
2. 在 .env 文件中设置[环境变量](#环境变量)
2. 创建云端 D1 数据库表结构
3. 运行部署脚本
```bash
pnpm db:migrate-remote
```
2. 部署主应用到 Cloudflare Pages
```bash
pnpm deploy:pages
```
3. 部署邮件 worker
```bash
pnpm deploy:email
```
4. 部署清理 worker
```bash
pnpm deploy:cleanup
pnpm dlx tsx ./scripts/deploy/index.ts
```
### Github Actions 部署
@@ -196,10 +146,7 @@ pnpm deploy:cleanup
本项目可使用 GitHub Actions 实现自动化部署。支持以下触发方式:
1. **自动触发**:推送新的 tag 时自动触发部署流程
2. **手动触发**:在 GitHub Actions 页面手动触发,可选择以下部署选项:
- Run database migrations执行数据库迁移
- Deploy email Worker重新部署邮件 Worker
- Deploy cleanup Worker重新部署清理 Worker
2. **手动触发**:在 GitHub Actions 页面手动触发
#### 部署步骤
@@ -207,8 +154,11 @@ pnpm deploy:cleanup
- `CLOUDFLARE_API_TOKEN`: Cloudflare API 令牌
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID
- `DATABASE_NAME`: D1 数据库名称
- `DATABASE_ID`: D1 数据库 ID
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID用于存储网站配置
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
- `AUTH_SECRET`: NextAuth Secret用来加密 session请设置一个随机字符串
- `CUSTOM_DOMAIN`: 网站自定义域名,用于访问 MoeMail (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
2. 选择触发方式:
@@ -225,23 +175,12 @@ pnpm deploy:cleanup
- 进入仓库的 Actions 页面
- 选择 "Deploy" workflow
- 点击 "Run workflow"
- 选择需要执行的部署选项
- 点击 "Run workflow" 开始部署
3. GitHub Actions 会自动执行以下任务:
- 构建并部署主应用到 Cloudflare Pages
- 根据选项或文件变更执行数据库迁移
- 根据选项或文件变更部署 Email Worker
- 根据选项或文件变更部署 Cleanup Worker
4. 部署进度可以在仓库的 Actions 标签页查看
3. 部署进度可以在仓库的 Actions 标签页查看
#### 注意事项
- 确保所有 Secrets 都已正确设置
- 使用 tag 触发时tag 必须以 `v` 开头例如v1.0.0
- 使用 tag 触发时,只有文件发生变更的部分会被部署
- 手动触发时,可以选择性地执行特定的部署任务
- 每次部署都会重新部署主应用
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
@@ -273,6 +212,7 @@ pnpm deploy:cleanup
### 注意事项
- 确保域名的 DNS 托管在 Cloudflare
- Email Worker 必须已经部署成功
- 如果 Catch-All 状态不可用(一直 loading),请点击`路由规则`旁边的`目标地址`, 进去绑定一个邮箱
## 权限系统
@@ -327,8 +267,18 @@ pnpm deploy:cleanup
- **Webhook 管理**:配置邮件通知的 Webhook
- **API Key 管理**:创建和管理 API 访问密钥
- **用户管理**:升降用户角色
- **系统置**:管理系统全局设置
- **系统置**:管理系统全局设置
## 系统设置
系统设置存储在 Cloudflare KV 中,包括以下内容:
- `DEFAULT_ROLE`: 新注册用户默认角色,可选值为 `CIVILIAN`、`KNIGHT`、`DUKE`
- `EMAIL_DOMAINS`: 支持的邮箱域名,多个域名用逗号分隔
- `ADMIN_CONTACT`: 管理员联系方式
- `MAX_EMAILS`: 每个用户可创建的最大邮箱数量
**皇帝**角色可以在个人中心页面设置
## Webhook 集成
@@ -470,8 +420,10 @@ const data = await res.json();
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare Account ID
- `DATABASE_NAME`: D1 数据库名称
- `DATABASE_ID`: D1 数据库 ID
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
- `DATABASE_ID`: D1 数据库 ID (可选, 如果不填, 则会自动通过 Cloudflare API 获取)
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID用于存储网站配置 (可选, 如果不填, 则会自动通过 Cloudflare API 获取)
- `CUSTOM_DOMAIN`: 网站自定义域名, 如moemail.app (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
## Github OAuth App 配置
@@ -490,10 +442,25 @@ const data = await res.json();
本项目采用 [MIT](LICENSE) 许可证
## 交流
<img src="https://pic.otaku.ren/20250210/AQADOMUxG7BRUFV-.jpg" style="width: 400px;"/>
<br />
如二维码失效请添加我的个人微信hansenones并备注 "MoeMail" 加入微信交流群
## 交流
<table>
<tr style="max-width: 360px">
<td>
<img src="https://pic.otaku.ren/20250309/AQADAcQxGxQjaVZ-.jpg" />
</td>
<td>
<img src="https://pic.otaku.ren/20250309/AQADCMQxGxQjaVZ-.jpg" />
</td>
</tr>
<tr style="max-width: 360px">
<td>
关注公众号了解更多项目进展以及AI区块链独立开发资讯
</td>
<td>
添加微信,备注 "MoeMail" 拉你进微信交流群
</td>
</tr>
</table>
## 支持

View File

@@ -1,12 +0,0 @@
import { getRequestContext } from "@cloudflare/next-on-pages"
export const runtime = "edge"
export async function GET() {
const env = getRequestContext().env
const adminContact = await env.SITE_CONFIG.get("ADMIN_CONTACT")
return Response.json({
adminContact: adminContact || ""
})
}

View File

@@ -1,28 +1,32 @@
import { Role, ROLES } from "@/lib/permissions"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { EMAIL_CONFIG } from "@/config"
export const runtime = "edge"
export async function GET() {
const env = getRequestContext().env
const [defaultRole, emailDomains, adminContact] = await Promise.all([
const [defaultRole, emailDomains, adminContact, maxEmails] = await Promise.all([
env.SITE_CONFIG.get("DEFAULT_ROLE"),
env.SITE_CONFIG.get("EMAIL_DOMAINS"),
env.SITE_CONFIG.get("ADMIN_CONTACT")
env.SITE_CONFIG.get("ADMIN_CONTACT"),
env.SITE_CONFIG.get("MAX_EMAILS")
])
return Response.json({
defaultRole: defaultRole || ROLES.CIVILIAN,
emailDomains: emailDomains || "",
adminContact: adminContact || ""
emailDomains: emailDomains || "moemail.app",
adminContact: adminContact || "",
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
})
}
export async function POST(request: Request) {
const { defaultRole, emailDomains, adminContact } = await request.json() as {
const { defaultRole, emailDomains, adminContact, maxEmails } = await request.json() as {
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
emailDomains: string,
adminContact: string
adminContact: string,
maxEmails: string
}
if (![ROLES.DUKE, ROLES.KNIGHT, ROLES.CIVILIAN].includes(defaultRole)) {
@@ -33,7 +37,8 @@ export async function POST(request: Request) {
await Promise.all([
env.SITE_CONFIG.put("DEFAULT_ROLE", defaultRole),
env.SITE_CONFIG.put("EMAIL_DOMAINS", emailDomains),
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact)
env.SITE_CONFIG.put("ADMIN_CONTACT", adminContact),
env.SITE_CONFIG.put("MAX_EMAILS", maxEmails)
])
return Response.json({ success: true })

View File

@@ -5,6 +5,56 @@ import { and, eq } from "drizzle-orm"
import { getUserId } from "@/lib/apiKey"
export const runtime = "edge"
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string; messageId: string }> }
) {
const userId = await getUserId()
try {
const db = createDb()
const { id, messageId } = await params
const email = await db.query.emails.findFirst({
where: and(
eq(emails.id, id),
eq(emails.userId, userId!)
)
})
if (!email) {
return NextResponse.json(
{ error: "Email not found or no permission to view" },
{ status: 403 }
)
}
const message = await db.query.messages.findFirst({
where: and(
eq(messages.emailId, id),
eq(messages.id, messageId)
)
})
if(!message) {
return NextResponse.json(
{ error: "Message not found or already deleted" },
{ status: 404 }
)
}
await db.delete(messages)
.where(eq(messages.id, messageId))
return NextResponse.json({ success: true })
} catch (error) {
console.error('Failed to delete email:', error)
return NextResponse.json(
{ error: "Failed to delete message" },
{ status: 500 }
)
}
}
export async function GET(_request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
try {
const { id, messageId } = await params

View File

@@ -1,18 +0,0 @@
import { getRequestContext } from "@cloudflare/next-on-pages"
import { NextResponse } from "next/server"
export const runtime = "edge"
export async function GET() {
try {
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
return NextResponse.json({ domains: domainString ? domainString.split(',') : ["moemail.app"] })
} catch (error) {
console.error('Failed to fetch domains:', error)
return NextResponse.json(
{ error: "获取域名列表失败" },
{ status: 500 }
)
}
}

View File

@@ -7,29 +7,37 @@ import { EXPIRY_OPTIONS } from "@/types/email"
import { EMAIL_CONFIG } from "@/config"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { getUserId } from "@/lib/apiKey"
import { getUserRole } from "@/lib/auth"
import { ROLES } from "@/lib/permissions"
export const runtime = "edge"
export async function POST(request: Request) {
const db = createDb()
const env = getRequestContext().env
const userId = await getUserId()
const userRole = await getUserRole(userId!)
try {
const activeEmailsCount = await db
.select({ count: sql<number>`count(*)` })
.from(emails)
.where(
and(
eq(emails.userId, userId!),
gt(emails.expiresAt, new Date())
if (userRole !== ROLES.EMPEROR) {
const maxEmails = await env.SITE_CONFIG.get("MAX_EMAILS") || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
const activeEmailsCount = await db
.select({ count: sql<number>`count(*)` })
.from(emails)
.where(
and(
eq(emails.userId, userId!),
gt(emails.expiresAt, new Date())
)
)
)
if (Number(activeEmailsCount[0].count) >= EMAIL_CONFIG.MAX_ACTIVE_EMAILS) {
return NextResponse.json(
{ error: `已达到最大邮箱数量限制 (${EMAIL_CONFIG.MAX_ACTIVE_EMAILS})` },
{ status: 403 }
)
if (Number(activeEmailsCount[0].count) >= Number(maxEmails)) {
return NextResponse.json(
{ error: `已达到最大邮箱数量限制 (${maxEmails})` },
{ status: 403 }
)
}
}
const { name, expiryTime, domain } = await request.json<{
@@ -45,7 +53,7 @@ export async function POST(request: Request) {
)
}
const domainString = await getRequestContext().env.SITE_CONFIG.get("EMAIL_DOMAINS")
const domainString = await env.SITE_CONFIG.get("EMAIL_DOMAINS")
const domains = domainString ? domainString.split(',') : ["moemail.app"]
if (!domains || !domains.includes(domain)) {

View File

@@ -12,16 +12,17 @@ import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { EXPIRY_OPTIONS } from "@/types/email"
import { useCopy } from "@/hooks/use-copy"
import { useConfig } from "@/hooks/use-config"
interface CreateDialogProps {
onEmailCreated: () => void
}
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
const { config } = useConfig()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [emailName, setEmailName] = useState("")
const [domains, setDomains] = useState<string[]>([])
const [currentDomain, setCurrentDomain] = useState("")
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const { toast } = useToast()
@@ -83,16 +84,11 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
}
}
const fetchDomains = async () => {
const response = await fetch("/api/emails/domains");
const data = (await response.json()) as { domains: string[] };
setDomains(data.domains || []);
setCurrentDomain(data.domains[0] || "");
};
useEffect(() => {
fetchDomains()
}, [])
if ((config?.emailDomainsArray?.length ?? 0) > 0) {
setCurrentDomain(config?.emailDomainsArray[0] ?? "")
}
}, [config])
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -114,13 +110,13 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
placeholder="输入邮箱名"
className="flex-1"
/>
{domains.length > 1 && (
{(config?.emailDomainsArray?.length ?? 0) > 1 && (
<Select value={currentDomain} onValueChange={setCurrentDomain}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domains.map(d => (
{config?.emailDomainsArray?.map(d => (
<SelectItem key={d} value={d}>@{d}</SelectItem>
))}
</SelectContent>

View File

@@ -19,6 +19,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { ROLES } from "@/lib/permissions"
import { useUserRole } from "@/hooks/use-user-role"
import { useConfig } from "@/hooks/use-config"
interface Email {
id: string
@@ -40,6 +43,8 @@ interface EmailResponse {
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
const { data: session } = useSession()
const { config } = useConfig()
const { role } = useUserRole()
const [emails, setEmails] = useState<Email[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -109,7 +114,6 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
useEffect(() => {
if (session) fetchEmails()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session])
const handleDelete = async (email: Email) => {
@@ -167,7 +171,11 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS}
{role === ROLES.EMPEROR ? (
`${total}/∞ 个邮箱`
) : (
`${total}/${config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱`
)}
</span>
</div>
<CreateDialog onEmailCreated={handleRefresh} />

View File

@@ -1,11 +1,22 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Mail, Calendar, RefreshCw } from "lucide-react"
import {Mail, Calendar, RefreshCw, Trash2} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useThrottle } from "@/hooks/use-throttle"
import { EMAIL_CONFIG } from "@/config"
import { useToast } from "@/components/ui/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from "@/components/ui/alert-dialog";
interface Message {
id: string
@@ -19,7 +30,7 @@ interface MessageListProps {
id: string
address: string
}
onMessageSelect: (messageId: string) => void
onMessageSelect: (messageId: string | null) => void
selectedMessageId?: string | null
}
@@ -38,6 +49,8 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
const pollTimeoutRef = useRef<Timer>()
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
const [total, setTotal] = useState(0)
const [messageToDelete, setMessageToDelete] = useState<Message | null>(null)
const { toast } = useToast()
// 当 messages 改变时更新 ref
useEffect(() => {
@@ -118,6 +131,44 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
}
}, 200)
const handleDelete = async (message: Message) => {
try {
const response = await fetch(`/api/emails/${email.id}/${message.id}`, {
method: "DELETE"
})
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
description: (data as { error: string }).error,
variant: "destructive"
})
return
}
setMessages(prev => prev.filter(e => e.id !== message.id))
setTotal(prev => prev - 1)
toast({
title: "成功",
description: "邮件已删除"
})
if (selectedMessageId === message.id) {
onMessageSelect(null)
}
} catch {
toast({
title: "错误",
description: "删除邮件失败",
variant: "destructive"
})
} finally {
setMessageToDelete(null)
}
}
useEffect(() => {
if (!email.id) {
return
@@ -134,6 +185,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
}, [email.id])
return (
<>
<div className="h-full flex flex-col">
<div className="p-2 flex justify-between items-center border-b border-primary/20">
<Button
@@ -160,7 +212,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
key={message.id}
onClick={() => onMessageSelect(message.id)}
className={cn(
"p-3 hover:bg-primary/5 cursor-pointer",
"p-3 hover:bg-primary/5 cursor-pointer group",
selectedMessageId === message.id && "bg-primary/10"
)}
>
@@ -176,6 +228,17 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setMessageToDelete(message)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
@@ -192,5 +255,25 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
)}
</div>
</div>
<AlertDialog open={!!messageToDelete} onOpenChange={() => setMessageToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{messageToDelete?.subject}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => messageToDelete && handleDelete(messageToDelete)}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"
import { Loader2 } from "lucide-react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { useTheme } from "next-themes"
interface Message {
id: string
@@ -27,6 +28,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>("html")
const iframeRef = useRef<HTMLIFrameElement>(null)
const { theme } = useTheme()
useEffect(() => {
const fetchMessage = async () => {
@@ -47,8 +49,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
fetchMessage()
}, [emailId, messageId])
// 处理 iframe 内容
useEffect(() => {
const updateIframeContent = () => {
if (viewMode === "html" && message?.html && iframeRef.current) {
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
@@ -66,8 +67,8 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
padding: 0;
min-height: 100%;
font-family: system-ui, -apple-system, sans-serif;
color: ${document.documentElement.classList.contains('dark') ? '#fff' : '#000'};
background: transparent;
color: ${theme === 'dark' ? '#fff' : '#000'};
background: ${theme === 'dark' ? '#1a1a1a' : '#fff'};
}
body {
padding: 20px;
@@ -88,21 +89,21 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: ${document.documentElement.classList.contains('dark')
? 'rgba(130, 109, 217, 0.3)'
background: ${theme === 'dark'
? 'rgba(130, 109, 217, 0.3)'
: 'rgba(130, 109, 217, 0.2)'};
border-radius: 9999px;
transition: background-color 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: ${document.documentElement.classList.contains('dark')
background: ${theme === 'dark'
? 'rgba(130, 109, 217, 0.5)'
: 'rgba(130, 109, 217, 0.4)'};
}
/* Firefox 滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: ${document.documentElement.classList.contains('dark')
scrollbar-color: ${theme === 'dark'
? 'rgba(130, 109, 217, 0.3) transparent'
: 'rgba(130, 109, 217, 0.2) transparent'};
}
@@ -139,7 +140,12 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
}
}
}
}, [message?.html, viewMode])
}
// 监听主题变化和内容变化
useEffect(() => {
updateIframeContent()
}, [message?.html, viewMode, theme])
if (loading) {
return (

View File

@@ -45,7 +45,10 @@ export function ThreeColumnLayout() {
</div>
<div className="flex-1 overflow-auto">
<EmailList
onEmailSelect={setSelectedEmail}
onEmailSelect={(email) => {
setSelectedEmail(email)
setSelectedMessageId(null)
}}
selectedEmailId={selectedEmail?.id}
/>
</div>

View File

@@ -0,0 +1,39 @@
"use client"
import { Github } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function FloatMenu() {
return (
<div className="fixed bottom-6 right-6">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20"
onClick={() => window.open("https://github.com/beilunyang/moemail", "_blank")}
>
<Github
className="w-4 h-4 transition-all duration-300 text-primary group-hover:scale-110"
/>
<span className="sr-only"></span>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<p></p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}

View File

@@ -2,10 +2,10 @@
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useAdminContact } from "@/hooks/use-admin-contact"
import { useConfig } from "@/hooks/use-config"
export function NoPermissionDialog() {
const router = useRouter()
const { adminContact } = useAdminContact()
const { config } = useConfig()
return (
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
@@ -15,8 +15,8 @@ export function NoPermissionDialog() {
<h1 className="text-xl md:text-2xl font-bold"></h1>
<p className="text-sm md:text-base text-muted-foreground">访</p>
{
adminContact && (
<p className="text-sm md:text-base text-muted-foreground">{adminContact}</p>
config?.adminContact && (
<p className="text-sm md:text-base text-muted-foreground">{config.adminContact}</p>
)
}
<Button

View File

@@ -20,7 +20,7 @@ import { Label } from "@/components/ui/label"
import { useCopy } from "@/hooks/use-copy"
import { useRolePermission } from "@/hooks/use-role-permission"
import { PERMISSIONS } from "@/lib/permissions"
import { useAdminContact } from "@/hooks/use-admin-contact"
import { useConfig } from "@/hooks/use-config"
type ApiKey = {
id: string
@@ -68,7 +68,7 @@ export function ApiKeyPanel() {
}
}, [canManageApiKey])
const { adminContact } = useAdminContact()
const { config } = useConfig()
const createApiKey = async () => {
if (!newKeyName.trim()) return
@@ -248,8 +248,8 @@ export function ApiKeyPanel() {
<p> API Key</p>
<p className="mt-2"></p>
{
adminContact && (
<p className="mt-2">{adminContact}</p>
config?.adminContact && (
<p className="mt-2">{config.adminContact}</p>
)
}
</div>

View File

@@ -13,11 +13,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { EMAIL_CONFIG } from "@/config"
export function ConfigPanel() {
const [defaultRole, setDefaultRole] = useState<string>("")
const [emailDomains, setEmailDomains] = useState<string>("")
const [adminContact, setAdminContact] = useState<string>("")
const [maxEmails, setMaxEmails] = useState<string>(EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
const [loading, setLoading] = useState(false)
const { toast } = useToast()
@@ -31,10 +33,14 @@ export function ConfigPanel() {
if (res.ok) {
const data = await res.json() as {
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>,
emailDomains: string
emailDomains: string,
adminContact: string,
maxEmails: string
}
setDefaultRole(data.defaultRole)
setEmailDomains(data.emailDomains)
setAdminContact(data.adminContact)
setMaxEmails(data.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString())
}
}
@@ -47,7 +53,8 @@ export function ConfigPanel() {
body: JSON.stringify({
defaultRole,
emailDomains,
adminContact
adminContact,
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
}),
})
@@ -112,6 +119,20 @@ export function ConfigPanel() {
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<div className="flex-1">
<Input
type="number"
min="1"
max="100"
value={maxEmails}
onChange={(e) => setMaxEmails(e.target.value)}
placeholder={`默认为 ${EMAIL_CONFIG.MAX_ACTIVE_EMAILS}`}
/>
</div>
</div>
<Button
onClick={handleSave}
disabled={loading}

View File

@@ -1,38 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { useToast } from "@/components/ui/use-toast"
export function useAdminContact() {
const [adminContact, setAdminContact] = useState("")
const [loading, setLoading] = useState(true)
const { toast } = useToast()
const fetchAdminContact = async () => {
try {
const res = await fetch("/api/admin-contact")
if (!res.ok) throw new Error("获取管理员联系方式失败")
const data = await res.json() as { adminContact: string }
setAdminContact(data.adminContact)
} catch (error) {
console.error(error)
toast({
title: "获取失败",
description: "获取管理员联系方式失败",
variant: "destructive"
})
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAdminContact()
}, [])
return {
adminContact,
loading,
refreshAdminContact: fetchAdminContact
}
}

62
app/hooks/use-config.ts Normal file
View File

@@ -0,0 +1,62 @@
"use client"
import { create } from "zustand"
import { Role, ROLES } from "@/lib/permissions"
import { EMAIL_CONFIG } from "@/config"
import { useEffect } from "react"
interface Config {
defaultRole: Exclude<Role, typeof ROLES.EMPEROR>
emailDomains: string
emailDomainsArray: string[]
adminContact: string
maxEmails: number
}
interface ConfigStore {
config: Config | null
loading: boolean
error: string | null
fetch: () => Promise<void>
}
const useConfigStore = create<ConfigStore>((set) => ({
config: null,
loading: false,
error: null,
fetch: async () => {
try {
set({ loading: true, error: null })
const res = await fetch("/api/config")
if (!res.ok) throw new Error("获取配置失败")
const data = await res.json() as Config
set({
config: {
defaultRole: data.defaultRole || ROLES.CIVILIAN,
emailDomains: data.emailDomains,
emailDomainsArray: data.emailDomains.split(','),
adminContact: data.adminContact || "",
maxEmails: Number(data.maxEmails) || EMAIL_CONFIG.MAX_ACTIVE_EMAILS
},
loading: false
})
} catch (error) {
set({
error: error instanceof Error ? error.message : "获取配置失败",
loading: false
})
}
}
}))
export function useConfig() {
const store = useConfigStore()
useEffect(() => {
if (!store.config && !store.loading) {
store.fetch()
}
}, [store.config, store.loading])
return store
}

View File

@@ -0,0 +1,21 @@
"use client"
import { useSession } from "next-auth/react"
import { Role } from "@/lib/permissions"
import { useEffect, useState } from "react"
export function useUserRole() {
const { data: session } = useSession()
const [role, setRole] = useState<Role | null>(null)
useEffect(() => {
if (session?.user?.roles?.[0]?.name) {
setRole(session.user.roles[0].name as Role)
}
}, [session])
return {
role,
loading: !session
}
}

View File

@@ -5,6 +5,7 @@ import type { Metadata, Viewport } from "next"
import { zpix } from "./fonts"
import "./globals.css"
import { Providers } from "./providers"
import { FloatMenu } from "@/components/float-menu"
export const metadata: Metadata = {
title: "MoeMail - 萌萌哒临时邮箱服务",
@@ -98,6 +99,7 @@ export default function RootLayout({
{children}
</Providers>
<Toaster />
<FloatMenu />
</ThemeProvider>
</body>
</html>

View File

@@ -52,6 +52,15 @@ export async function assignRoleToUser(db: Db, userId: string, roleId: string) {
})
}
export async function getUserRole(userId: string) {
const db = createDb()
const userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, userId),
with: { role: true },
})
return userRoleRecords[0].role.name
}
export async function checkPermission(permission: Permission) {
const session = await auth()
if (!session?.user?.id) return false

View File

@@ -1,4 +1,4 @@
import { integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"
import { integer, sqliteTable, text, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
import type { AdapterAccountType } from "next-auth/adapters"
import { relations } from 'drizzle-orm';
@@ -96,12 +96,14 @@ export const userRoles = sqliteTable("user_role", {
export const apiKeys = sqliteTable('api_keys', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull().unique(),
name: text('name').notNull(),
key: text('key').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
expiresAt: integer('expires_at', { mode: 'timestamp' }),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
});
}, (table) => ({
nameUserIdUnique: uniqueIndex('name_user_id_unique').on(table.name, table.userId)
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS `api_keys_name_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `name_user_id_unique` ON `api_keys` (`name`,`user_id`);

View File

@@ -0,0 +1,609 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b43606bc-3df5-471f-ac46-2c2eb52b1440",
"prevId": "75ed0edc-e2f7-4782-b317-e16de23405f8",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"columns": [
"key"
],
"isUnique": true
},
"name_user_id_unique": {
"name": "name_user_id_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"api_keys_user_id_user_id_fk": {
"name": "api_keys_user_id_user_id_fk",
"tableFrom": "api_keys",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -78,6 +78,13 @@
"when": 1739157879946,
"tag": "0010_brief_stellaris",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1741490895849,
"tag": "0011_simple_vulcan",
"breakpoints": true
}
]
}

View File

@@ -32,6 +32,10 @@ export async function middleware(request: Request) {
)
}
if (pathname === '/api/config' && request.method === 'GET') {
return NextResponse.next()
}
for (const [route, permission] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(route)) {
const hasAccess = await checkPermission(permission)
@@ -56,6 +60,5 @@ export const config = {
'/api/roles/:path*',
'/api/config/:path*',
'/api/api-keys/:path*',
'/api/admin-contact',
]
}

View File

@@ -8,14 +8,14 @@
"start": "next start",
"lint": "next lint",
"build:pages": "npx @cloudflare/next-on-pages",
"db:migrate-local": "bun run scripts/migrate.ts local",
"db:migrate-remote": "bun run scripts/migrate.ts remote",
"db:migrate-local": "tsx scripts/migrate.ts local",
"db:migrate-remote": "tsx scripts/migrate.ts remote",
"webhook-test-server": "bun run scripts/webhook-test-server.ts",
"generate-test-data": "wrangler dev scripts/generate-test-data.ts",
"dev:cleanup": "wrangler dev --config wrangler.cleanup.toml --test-scheduled",
"dev:cleanup": "wrangler dev --config wrangler.cleanup.json --test-scheduled",
"test:cleanup": "curl http://localhost:8787/__scheduled",
"deploy:email": "wrangler deploy --config wrangler.email.toml",
"deploy:cleanup": "wrangler deploy --config wrangler.cleanup.toml",
"deploy:email": "wrangler deploy --config wrangler.email.json",
"deploy:cleanup": "wrangler deploy --config wrangler.cleanup.json",
"deploy:pages": "npm run build:pages && wrangler pages deploy .vercel/output/static --branch main"
},
"type": "module",
@@ -48,22 +48,25 @@
"react-dom": "19.0.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241127.0",
"@iarna/toml": "^3.0.0",
"@types/bun": "^1.1.14",
"@types/next-pwa": "^5.6.9",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"bun": "^1.1.39",
"cloudflare": "^4.1.0",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.28.1",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.19.3",
"typescript": "^5",
"vercel": "39.1.1",
"wrangler": "^3.91.0"

526
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
import Cloudflare from "cloudflare";
import "dotenv/config";
const CF_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
const CF_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN;
const CUSTOM_DOMAIN = process.env.CUSTOM_DOMAIN;
const PROJECT_NAME = process.env.PROJECT_NAME || "moemail";
const DATABASE_NAME = process.env.DATABASE_NAME || "moemail-db";
const KV_NAMESPACE_NAME = process.env.KV_NAMESPACE_NAME || "moemail-kv";
const DATABASE_ID = process.env.DATABASE_ID;
const client = new Cloudflare({
apiKey: CF_API_TOKEN,
});
export const getPages = async () => {
const projectInfo = await client.pages.projects.get(PROJECT_NAME, {
account_id: CF_ACCOUNT_ID,
});
return projectInfo;
};
export const createPages = async () => {
console.log(`🆕 Creating new Cloudflare Pages project: "${PROJECT_NAME}"`);
const project = await client.pages.projects.create({
account_id: CF_ACCOUNT_ID,
name: PROJECT_NAME,
production_branch: "main",
});
if (CUSTOM_DOMAIN) {
console.log("🔗 Setting pages domain...");
await client.pages.projects.domains.create(PROJECT_NAME, {
account_id: CF_ACCOUNT_ID,
name: CUSTOM_DOMAIN,
});
console.log("✅ Pages domain set successfully");
}
console.log("✅ Project created successfully");
return project;
};
export const getDatabase = async () => {
if (DATABASE_ID) {
return {
uuid: DATABASE_ID,
}
}
const database = await client.d1.database.get(DATABASE_NAME, {
account_id: CF_ACCOUNT_ID,
});
return database;
};
export const createDatabase = async () => {
console.log(`🆕 Creating new D1 database: "${DATABASE_NAME}"`);
const database = await client.d1.database.create({
account_id: CF_ACCOUNT_ID,
name: DATABASE_NAME,
});
console.log("✅ Database created successfully");
return database;
};
export const getKVNamespaceList = async () => {
const kvNamespaces = [];
for await (const namespace of client.kv.namespaces.list({
account_id: CF_ACCOUNT_ID,
})) {
kvNamespaces.push(namespace);
}
return kvNamespaces;
};
export const createKVNamespace = async () => {
console.log(`🆕 Creating new KV namespace: "${KV_NAMESPACE_NAME}"`);
const kvNamespace = await client.kv.namespaces.create({
account_id: CF_ACCOUNT_ID,
title: KV_NAMESPACE_NAME,
});
console.log("✅ KV namespace created successfully");
return kvNamespace;
};

448
scripts/deploy/index.ts Normal file
View File

@@ -0,0 +1,448 @@
import { NotFoundError } from "cloudflare";
import "dotenv/config";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import {
createDatabase,
createKVNamespace,
createPages,
getDatabase,
getKVNamespaceList,
getPages,
} from "./cloudflare";
const PROJECT_NAME = process.env.PROJECT_NAME || "moemail";
const DATABASE_NAME = process.env.DATABASE_NAME || "moemail-db";
const KV_NAMESPACE_NAME = process.env.KV_NAMESPACE_NAME || "moemail-kv";
const CUSTOM_DOMAIN = process.env.CUSTOM_DOMAIN;
const KV_NAMESPACE_ID = process.env.KV_NAMESPACE_ID;
/**
* 验证必要的环境变量
*/
const validateEnvironment = () => {
const requiredEnvVars = ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"];
const missing = requiredEnvVars.filter((varName) => !process.env[varName]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}`
);
}
};
/**
* 处理JSON配置文件
*/
const setupConfigFile = (examplePath: string, targetPath: string) => {
try {
// 如果目标文件已存在,则跳过
if (existsSync(targetPath)) {
console.log(`✨ Configuration ${targetPath} already exists.`);
return;
}
if (!existsSync(examplePath)) {
console.log(`⚠️ Example file ${examplePath} does not exist, skipping...`);
return;
}
const configContent = readFileSync(examplePath, "utf-8");
const json = JSON.parse(configContent);
// 处理数据库配置
if (json.d1_databases && json.d1_databases.length > 0) {
json.d1_databases[0].database_name = DATABASE_NAME;
}
// 写入配置文件
writeFileSync(targetPath, JSON.stringify(json, null, 2));
console.log(`✅ Configuration ${targetPath} setup successfully.`);
} catch (error) {
console.error(`❌ Failed to setup ${targetPath}:`, error);
throw error;
}
};
/**
* 设置所有Wrangler配置文件
*/
const setupWranglerConfigs = () => {
console.log("🔧 Setting up Wrangler configuration files...");
const configs = [
{ example: "wrangler.example.json", target: "wrangler.json" },
{ example: "wrangler.email.example.json", target: "wrangler.email.json" },
{ example: "wrangler.cleanup.example.json", target: "wrangler.cleanup.json" },
];
// 处理每个配置文件
for (const config of configs) {
setupConfigFile(
resolve(config.example),
resolve(config.target)
);
}
};
/**
* 更新数据库ID到所有配置文件
*/
const updateDatabaseConfig = (dbId: string) => {
console.log(`📝 Updating database ID (${dbId}) in configurations...`);
// 更新所有配置文件
const configFiles = [
"wrangler.json",
"wrangler.email.json",
"wrangler.cleanup.json",
];
for (const filename of configFiles) {
const configPath = resolve(filename);
if (!existsSync(configPath)) continue;
try {
const json = JSON.parse(readFileSync(configPath, "utf-8"));
if (json.d1_databases && json.d1_databases.length > 0) {
json.d1_databases[0].database_id = dbId;
}
writeFileSync(configPath, JSON.stringify(json, null, 2));
console.log(`✅ Updated database ID in ${filename}`);
} catch (error) {
console.error(`❌ Failed to update ${filename}:`, error);
}
}
};
/**
* 更新KV命名空间ID到所有配置文件
*/
const updateKVConfig = (namespaceId: string) => {
console.log(`📝 Updating KV namespace ID (${namespaceId}) in configurations...`);
// KV命名空间只在主wrangler.json中使用
const wranglerPath = resolve("wrangler.json");
if (existsSync(wranglerPath)) {
try {
const json = JSON.parse(readFileSync(wranglerPath, "utf-8"));
if (json.kv_namespaces && json.kv_namespaces.length > 0) {
json.kv_namespaces[0].id = namespaceId;
}
writeFileSync(wranglerPath, JSON.stringify(json, null, 2));
console.log(`✅ Updated KV namespace ID in wrangler.json`);
} catch (error) {
console.error(`❌ Failed to update wrangler.json:`, error);
}
}
};
/**
* 检查并创建数据库
*/
const checkAndCreateDatabase = async () => {
console.log(`🔍 Checking if database "${DATABASE_NAME}" exists...`);
try {
const database = await getDatabase();
if (!database || !database.uuid) {
throw new Error('Database object is missing a valid UUID');
}
updateDatabaseConfig(database.uuid);
console.log(`✅ Database "${DATABASE_NAME}" already exists (ID: ${database.uuid})`);
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`⚠️ Database not found, creating new database...`);
try {
const database = await createDatabase();
if (!database || !database.uuid) {
throw new Error('Database object is missing a valid UUID');
}
updateDatabaseConfig(database.uuid);
console.log(`✅ Database "${DATABASE_NAME}" created successfully (ID: ${database.uuid})`);
} catch (createError) {
console.error(`❌ Failed to create database:`, createError);
throw createError;
}
} else {
console.error(`❌ An error occurred while checking the database:`, error);
throw error;
}
}
};
/**
* 迁移数据库
*/
const migrateDatabase = () => {
console.log("📝 Migrating remote database...");
try {
execSync("pnpm run db:migrate-remote", { stdio: "inherit" });
console.log("✅ Database migration completed successfully");
} catch (error) {
console.error("❌ Database migration failed:", error);
throw error;
}
};
/**
* 检查并创建KV命名空间
*/
const checkAndCreateKVNamespace = async () => {
console.log(`🔍 Checking if KV namespace "${KV_NAMESPACE_NAME}" exists...`);
if (KV_NAMESPACE_ID) {
updateKVConfig(KV_NAMESPACE_ID);
console.log(`✅ User specified KV namespace (ID: ${KV_NAMESPACE_ID})`);
return;
}
try {
let namespace;
const namespaceList = await getKVNamespaceList();
namespace = namespaceList.find(ns => ns.title === KV_NAMESPACE_NAME);
if (namespace && namespace.id) {
updateKVConfig(namespace.id);
console.log(`✅ KV namespace "${KV_NAMESPACE_NAME}" found by name (ID: ${namespace.id})`);
} else {
console.log("⚠️ KV namespace not found by name, creating new KV namespace...");
namespace = await createKVNamespace();
updateKVConfig(namespace.id);
console.log(`✅ KV namespace "${KV_NAMESPACE_NAME}" created successfully (ID: ${namespace.id})`);
}
} catch (error) {
console.error(`❌ An error occurred while checking the KV namespace:`, error);
throw error;
}
};
/**
* 检查并创建Pages项目
*/
const checkAndCreatePages = async () => {
console.log(`🔍 Checking if project "${PROJECT_NAME}" exists...`);
try {
await getPages();
console.log("✅ Project already exists, proceeding with update...");
} catch (error) {
if (error instanceof NotFoundError) {
console.log("⚠️ Project not found, creating new project...");
const pages = await createPages();
if (!CUSTOM_DOMAIN && pages.subdomain) {
console.log("⚠️ CUSTOM_DOMAIN is empty, using pages default domain...");
console.log("📝 Updating environment variables...");
// 更新环境变量为默认的Pages域名
const appUrl = `https://${pages.subdomain}`;
updateEnvVar("CUSTOM_DOMAIN", appUrl);
}
} else {
console.error(`❌ An error occurred while checking the project:`, error);
throw error;
}
}
};
/**
* 推送Pages密钥
*/
const pushPagesSecret = () => {
console.log("🔐 Pushing environment secrets to Pages...");
// 定义运行时所需的环境变量列表
const runtimeEnvVars = ['AUTH_GITHUB_ID', 'AUTH_GITHUB_SECRET', 'AUTH_SECRET'];
// 兼容老的部署方式,如果这些环境变量不存在,则说明是老的部署方式,跳过推送
for (const varName of runtimeEnvVars) {
if (!process.env[varName]) {
console.log(`🔐 Skipping pushing secrets to Pages...`);
return;
}
}
try {
// 确保.env文件存在
if (!existsSync(resolve('.env'))) {
setupEnvFile();
}
// 创建一个临时文件,只包含运行时所需的环境变量
const envContent = readFileSync(resolve('.env'), 'utf-8');
const runtimeEnvFile = resolve('.env.runtime');
// 从.env文件中提取运行时变量
const runtimeEnvContent = envContent
.split('\n')
.filter(line => {
const trimmedLine = line.trim();
// 跳过注释和空行
if (!trimmedLine || trimmedLine.startsWith('#')) return false;
// 检查是否为运行时所需的环境变量
for (const varName of runtimeEnvVars) {
if (line.startsWith(`${varName} =`) || line.startsWith(`${varName}=`)) {
return true;
}
}
return false;
})
.join('\n');
// 写入临时文件
writeFileSync(runtimeEnvFile, runtimeEnvContent);
// 使用临时文件推送secrets
execSync(`pnpm dlx wrangler pages secret bulk ${runtimeEnvFile}`, { stdio: "inherit" });
// 清理临时文件
execSync(`rm ${runtimeEnvFile}`, { stdio: "inherit" });
console.log("✅ Secrets pushed successfully");
} catch (error) {
console.error("❌ Failed to push secrets:", error);
throw error;
}
};
/**
* 部署Pages应用
*/
const deployPages = () => {
console.log("🚧 Deploying to Cloudflare Pages...");
try {
execSync("pnpm run deploy:pages", { stdio: "inherit" });
console.log("✅ Pages deployment completed successfully");
} catch (error) {
console.error("❌ Pages deployment failed:", error);
throw error;
}
};
/**
* 部署Email Worker
*/
const deployEmailWorker = () => {
console.log("🚧 Deploying Email Worker...");
try {
execSync("pnpm dlx wrangler deploy --config wrangler.email.json", { stdio: "inherit" });
console.log("✅ Email Worker deployed successfully");
} catch (error) {
console.error("❌ Email Worker deployment failed:", error);
// 继续执行而不中断
}
};
/**
* 部署Cleanup Worker
*/
const deployCleanupWorker = () => {
console.log("🚧 Deploying Cleanup Worker...");
try {
execSync("pnpm dlx wrangler deploy --config wrangler.cleanup.json", { stdio: "inherit" });
console.log("✅ Cleanup Worker deployed successfully");
} catch (error) {
console.error("❌ Cleanup Worker deployment failed:", error);
// 继续执行而不中断
}
};
/**
* 创建或更新环境变量文件
*/
const setupEnvFile = () => {
console.log("📄 Setting up environment file...");
const envFilePath = resolve(".env");
const envExamplePath = resolve(".env.example");
// 如果.env文件不存在则从.env.example复制创建
if (!existsSync(envFilePath) && existsSync(envExamplePath)) {
console.log("⚠️ .env file does not exist, creating from example...");
// 从示例文件复制
let envContent = readFileSync(envExamplePath, "utf-8");
// 填充当前的环境变量
const envVarMatches = envContent.match(/^([A-Z_]+)\s*=\s*".*?"/gm);
if (envVarMatches) {
for (const match of envVarMatches) {
const varName = match.split("=")[0].trim();
if (process.env[varName]) {
const regex = new RegExp(`${varName}\\s*=\\s*".*?"`, "g");
envContent = envContent.replace(regex, `${varName} = "${process.env[varName]}"`);
}
}
}
writeFileSync(envFilePath, envContent);
console.log("✅ .env file created from example");
} else if (existsSync(envFilePath)) {
console.log("✨ .env file already exists");
} else {
console.error("❌ .env.example file not found!");
throw new Error(".env.example file not found");
}
};
/**
* 更新环境变量
*/
const updateEnvVar = (name: string, value: string) => {
// 首先更新进程环境变量
process.env[name] = value;
// 然后尝试更新.env文件
const envFilePath = resolve(".env");
if (!existsSync(envFilePath)) {
setupEnvFile();
}
let envContent = readFileSync(envFilePath, "utf-8");
const regex = new RegExp(`^${name}\\s*=\\s*".*?"`, "m");
if (envContent.match(regex)) {
envContent = envContent.replace(regex, `${name} = "${value}"`);
} else {
envContent += `\n${name} = "${value}"`;
}
writeFileSync(envFilePath, envContent);
console.log(`✅ Updated ${name} in .env file`);
};
/**
* 主函数
*/
const main = async () => {
try {
console.log("🚀 Starting deployment process...");
validateEnvironment();
setupEnvFile();
setupWranglerConfigs();
await checkAndCreateDatabase();
migrateDatabase();
await checkAndCreateKVNamespace();
await checkAndCreatePages();
pushPagesSecret();
deployPages();
deployEmailWorker();
deployCleanupWorker();
console.log("🎉 Deployment completed successfully");
} catch (error) {
console.error("❌ Deployment failed:", error);
process.exit(1);
}
};
main();

View File

@@ -1,8 +1,7 @@
import { drizzle } from 'drizzle-orm/d1'
import { emails, messages } from '../app/lib/schema'
import { emails, messages, users } from '../app/lib/schema'
import { nanoid } from 'nanoid'
const TEST_USER_ID = '4e4c1d5d-a3c9-407a-8808-2a2424b38c62'
import { eq } from 'drizzle-orm'
interface Env {
DB: D1Database
@@ -12,16 +11,44 @@ const MAX_EMAIL_COUNT = 5
const MAX_MESSAGE_COUNT = 50
const BATCH_SIZE = 10 // SQLite 变量限制
async function generateTestData(env: Env) {
async function getUserId(db: ReturnType<typeof drizzle>, identifier: string): Promise<string | null> {
// 尝试通过 email 查找用户
let user = await db
.select()
.from(users)
.where(eq(users.email, identifier))
.limit(1)
.then(rows => rows[0])
// 如果没找到,尝试通过 username 查找
if (!user) {
user = await db
.select()
.from(users)
.where(eq(users.username, identifier))
.limit(1)
.then(rows => rows[0])
}
return user?.id || null
}
async function generateTestData(env: Env, userIdentifier: string) {
const db = drizzle(env.DB)
const now = new Date()
try {
// 获取用户 ID
const userId = await getUserId(db, userIdentifier)
if (!userId) {
throw new Error(`未找到用户: ${userIdentifier}`)
}
// 生成测试邮箱
const testEmails = Array.from({ length: MAX_EMAIL_COUNT }).map(() => ({
id: crypto.randomUUID(),
address: `${nanoid(6)}@moemail.app`,
userId: TEST_USER_ID,
userId: userId,
createdAt: now,
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
}))
@@ -66,7 +93,14 @@ async function generateTestData(env: Env) {
export default {
async fetch(request: Request, env: Env) {
if (request.method === 'GET') {
await generateTestData(env)
const url = new URL(request.url)
const userIdentifier = url.searchParams.get('user')
if (!userIdentifier) {
return new Response('Missing user parameter', { status: 400 })
}
await generateTestData(env, userIdentifier)
return new Response('Test data generated successfully', { status: 200 })
}
return new Response('Method not allowed', { status: 405 })

View File

@@ -1,4 +1,3 @@
import { parse } from '@iarna/toml'
import { readFileSync } from 'fs'
import { exec } from 'child_process'
import { promisify } from 'util'
@@ -27,23 +26,23 @@ async function migrate() {
process.exit(1)
}
// Read wrangler.toml
const wranglerPath = join(process.cwd(), 'wrangler.toml')
// Read wrangler.json
const wranglerPath = join(process.cwd(), 'wrangler.json')
let wranglerContent: string
try {
wranglerContent = readFileSync(wranglerPath, 'utf-8')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
console.error('Error: wrangler.toml not found')
console.error('Error: wrangler.json not found')
process.exit(1)
}
// Parse wrangler.toml
const config = parse(wranglerContent) as unknown as WranglerConfig
// Parse wrangler.json
const config = JSON.parse(wranglerContent) as WranglerConfig
if (!config.d1_databases?.[0]?.database_name) {
console.error('Error: Database name not found in wrangler.toml')
console.error('Error: Database name not found in wrangler.json')
process.exit(1)
}
@@ -53,7 +52,7 @@ async function migrate() {
console.log('Generating migrations...')
await execAsync('drizzle-kit generate')
// Apply migrations
// Applying migrations
console.log(`Applying migrations to ${mode} database: ${dbName}`)
await execAsync(`wrangler d1 migrations apply ${dbName} --${mode}`)
@@ -64,4 +63,4 @@ async function migrate() {
}
}
migrate()
migrate()

View File

@@ -0,0 +1,18 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cleanup-worker",
"main": "workers/cleanup.ts",
"compatibility_date": "2024-03-20",
"compatibility_flags": ["nodejs_compat"],
"triggers": {
"crons": ["0 * * * *"]
},
"d1_databases": [
{
"binding": "DB",
"migrations_dir": "drizzle",
"database_name": "moemail",
"database_id": "${DATABASE_ID}"
}
]
}

View File

@@ -1,14 +0,0 @@
name = "cleanup-worker"
main = "workers/cleanup.ts"
compatibility_date = "2024-03-20"
compatibility_flags = ["nodejs_compat"]
# 每 1 小时运行一次
[triggers]
crons = ["0 * * * *"]
[[d1_databases]]
binding = "DB"
migrations_dir = "drizzle"
database_name = ""
database_id = ""

View File

@@ -0,0 +1,15 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "email-receiver-worker",
"compatibility_date": "2024-03-20",
"compatibility_flags": ["nodejs_compat"],
"main": "workers/email-receiver.ts",
"d1_databases": [
{
"binding": "DB",
"migrations_dir": "drizzle",
"database_name": "moemail",
"database_id": "${DATABASE_ID}"
}
]
}

View File

@@ -1,11 +0,0 @@
name = "email-receiver-worker"
compatibility_date = "2024-03-20"
compatibility_flags = ["nodejs_compat"]
main = "workers/email-receiver.ts"
[[d1_databases]]
binding = "DB"
migrations_dir = "drizzle"
database_name = ""
database_id = ""

21
wrangler.example.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "moemail",
"compatibility_date": "2024-03-20",
"compatibility_flags": ["nodejs_compat"],
"pages_build_output_dir": ".vercel/output/static",
"d1_databases": [
{
"binding": "DB",
"database_name": "moemail",
"database_id": "${DATABASE_ID}",
"migrations_dir": "drizzle"
}
],
"kv_namespaces": [
{
"binding": "SITE_CONFIG",
"id": "${KV_NAMESPACE_ID}"
}
]
}

View File

@@ -1,14 +0,0 @@
name = "moemail"
compatibility_date = "2024-03-20"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
[[d1_databases]]
binding = "DB"
migrations_dir = "drizzle"
database_name = ""
database_id = ""
[[kv_namespaces]]
binding = "SITE_CONFIG"
id = ""