mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f93d9b52 | ||
|
|
0f5aaab26b | ||
|
|
70c1f2e902 | ||
|
|
166d28405b | ||
|
|
408f97f98d | ||
|
|
e85f6b04bd | ||
|
|
9d55564073 | ||
|
|
7a04a8165c | ||
|
|
60d40a7a32 | ||
|
|
7f7e29a80f | ||
|
|
f465e13620 | ||
|
|
3cac33bed3 | ||
|
|
eb6c3fe5eb | ||
|
|
fa1dadfb21 | ||
|
|
0b9f457e52 | ||
|
|
eb88cbcb31 | ||
|
|
92116b9e3f | ||
|
|
bf11aae52e | ||
|
|
f5d49790a7 | ||
|
|
6ddd5bdf4e | ||
|
|
e8e2349a97 | ||
|
|
fd46bf2661 | ||
|
|
21d09a2cb0 | ||
|
|
eb8023280b | ||
|
|
58e6d06bed | ||
|
|
436666a88b | ||
|
|
dae8122231 | ||
|
|
7210c68fbd | ||
|
|
994ab8acc3 | ||
|
|
c405c02a34 | ||
|
|
d9fb486104 | ||
|
|
77cb52e608 | ||
|
|
16bc357973 | ||
|
|
b75d9ada43 | ||
|
|
da979d2a51 | ||
|
|
200d82f874 | ||
|
|
ed8885a2d8 | ||
|
|
cd429b96d8 | ||
|
|
5173cbf9d3 | ||
|
|
9b7ed0b031 | ||
|
|
ea7fd5490c | ||
|
|
b1d898e298 | ||
|
|
f86d944c25 | ||
|
|
59671091b6 | ||
|
|
19d805de57 | ||
|
|
2566a8a105 | ||
|
|
821a32aa4b |
@@ -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 = ""
|
||||
143
.github/workflows/deploy.yml
vendored
143
.github/workflows/deploy.yml
vendored
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
317
README.md
317
README.md
@@ -15,6 +15,8 @@
|
||||
<a href="#部署">部署</a> •
|
||||
<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> •
|
||||
@@ -46,6 +48,7 @@
|
||||
- 📱 **PWA 支持**:支持 PWA 安装
|
||||
- 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用
|
||||
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
|
||||
- 📤 **发件功能**:支持使用临时邮箱发送邮件,基于 Resend 服务
|
||||
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
|
||||
- 🛡️ **权限系统**:支持基于角色的权限控制系统
|
||||
- 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI
|
||||
@@ -86,9 +89,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 +131,19 @@ 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,19 +151,20 @@ pnpm deploy:cleanup
|
||||
本项目可使用 GitHub Actions 实现自动化部署。支持以下触发方式:
|
||||
|
||||
1. **自动触发**:推送新的 tag 时自动触发部署流程
|
||||
2. **手动触发**:在 GitHub Actions 页面手动触发,可选择以下部署选项:
|
||||
- Run database migrations:执行数据库迁移
|
||||
- Deploy email Worker:重新部署邮件 Worker
|
||||
- Deploy cleanup Worker:重新部署清理 Worker
|
||||
2. **手动触发**:在 GitHub Actions 页面手动触发
|
||||
|
||||
#### 部署步骤
|
||||
|
||||
1. 在 GitHub 仓库设置中添加以下 Secrets:
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API 令牌
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID
|
||||
- `DATABASE_NAME`: D1 数据库名称
|
||||
- `DATABASE_ID`: D1 数据库 ID
|
||||
- `KV_NAMESPACE_ID`: Cloudflare KV namespace ID,用于存储网站配置
|
||||
- `AUTH_GITHUB_ID`: GitHub OAuth App ID
|
||||
- `AUTH_GITHUB_SECRET`: GitHub OAuth App Secret
|
||||
- `AUTH_SECRET`: NextAuth Secret,用来加密 session,请设置一个随机字符串
|
||||
- `CUSTOM_DOMAIN`: 网站自定义域名,用于访问 MoeMail (可选, 如果不填, 则会使用 Cloudflare Pages 默认域名)
|
||||
- `PROJECT_NAME`: Pages 项目名 (可选,如果不填,则为 moemail)
|
||||
- `DATABASE_NAME`: D1 数据库名称 (可选,如果不填,则为 moemail-db)
|
||||
- `KV_NAMESPACE_NAME`: Cloudflare KV namespace 名称,用于存储网站配置 (可选,如果不填,则为 moemail-kv)
|
||||
|
||||
2. 选择触发方式:
|
||||
|
||||
@@ -225,23 +181,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 触发时,只有文件发生变更的部分会被部署
|
||||
- 手动触发时,可以选择性地执行特定的部署任务
|
||||
- 每次部署都会重新部署主应用
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
|
||||
|
||||
@@ -273,6 +218,7 @@ pnpm deploy:cleanup
|
||||
### 注意事项
|
||||
- 确保域名的 DNS 托管在 Cloudflare
|
||||
- Email Worker 必须已经部署成功
|
||||
- 如果 Catch-All 状态不可用(一直 loading),请点击`路由规则`旁边的`目标地址`, 进去绑定一个邮箱
|
||||
|
||||
## 权限系统
|
||||
|
||||
@@ -327,8 +273,85 @@ pnpm deploy:cleanup
|
||||
- **Webhook 管理**:配置邮件通知的 Webhook
|
||||
- **API Key 管理**:创建和管理 API 访问密钥
|
||||
- **用户管理**:升降用户角色
|
||||
- **系统配置**:管理系统全局设置
|
||||
- **系统设置**:管理系统全局设置
|
||||
|
||||
## 系统设置
|
||||
|
||||
系统设置存储在 Cloudflare KV 中,包括以下内容:
|
||||
|
||||
- `DEFAULT_ROLE`: 新注册用户默认角色,可选值为 `CIVILIAN`、`KNIGHT`、`DUKE`
|
||||
- `EMAIL_DOMAINS`: 支持的邮箱域名,多个域名用逗号分隔
|
||||
- `ADMIN_CONTACT`: 管理员联系方式
|
||||
- `MAX_EMAILS`: 每个用户可创建的最大邮箱数量
|
||||
|
||||
**皇帝**角色可以在个人中心页面设置
|
||||
|
||||
## 发件功能
|
||||
|
||||
MoeMail 支持使用临时邮箱发送邮件,基于 [Resend](https://resend.com/) 服务。
|
||||
|
||||
### 功能特性
|
||||
|
||||
- 📨 **临时邮箱发件**:可以使用创建的临时邮箱作为发件人发送邮件
|
||||
- 🎯 **角色权限控制**:不同角色有不同的每日发件限制
|
||||
- 💌 **支持 HTML**:支持发送富文本格式邮件
|
||||
|
||||
### 角色发件权限
|
||||
|
||||
| 角色 | 每日发件限制 | 说明 |
|
||||
|------|-------------|------|
|
||||
| 皇帝 (Emperor) | 无限制 | 网站管理员,无发件限制 |
|
||||
| 公爵 (Duke) | 5封/天 | 默认每日可发送5封邮件 |
|
||||
| 骑士 (Knight) | 2封/天 | 默认每日可发送2封邮件 |
|
||||
| 平民 (Civilian) | 禁止发件 | 无发件权限 |
|
||||
|
||||
> 💡 **提示**:皇帝可以在个人中心的邮件服务配置中自定义公爵和骑士的每日发件限制。
|
||||
|
||||
### 配置发件服务
|
||||
|
||||
1. **获取 Resend API Key**
|
||||
- 访问 [Resend 官网](https://resend.com/) 注册账号
|
||||
- 在控制台中创建 API Key
|
||||
- 复制 API Key 供后续配置使用
|
||||
|
||||
2. **配置发件服务**
|
||||
- 皇帝角色登录 MoeMail
|
||||
- 进入个人中心页面
|
||||
- 在"Resend 发件服务配置"部分:
|
||||
- 启用发件服务开关
|
||||
- 填入 Resend API Key
|
||||
- 设置公爵和骑士的每日发件限制(可选)
|
||||
- 点击保存配置
|
||||
|
||||
3. **验证配置**
|
||||
- 配置保存后,有权限的用户在邮箱列表页面会看到"发送邮件"按钮
|
||||
- 点击按钮可以打开发件对话框进行测试
|
||||
|
||||
### 使用发件功能
|
||||
|
||||
1. **创建临时邮箱**
|
||||
- 在邮箱页面创建一个新的临时邮箱
|
||||
|
||||
2. **发送邮件**
|
||||
- 在邮箱列表中找到要使用的邮箱
|
||||
- 点击邮箱旁边的"发送邮件"按钮
|
||||
- 在弹出的对话框中填写:
|
||||
- 收件人邮箱地址
|
||||
- 邮件主题
|
||||
- 邮件内容(支持 HTML 格式)
|
||||
- 点击"发送"按钮
|
||||
|
||||
3. **查看发送记录**
|
||||
- 发送的邮件会自动保存到对应邮箱的消息列表中
|
||||
- 可以在邮箱详情页面查看所有发送和接收的邮件
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 📋 **Resend 限制**:请注意 Resend 服务的发送限制和定价政策
|
||||
- 🔐 **域名验证**:使用自定义域名发件需要在 Resend 中验证域名
|
||||
- 🚫 **反垃圾邮件**:请遵守邮件发送规范,避免发送垃圾邮件
|
||||
- 📊 **配额监控**:系统会自动统计每日发件数量,达到限额后将无法继续发送
|
||||
- 🔄 **配额重置**:每日发件配额在每天 00:00 自动重置
|
||||
|
||||
## Webhook 集成
|
||||
|
||||
@@ -393,6 +416,25 @@ X-API-Key: YOUR_API_KEY
|
||||
|
||||
### API 接口
|
||||
|
||||
#### 获取系统配置
|
||||
```http
|
||||
GET /api/config
|
||||
```
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"defaultRole": "CIVILIAN",
|
||||
"emailDomains": "moemail.app,example.com",
|
||||
"adminContact": "admin@example.com",
|
||||
"maxEmails": "10"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `defaultRole`: 新用户默认角色,可选值:`CIVILIAN`(平民)、`KNIGHT`(骑士)、`DUKE`(公爵)
|
||||
- `emailDomains`: 支持的邮箱域名,多个域名用逗号分隔
|
||||
- `adminContact`: 管理员联系方式
|
||||
- `maxEmails`: 每个用户可创建的最大邮箱数量
|
||||
|
||||
#### 创建临时邮箱
|
||||
```http
|
||||
POST /api/emails/generate
|
||||
@@ -407,7 +449,18 @@ Content-Type: application/json
|
||||
参数说明:
|
||||
- `name`: 邮箱前缀,可选
|
||||
- `expiryTime`: 有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)
|
||||
- `domain`: 邮箱域名,可通过 `/api/emails/domains` 获取可用域名列表
|
||||
- `domain`: 邮箱域名,可通过 `/api/config` 接口获取
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"id": "email-uuid-123",
|
||||
"email": "test@moemail.app"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `id`: 邮箱的唯一标识符
|
||||
- `email`: 创建的邮箱地址
|
||||
|
||||
#### 获取邮箱列表
|
||||
```http
|
||||
@@ -416,22 +469,100 @@ GET /api/emails?cursor=xxx
|
||||
参数说明:
|
||||
- `cursor`: 分页游标,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"id": "email-uuid-123",
|
||||
"address": "test@moemail.app",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"userId": "user-uuid-456"
|
||||
}
|
||||
],
|
||||
"nextCursor": "encoded-cursor-string",
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `emails`: 邮箱列表数组
|
||||
- `nextCursor`: 下一页游标,用于分页请求
|
||||
- `total`: 邮箱总数量
|
||||
|
||||
#### 获取指定邮箱邮件列表
|
||||
```http
|
||||
GET /api/emails/{emailId}?cursor=xxx
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `cursor`: 分页游标,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": "message-uuid-789",
|
||||
"from_address": "sender@example.com",
|
||||
"subject": "邮件主题",
|
||||
"received_at": 1704110400000
|
||||
}
|
||||
],
|
||||
"nextCursor": "encoded-cursor-string",
|
||||
"total": 3
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `messages`: 邮件列表数组
|
||||
- `nextCursor`: 下一页游标,用于分页请求
|
||||
- `total`: 邮件总数量
|
||||
|
||||
#### 删除邮箱
|
||||
```http
|
||||
DELETE /api/emails/{emailId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `success`: 删除操作是否成功
|
||||
|
||||
#### 获取单封邮件内容
|
||||
```http
|
||||
GET /api/emails/{emailId}/{messageId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"id": "message-uuid-789",
|
||||
"from_address": "sender@example.com",
|
||||
"subject": "邮件主题",
|
||||
"content": "邮件文本内容",
|
||||
"html": "<p>邮件HTML内容</p>",
|
||||
"received_at": 1704110400000
|
||||
}
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `message`: 邮件详细信息对象
|
||||
- `id`: 邮件的唯一标识符
|
||||
- `from_address`: 发件人邮箱地址
|
||||
- `subject`: 邮件主题
|
||||
- `content`: 邮件纯文本内容
|
||||
- `html`: 邮件HTML内容
|
||||
- `received_at`: 接收时间(时间戳)
|
||||
|
||||
### 使用示例
|
||||
|
||||
@@ -470,8 +601,11 @@ 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 默认域名)
|
||||
- `PROJECT_NAME`: Pages 项目名 (可选,如果不填,则为 moemail)
|
||||
|
||||
## Github OAuth App 配置
|
||||
|
||||
@@ -490,10 +624,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>
|
||||
|
||||
## 支持
|
||||
|
||||
|
||||
@@ -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 || ""
|
||||
})
|
||||
}
|
||||
99
app/api/config/email-service/route.ts
Normal file
99
app/api/config/email-service/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
interface EmailServiceConfig {
|
||||
enabled: boolean
|
||||
apiKey: string
|
||||
roleLimits: {
|
||||
duke?: number
|
||||
knight?: number
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
if (!canAccess) {
|
||||
return NextResponse.json({
|
||||
error: "权限不足"
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const env = getRequestContext().env
|
||||
const [enabled, apiKey, roleLimits] = await Promise.all([
|
||||
env.SITE_CONFIG.get("EMAIL_SERVICE_ENABLED"),
|
||||
env.SITE_CONFIG.get("RESEND_API_KEY"),
|
||||
env.SITE_CONFIG.get("EMAIL_ROLE_LIMITS")
|
||||
])
|
||||
|
||||
const customLimits = roleLimits ? JSON.parse(roleLimits) : {}
|
||||
|
||||
const finalLimits = {
|
||||
duke: customLimits.duke !== undefined ? customLimits.duke : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.duke,
|
||||
knight: customLimits.knight !== undefined ? customLimits.knight : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.knight,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: enabled === "true",
|
||||
apiKey: apiKey || "",
|
||||
roleLimits: finalLimits
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get email service config:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "获取 Resend 发件服务配置失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
if (!canAccess) {
|
||||
return NextResponse.json({
|
||||
error: "权限不足"
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await request.json() as EmailServiceConfig
|
||||
|
||||
if (config.enabled && !config.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "启用 Resend 时,API Key 为必填项" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const env = getRequestContext().env
|
||||
|
||||
const customLimits: { duke?: number; knight?: number } = {}
|
||||
if (config.roleLimits?.duke !== undefined) {
|
||||
customLimits.duke = config.roleLimits.duke
|
||||
}
|
||||
if (config.roleLimits?.knight !== undefined) {
|
||||
customLimits.knight = config.roleLimits.knight
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
env.SITE_CONFIG.put("EMAIL_SERVICE_ENABLED", config.enabled.toString()),
|
||||
env.SITE_CONFIG.put("RESEND_API_KEY", config.apiKey),
|
||||
env.SITE_CONFIG.put("EMAIL_ROLE_LIMITS", JSON.stringify(customLimits))
|
||||
])
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to save email service config:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "保存 Resend 发件服务配置失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,41 @@
|
||||
import { Role, ROLES } from "@/lib/permissions"
|
||||
import { PERMISSIONS, Role, ROLES } from "@/lib/permissions"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
import { checkPermission } from "@/lib/auth"
|
||||
|
||||
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 canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
|
||||
|
||||
if (!canAccess) {
|
||||
return Response.json({
|
||||
error: "权限不足"
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
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 +46,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 })
|
||||
|
||||
@@ -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
|
||||
@@ -43,10 +93,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
message: {
|
||||
id: message.id,
|
||||
from_address: message.fromAddress,
|
||||
to_address: message.toAddress,
|
||||
subject: message.subject,
|
||||
content: message.content,
|
||||
html: message.html,
|
||||
received_at: message.receivedAt.getTime()
|
||||
received_at: message.receivedAt.getTime(),
|
||||
sent_at: message.receivedAt.getTime(),
|
||||
type: message.type as 'received' | 'sent'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emails, messages } from "@/lib/schema"
|
||||
import { eq, and, lt, or, sql } from "drizzle-orm"
|
||||
import { eq, and, lt, or, sql, ne } from "drizzle-orm"
|
||||
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||
import { getUserId } from "@/lib/apiKey"
|
||||
import { checkBasicSendPermission } from "@/lib/send-permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function DELETE(
|
||||
@@ -52,12 +54,22 @@ export async function GET(
|
||||
) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const cursorStr = searchParams.get('cursor')
|
||||
const messageType = searchParams.get('type')
|
||||
|
||||
try {
|
||||
const db = createDb()
|
||||
const { id } = await params
|
||||
|
||||
const userId = await getUserId()
|
||||
if (messageType === 'sent') {
|
||||
const permissionResult = await checkBasicSendPermission(userId!)
|
||||
if (!permissionResult.canSend) {
|
||||
return NextResponse.json(
|
||||
{ error: permissionResult.error || "您没有查看发送邮件的权限" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: and(
|
||||
@@ -73,7 +85,10 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
const baseConditions = eq(messages.emailId, id)
|
||||
const baseConditions = and(
|
||||
eq(messages.emailId, id),
|
||||
messageType === 'sent' ? eq(messages.type, "sent") : ne(messages.type, "sent")
|
||||
)
|
||||
|
||||
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(messages)
|
||||
@@ -84,22 +99,24 @@ export async function GET(
|
||||
|
||||
if (cursorStr) {
|
||||
const { timestamp, id } = decodeCursor(cursorStr)
|
||||
const orderByTime = messageType === 'sent' ? messages.sentAt : messages.receivedAt
|
||||
conditions.push(
|
||||
// @ts-expect-error "ignore the error"
|
||||
or(
|
||||
lt(messages.receivedAt, new Date(timestamp)),
|
||||
lt(orderByTime, new Date(timestamp)),
|
||||
and(
|
||||
eq(messages.receivedAt, new Date(timestamp)),
|
||||
eq(orderByTime, new Date(timestamp)),
|
||||
lt(messages.id, id)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const orderByTime = messageType === 'sent' ? messages.sentAt : messages.receivedAt
|
||||
|
||||
const results = await db.query.messages.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (messages, { desc }) => [
|
||||
desc(messages.receivedAt),
|
||||
desc(orderByTime),
|
||||
desc(messages.id)
|
||||
],
|
||||
limit: PAGE_SIZE + 1
|
||||
@@ -108,7 +125,9 @@ export async function GET(
|
||||
const hasMore = results.length > PAGE_SIZE
|
||||
const nextCursor = hasMore
|
||||
? encodeCursor(
|
||||
results[PAGE_SIZE - 1].receivedAt.getTime(),
|
||||
messageType === 'sent'
|
||||
? results[PAGE_SIZE - 1].sentAt!.getTime()
|
||||
: results[PAGE_SIZE - 1].receivedAt.getTime(),
|
||||
results[PAGE_SIZE - 1].id
|
||||
)
|
||||
: null
|
||||
@@ -117,9 +136,13 @@ export async function GET(
|
||||
return NextResponse.json({
|
||||
messages: messageList.map(msg => ({
|
||||
id: msg.id,
|
||||
from_address: msg.fromAddress,
|
||||
from_address: msg?.fromAddress,
|
||||
to_address: msg?.toAddress,
|
||||
subject: msg.subject,
|
||||
received_at: msg.receivedAt.getTime()
|
||||
content: msg.content,
|
||||
html: msg.html,
|
||||
sent_at: msg.sentAt?.getTime(),
|
||||
received_at: msg.receivedAt?.getTime()
|
||||
})),
|
||||
nextCursor,
|
||||
total: totalCount
|
||||
|
||||
134
app/api/emails/[id]/send/route.ts
Normal file
134
app/api/emails/[id]/send/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { createDb } from "@/lib/db"
|
||||
import { emails, messages } from "@/lib/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { checkSendPermission } from "@/lib/send-permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
interface SendEmailRequest {
|
||||
to: string
|
||||
subject: string
|
||||
content: string
|
||||
}
|
||||
|
||||
async function sendWithResend(
|
||||
to: string,
|
||||
subject: string,
|
||||
content: string,
|
||||
fromEmail: string,
|
||||
config: { apiKey: string }
|
||||
) {
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: fromEmail,
|
||||
to: [to],
|
||||
subject: subject,
|
||||
html: content,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json() as { message?: string }
|
||||
console.error('Resend API error:', errorData)
|
||||
throw new Error(errorData.message || "Resend发送失败,请稍后重试")
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "未授权" },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const db = createDb()
|
||||
|
||||
const permissionResult = await checkSendPermission(session.user.id)
|
||||
if (!permissionResult.canSend) {
|
||||
return NextResponse.json(
|
||||
{ error: permissionResult.error },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const remainingEmails = permissionResult.remainingEmails
|
||||
|
||||
const { to, subject, content } = await request.json() as SendEmailRequest
|
||||
|
||||
if (!to || !subject || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: "收件人、主题和内容都是必填项" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const email = await db.query.emails.findFirst({
|
||||
where: eq(emails.id, id)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json(
|
||||
{ error: "邮箱不存在" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (email.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "无权访问此邮箱" },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const env = getRequestContext().env
|
||||
const apiKey = await env.SITE_CONFIG.get("RESEND_API_KEY")
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "Resend 发件服务未配置,请联系管理员" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
await sendWithResend(to, subject, content, email.address, { apiKey })
|
||||
|
||||
await db.insert(messages).values({
|
||||
emailId: email.id,
|
||||
fromAddress: email.address,
|
||||
toAddress: to,
|
||||
subject,
|
||||
content: '',
|
||||
type: "sent",
|
||||
html: content
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "邮件发送成功",
|
||||
remainingEmails
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "发送邮件失败" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
29
app/api/emails/send-permission/route.ts
Normal file
29
app/api/emails/send-permission/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { checkSendPermission } from "@/lib/send-permissions"
|
||||
|
||||
export const runtime = "edge"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({
|
||||
canSend: false,
|
||||
error: "未授权"
|
||||
})
|
||||
}
|
||||
const result = await checkSendPermission(session.user.id)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to check send permission:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
canSend: false,
|
||||
error: "权限检查失败"
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
76
app/components/emails/message-list-container.tsx
Normal file
76
app/components/emails/message-list-container.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Send, Inbox } from "lucide-react"
|
||||
import { Tabs, SlidingTabsList, SlidingTabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { MessageList } from "./message-list"
|
||||
import { useSendPermission } from "@/hooks/use-send-permission"
|
||||
|
||||
interface MessageListContainerProps {
|
||||
email: {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
onMessageSelect: (messageId: string | null, messageType?: 'received' | 'sent') => void
|
||||
selectedMessageId?: string | null
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
export function MessageListContainer({ email, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListContainerProps) {
|
||||
const [activeTab, setActiveTab] = useState<'received' | 'sent'>('received')
|
||||
const { canSend: canSendEmails } = useSendPermission()
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setActiveTab(tabId as 'received' | 'sent')
|
||||
onMessageSelect(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{canSendEmails ? (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
|
||||
<div className="p-2 border-b border-primary/20">
|
||||
<SlidingTabsList>
|
||||
<SlidingTabsTrigger value="received">
|
||||
<Inbox className="h-4 w-4" />
|
||||
收件箱
|
||||
</SlidingTabsTrigger>
|
||||
<SlidingTabsTrigger value="sent">
|
||||
<Send className="h-4 w-4" />
|
||||
已发送
|
||||
</SlidingTabsTrigger>
|
||||
</SlidingTabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="received" className="flex-1 overflow-hidden m-0">
|
||||
<MessageList
|
||||
email={email}
|
||||
messageType="received"
|
||||
onMessageSelect={onMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sent" className="flex-1 overflow-hidden m-0">
|
||||
<MessageList
|
||||
email={email}
|
||||
messageType="sent"
|
||||
onMessageSelect={onMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MessageList
|
||||
email={email}
|
||||
messageType="received"
|
||||
onMessageSelect={onMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
"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
|
||||
from_address: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
received_at: number
|
||||
received_at?: number
|
||||
sent_at?: number
|
||||
content?: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -19,8 +34,10 @@ interface MessageListProps {
|
||||
id: string
|
||||
address: string
|
||||
}
|
||||
onMessageSelect: (messageId: string) => void
|
||||
messageType: 'received' | 'sent'
|
||||
onMessageSelect: (messageId: string | null, messageType?: 'received' | 'sent') => void
|
||||
selectedMessageId?: string | null
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
@@ -29,15 +46,17 @@ interface MessageResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
|
||||
export function MessageList({ email, messageType, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const pollTimeoutRef = useRef<Timer>()
|
||||
const pollTimeoutRef = useRef<Timer>(null)
|
||||
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
||||
const [total, setTotal] = useState(0)
|
||||
const [messageToDelete, setMessageToDelete] = useState<Message | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 当 messages 改变时更新 ref
|
||||
useEffect(() => {
|
||||
@@ -47,6 +66,9 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
const fetchMessages = async (cursor?: string) => {
|
||||
try {
|
||||
const url = new URL(`/api/emails/${email.id}`, window.location.origin)
|
||||
if (messageType === 'sent') {
|
||||
url.searchParams.set('type', 'sent')
|
||||
}
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor)
|
||||
}
|
||||
@@ -96,7 +118,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
const stopPolling = () => {
|
||||
if (pollTimeoutRef.current) {
|
||||
clearInterval(pollTimeoutRef.current)
|
||||
pollTimeoutRef.current = undefined
|
||||
pollTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +140,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}${messageType === 'sent' ? '?type=sent' : ''}`, {
|
||||
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
|
||||
@@ -133,7 +193,16 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger && refreshTrigger > 0) {
|
||||
setRefreshing(true)
|
||||
fetchMessages()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshTrigger])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||
<Button
|
||||
@@ -158,9 +227,9 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
{messages.map(message => (
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => onMessageSelect(message.id)}
|
||||
onClick={() => onMessageSelect(message.id, messageType)}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@@ -169,13 +238,26 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{message.subject}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">{message.from_address}</span>
|
||||
<span className="truncate">
|
||||
{message.from_address || message.to_address || ''}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(message.received_at).toLocaleString()}
|
||||
{new Date(message.received_at || message.sent_at || 0).toLocaleString()}
|
||||
</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>
|
||||
))}
|
||||
@@ -187,10 +269,30 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
暂无邮件
|
||||
{messageType === 'sent' ? '暂无发送的邮件' : '暂无收到的邮件'}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,40 +4,73 @@ 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"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
from_address: string
|
||||
from_address?: string
|
||||
to_address?: string
|
||||
subject: string
|
||||
content: string
|
||||
html: string | null
|
||||
received_at: number
|
||||
html?: string
|
||||
received_at?: number
|
||||
sent_at?: number
|
||||
}
|
||||
|
||||
interface MessageViewProps {
|
||||
emailId: string
|
||||
messageId: string
|
||||
messageType?: 'received' | 'sent'
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ViewMode = "html" | "text"
|
||||
|
||||
export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
export function MessageView({ emailId, messageId, messageType = 'received' }: MessageViewProps) {
|
||||
const [message, setMessage] = useState<Message | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("html")
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const { theme } = useTheme()
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/${messageId}`)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const url = `/api/emails/${emailId}/${messageId}${messageType === 'sent' ? '?type=sent' : ''}`;
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const errorMessage = (errorData as { error?: string }).error || '获取邮件详情失败'
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json() as { message: Message }
|
||||
setMessage(data.message)
|
||||
if (!data.message.html) {
|
||||
setViewMode("text")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = "网络错误,请稍后重试"
|
||||
setError(errorMessage)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
})
|
||||
console.error("Failed to fetch message:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -45,10 +78,9 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
}
|
||||
|
||||
fetchMessage()
|
||||
}, [emailId, messageId])
|
||||
}, [emailId, messageId, messageType, toast])
|
||||
|
||||
// 处理 iframe 内容
|
||||
useEffect(() => {
|
||||
const updateIframeContent = () => {
|
||||
if (viewMode === "html" && message?.html && iframeRef.current) {
|
||||
const iframe = iframeRef.current
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document
|
||||
@@ -66,8 +98,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 +120,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,12 +171,32 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [message?.html, viewMode])
|
||||
}
|
||||
|
||||
// 监听主题变化和内容变化
|
||||
useEffect(() => {
|
||||
updateIframeContent()
|
||||
}, [message?.html, viewMode, theme])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
|
||||
<span className="ml-2 text-sm text-gray-500">加载邮件详情...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
||||
<p className="text-sm text-destructive mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -156,12 +208,17 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||
<div className="p-4 space-y-3 border-b border-primary/20">
|
||||
<h3 className="text-base font-bold">{message.subject}</h3>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>发件人:{message.from_address}</p>
|
||||
<p>时间:{new Date(message.received_at).toLocaleString()}</p>
|
||||
{message.from_address && (
|
||||
<p>发件人:{message.from_address}</p>
|
||||
)}
|
||||
{message.to_address && (
|
||||
<p>收件人:{message.to_address}</p>
|
||||
)}
|
||||
<p>时间:{new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.html && (
|
||||
{message.html && message.content && (
|
||||
<div className="border-b border-primary/20 p-2">
|
||||
<RadioGroup
|
||||
value={viewMode}
|
||||
|
||||
138
app/components/emails/send-dialog.tsx
Normal file
138
app/components/emails/send-dialog.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Send } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface SendDialogProps {
|
||||
emailId: string
|
||||
fromAddress: string
|
||||
onSendSuccess?: () => void
|
||||
}
|
||||
|
||||
export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [to, setTo] = useState("")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const { toast } = useToast()
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!to.trim() || !subject.trim() || !content.trim()) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "收件人、主题和内容都是必填项",
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/emails/${emailId}/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ to, subject, content })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
toast({
|
||||
title: "错误",
|
||||
description: (data as { error: string }).error,
|
||||
variant: "destructive"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "邮件已发送"
|
||||
})
|
||||
setOpen(false)
|
||||
setTo("")
|
||||
setSubject("")
|
||||
setContent("")
|
||||
|
||||
onSendSuccess?.()
|
||||
|
||||
} catch {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "发送邮件失败",
|
||||
variant: "destructive"
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<DialogTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-2 hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">发送邮件</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogTrigger>
|
||||
<TooltipContent className="sm:hidden">
|
||||
<p>使用此邮箱发送新邮件</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送新邮件</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
发件人: {fromAddress}
|
||||
</div>
|
||||
<Input
|
||||
value={to}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
|
||||
placeholder="收件人邮箱地址"
|
||||
/>
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSubject(e.target.value)}
|
||||
placeholder="邮件主题"
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
|
||||
placeholder="邮件内容"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSend} disabled={loading}>
|
||||
{loading ? "发送中..." : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { EmailList } from "./email-list"
|
||||
import { MessageList } from "./message-list"
|
||||
import { MessageListContainer } from "./message-list-container"
|
||||
import { MessageView } from "./message-view"
|
||||
import { SendDialog } from "./send-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCopy } from "@/hooks/use-copy"
|
||||
import { useSendPermission } from "@/hooks/use-send-permission"
|
||||
import { Copy } from "lucide-react"
|
||||
|
||||
interface Email {
|
||||
@@ -16,7 +18,10 @@ interface Email {
|
||||
export function ThreeColumnLayout() {
|
||||
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
|
||||
const [selectedMessageType, setSelectedMessageType] = useState<'received' | 'sent'>('received')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const { copyToClipboard } = useCopy()
|
||||
const { canSend: canSendEmails } = useSendPermission()
|
||||
|
||||
const columnClass = "border-2 border-primary/20 bg-background rounded-lg overflow-hidden flex flex-col"
|
||||
const headerClass = "p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0"
|
||||
@@ -35,6 +40,15 @@ export function ThreeColumnLayout() {
|
||||
copyToClipboard(selectedEmail?.address || "")
|
||||
}
|
||||
|
||||
const handleMessageSelect = (messageId: string | null, messageType: 'received' | 'sent' = 'received') => {
|
||||
setSelectedMessageId(messageId)
|
||||
setSelectedMessageType(messageType)
|
||||
}
|
||||
|
||||
const handleSendSuccess = () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-5 pt-20 h-full flex flex-col">
|
||||
{/* 桌面端三栏布局 */}
|
||||
@@ -45,7 +59,10 @@ export function ThreeColumnLayout() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EmailList
|
||||
onEmailSelect={setSelectedEmail}
|
||||
onEmailSelect={(email) => {
|
||||
setSelectedEmail(email)
|
||||
setSelectedMessageId(null)
|
||||
}}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
/>
|
||||
</div>
|
||||
@@ -55,11 +72,20 @@ export function ThreeColumnLayout() {
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedEmail ? (
|
||||
<div className="w-full flex items-center gap-2">
|
||||
<span className="truncate min-w-0">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
<div className="w-full flex justify-between items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate min-w-0">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
{selectedEmail && canSendEmails && (
|
||||
<SendDialog
|
||||
emailId={selectedEmail.id}
|
||||
fromAddress={selectedEmail.address}
|
||||
onSendSuccess={handleSendSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"选择邮箱查看消息"
|
||||
@@ -68,10 +94,11 @@ export function ThreeColumnLayout() {
|
||||
</div>
|
||||
{selectedEmail && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageList
|
||||
<MessageListContainer
|
||||
email={selectedEmail}
|
||||
onMessageSelect={setSelectedMessageId}
|
||||
onMessageSelect={handleMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -88,6 +115,7 @@ export function ThreeColumnLayout() {
|
||||
<MessageView
|
||||
emailId={selectedEmail.id}
|
||||
messageId={selectedMessageId}
|
||||
messageType={selectedMessageType}
|
||||
onClose={() => setSelectedMessageId(null)}
|
||||
/>
|
||||
</div>
|
||||
@@ -125,18 +153,28 @@ export function ThreeColumnLayout() {
|
||||
>
|
||||
← 返回邮箱列表
|
||||
</button>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 flex-1 text-right">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
<div className="flex-1 flex justify-between items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate min-w-0 flex-1 text-right">{selectedEmail.address}</span>
|
||||
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
|
||||
<Copy className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
{canSendEmails && (
|
||||
<SendDialog
|
||||
emailId={selectedEmail.id}
|
||||
fromAddress={selectedEmail.address}
|
||||
onSendSuccess={handleSendSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MessageList
|
||||
<MessageListContainer
|
||||
email={selectedEmail}
|
||||
onMessageSelect={setSelectedMessageId}
|
||||
onMessageSelect={handleMessageSelect}
|
||||
selectedMessageId={selectedMessageId}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,6 +195,7 @@ export function ThreeColumnLayout() {
|
||||
<MessageView
|
||||
emailId={selectedEmail.id}
|
||||
messageId={selectedMessageId}
|
||||
messageType={selectedMessageType}
|
||||
onClose={() => setSelectedMessageId(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
39
app/components/float-menu.tsx
Normal file
39
app/components/float-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -317,6 +317,26 @@ export function ApiKeyPanel() {
|
||||
|
||||
{showExamples && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">获取系统配置</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/config \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/config \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">生成临时邮箱</div>
|
||||
@@ -413,10 +433,11 @@ export function ApiKeyPanel() {
|
||||
<p>注意:</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>请将 YOUR_API_KEY 替换为你的实际 API Key</li>
|
||||
<li>/api/config 接口可获取系统配置,包括可用的邮箱域名列表</li>
|
||||
<li>emailId 是邮箱的唯一标识符</li>
|
||||
<li>messageId 是邮件的唯一标识符</li>
|
||||
<li>expiryTime 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)</li>
|
||||
<li>domain 是邮箱域名,可通过 /api/emails/domains 获取可用域名列表</li>
|
||||
<li>domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表</li>
|
||||
<li>cursor 用于分页,从上一次请求的响应中获取 nextCursor</li>
|
||||
<li>所有请求都需要包含 X-API-Key 请求头</li>
|
||||
</ul>
|
||||
|
||||
261
app/components/profile/email-service-config.tsx
Normal file
261
app/components/profile/email-service-config.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Zap, Eye, EyeOff } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
|
||||
interface EmailServiceConfig {
|
||||
enabled: boolean
|
||||
apiKey: string
|
||||
roleLimits: {
|
||||
duke: number
|
||||
knight: number
|
||||
}
|
||||
}
|
||||
|
||||
export function EmailServiceConfig() {
|
||||
const [config, setConfig] = useState<EmailServiceConfig>({
|
||||
enabled: false,
|
||||
apiKey: "",
|
||||
roleLimits: {
|
||||
duke: -1,
|
||||
knight: -1,
|
||||
}
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/config/email-service")
|
||||
if (res.ok) {
|
||||
const data = await res.json() as EmailServiceConfig
|
||||
setConfig(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch email service config:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const saveData = {
|
||||
enabled: config.enabled,
|
||||
apiKey: config.apiKey,
|
||||
roleLimits: config.roleLimits
|
||||
}
|
||||
|
||||
const res = await fetch("/api/config/email-service", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(saveData),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json() as { error: string }
|
||||
throw new Error(error.error || "保存失败")
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "Resend 发件服务配置已更新",
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "保存失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">Resend 发件服务配置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
启用 Resend 发件服务
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
开启后将使用 Resend 发送邮件
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={config.enabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setConfig((prev: EmailServiceConfig) => ({ ...prev, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey" className="text-sm font-medium">
|
||||
Resend API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig((prev: EmailServiceConfig) => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="输入 Resend API Key"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
允许使用发件功能的角色
|
||||
</Label>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg text-sm">
|
||||
<p className="font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
固定权限规则
|
||||
</p>
|
||||
<div className="space-y-2 text-blue-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
|
||||
<span><strong>Emperor (皇帝)</strong> - 可以无限发件,不受任何限制</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
|
||||
<span><strong>Civilian (平民)</strong> - 永远不能发件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
|
||||
<p className="text-sm font-medium text-gray-900">可配置的角色权限</p>
|
||||
</div>
|
||||
{[
|
||||
{ value: "duke", label: "Duke (公爵)", key: "duke" as const },
|
||||
{ value: "knight", label: "Knight (骑士)", key: "knight" as const }
|
||||
].map((role) => {
|
||||
const isDisabled = config.roleLimits[role.key] === -1
|
||||
const isEnabled = !isDisabled
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role.value}
|
||||
className={`group relative p-4 border-2 rounded-xl transition-all duration-200 ${
|
||||
isEnabled
|
||||
? 'border-primary/30 bg-primary/5 shadow-sm'
|
||||
: 'border-gray-200 hover:border-primary/20 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<Checkbox
|
||||
id={`role-${role.value}`}
|
||||
checked={isEnabled}
|
||||
onChange={(checked: boolean) => {
|
||||
setConfig((prev: EmailServiceConfig) => ({
|
||||
...prev,
|
||||
roleLimits: {
|
||||
...prev.roleLimits,
|
||||
[role.key]: checked ? 0 : -1
|
||||
}
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`role-${role.value}`}
|
||||
className="text-base font-semibold cursor-pointer select-none flex items-center gap-2"
|
||||
>
|
||||
<span className="text-2xl">
|
||||
{role.value === 'duke' ? '🏰' : '⚔️'}
|
||||
</span>
|
||||
{role.label}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isEnabled ? '已启用发件权限' : '未启用发件权限'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<Label className="text-xs font-medium text-gray-600 block mb-1">每日限制</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={config.roleLimits[role.key]}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfig((prev: EmailServiceConfig) => ({
|
||||
...prev,
|
||||
roleLimits: {
|
||||
...prev.roleLimits,
|
||||
[role.key]: parseInt(e.target.value) || 0
|
||||
}
|
||||
}))
|
||||
}
|
||||
className="w-20 h-9 text-center text-sm font-medium"
|
||||
placeholder="0"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">封/天</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">0 = 无限制</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { User } from "next-auth"
|
||||
import Image from "next/image"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signOut } from "next-auth/react"
|
||||
import { Github, Mail, Settings, Crown, Sword, User2, Gem } from "lucide-react"
|
||||
import { Github, Settings, Crown, Sword, User2, Gem, Mail } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { WebhookConfig } from "./webhook-config"
|
||||
import { PromotePanel } from "./promote-panel"
|
||||
import { EmailServiceConfig } from "./email-service-config"
|
||||
import { useRolePermission } from "@/hooks/use-role-permission"
|
||||
import { PERMISSIONS } from "@/lib/permissions"
|
||||
import { ConfigPanel } from "./config-panel"
|
||||
import { WebsiteConfigPanel } from "./website-config-panel"
|
||||
import { ApiKeyPanel } from "./api-key-panel"
|
||||
|
||||
interface ProfileCardProps {
|
||||
@@ -96,7 +97,8 @@ export function ProfileCard({ user }: ProfileCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManageConfig && <ConfigPanel />}
|
||||
{canManageConfig && <WebsiteConfigPanel />}
|
||||
{canManageConfig && <EmailServiceConfig />}
|
||||
{canPromote && <PromotePanel />}
|
||||
{canManageWebhook && <ApiKeyPanel />}
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export function ConfigPanel() {
|
||||
export function WebsiteConfigPanel() {
|
||||
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}
|
||||
53
app/components/ui/checkbox.tsx
Normal file
53
app/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CheckboxProps {
|
||||
id?: string
|
||||
checked?: boolean
|
||||
onChange?: (checked: boolean) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Checkbox: React.FC<CheckboxProps> = ({
|
||||
id,
|
||||
checked = false,
|
||||
onChange,
|
||||
className,
|
||||
disabled = false
|
||||
}) => {
|
||||
const handleChange = () => {
|
||||
if (!disabled && onChange) {
|
||||
onChange(!checked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center w-5 h-5 rounded border-2 cursor-pointer transition-all duration-200",
|
||||
checked
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-background border-input hover:border-primary/50",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
onClick={handleChange}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={() => {}} // Controlled by div onClick
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{checked && (
|
||||
<Check
|
||||
className="w-3 h-3 text-current animate-in fade-in-0 scale-in-95 duration-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,4 +51,83 @@ const TabsContent = React.forwardRef<
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
const SlidingTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const [activeIndex, setActiveIndex] = React.useState(0)
|
||||
|
||||
const combinedRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
const updateActiveIndex = () => {
|
||||
const triggers = node.querySelectorAll('[data-state="active"]')
|
||||
if (triggers.length > 0) {
|
||||
const allTriggers = node.querySelectorAll('[role="tab"]')
|
||||
const activeElement = triggers[0]
|
||||
const index = Array.from(allTriggers).indexOf(activeElement)
|
||||
if (index >= 0) {
|
||||
setActiveIndex(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(updateActiveIndex, 0)
|
||||
|
||||
const observer = new MutationObserver(updateActiveIndex)
|
||||
observer.observe(node, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-state'],
|
||||
subtree: true
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const childrenArray = React.Children.toArray(children)
|
||||
const tabCount = childrenArray.length
|
||||
const tabWidth = `calc(${100 / tabCount}% - ${2 * (tabCount - 1) / tabCount}px)`
|
||||
const slidePosition = `calc(${(100 / tabCount) * activeIndex}% + ${activeIndex}px)`
|
||||
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={combinedRef}
|
||||
className={cn(
|
||||
"relative flex w-full bg-muted rounded-lg p-1 h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-primary rounded-md shadow-sm transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: tabWidth,
|
||||
left: slidePosition
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</TabsPrimitive.List>
|
||||
)
|
||||
})
|
||||
SlidingTabsList.displayName = "SlidingTabsList"
|
||||
|
||||
const SlidingTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex-1 h-8 gap-2 flex items-center justify-center text-sm font-medium transition-colors duration-200 rounded-md px-3 py-2 data-[state=active]:text-primary-foreground data-[state=active]:bg-transparent data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SlidingTabsTrigger.displayName = "SlidingTabsTrigger"
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, SlidingTabsList, SlidingTabsTrigger }
|
||||
26
app/components/ui/textarea.tsx
Normal file
26
app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,6 +1,12 @@
|
||||
export const EMAIL_CONFIG = {
|
||||
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
|
||||
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
|
||||
DEFAULT_DAILY_SEND_LIMITS: {
|
||||
emperor: 0, // 皇帝无限制
|
||||
duke: 5, // 公爵每日5封
|
||||
knight: 2, // 骑士每日2封
|
||||
civilian: -1, // 平民禁止发件
|
||||
},
|
||||
} as const
|
||||
|
||||
export type EmailConfig = typeof EMAIL_CONFIG
|
||||
@@ -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
62
app/hooks/use-config.ts
Normal 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
|
||||
}
|
||||
52
app/hooks/use-send-permission.ts
Normal file
52
app/hooks/use-send-permission.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface SendPermissionResponse {
|
||||
canSend: boolean
|
||||
error?: string
|
||||
remainingEmails?: number
|
||||
}
|
||||
|
||||
export function useSendPermission() {
|
||||
const [canSend, setCanSend] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [remainingEmails, setRemainingEmails] = useState<number | undefined>()
|
||||
|
||||
const checkPermission = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/emails/send-permission')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('权限检查失败')
|
||||
}
|
||||
|
||||
const data = await response.json() as SendPermissionResponse
|
||||
setCanSend(data.canSend)
|
||||
setRemainingEmails(data.remainingEmails)
|
||||
|
||||
if (!data.canSend && data.error) {
|
||||
setError(data.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setCanSend(false)
|
||||
setError(err instanceof Error ? err.message : '权限检查失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkPermission()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
canSend,
|
||||
loading,
|
||||
error,
|
||||
remainingEmails,
|
||||
checkPermission
|
||||
}
|
||||
}
|
||||
21
app/hooks/use-user-role.ts
Normal file
21
app/hooks/use-user-role.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function getUserByApiKey(key: string): Promise<User | null> {
|
||||
}
|
||||
|
||||
export async function handleApiKeyAuth(apiKey: string, pathname: string) {
|
||||
if (!pathname.startsWith('/api/emails')) {
|
||||
if (!pathname.startsWith('/api/emails') && !pathname.startsWith('/api/config')) {
|
||||
return NextResponse.json(
|
||||
{ error: "无权限查看" },
|
||||
{ status: 403 }
|
||||
|
||||
@@ -10,6 +10,7 @@ import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import { hashPassword, comparePassword } from "@/lib/utils"
|
||||
import { authSchema } from "@/lib/validation"
|
||||
import { generateAvatarUrl } from "./avatar"
|
||||
import { getUserId } from "./apiKey"
|
||||
|
||||
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
[ROLES.EMPEROR]: "皇帝(网站所有者)",
|
||||
@@ -20,7 +21,16 @@ const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
|
||||
const getDefaultRole = async (): Promise<Role> => {
|
||||
const defaultRole = await getRequestContext().env.SITE_CONFIG.get("DEFAULT_ROLE")
|
||||
return defaultRole === ROLES.KNIGHT ? ROLES.KNIGHT : ROLES.CIVILIAN
|
||||
|
||||
if (
|
||||
defaultRole === ROLES.DUKE ||
|
||||
defaultRole === ROLES.KNIGHT ||
|
||||
defaultRole === ROLES.CIVILIAN
|
||||
) {
|
||||
return defaultRole as Role
|
||||
}
|
||||
|
||||
return ROLES.CIVILIAN
|
||||
}
|
||||
|
||||
async function findOrCreateRole(db: Db, roleName: Role) {
|
||||
@@ -52,13 +62,23 @@ 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
|
||||
const userId = await getUserId()
|
||||
|
||||
if (!userId) return false
|
||||
|
||||
const db = createDb()
|
||||
const userRoleRecords = await db.query.userRoles.findMany({
|
||||
where: eq(userRoles.userId, session.user.id),
|
||||
where: eq(userRoles.userId, userId),
|
||||
with: { role: true },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { integer, sqliteTable, text, primaryKey, uniqueIndex, index } from "drizzle-orm/sqlite-core"
|
||||
import type { AdapterAccountType } from "next-auth/adapters"
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
@@ -46,21 +46,30 @@ export const emails = sqliteTable("email", {
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
})
|
||||
}, (table) => ({
|
||||
expiresAtIdx: index("email_expires_at_idx").on(table.expiresAt),
|
||||
}))
|
||||
|
||||
export const messages = sqliteTable("message", {
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
emailId: text("emailId")
|
||||
.notNull()
|
||||
.references(() => emails.id, { onDelete: "cascade" }),
|
||||
fromAddress: text("from_address").notNull(),
|
||||
fromAddress: text("from_address"),
|
||||
toAddress: text("to_address"),
|
||||
subject: text("subject").notNull(),
|
||||
content: text("content").notNull(),
|
||||
html: text("html"),
|
||||
type: text("type"),
|
||||
receivedAt: integer("received_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
})
|
||||
sentAt: integer("sent_at", { mode: "timestamp_ms" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
}, (table) => ({
|
||||
emailIdIdx: index("message_email_id_idx").on(table.emailId),
|
||||
}))
|
||||
|
||||
export const webhooks = sqliteTable('webhook', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
@@ -96,12 +105,16 @@ 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, {
|
||||
|
||||
125
app/lib/send-permissions.ts
Normal file
125
app/lib/send-permissions.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { createDb } from "@/lib/db"
|
||||
import { userRoles, roles, messages, emails } from "@/lib/schema"
|
||||
import { eq, and, gte } from "drizzle-orm"
|
||||
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||
import { EMAIL_CONFIG } from "@/config"
|
||||
|
||||
export interface SendPermissionResult {
|
||||
canSend: boolean
|
||||
error?: string
|
||||
remainingEmails?: number
|
||||
}
|
||||
|
||||
export async function checkSendPermission(
|
||||
userId: string,
|
||||
skipDailyLimitCheck = false
|
||||
): Promise<SendPermissionResult> {
|
||||
try {
|
||||
const env = getRequestContext().env
|
||||
const enabled = await env.SITE_CONFIG.get("EMAIL_SERVICE_ENABLED")
|
||||
|
||||
if (enabled !== "true") {
|
||||
return {
|
||||
canSend: false,
|
||||
error: "邮件发送服务未启用"
|
||||
}
|
||||
}
|
||||
|
||||
const userDailyLimit = await getUserDailyLimit(userId)
|
||||
|
||||
if (userDailyLimit === -1) {
|
||||
return {
|
||||
canSend: false,
|
||||
error: "您的角色没有发件权限"
|
||||
}
|
||||
}
|
||||
|
||||
if (skipDailyLimitCheck || userDailyLimit === 0) {
|
||||
return {
|
||||
canSend: true
|
||||
}
|
||||
}
|
||||
|
||||
const db = createDb()
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const sentToday = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.innerJoin(emails, eq(messages.emailId, emails.id))
|
||||
.where(
|
||||
and(
|
||||
eq(emails.userId, userId),
|
||||
eq(messages.type, "sent"),
|
||||
gte(messages.receivedAt, today)
|
||||
)
|
||||
)
|
||||
|
||||
const remainingEmails = Math.max(0, userDailyLimit - sentToday.length)
|
||||
|
||||
if (sentToday.length >= userDailyLimit) {
|
||||
return {
|
||||
canSend: false,
|
||||
error: `您今天已达到发件限制 (${userDailyLimit} 封),请明天再试`,
|
||||
remainingEmails: 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canSend: true,
|
||||
remainingEmails
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check send permission:', error)
|
||||
return {
|
||||
canSend: false,
|
||||
error: "权限检查失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserDailyLimit(userId: string): Promise<number> {
|
||||
try {
|
||||
const db = createDb()
|
||||
|
||||
const userRoleData = await db
|
||||
.select({ roleName: roles.name })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId))
|
||||
|
||||
const userRoleNames = userRoleData.map(r => r.roleName)
|
||||
|
||||
const env = getRequestContext().env
|
||||
const roleLimitsStr = await env.SITE_CONFIG.get("EMAIL_ROLE_LIMITS")
|
||||
|
||||
const customLimits = roleLimitsStr ? JSON.parse(roleLimitsStr) : {}
|
||||
|
||||
const finalLimits = {
|
||||
emperor: EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.emperor,
|
||||
duke: customLimits.duke !== undefined ? customLimits.duke : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.duke,
|
||||
knight: customLimits.knight !== undefined ? customLimits.knight : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.knight,
|
||||
civilian: EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.civilian,
|
||||
}
|
||||
|
||||
if (userRoleNames.includes("emperor")) {
|
||||
return finalLimits.emperor
|
||||
} else if (userRoleNames.includes("duke")) {
|
||||
return finalLimits.duke
|
||||
} else if (userRoleNames.includes("knight")) {
|
||||
return finalLimits.knight
|
||||
} else if (userRoleNames.includes("civilian")) {
|
||||
return finalLimits.civilian
|
||||
}
|
||||
|
||||
return -1
|
||||
} catch (error) {
|
||||
console.error('Failed to get user daily limit:', error)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkBasicSendPermission(userId: string): Promise<SendPermissionResult> {
|
||||
return checkSendPermission(userId, true)
|
||||
}
|
||||
2
drizzle/0011_simple_vulcan.sql
Normal file
2
drizzle/0011_simple_vulcan.sql
Normal 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`);
|
||||
2
drizzle/0012_steady_nitro.sql
Normal file
2
drizzle/0012_steady_nitro.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX `email_expires_at_idx` ON `email` (`expires_at`);--> statement-breakpoint
|
||||
CREATE INDEX `message_email_id_idx` ON `message` (`emailId`);
|
||||
20
drizzle/0013_illegal_senator_kelly.sql
Normal file
20
drizzle/0013_illegal_senator_kelly.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_message` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`emailId` text NOT NULL,
|
||||
`from_address` text,
|
||||
`to_address` text,
|
||||
`subject` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`html` text,
|
||||
`type` text,
|
||||
`received_at` integer NOT NULL,
|
||||
`sent_at` integer NOT NULL,
|
||||
FOREIGN KEY (`emailId`) REFERENCES `email`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_message`("id", "emailId", "from_address", "to_address", "subject", "content", "html", "type", "received_at", "sent_at") SELECT "id", "emailId", "from_address", "to_address", "subject", "content", "html", "type", "received_at", "sent_at" FROM `message`;--> statement-breakpoint
|
||||
DROP TABLE `message`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_message` RENAME TO `message`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `message_email_id_idx` ON `message` (`emailId`);
|
||||
609
drizzle/meta/0011_snapshot.json
Normal file
609
drizzle/meta/0011_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
624
drizzle/meta/0012_snapshot.json
Normal file
624
drizzle/meta/0012_snapshot.json
Normal file
@@ -0,0 +1,624 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6b001f75-97b4-4e3d-9025-43b977cb2619",
|
||||
"prevId": "b43606bc-3df5-471f-ac46-2c2eb52b1440",
|
||||
"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
|
||||
},
|
||||
"email_expires_at_idx": {
|
||||
"name": "email_expires_at_idx",
|
||||
"columns": [
|
||||
"expires_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"message_email_id_idx": {
|
||||
"name": "message_email_id_idx",
|
||||
"columns": [
|
||||
"emailId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
645
drizzle/meta/0013_snapshot.json
Normal file
645
drizzle/meta/0013_snapshot.json
Normal file
@@ -0,0 +1,645 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "eb2c55e5-a514-4048-9787-e8afc4c33308",
|
||||
"prevId": "6b001f75-97b4-4e3d-9025-43b977cb2619",
|
||||
"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
|
||||
},
|
||||
"email_expires_at_idx": {
|
||||
"name": "email_expires_at_idx",
|
||||
"columns": [
|
||||
"expires_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"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": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_address": {
|
||||
"name": "to_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"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
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"received_at": {
|
||||
"name": "received_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sent_at": {
|
||||
"name": "sent_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"message_email_id_idx": {
|
||||
"name": "message_email_id_idx",
|
||||
"columns": [
|
||||
"emailId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,27 @@
|
||||
"when": 1739157879946,
|
||||
"tag": "0010_brief_stellaris",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1741490895849,
|
||||
"tag": "0011_simple_vulcan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1747926565177,
|
||||
"tag": "0012_steady_nitro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1750081604094,
|
||||
"tag": "0013_illegal_senator_kelly",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
}
|
||||
22
package.json
22
package.json
@@ -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",
|
||||
@@ -33,7 +33,6 @@
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
@@ -48,22 +47,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",
|
||||
"@types/react": "19.0.0",
|
||||
"@types/react-dom": "19.0.0",
|
||||
"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"
|
||||
|
||||
1259
pnpm-lock.yaml
generated
1259
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
99
scripts/deploy/cloudflare.ts
Normal file
99
scripts/deploy/cloudflare.ts
Normal 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;
|
||||
};
|
||||
466
scripts/deploy/index.ts
Normal file
466
scripts/deploy/index.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
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 (PROJECT_NAME !== "moemail") {
|
||||
const wranglerFileName = targetPath.split("/").at(-1);
|
||||
|
||||
switch (wranglerFileName) {
|
||||
case "wrangler.json":
|
||||
json.name = PROJECT_NAME;
|
||||
break;
|
||||
case "wrangler.email.json":
|
||||
json.name = `${PROJECT_NAME}-email-receiver-worker`;
|
||||
break;
|
||||
case "wrangler.cleanup.json":
|
||||
json.name = `${PROJECT_NAME}-cleanup-worker`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理数据库配置
|
||||
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();
|
||||
@@ -1,59 +1,97 @@
|
||||
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
|
||||
}
|
||||
|
||||
const MAX_EMAIL_COUNT = 5
|
||||
const MAX_MESSAGE_COUNT = 50
|
||||
const MAX_EMAIL_COUNT = 10
|
||||
const MAX_MESSAGE_COUNT = 100
|
||||
const BATCH_SIZE = 10 // SQLite 变量限制
|
||||
|
||||
async function generateTestData(env: Env) {
|
||||
async function getUserId(db: ReturnType<typeof drizzle>, identifier: string): Promise<string | null> {
|
||||
let user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, identifier))
|
||||
.limit(1)
|
||||
.then(rows => rows[0])
|
||||
|
||||
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 {
|
||||
// 生成测试邮箱
|
||||
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),
|
||||
}))
|
||||
|
||||
// 插入测试邮箱
|
||||
const emailResults = await db.insert(emails).values(testEmails).returning()
|
||||
console.log('Created test emails:', emailResults)
|
||||
|
||||
// 为每个邮箱生成测试消息
|
||||
for (const email of emailResults) {
|
||||
const allMessages = Array.from({ length: MAX_MESSAGE_COUNT }).map((_, index) => ({
|
||||
const receivedMessages = Array.from({ length: Math.floor(MAX_MESSAGE_COUNT * 0.7) }).map((_, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
emailId: email.id,
|
||||
fromAddress: `sender${index + 1}@example.com`,
|
||||
subject: `Test Message ${index + 1} - ${nanoid(6)}`,
|
||||
content: `This is test message ${index + 1} content.\n\nBest regards,\nSender ${index + 1}`,
|
||||
toAddress: null,
|
||||
subject: `Received Message ${index + 1} - ${nanoid(6)}`,
|
||||
content: `This is received message ${index + 1} content.\n\nBest regards,\nSender ${index + 1}`,
|
||||
html: `<div>
|
||||
<h1>Test Message ${index + 1}</h1>
|
||||
<p>This is test message ${index + 1} content.</p>
|
||||
<h1>Received Message ${index + 1}</h1>
|
||||
<p>This is received message ${index + 1} content.</p>
|
||||
<p>With some <strong>HTML</strong> formatting.</p>
|
||||
<br>
|
||||
<p>Best regards,<br>Sender ${index + 1}</p>
|
||||
</div>`,
|
||||
type: 'received',
|
||||
receivedAt: new Date(now.getTime() - index * 60 * 60 * 1000),
|
||||
}))
|
||||
|
||||
// 分批插入消息
|
||||
const sentMessages = Array.from({ length: Math.floor(MAX_MESSAGE_COUNT * 0.3) }).map((_, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
emailId: email.id,
|
||||
fromAddress: null,
|
||||
toAddress: `recipient${index + 1}@example.com`,
|
||||
subject: `Sent Message ${index + 1} - ${nanoid(6)}`,
|
||||
html: `This is sent message ${index + 1} content.\n\nBest regards,\n${email.address}`,
|
||||
content: '',
|
||||
type: 'sent',
|
||||
sentAt: new Date(now.getTime() - index * 60 * 60 * 1000),
|
||||
}))
|
||||
|
||||
const allMessages = [...receivedMessages, ...sentMessages]
|
||||
|
||||
for (let i = 0; i < allMessages.length; i += BATCH_SIZE) {
|
||||
const batch = allMessages.slice(i, i + BATCH_SIZE)
|
||||
await db.insert(messages).values(batch)
|
||||
console.log(`Created batch of ${batch.length} messages for email ${email.address}`)
|
||||
console.log(`Created batch of ${batch.length} messages (received + sent) for email ${email.address}`)
|
||||
}
|
||||
|
||||
console.log(`Email ${email.address}: ${receivedMessages.length} received, ${sentMessages.length} sent messages`)
|
||||
}
|
||||
|
||||
console.log('Test data generation completed successfully!')
|
||||
@@ -66,7 +104,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 })
|
||||
|
||||
@@ -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()
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
@@ -78,7 +78,7 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
plugins: [tailwindcssAnimate],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -6,61 +6,33 @@ const CLEANUP_CONFIG = {
|
||||
// Whether to delete expired emails
|
||||
DELETE_EXPIRED_EMAILS: true,
|
||||
|
||||
// Whether to delete messages from expired emails if not deleting the emails themselves
|
||||
DELETE_MESSAGES_FROM_EXPIRED: true,
|
||||
|
||||
// Batch processing size
|
||||
BATCH_SIZE: 100,
|
||||
} as const
|
||||
|
||||
const main = {
|
||||
async scheduled(event: ScheduledEvent, env: Env) {
|
||||
async scheduled(_: ScheduledEvent, env: Env) {
|
||||
const now = Date.now()
|
||||
|
||||
try {
|
||||
// Find expired emails
|
||||
const { results: expiredEmails } = await env.DB
|
||||
.prepare(`
|
||||
SELECT id
|
||||
FROM email
|
||||
WHERE expires_at < ?
|
||||
LIMIT ?
|
||||
`)
|
||||
.bind(now, CLEANUP_CONFIG.BATCH_SIZE)
|
||||
.all()
|
||||
|
||||
if (!expiredEmails?.length) {
|
||||
console.log('No expired emails found')
|
||||
if (!CLEANUP_CONFIG.DELETE_EXPIRED_EMAILS) {
|
||||
console.log('Expired email deletion is disabled')
|
||||
return
|
||||
}
|
||||
|
||||
const expiredEmailIds = expiredEmails.map(email => email.id)
|
||||
const placeholders = expiredEmailIds.map(() => '?').join(',')
|
||||
|
||||
if (CLEANUP_CONFIG.DELETE_EXPIRED_EMAILS) {
|
||||
// First delete associated messages
|
||||
await env.DB.prepare(`
|
||||
DELETE FROM message
|
||||
WHERE emailId IN (${placeholders})
|
||||
`).bind(...expiredEmailIds).run()
|
||||
|
||||
// Then delete the emails
|
||||
await env.DB.prepare(`
|
||||
const result = await env.DB
|
||||
.prepare(`
|
||||
DELETE FROM email
|
||||
WHERE id IN (${placeholders})
|
||||
`).bind(...expiredEmailIds).run()
|
||||
|
||||
console.log(`Deleted ${expiredEmails.length} expired emails and their messages`)
|
||||
} else if (CLEANUP_CONFIG.DELETE_MESSAGES_FROM_EXPIRED) {
|
||||
// Only delete messages from expired emails
|
||||
await env.DB.prepare(`
|
||||
DELETE FROM message
|
||||
WHERE emailId IN (${placeholders})
|
||||
`).bind(...expiredEmailIds).run()
|
||||
|
||||
console.log(`Deleted messages from ${expiredEmails.length} expired emails`)
|
||||
WHERE expires_at < ?
|
||||
LIMIT ?
|
||||
`)
|
||||
.bind(now, CLEANUP_CONFIG.BATCH_SIZE)
|
||||
.run()
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Deleted ${result?.meta?.changes ?? 0} expired emails and their associated messages`)
|
||||
} else {
|
||||
console.log('No cleanup actions performed (disabled in config)')
|
||||
console.error('Failed to delete expired emails')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup:', error)
|
||||
|
||||
18
wrangler.cleanup.example.json
Normal file
18
wrangler.cleanup.example.json
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 = ""
|
||||
15
wrangler.email.example.json
Normal file
15
wrangler.email.example.json
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
21
wrangler.example.json
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 = ""
|
||||
Reference in New Issue
Block a user