60 Commits

Author SHA1 Message Date
beilunyang
0c7a4d84a5 refactor(shared-error-page): Update language switcher positioning and component 2025-10-18 22:16:48 +08:00
beilunyang
503856a8a8 fix(shared-email): Improve date handling and error resilience in shared email page 2025-10-18 22:03:27 +08:00
beilunyang
b83b05b7b0 docs(metadata): Update SEO metadata for improved description and keywords 2025-10-18 20:44:24 +08:00
beilunyang
9ad1707b00 refactor(ui): Improve responsive layout and spacing across components 2025-10-18 20:37:09 +08:00
beilunyang
dbe8c42b11 feat(sharing): add email and message sharing functionality 2025-10-18 20:08:42 +08:00
beilunyang
47d555eaf5 refactor: remove unused translation for common actions in email service config 2025-10-13 01:16:10 +08:00
beilunyang
923253fb8b Revert "chore: update dependencies and package versions"
This reverts commit 0fcc4b9e85.
2025-10-13 01:11:23 +08:00
beilunyang
048d9219d0 fix: ts type 2025-10-13 01:02:54 +08:00
beilunyang
d175017b51 feat: add internationalization support with next-intl 2025-10-13 00:57:32 +08:00
beilunyang
0fcc4b9e85 chore: update dependencies and package versions 2025-10-12 21:13:35 +08:00
BeilunYang
fd585851fc Merge pull request #78 from 1411430556/patch-1
docs: add star-history
2025-09-17 22:42:06 +08:00
COYG⚡️
7a4cf7096a Update README.md 2025-09-17 12:02:16 +08:00
beilunyang
8ec1ecdbb4 fix: new received emails not showing 2025-06-23 00:37:12 +08:00
BeilunYang
f4f93d9b52 Merge pull request #57 from beilunyang/feat/resend
[Feat] Implement email sending functionality via Resend service
2025-06-22 22:44:43 +08:00
beilunyang
0f5aaab26b Revert "chore: Update wrangler-cli"
This reverts commit 166d28405b.
2025-06-22 22:29:14 +08:00
beilunyang
70c1f2e902 fix: ts type error 2025-06-22 21:54:35 +08:00
beilunyang
166d28405b chore: Update wrangler-cli 2025-06-22 21:05:19 +08:00
beilunyang
408f97f98d docs: Update readme 2025-06-22 20:57:06 +08:00
beilunyang
e85f6b04bd feat: implement email sending functionality via Resend service 2025-06-22 00:32:09 +08:00
beilunyang
9d55564073 fix: the duke can not be used as the default role 2025-06-06 00:04:23 +08:00
beilunyang
7a04a8165c docs: Update README and API Key Panel to add information about api/config endpoints 2025-06-05 23:50:29 +08:00
beilunyang
60d40a7a32 fix: cleanup worker 2025-05-24 21:15:45 +08:00
beilunyang
7f7e29a80f feat: Update db migration files 2025-05-22 23:10:12 +08:00
BeilunYang
f465e13620 Merge pull request #50 from fuckdywl/master close #49
Optimize email cleanup and add database indexes
2025-05-22 23:08:01 +08:00
selenium39
3cac33bed3 Optimize email cleanup and add database indexes 2025-05-22 11:16:49 +08:00
beilunyang
eb6c3fe5eb feat: /api/config endpoint can be called using APIKey 2025-05-13 00:01:15 +08:00
beilunyang
fa1dadfb21 docs: Video version of the deployment tutorial 2025-04-06 21:01:08 +08:00
beilunyang
0b9f457e52 fix: Update deploy script for support custom project name 2025-04-06 20:52:52 +08:00
BeilunYang
eb88cbcb31 Merge pull request #43 from sdrpsps/fix/deploy-pages-name
fix: Deploy script PROJECT_NAME is invalid
2025-04-06 20:35:08 +08:00
sunny
92116b9e3f fix: Deploy script PROJECT_NAME is invalid 2025-04-01 10:47:00 +08:00
beilunyang
bf11aae52e docs: update docs about PROJECT_NAME 2025-03-31 23:00:25 +08:00
ty
f5d49790a7 feat: integrate theme support in message view for improved styling 2025-03-16 15:43:04 +08:00
ty
6ddd5bdf4e chore: enhance test data generation with user identification 2025-03-16 15:34:50 +08:00
ty
e8e2349a97 fix: Github Action failed to build pages 2025-03-16 15:25:29 +08:00
BeilunYang
fd46bf2661 Merge pull request #38 from jieyitang/master
feat: Add delete function for single message
2025-03-16 15:17:36 +08:00
jieyitang
21d09a2cb0 feat: add delete function for single email message 2025-03-14 16:52:38 +08:00
jieyitang
eb8023280b fix: prevent unnecessary requests when switching emails 2025-03-14 16:48:54 +08:00
beilunyang
58e6d06bed docs: Add GitHub OAuth configuration variables to README 2025-03-09 12:40:03 +08:00
beilunyang
436666a88b fix: Breaking update due to new deployment scripts 2025-03-09 12:03:35 +08:00
beilunyang
dae8122231 feat: Update API keys index to enforce unique name per user 2025-03-09 11:28:43 +08:00
beilunyang
7210c68fbd feat: Add optional database and KV namespace IDs to deployment workflow 2025-03-09 11:17:21 +08:00
beilunyang
994ab8acc3 feat: Modify apikeys schema and README configuration files 2025-03-09 11:07:21 +08:00
beilunyang
c405c02a34 docs: Update deployment instructions in README 2025-03-09 10:57:07 +08:00
beilunyang
d9fb486104 feat: Add optional environment variables for database and KV namespace configuration 2025-03-09 10:22:06 +08:00
BeilunYang
77cb52e608 Merge pull request #34 from sdrpsps/chore/deploy-script
chore: Update deploy script
2025-03-09 10:08:07 +08:00
sunny
16bc357973 chore: Remove environment variable DATABASE_ID and KV_NAMESPACE_ID, auto-pagination kvNamespaces 2025-03-06 14:33:56 +08:00
sunny
b75d9ada43 chore: Update checking kv namespace function to avoid failed to create namespace 2025-03-05 13:28:45 +08:00
sunny
da979d2a51 chore: Update and simplify deployment workflow 2025-03-05 11:58:15 +08:00
sunny
200d82f874 chore: Extract and push only runtime-required environment variables 2025-03-05 11:16:00 +08:00
sunny
ed8885a2d8 chore: Change environment variable PROJECT_URL to CUSTOM_DOMAIN 2025-03-05 11:12:19 +08:00
sunny
cd429b96d8 fix: Database migration failed 2025-03-04 17:31:54 +08:00
sunny
5173cbf9d3 chore: Update deploy script 2025-03-04 16:38:15 +08:00
beilunyang
9b7ed0b031 docs: Add system settings section to README with configuration details 2025-03-01 10:40:59 +08:00
beilunyang
ea7fd5490c refactor: Consolidate configuration management with Zustand store 2025-03-01 10:29:50 +08:00
beilunyang
b1d898e298 feat: Add configurable maximum email limit for users 2025-02-28 00:30:37 +08:00
beilunyang
f86d944c25 feat: Add role-based email limit exemption for emperors 2025-02-27 23:59:36 +08:00
BeilunYang
59671091b6 docs: Update Contract QRCode 2025-02-21 17:08:58 +08:00
BeilunYang
19d805de57 Merge pull request #26 from Ktovoz/updateReadme
docs:补充 Catch-All 状态不可用时的解决方案- 在 README.md 文件中添加了新的注意事项
2025-02-21 16:13:53 +08:00
ktoWYY
2566a8a105 docs:补充 Catch-All 状态不可用时的解决方案- 在 README.md 文件中添加了新的注意事项
- 提供了当 Catch-All 状态不可用时的替代方案,即绑定一个邮箱
2025-02-21 15:41:48 +08:00
beilunyang
821a32aa4b feat: Add GitHub link float menu button 2025-02-17 23:22:39 +08:00
119 changed files with 15418 additions and 1617 deletions

View File

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

View File

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

6
.gitignore vendored
View File

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

2
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,2 @@
{
}

508
README.md
View File

@@ -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,9 +48,11 @@
- 📱 **PWA 支持**:支持 PWA 安装
- 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
- 📤 **发件功能**:支持使用临时邮箱发送邮件,基于 Resend 服务
- 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知
- 🛡️ **权限系统**:支持基于角色的权限控制系统
- 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI
- 🌍 **多语言支持**:支持中文和英文界面,可自由切换
## 技术栈
@@ -61,6 +65,7 @@
- **邮件处理**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
- **类型安全**: [TypeScript](https://www.typescriptlang.org/)
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
- **国际化**: [next-intl](https://next-intl-docs.vercel.app/) 支持多语言
## 本地运行
@@ -86,9 +91,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 +133,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 +153,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 +183,12 @@ pnpm deploy:cleanup
- 进入仓库的 Actions 页面
- 选择 "Deploy" workflow
- 点击 "Run workflow"
- 选择需要执行的部署选项
- 点击 "Run workflow" 开始部署
3. GitHub Actions 会自动执行以下任务:
- 构建并部署主应用到 Cloudflare Pages
- 根据选项或文件变更执行数据库迁移
- 根据选项或文件变更部署 Email Worker
- 根据选项或文件变更部署 Cleanup Worker
4. 部署进度可以在仓库的 Actions 标签页查看
3. 部署进度可以在仓库的 Actions 标签页查看
#### 注意事项
- 确保所有 Secrets 都已正确设置
- 使用 tag 触发时tag 必须以 `v` 开头例如v1.0.0
- 使用 tag 触发时,只有文件发生变更的部分会被部署
- 手动触发时,可以选择性地执行特定的部署任务
- 每次部署都会重新部署主应用
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
@@ -273,6 +220,7 @@ pnpm deploy:cleanup
### 注意事项
- 确保域名的 DNS 托管在 Cloudflare
- Email Worker 必须已经部署成功
- 如果 Catch-All 状态不可用(一直 loading),请点击`路由规则`旁边的`目标地址`, 进去绑定一个邮箱
## 权限系统
@@ -327,8 +275,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 +418,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 +451,18 @@ Content-Type: application/json
参数说明:
- `name`: 邮箱前缀,可选
- `expiryTime`: 有效期毫秒可选值36000001小时、864000001天、6048000007天、0永久
- `domain`: 邮箱域名,可通过 `/api/emails/domains` 获取可用域名列表
- `domain`: 邮箱域名,可通过 `/api/config` 接口获取
返回响应:
```json
{
"id": "email-uuid-123",
"email": "test@moemail.app"
}
```
响应字段说明:
- `id`: 邮箱的唯一标识符
- `email`: 创建的邮箱地址
#### 获取邮箱列表
```http
@@ -416,22 +471,253 @@ 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`: 接收时间(时间戳)
#### 创建邮箱分享链接
```http
POST /api/emails/{emailId}/share
Content-Type: application/json
{
"expiresIn": 86400000
}
```
参数说明:
- `emailId`: 邮箱的唯一标识符,必填
- `expiresIn`: 分享链接有效期毫秒0 表示永久有效,可选
返回响应:
```json
{
"id": "share-uuid-123",
"emailId": "email-uuid-123",
"token": "abc123def456",
"expiresAt": "2024-01-02T12:00:00.000Z",
"createdAt": "2024-01-01T12:00:00.000Z"
}
```
响应字段说明:
- `id`: 分享记录的唯一标识符
- `emailId`: 关联的邮箱 ID
- `token`: 分享链接的访问令牌
- `expiresAt`: 分享链接过期时间null 表示永久有效
- `createdAt`: 创建时间
分享链接访问地址:`https://your-domain.com/shared/{token}`
#### 获取邮箱的所有分享链接
```http
GET /api/emails/{emailId}/share
```
参数说明:
- `emailId`: 邮箱的唯一标识符,必填
返回响应:
```json
{
"shares": [
{
"id": "share-uuid-123",
"emailId": "email-uuid-123",
"token": "abc123def456",
"expiresAt": "2024-01-02T12:00:00.000Z",
"createdAt": "2024-01-01T12:00:00.000Z"
}
],
"total": 1
}
```
响应字段说明:
- `shares`: 分享链接列表数组
- `total`: 分享链接总数
#### 删除邮箱分享链接
```http
DELETE /api/emails/{emailId}/share/{shareId}
```
参数说明:
- `emailId`: 邮箱的唯一标识符,必填
- `shareId`: 分享记录的唯一标识符,必填
返回响应:
```json
{
"success": true
}
```
响应字段说明:
- `success`: 删除操作是否成功
#### 创建邮件分享链接
```http
POST /api/emails/{emailId}/messages/{messageId}/share
Content-Type: application/json
{
"expiresIn": 86400000
}
```
参数说明:
- `emailId`: 邮箱的唯一标识符,必填
- `messageId`: 邮件的唯一标识符,必填
- `expiresIn`: 分享链接有效期毫秒0 表示永久有效,可选
返回响应:
```json
{
"id": "share-uuid-456",
"messageId": "message-uuid-789",
"token": "xyz789ghi012",
"expiresAt": "2024-01-02T12:00:00.000Z",
"createdAt": "2024-01-01T12:00:00.000Z"
}
```
响应字段说明:
- `id`: 分享记录的唯一标识符
- `messageId`: 关联的邮件 ID
- `token`: 分享链接的访问令牌
- `expiresAt`: 分享链接过期时间null 表示永久有效
- `createdAt`: 创建时间
分享链接访问地址:`https://your-domain.com/shared/message/{token}`
#### 获取邮件的所有分享链接
```http
GET /api/emails/{emailId}/messages/{messageId}/share
```
参数说明:
- `emailId`: 邮箱的唯一标识符,必填
- `messageId`: 邮件的唯一标识符,必填
返回响应:
```json
{
"shares": [
{
"id": "share-uuid-456",
"messageId": "message-uuid-789",
"token": "xyz789ghi012",
"expiresAt": "2024-01-02T12:00:00.000Z",
"createdAt": "2024-01-01T12:00:00.000Z"
}
],
"total": 1
}
```
响应字段说明:
- `shares`: 分享链接列表数组
- `total`: 分享链接总数
#### 删除邮件分享链接
```http
DELETE /api/emails/{emailId}/messages/{messageId}/share/{shareId}
```
参数说明:
- `emailId`: 邮箱的唯一标识符,必填
- `messageId`: 邮件的唯一标识符,必填
- `shareId`: 分享记录的唯一标识符,必填
返回响应:
```json
{
"success": true
}
```
响应字段说明:
- `success`: 删除操作是否成功
### 使用示例
@@ -457,6 +743,32 @@ const res = await fetch('https://your-domain.com/api/emails/your-email-id', {
const data = await res.json();
```
使用 curl 创建邮箱分享链接:
```bash
curl -X POST https://your-domain.com/api/emails/your-email-id/share \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"expiresIn": 86400000
}'
```
使用 JavaScript 创建邮件分享链接:
```javascript
const res = await fetch('https://your-domain.com/api/emails/your-email-id/messages/your-message-id/share', {
method: 'POST',
headers: {
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
expiresIn: 0 // 永久有效
})
});
const data = await res.json();
console.log('分享链接:', `https://your-domain.com/shared/message/${data.token}`);
```
## 环境变量
本项目使用以下环境变量:
@@ -470,8 +782,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 +805,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>
## 支持
@@ -505,3 +835,13 @@ const data = await res.json();
<br />
<br />
<a href="https://www.buymeacoffee.com/beilunyang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-blue.png" alt="Buy Me A Coffee" style="width: 400px;" ></a>
## Star History
<a href="https://www.star-history.com/#beilunyang/moemail&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=beilunyang/moemail&type=Date" />
</picture>
</a>

148
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { NextIntlClientProvider } from "next-intl"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { i18n, type Locale } from "@/i18n/config"
import type { Metadata, Viewport } from "next"
import { FloatMenu } from "@/components/float-menu"
import { ThemeProvider } from "@/components/theme/theme-provider"
import { Toaster } from "@/components/ui/toaster"
import { cn } from "@/lib/utils"
import { zpix } from "../fonts"
import "../globals.css"
import { Providers } from "../providers"
export const runtime = "edge"
export const viewport: Viewport = {
themeColor: '#826DD9',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
async function getMessages(locale: Locale) {
try {
const common = (await import(`@/i18n/messages/${locale}/common.json`)).default
const home = (await import(`@/i18n/messages/${locale}/home.json`)).default
const auth = (await import(`@/i18n/messages/${locale}/auth.json`)).default
const metadata = (await import(`@/i18n/messages/${locale}/metadata.json`)).default
const emails = (await import(`@/i18n/messages/${locale}/emails.json`)).default
const profile = (await import(`@/i18n/messages/${locale}/profile.json`)).default
return { common, home, auth, metadata, emails, profile }
} catch (error) {
console.error(`Failed to load messages for locale ${locale}:`, error)
return { common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {} }
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const t = await getTranslations({ locale, namespace: "metadata" })
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://moemail.app"
// Generate hreflang links for all supported locales
const languages: Record<string, string> = {}
i18n.locales.forEach((loc) => {
languages[loc] = `${baseUrl}/${loc}`
})
return {
title: t("title"),
description: t("description"),
keywords: t("keywords"),
authors: [{ name: "SoftMoe Studio" }],
creator: "SoftMoe Studio",
publisher: "SoftMoe Studio",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
type: "website",
locale: locale === "zh-CN" ? "zh_CN" : locale,
url: `${baseUrl}/${locale}`,
title: t("title"),
description: t("description"),
siteName: "MoeMail",
},
twitter: {
card: "summary_large_image",
title: t("title"),
description: t("description"),
},
alternates: {
canonical: `${baseUrl}/${locale}`,
languages,
},
manifest: '/manifest.json',
icons: [
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
],
}
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
if (!i18n.locales.includes(locale)) {
notFound()
}
const messages = await getMessages(locale)
return (
<html lang={locale} suppressHydrationWarning>
<head>
<meta name="application-name" content="MoeMail" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="MoeMail" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body
className={cn(
zpix.variable,
"font-zpix min-h-screen antialiased",
"bg-background text-foreground",
"transition-colors duration-300"
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
storageKey="temp-mail-theme"
>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<FloatMenu />
</NextIntlClientProvider>
</Providers>
<Toaster />
</ThemeProvider>
</body>
</html>
)
}

View File

@@ -1,14 +1,21 @@
import { LoginForm } from "@/components/auth/login-form"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function LoginPage() {
export default async function LoginPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (session?.user) {
redirect("/")
redirect(`/${locale}`)
}
return (
@@ -16,4 +23,5 @@ export default async function LoginPage() {
<LoginForm />
</div>
)
}
}

View File

@@ -5,14 +5,21 @@ import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { checkPermission } from "@/lib/auth"
import { PERMISSIONS } from "@/lib/permissions"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function MoePage() {
export default async function MoePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (!session?.user) {
redirect("/")
redirect(`/${locale}`)
}
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL)
@@ -28,4 +35,5 @@ export default async function MoePage() {
</div>
</div>
)
}
}

74
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { Header } from "@/components/layout/header"
import { auth } from "@/lib/auth"
import { Shield, Share2, Clock, Code2 } from "lucide-react"
import { ActionButton } from "@/components/home/action-button"
import { FeatureCard } from "@/components/home/feature-card"
import { getTranslations } from "next-intl/server"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function Home({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
const t = await getTranslations({ locale, namespace: "home" })
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 min-h-screen">
<div className="container mx-auto px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="pt-16">
<div className="h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-2 relative overflow-hidden">
<div className="absolute inset-0 -z-10 bg-grid-primary/5" />
<div className="w-full max-w-3xl mx-auto space-y-6 sm:space-y-8 py-4">
<div className="space-y-2 sm:space-y-3">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
{t("title")}
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 tracking-wide">
{t("subtitle")}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 px-2 sm:px-0">
<FeatureCard
icon={<Shield className="w-5 h-5" />}
title={t("features.privacy.title")}
description={t("features.privacy.description")}
/>
<FeatureCard
icon={<Share2 className="w-5 h-5" />}
title={t("features.instant.title")}
description={t("features.instant.description")}
/>
<FeatureCard
icon={<Clock className="w-5 h-5" />}
title={t("features.expiry.title")}
description={t("features.expiry.description")}
/>
<FeatureCard
icon={<Code2 className="w-5 h-5" />}
title={t("features.openapi.title")}
description={t("features.openapi.description")}
/>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-2 sm:px-0">
<ActionButton isLoggedIn={!!session} />
</div>
</div>
</div>
</main>
</div>
</div>
)
}

View File

@@ -2,14 +2,21 @@ import { Header } from "@/components/layout/header"
import { ProfileCard } from "@/components/profile/profile-card"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function ProfilePage() {
export default async function ProfilePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (!session?.user) {
redirect("/")
redirect(`/${locale}`)
}
return (
@@ -22,4 +29,5 @@ export default async function ProfilePage() {
</div>
</div>
)
}
}

View File

@@ -0,0 +1,380 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useTranslations } from "next-intl"
import { BrandHeader } from "@/components/ui/brand-header"
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
import { SharedMessageList } from "@/components/emails/shared-message-list"
import { SharedMessageDetail } from "@/components/emails/shared-message-detail"
import { EMAIL_CONFIG } from "@/config"
interface Email {
id: string
address: string
createdAt: Date
expiresAt: Date
}
interface Message {
id: string
from_address?: string
to_address?: string
subject: string
received_at?: Date
sent_at?: Date
}
interface MessageDetail extends Message {
content?: string
html?: string
}
interface SharedEmailPageClientProps {
email: Email
initialMessages: Message[]
initialNextCursor: string | null
initialTotal: number
token: string
}
export function SharedEmailPageClient({
email,
initialMessages,
initialNextCursor,
initialTotal,
token
}: SharedEmailPageClientProps) {
const t = useTranslations("emails")
const tShared = useTranslations("emails.shared")
const [messages, setMessages] = useState<Message[]>(initialMessages)
const [selectedMessage, setSelectedMessage] = useState<MessageDetail | null>(null)
const [messageLoading, setMessageLoading] = useState(false)
const [nextCursor, setNextCursor] = useState<string | null>(initialNextCursor)
const [loadingMore, setLoadingMore] = useState(false)
const [total, setTotal] = useState(initialTotal)
const [refreshing, setRefreshing] = useState(false)
const pollTimeoutRef = useRef<Timer | null>(null)
const messagesRef = useRef<Message[]>(initialMessages)
// 当 messages 改变时更新 ref
useEffect(() => {
messagesRef.current = messages
}, [messages])
const fetchMessages = async (cursor?: string) => {
try {
if (cursor) {
setLoadingMore(true)
}
const url = new URL(`/api/shared/${token}/messages`, window.location.origin)
if (cursor) {
url.searchParams.set('cursor', cursor)
}
const messagesResponse = await fetch(url)
if (messagesResponse.ok) {
const messagesData = await messagesResponse.json() as {
messages: Message[]
nextCursor: string | null
total: number
}
if (!cursor) {
// 刷新时:合并新消息和旧消息,避免重复
const newMessages = messagesData.messages
const oldMessages = messagesRef.current
// 找到第一个重复的消息
const lastDuplicateIndex = newMessages.findIndex(
newMsg => oldMessages.some(oldMsg => oldMsg.id === newMsg.id)
)
if (lastDuplicateIndex === -1) {
// 没有重复,直接使用新消息
setMessages(newMessages)
setNextCursor(messagesData.nextCursor)
setTotal(messagesData.total)
return
}
// 有重复,只添加新的消息
const uniqueNewMessages = newMessages.slice(0, lastDuplicateIndex)
setMessages([...uniqueNewMessages, ...oldMessages])
setTotal(messagesData.total)
return
}
// 加载更多:追加到列表末尾
setMessages(prev => [...prev, ...(messagesData.messages || [])])
setNextCursor(messagesData.nextCursor)
setTotal(messagesData.total)
}
} catch (err) {
console.error("Failed to fetch messages:", err)
} finally {
setLoadingMore(false)
setRefreshing(false)
}
}
const startPolling = () => {
stopPolling()
pollTimeoutRef.current = setInterval(() => {
if (!refreshing && !loadingMore) {
fetchMessages()
}
}, EMAIL_CONFIG.POLL_INTERVAL)
}
const stopPolling = () => {
if (pollTimeoutRef.current) {
clearInterval(pollTimeoutRef.current)
pollTimeoutRef.current = null
}
}
const handleRefresh = async () => {
setRefreshing(true)
await fetchMessages()
}
// 启动轮询
useEffect(() => {
startPolling()
return () => {
stopPolling()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token])
const handleLoadMore = () => {
if (nextCursor && !loadingMore) {
fetchMessages(nextCursor)
}
}
const fetchMessageDetail = async (messageId: string) => {
try {
setMessageLoading(true)
const response = await fetch(`/api/shared/${token}/messages/${messageId}`)
if (!response.ok) {
throw new Error("Failed to load message")
}
const data = await response.json() as { message: MessageDetail }
setSelectedMessage(data.message)
} catch (err) {
console.error("Failed to fetch message:", err)
} finally {
setMessageLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-7xl">
<BrandHeader
title={email.address}
subtitle={(() => {
try {
const expiresDate = new Date(email.expiresAt)
if (isNaN(expiresDate.getTime())) return tShared("sharedMailbox")
return expiresDate.getFullYear() === 9999
? tShared("permanent")
: `${tShared("expiresAt")}: ${expiresDate.toLocaleDateString()} ${expiresDate.toLocaleTimeString()}`
} catch {
return tShared("sharedMailbox")
}
})()}
showCta={true}
ctaText={tShared("createOwnEmail")}
/>
{/* 桌面端双栏布局 */}
<div className="hidden lg:grid grid-cols-2 gap-4 h-[calc(100vh-280px)] mt-6">
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden">
<SharedMessageList
messages={messages.map(msg => ({
...msg,
received_at: (() => {
if (!msg.received_at) return undefined
try {
const date = new Date(msg.received_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})(),
sent_at: (() => {
if (!msg.sent_at) return undefined
try {
const date = new Date(msg.sent_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})()
}))}
selectedMessageId={selectedMessage?.id}
onMessageSelect={fetchMessageDetail}
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
loading={false}
loadingMore={loadingMore}
refreshing={refreshing}
hasMore={!!nextCursor}
total={total}
t={{
received: t("messages.received"),
noMessages: t("messages.noMessages"),
messageCount: t("messages.messageCount"),
loading: t("messageView.loading"),
loadingMore: t("messages.loadingMore")
}}
/>
</div>
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden">
<SharedMessageDetail
message={selectedMessage ? {
...selectedMessage,
received_at: (() => {
if (!selectedMessage.received_at) return undefined
try {
const date = new Date(selectedMessage.received_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})(),
sent_at: (() => {
if (!selectedMessage.sent_at) return undefined
try {
const date = new Date(selectedMessage.sent_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})()
} : null}
loading={messageLoading}
t={{
messageContent: t("layout.messageContent"),
selectMessage: t("layout.selectMessage"),
loading: t("messageView.loading"),
from: t("messageView.from"),
to: t("messageView.to"),
subject: t("messages.subject"),
time: t("messageView.time"),
htmlFormat: t("messageView.htmlFormat"),
textFormat: t("messageView.textFormat")
}}
/>
</div>
</div>
{/* 移动端单栏布局 */}
<div className="lg:hidden h-[calc(100vh-260px)] mt-6">
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden h-full flex flex-col">
{!selectedMessage ? (
// 消息列表视图
<SharedMessageList
messages={messages.map(msg => ({
...msg,
received_at: (() => {
if (!msg.received_at) return undefined
try {
const date = new Date(msg.received_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})(),
sent_at: (() => {
if (!msg.sent_at) return undefined
try {
const date = new Date(msg.sent_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})()
}))}
selectedMessageId={null}
onMessageSelect={fetchMessageDetail}
onLoadMore={handleLoadMore}
onRefresh={handleRefresh}
loading={false}
loadingMore={loadingMore}
refreshing={refreshing}
hasMore={!!nextCursor}
total={total}
t={{
received: t("messages.received"),
noMessages: t("messages.noMessages"),
messageCount: t("messages.messageCount"),
loading: t("messageView.loading"),
loadingMore: t("messages.loadingMore")
}}
/>
) : (
// 消息详情视图
<>
<div className="p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0">
<button
onClick={() => setSelectedMessage(null)}
className="text-sm text-primary"
>
{t("layout.backToMessageList")}
</button>
<span className="text-sm font-medium">{t("layout.messageContent")}</span>
</div>
<div className="flex-1 overflow-auto">
<SharedMessageDetail
message={{
...selectedMessage,
received_at: (() => {
if (!selectedMessage.received_at) return undefined
try {
const date = new Date(selectedMessage.received_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})(),
sent_at: (() => {
if (!selectedMessage.sent_at) return undefined
try {
const date = new Date(selectedMessage.sent_at)
return isNaN(date.getTime()) ? undefined : date.getTime()
} catch {
return undefined
}
})()
}}
loading={messageLoading}
t={{
messageContent: t("layout.messageContent"),
selectMessage: t("layout.selectMessage"),
loading: t("messageView.loading"),
from: t("messageView.from"),
to: t("messageView.to"),
subject: t("messages.subject"),
time: t("messageView.time"),
htmlFormat: t("messageView.htmlFormat"),
textFormat: t("messageView.textFormat")
}}
/>
</div>
</>
)}
</div>
</div>
</div>
<FloatingLanguageSwitcher />
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { getTranslations } from "next-intl/server"
import { getSharedEmail, getSharedEmailMessages } from "@/lib/shared-data"
import { SharedErrorPage } from "@/components/emails/shared-error-page"
import { SharedEmailPageClient } from "./page-client"
interface PageProps {
params: Promise<{
token: string
locale: string
}>
}
export default async function SharedEmailPage({ params }: PageProps) {
const { token } = await params
const tShared = await getTranslations("emails.shared")
// 服务端获取数据
const email = await getSharedEmail(token)
if (!email) {
return (
<SharedErrorPage
title={tShared("emailNotFound")}
subtitle={tShared("linkExpired")}
error={tShared("linkInvalid")}
description={tShared("linkInvalidDescription")}
ctaText={tShared("createOwnEmail")}
/>
)
}
// 获取初始消息列表
const messagesResult = await getSharedEmailMessages(token)
return (
<SharedEmailPageClient
email={email}
initialMessages={messagesResult.messages}
initialNextCursor={messagesResult.nextCursor}
initialTotal={messagesResult.total}
token={token}
/>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useTranslations } from "next-intl"
import { BrandHeader } from "@/components/ui/brand-header"
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
import { SharedMessageDetail } from "@/components/emails/shared-message-detail"
interface MessageDetail {
id: string
from_address?: string
to_address?: string
subject: string
content?: string
html?: string
received_at?: Date
sent_at?: Date
expiresAt?: Date
emailAddress?: string
emailExpiresAt?: Date
}
interface SharedMessagePageClientProps {
message: MessageDetail
}
export function SharedMessagePageClient({ message }: SharedMessagePageClientProps) {
const t = useTranslations("emails")
const tShared = useTranslations("emails.shared")
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-7xl">
<BrandHeader
title={message.emailAddress || message.to_address || message.subject}
subtitle={message.emailExpiresAt && new Date(message.emailExpiresAt).getFullYear() === 9999
? tShared("permanent")
: message.emailExpiresAt
? `${tShared("expiresAt")}: ${new Date(message.emailExpiresAt).toLocaleString()}`
: tShared("sharedMessage")}
showCta={true}
ctaText={tShared("createOwnEmail")}
/>
<div className="mt-6">
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden h-[calc(100vh-260px)] lg:h-[calc(100vh-280px)]">
<SharedMessageDetail
message={{
...message,
received_at: message.received_at ? new Date(message.received_at).getTime() : undefined,
sent_at: message.sent_at ? new Date(message.sent_at).getTime() : undefined
}}
loading={false}
t={{
messageContent: t("layout.messageContent"),
selectMessage: t("layout.selectMessage"),
loading: tShared("loading"),
from: t("messageView.from"),
to: t("messageView.to"),
subject: t("messages.subject"),
time: t("messageView.time"),
htmlFormat: t("messageView.htmlFormat"),
textFormat: t("messageView.textFormat")
}}
/>
</div>
</div>
</div>
<FloatingLanguageSwitcher />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { getTranslations } from "next-intl/server"
import { getSharedMessage } from "@/lib/shared-data"
import { SharedErrorPage } from "@/components/emails/shared-error-page"
import { SharedMessagePageClient } from "./page-client"
interface PageProps {
params: Promise<{
token: string
locale: string
}>
}
export default async function SharedMessagePage({ params }: PageProps) {
const { token } = await params
const tShared = await getTranslations("emails.shared")
// 服务端获取数据
const message = await getSharedMessage(token)
if (!message) {
return (
<SharedErrorPage
title={tShared("messageNotFound")}
subtitle={tShared("linkExpired")}
error={tShared("linkInvalid")}
description={tShared("linkInvalidDescription")}
ctaText={tShared("createOwnEmail")}
/>
)
}
return <SharedMessagePageClient message={message} />
}

View File

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

View File

@@ -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 }
)
}
}

View File

@@ -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 })

View File

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

View File

@@ -0,0 +1,55 @@
import { createDb } from "@/lib/db"
import { messageShares, messages, emails } from "@/lib/schema"
import { eq, and } from "drizzle-orm"
import { NextResponse } from "next/server"
import { getUserId } from "@/lib/apiKey"
export const runtime = "edge"
// 删除消息分享链接
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string; messageId: string; shareId: string }> }
) {
const userId = await getUserId()
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: emailId, messageId, shareId } = await params
const db = createDb()
try {
// 验证邮箱所有权
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
})
if (!email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 })
}
// 获取消息并验证
const message = await db.query.messages.findFirst({
where: and(eq(messages.id, messageId), eq(messages.emailId, emailId))
})
if (!message) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}
// 删除分享记录
await db.delete(messageShares).where(
and(eq(messageShares.id, shareId), eq(messageShares.messageId, messageId))
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete message share:", error)
return NextResponse.json(
{ error: "Failed to delete share" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,119 @@
import { createDb } from "@/lib/db"
import { messageShares, messages, emails } from "@/lib/schema"
import { eq, and } from "drizzle-orm"
import { NextResponse } from "next/server"
import { getUserId } from "@/lib/apiKey"
import { nanoid } from "nanoid"
export const runtime = "edge"
// 获取消息的所有分享链接
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string; messageId: string }> }
) {
const userId = await getUserId()
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: emailId, messageId } = await params
const db = createDb()
try {
// 验证邮箱所有权
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
})
if (!email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 })
}
// 获取消息
const message = await db.query.messages.findFirst({
where: and(eq(messages.id, messageId), eq(messages.emailId, emailId))
})
if (!message) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}
// 获取该消息的所有分享链接
const shares = await db.query.messageShares.findMany({
where: eq(messageShares.messageId, messageId),
orderBy: (messageShares, { desc }) => [desc(messageShares.createdAt)]
})
return NextResponse.json({ shares, total: shares.length })
} catch (error) {
console.error("Failed to fetch message shares:", error)
return NextResponse.json(
{ error: "Failed to fetch shares" },
{ status: 500 }
)
}
}
// 创建新的消息分享链接
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string; messageId: string }> }
) {
const userId = await getUserId()
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: emailId, messageId } = await params
const db = createDb()
try {
// 验证邮箱所有权
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
})
if (!email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 })
}
// 获取消息并验证
const message = await db.query.messages.findFirst({
where: and(eq(messages.id, messageId), eq(messages.emailId, emailId))
})
if (!message) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}
// 解析请求体
const body = await request.json() as { expiresIn: number }
const { expiresIn } = body // expiresIn 单位为毫秒0表示永久
// 生成简短的分享token (16个字符)
const token = nanoid(16)
// 计算过期时间
let expiresAt = null
if (expiresIn && expiresIn > 0) {
expiresAt = new Date(Date.now() + expiresIn)
}
// 创建分享记录
const [share] = await db.insert(messageShares).values({
messageId,
token,
expiresAt
}).returning()
return NextResponse.json(share, { status: 201 })
} catch (error) {
console.error("Failed to create message share:", error)
return NextResponse.json(
{ error: "Failed to create share" },
{ status: 500 }
)
}
}

View File

@@ -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, isNull } 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,15 @@ export async function GET(
)
}
const baseConditions = eq(messages.emailId, id)
const baseConditions = and(
eq(messages.emailId, id),
messageType === 'sent'
? eq(messages.type, "sent")
: or(
ne(messages.type, "sent"),
isNull(messages.type)
)
)
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(messages)
@@ -84,22 +104,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 +130,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 +141,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

View 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 }
)
}
}

View File

@@ -0,0 +1,46 @@
import { createDb } from "@/lib/db"
import { emailShares, emails } from "@/lib/schema"
import { eq, and } from "drizzle-orm"
import { NextResponse } from "next/server"
import { getUserId } from "@/lib/apiKey"
export const runtime = "edge"
// 删除分享链接
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string; shareId: string }> }
) {
const userId = await getUserId()
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: emailId, shareId } = await params
const db = createDb()
try {
// 验证邮箱所有权
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
})
if (!email) {
return NextResponse.json({ error: "Email not found" }, { status: 404 })
}
// 删除分享记录
await db.delete(emailShares).where(
and(eq(emailShares.id, shareId), eq(emailShares.emailId, emailId))
)
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to delete email share:", error)
return NextResponse.json(
{ error: "Failed to delete share" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,101 @@
import { createDb } from "@/lib/db"
import { emailShares, emails } from "@/lib/schema"
import { eq, and } from "drizzle-orm"
import { NextResponse } from "next/server"
import { getUserId } from "@/lib/apiKey"
import { nanoid } from "nanoid"
export const runtime = "edge"
// 获取邮箱的所有分享链接
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const userId = await getUserId()
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: emailId } = await params
const db = createDb()
try {
// 验证邮箱所有权
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
})
if (!email) {
return NextResponse.json({ error: "Email not found" }, { status: 404 })
}
// 获取该邮箱的所有分享链接
const shares = await db.query.emailShares.findMany({
where: eq(emailShares.emailId, emailId),
orderBy: (emailShares, { desc }) => [desc(emailShares.createdAt)]
})
return NextResponse.json({ shares, total: shares.length })
} catch (error) {
console.error("Failed to fetch email shares:", error)
return NextResponse.json(
{ error: "Failed to fetch shares" },
{ status: 500 }
)
}
}
// 创建新的分享链接
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const userId = await getUserId()
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: emailId } = await params
const db = createDb()
try {
// 验证邮箱所有权
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, emailId), eq(emails.userId, userId))
})
if (!email) {
return NextResponse.json({ error: "Email not found" }, { status: 404 })
}
// 解析请求体
const body = await request.json() as { expiresIn: number }
const { expiresIn } = body // expiresIn 单位为毫秒0表示永久
// 生成简短的分享token (16个字符)
const token = nanoid(16)
// 计算过期时间
let expiresAt = null
if (expiresIn && expiresIn > 0) {
expiresAt = new Date(Date.now() + expiresIn)
}
// 创建分享记录
const [share] = await db.insert(emailShares).values({
emailId,
token,
expiresAt
}).returning()
return NextResponse.json(share, { status: 201 })
} catch (error) {
console.error("Failed to create email share:", error)
return NextResponse.json(
{ error: "Failed to create share" },
{ status: 500 }
)
}
}

View File

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

View File

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

View File

@@ -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 }
)
}
}

View File

@@ -0,0 +1,83 @@
import { createDb } from "@/lib/db"
import { emailShares, messages } from "@/lib/schema"
import { eq, and } from "drizzle-orm"
import { NextResponse } from "next/server"
export const runtime = "edge"
// 通过分享token获取消息详情
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string; messageId: string }> }
) {
const { token, messageId } = await params
const db = createDb()
try {
// 验证分享token
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return NextResponse.json(
{ error: "Share link not found or expired" },
{ status: 404 }
)
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Share link has expired" },
{ status: 410 }
)
}
// 检查邮箱是否过期
if (share.email.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Email has expired" },
{ status: 410 }
)
}
// 获取消息详情
const message = await db.query.messages.findFirst({
where: and(
eq(messages.id, messageId),
eq(messages.emailId, share.email.id)
)
})
if (!message) {
return NextResponse.json(
{ error: "Message not found" },
{ status: 404 }
)
}
return NextResponse.json({
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,
sent_at: message.sentAt
}
})
} catch (error) {
console.error("Failed to fetch shared message:", error)
return NextResponse.json(
{ error: "Failed to fetch message" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,124 @@
import { createDb } from "@/lib/db"
import { emailShares, messages } from "@/lib/schema"
import { eq, and, lt, or, sql, ne, isNull } from "drizzle-orm"
import { NextResponse } from "next/server"
import { encodeCursor, decodeCursor } from "@/lib/cursor"
export const runtime = "edge"
const PAGE_SIZE = 20
// 通过分享token获取邮箱的消息列表
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params
const db = createDb()
const { searchParams } = new URL(request.url)
const cursor = searchParams.get('cursor')
try {
// 验证分享token
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return NextResponse.json(
{ error: "Share link not found or expired" },
{ status: 404 }
)
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Share link has expired" },
{ status: 410 }
)
}
// 检查邮箱是否过期
if (share.email.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Email has expired" },
{ status: 410 }
)
}
const emailId = share.email.id
// 只显示接收的邮件,不显示发送的邮件
const baseConditions = and(
eq(messages.emailId, emailId),
or(
ne(messages.type, "sent"),
isNull(messages.type)
)
)
// 获取消息总数(只统计接收的邮件)
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(messages)
.where(baseConditions)
const totalCount = Number(totalResult[0].count)
const conditions = [baseConditions]
if (cursor) {
const { timestamp, id } = decodeCursor(cursor)
const cursorCondition = or(
lt(messages.receivedAt, new Date(timestamp)),
and(
eq(messages.receivedAt, new Date(timestamp)),
lt(messages.id, id)
)
)
if (cursorCondition) {
conditions.push(cursorCondition)
}
}
const results = await db.query.messages.findMany({
where: and(...conditions),
orderBy: (messages, { desc }) => [
desc(messages.receivedAt),
desc(messages.id)
],
limit: PAGE_SIZE + 1
})
const hasMore = results.length > PAGE_SIZE
const nextCursor = hasMore
? encodeCursor(
results[PAGE_SIZE - 1].receivedAt.getTime(),
results[PAGE_SIZE - 1].id
)
: null
const messageList = hasMore ? results.slice(0, PAGE_SIZE) : results
return NextResponse.json({
messages: messageList.map(msg => ({
id: msg.id,
from_address: msg.fromAddress,
to_address: msg.toAddress,
subject: msg.subject,
received_at: msg.receivedAt,
sent_at: msg.sentAt
})),
nextCursor,
total: totalCount
})
} catch (error) {
console.error("Failed to fetch shared messages:", error)
return NextResponse.json(
{ error: "Failed to fetch messages" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,64 @@
import { createDb } from "@/lib/db"
import { emailShares } from "@/lib/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"
export const runtime = "edge"
// 通过分享token获取邮箱信息
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params
const db = createDb()
try {
// 查找分享记录
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return NextResponse.json(
{ error: "Share link not found or expired" },
{ status: 404 }
)
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Share link has expired" },
{ status: 410 }
)
}
// 检查邮箱是否过期
if (share.email.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Email has expired" },
{ status: 410 }
)
}
return NextResponse.json({
email: {
id: share.email.id,
address: share.email.address,
createdAt: share.email.createdAt,
expiresAt: share.email.expiresAt
}
})
} catch (error) {
console.error("Failed to fetch shared email:", error)
return NextResponse.json(
{ error: "Failed to fetch shared email" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,69 @@
import { createDb } from "@/lib/db"
import { messageShares, messages } from "@/lib/schema"
import { eq } from "drizzle-orm"
import { NextResponse } from "next/server"
export const runtime = "edge"
// 通过分享token获取消息详情
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params
const db = createDb()
try {
// 验证分享token
const share = await db.query.messageShares.findFirst({
where: eq(messageShares.token, token)
})
if (!share) {
return NextResponse.json(
{ error: "Share link not found or disabled" },
{ status: 404 }
)
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Share link has expired" },
{ status: 410 }
)
}
// 获取消息详情
const message = await db.query.messages.findFirst({
where: eq(messages.id, share.messageId)
})
if (!message) {
return NextResponse.json(
{ error: "Message not found" },
{ status: 404 }
)
}
return NextResponse.json({
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,
sent_at: message.sentAt
}
})
} catch (error) {
console.error("Failed to fetch shared message:", error)
return NextResponse.json(
{ error: "Failed to fetch message" },
{ status: 500 }
)
}
}

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -34,25 +35,26 @@ export function LoginForm() {
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const { toast } = useToast()
const t = useTranslations("auth.loginForm")
const validateLoginForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (username.includes('@')) newErrors.username = "用户名不能包含 @ 符号"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
if (!username) newErrors.username = t("errors.usernameRequired")
if (!password) newErrors.password = t("errors.passwordRequired")
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const validateRegisterForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (username.includes('@')) newErrors.username = "用户名不能包含 @ 符号"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
if (!confirmPassword) newErrors.confirmPassword = "请确认密码"
if (password !== confirmPassword) newErrors.confirmPassword = "两次输入的密码不一致"
if (!username) newErrors.username = t("errors.usernameRequired")
if (!password) newErrors.password = t("errors.passwordRequired")
if (username.includes('@')) newErrors.username = t("errors.usernameInvalid")
if (password && password.length < 8) newErrors.password = t("errors.passwordTooShort")
if (!confirmPassword) newErrors.confirmPassword = t("errors.confirmPasswordRequired")
if (password !== confirmPassword) newErrors.confirmPassword = t("errors.passwordMismatch")
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
@@ -70,8 +72,8 @@ export function LoginForm() {
if (result?.error) {
toast({
title: "登录失败",
description: "用户名或密码错误",
title: t("toast.loginFailed"),
description: t("toast.loginFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -81,8 +83,8 @@ export function LoginForm() {
window.location.href = "/"
} catch (error) {
toast({
title: "登录失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("toast.loginFailed"),
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -104,8 +106,8 @@ export function LoginForm() {
if (!response.ok) {
toast({
title: "注册失败",
description: data.error || "请稍后重试",
title: t("toast.registerFailed"),
description: data.error || t("toast.registerFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -121,8 +123,8 @@ export function LoginForm() {
if (result?.error) {
toast({
title: "登录失败",
description: "自动登录失败,请手动登录",
title: t("toast.loginFailed"),
description: t("toast.autoLoginFailed"),
variant: "destructive",
})
setLoading(false)
@@ -132,8 +134,8 @@ export function LoginForm() {
window.location.href = "/"
} catch (error) {
toast({
title: "注册失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("toast.registerFailed"),
description: error instanceof Error ? error.message : t("toast.registerFailedDesc"),
variant: "destructive",
})
setLoading(false)
@@ -155,17 +157,17 @@ export function LoginForm() {
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
使 MoeMail
{t("title")}
</CardTitle>
<CardDescription className="text-center">
()
{t("subtitle")}
</CardDescription>
</CardHeader>
<CardContent className="px-6">
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login"></TabsTrigger>
<TabsTrigger value="register"></TabsTrigger>
<TabsTrigger value="login">{t("tabs.login")}</TabsTrigger>
<TabsTrigger value="register">{t("tabs.register")}</TabsTrigger>
</TabsList>
<div className="min-h-[220px]">
<TabsContent value="login" className="space-y-4 mt-0">
@@ -180,7 +182,7 @@ export function LoginForm() {
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
placeholder={t("fields.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value)
@@ -204,7 +206,7 @@ export function LoginForm() {
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
placeholder={t("fields.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value)
@@ -226,7 +228,7 @@ export function LoginForm() {
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("actions.login")}
</Button>
<div className="relative">
@@ -235,7 +237,7 @@ export function LoginForm() {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
{t("actions.or")}
</span>
</div>
</div>
@@ -246,7 +248,7 @@ export function LoginForm() {
onClick={handleGithubLogin}
>
<Github className="mr-2 h-4 w-4" />
使 GitHub
{t("actions.githubLogin")}
</Button>
</div>
</TabsContent>
@@ -262,7 +264,7 @@ export function LoginForm() {
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
placeholder={t("fields.username")}
value={username}
onChange={(e) => {
setUsername(e.target.value)
@@ -286,7 +288,7 @@ export function LoginForm() {
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
placeholder={t("fields.password")}
value={password}
onChange={(e) => {
setPassword(e.target.value)
@@ -310,7 +312,7 @@ export function LoginForm() {
errors.confirmPassword && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="确认密码"
placeholder={t("fields.confirmPassword")}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
@@ -332,7 +334,7 @@ export function LoginForm() {
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("actions.register")}
</Button>
</div>
</TabsContent>

View File

@@ -5,6 +5,7 @@ import Image from "next/image"
import { signOut, useSession } from "next-auth/react"
import { LogIn } from "lucide-react"
import { useRouter } from 'next/navigation'
import { useTranslations, useLocale } from "next-intl"
import Link from "next/link"
import { cn } from "@/lib/utils"
@@ -14,7 +15,9 @@ interface SignButtonProps {
export function SignButton({ size = "default" }: SignButtonProps) {
const router = useRouter()
const locale = useLocale()
const { data: session, status } = useSession()
const t = useTranslations("auth.signButton")
const loading = status === "loading"
if (loading) {
@@ -23,32 +26,32 @@ export function SignButton({ size = "default" }: SignButtonProps) {
if (!session?.user) {
return (
<Button onClick={() => router.push('/login')} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
<Button onClick={() => router.push(`/${locale}/login`)} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
<LogIn className={size === "lg" ? "w-5 h-5" : "w-4 h-4"} />
/
{t("login")}
</Button>
)
}
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-y-4 gap-x-3 sm:gap-x-4">
<Link
href="/profile"
href={`/${locale}/profile`}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
{session.user.image && (
<Image
src={session.user.image}
alt={session.user.name || "用户头像"}
alt={session.user.name || t("userAvatar")}
width={24}
height={24}
className="rounded-full"
/>
)}
<span className="text-sm">{session.user.name}</span>
<span className="hidden sm:inline-block text-sm">{session.user.name}</span>
</Link>
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
<Button onClick={() => signOut({ callbackUrl: `/${locale}` })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
{t("logout")}
</Button>
</div>
)

View File

@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -12,16 +13,20 @@ 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 t = useTranslations("emails.create")
const tList = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
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()
@@ -36,8 +41,8 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
const createEmail = async () => {
if (!emailName.trim()) {
toast({
title: "错误",
description: "请输入邮箱名",
title: tList("error"),
description: t("namePlaceholder"),
variant: "destructive"
})
return
@@ -58,7 +63,7 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
title: tList("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
@@ -66,16 +71,16 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
}
toast({
title: "成功",
description: "已创建新的临时邮箱"
title: tList("success"),
description: t("success")
})
onEmailCreated()
setOpen(false)
setEmailName("")
} catch {
toast({
title: "错误",
description: "创建邮箱失败",
title: tList("error"),
description: t("failed"),
variant: "destructive"
})
} finally {
@@ -83,44 +88,39 @@ 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}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="w-4 h-4" />
{t("title")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
value={emailName}
onChange={(e) => setEmailName(e.target.value)}
placeholder="输入邮箱名"
placeholder={t("namePlaceholder")}
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>
@@ -137,25 +137,28 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
</div>
<div className="flex items-center gap-4">
<Label className="shrink-0 text-muted-foreground"></Label>
<Label className="shrink-0 text-muted-foreground">{t("expiryTime")}</Label>
<RadioGroup
value={expiryTime}
onValueChange={setExpiryTime}
className="flex gap-6"
>
{EXPIRY_OPTIONS.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
{option.label}
</Label>
</div>
))}
{EXPIRY_OPTIONS.map((option, index) => {
const labels = [t("oneHour"), t("oneDay"), t("threeDays"), t("permanent")]
return (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
{labels[index]}
</Label>
</div>
)
})}
</RadioGroup>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="shrink-0">:</span>
<span className="shrink-0">{t("domain")}:</span>
{emailName ? (
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{`${emailName}@${currentDomain}`}</span>
@@ -173,10 +176,10 @@ export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
{tCommon("cancel")}
</Button>
<Button onClick={createEmail} disabled={loading}>
{loading ? "创建中..." : "创建"}
{loading ? t("creating") : t("create")}
</Button>
</div>
</DialogContent>

View File

@@ -2,7 +2,9 @@
import { useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { CreateDialog } from "./create-dialog"
import { ShareDialog } from "./share-dialog"
import { Mail, RefreshCw, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -19,6 +21,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 +45,10 @@ interface EmailResponse {
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
const { data: session } = useSession()
const { config } = useConfig()
const { role } = useUserRole()
const t = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
const [emails, setEmails] = useState<Email[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -109,7 +118,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) => {
@@ -121,7 +129,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
title: t("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
@@ -132,8 +140,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
setTotal(prev => prev - 1)
toast({
title: "成功",
description: "邮箱已删除"
title: t("success"),
description: t("deleteSuccess")
})
if (selectedEmailId === email.id) {
@@ -141,8 +149,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
}
} catch {
toast({
title: "错误",
description: "删除邮箱失败",
title: t("error"),
description: t("deleteFailed"),
variant: "destructive"
})
} finally {
@@ -167,7 +175,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 ? (
t("emailCountUnlimited", { count: total })
) : (
t("emailCount", { count: total, max: config?.maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS })
)}
</span>
</div>
<CreateDialog onEmailCreated={handleRefresh} />
@@ -175,7 +187,7 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
{loading ? (
<div className="text-center text-sm text-gray-500">...</div>
<div className="text-center text-sm text-gray-500">{t("loading")}</div>
) : emails.length > 0 ? (
<div className="space-y-1">
{emails.map(email => (
@@ -192,34 +204,37 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<div className="font-medium truncate">{email.address}</div>
<div className="text-xs text-gray-500">
{new Date(email.expiresAt).getFullYear() === 9999 ? (
"永久有效"
t("permanent")
) : (
`过期时间: ${new Date(email.expiresAt).toLocaleString()}`
`${t("expiresAt")}: ${new Date(email.expiresAt).toLocaleString()}`
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setEmailToDelete(email)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
<ShareDialog emailId={email.id} emailAddress={email.address} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setEmailToDelete(email)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
...
{t("loadingMore")}
</div>
)}
</div>
) : (
<div className="text-center text-sm text-gray-500">
{t("noEmails")}
</div>
)}
</div>
@@ -228,18 +243,18 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<AlertDialog open={!!emailToDelete} onOpenChange={() => setEmailToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{emailToDelete?.address}
{t("deleteDescription", { email: emailToDelete?.address || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => emailToDelete && handleDelete(emailToDelete)}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,78 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
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 t = useTranslations("emails.messages")
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" />
{t("received")}
</SlidingTabsTrigger>
<SlidingTabsTrigger value="sent">
<Send className="h-4 w-4" />
{t("sent")}
</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>
)
}

View File

@@ -1,17 +1,34 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Mail, Calendar, RefreshCw } from "lucide-react"
import { useTranslations } from "next-intl"
import {Mail, Calendar, RefreshCw, Trash2, Share2} 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 { ShareMessageDialog } from "./share-message-dialog"
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 +36,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 +48,20 @@ interface MessageResponse {
total: number
}
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
export function MessageList({ email, messageType, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListProps) {
const t = useTranslations("emails.messages")
const tList = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
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 +71,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 +123,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
const stopPolling = () => {
if (pollTimeoutRef.current) {
clearInterval(pollTimeoutRef.current)
pollTimeoutRef.current = undefined
pollTimeoutRef.current = null
}
}
@@ -118,6 +145,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: tList("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
return
}
setMessages(prev => prev.filter(e => e.id !== message.id))
setTotal(prev => prev - 1)
toast({
title: tList("success"),
description: tList("deleteSuccess")
})
if (selectedMessageId === message.id) {
onMessageSelect(null)
}
} catch {
toast({
title: tList("error"),
description: tList("deleteFailed"),
variant: "destructive"
})
} finally {
setMessageToDelete(null)
}
}
useEffect(() => {
if (!email.id) {
return
@@ -133,7 +198,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
@@ -146,21 +220,21 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} 封邮件` : "暂无邮件"}
{total > 0 ? `${total} ${t("messageCount")}` : t("noMessages")}
</span>
</div>
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500">...</div>
<div className="p-4 text-center text-sm text-gray-500">{t("loading")}</div>
) : messages.length > 0 ? (
<div className="divide-y divide-primary/10">
{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,28 +243,77 @@ 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>
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
<ShareMessageDialog
emailId={email.id}
messageId={message.id}
messageSubject={message.subject}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Share2 className="h-4 w-4" />
</Button>
}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setMessageToDelete(message)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
...
{t("loadingMore")}
</div>
)}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
{t("noMessages")}
</div>
)}
</div>
</div>
<AlertDialog open={!!messageToDelete} onOpenChange={() => setMessageToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{tList("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{tList("deleteDescription", { email: messageToDelete?.subject || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => messageToDelete && handleDelete(messageToDelete)}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -1,43 +1,80 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Loader2, Share2 } 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"
import { ShareMessageDialog } from "./share-message-dialog"
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 t = useTranslations("emails.messageView")
const tList = useTranslations("emails.list")
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 || t("loadError")
setError(errorMessage)
toast({
title: tList("error"),
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 = t("networkError")
setError(errorMessage)
toast({
title: tList("error"),
description: errorMessage,
variant: "destructive"
})
console.error("Failed to fetch message:", error)
} finally {
setLoading(false)
@@ -45,10 +82,9 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
}
fetchMessage()
}, [emailId, messageId])
}, [emailId, messageId, messageType, toast, t, tList])
// 处理 iframe 内容
useEffect(() => {
const updateIframeContent = () => {
if (viewMode === "html" && message?.html && iframeRef.current) {
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
@@ -66,8 +102,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 +124,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 +175,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">{t("loading")}</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"
>
{t("retry")}
</button>
</div>
)
}
@@ -154,14 +210,31 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
return (
<div className="h-full flex flex-col">
<div className="p-4 space-y-3 border-b border-primary/20">
<h3 className="text-base font-bold">{message.subject}</h3>
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
<ShareMessageDialog
emailId={emailId}
messageId={message.id}
messageSubject={message.subject}
trigger={
<button className="p-1.5 hover:bg-primary/10 rounded-md transition-colors">
<Share2 className="h-4 w-4 text-gray-500" />
</button>
}
/>
</div>
<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>{t("from")}: {message.from_address}</p>
)}
{message.to_address && (
<p>{t("to")}: {message.to_address}</p>
)}
<p>{t("time")}: {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}
@@ -174,7 +247,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
htmlFor="html"
className="text-xs cursor-pointer"
>
HTML
{t("htmlFormat")}
</Label>
</div>
<div className="flex items-center space-x-2">
@@ -183,7 +256,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
htmlFor="text"
className="text-xs cursor-pointer"
>
{t("textFormat")}
</Label>
</div>
</RadioGroup>

View File

@@ -0,0 +1,142 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
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 t = useTranslations("emails.send")
const tList = useTranslations("emails.list")
const tCommon = useTranslations("common.actions")
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: tList("error"),
description: t("toPlaceholder") + ", " + t("subjectPlaceholder") + ", " + t("contentPlaceholder"),
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: tList("error"),
description: (data as { error: string }).error,
variant: "destructive"
})
return
}
toast({
title: tList("success"),
description: t("success")
})
setOpen(false)
setTo("")
setSubject("")
setContent("")
onSendSuccess?.()
} catch {
toast({
title: tList("error"),
description: t("failed"),
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">{t("title")}</span>
</Button>
</TooltipTrigger>
</DialogTrigger>
<TooltipContent className="sm:hidden">
<p>{t("title")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm text-muted-foreground">
{t("from")}: {fromAddress}
</div>
<Input
value={to}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
placeholder={t("toPlaceholder")}
/>
<Input
value={subject}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSubject(e.target.value)}
placeholder={t("subjectPlaceholder")}
/>
<Textarea
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
placeholder={t("contentPlaceholder")}
rows={6}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSend} disabled={loading}>
{loading ? t("sending") : t("send")}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,347 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Share2, Copy, Trash2, Link2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useToast } from "@/components/ui/use-toast"
import { useCopy } from "@/hooks/use-copy"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { EXPIRY_OPTIONS } from "@/types/email"
interface ShareDialogProps {
emailId: string
emailAddress: string
}
interface ShareLink {
id: string
token: string
createdAt: number | string | Date
expiresAt: number | string | Date | null
enabled: boolean
}
export function ShareDialog({ emailId }: ShareDialogProps) {
const t = useTranslations("emails.share")
const { toast } = useToast()
const { copyToClipboard } = useCopy()
const [open, setOpen] = useState(false)
const [shares, setShares] = useState<ShareLink[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(null)
const fetchShares = async () => {
try {
setLoading(true)
const response = await fetch(`/api/emails/${emailId}/share`)
if (!response.ok) throw new Error("Failed to fetch shares")
const data = await response.json() as { shares: ShareLink[] }
setShares(data.shares || [])
} catch (error) {
console.error("Failed to fetch shares:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setLoading(false)
}
}
const createShare = async () => {
try {
setCreating(true)
const response = await fetch(`/api/emails/${emailId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiresIn: Number(expiryTime) })
})
if (!response.ok) throw new Error("Failed to create share")
const share = await response.json() as ShareLink
setShares(prev => [share, ...prev])
toast({
title: t("createSuccess"),
})
} catch (error) {
console.error("Failed to create share:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setCreating(false)
}
}
const deleteShare = async (share: ShareLink) => {
try {
const response = await fetch(`/api/emails/${emailId}/share/${share.id}`, {
method: "DELETE"
})
if (!response.ok) throw new Error("Failed to delete share")
setShares(prev => prev.filter(s => s.id !== share.id))
toast({
title: t("deleteSuccess"),
})
} catch (error) {
console.error("Failed to delete share:", error)
toast({
title: t("deleteFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setDeleteTarget(null)
}
}
const getShareUrl = (token: string) => {
return `${window.location.origin}/shared/${token}`
}
const handleCopy = async (token: string) => {
const url = getShareUrl(token)
const success = await copyToClipboard(url)
if (success) {
toast({
title: t("copied"),
})
} else {
toast({
title: t("copyFailed"),
variant: "destructive"
})
}
}
useEffect(() => {
if (open) {
fetchShares()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-[600px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
if (deleteTarget) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Create new share link */}
<div className="space-y-2">
<Label>{t("expiryTime")}</Label>
<div className="flex gap-2">
<Select value={expiryTime} onValueChange={setExpiryTime}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
{creating ? t("creating") : t("createLink")}
</Button>
</div>
</div>
{/* Active share links */}
<div className="space-y-2">
<Label>{t("activeLinks")}</Label>
<div className="h-[270px] overflow-y-auto">
{loading ? (
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span>{t("loading")}</span>
</div>
) : shares.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
{t("noLinks")}
</div>
) : (
<div className="space-y-2">
{shares.map(share => {
// 将expiresAt转换为时间戳进行比较
const expiresAtTime = share.expiresAt
? (typeof share.expiresAt === 'number'
? share.expiresAt
: new Date(share.expiresAt).getTime())
: null
const isExpired = expiresAtTime !== null && expiresAtTime < Date.now()
return (
<div
key={share.id}
className={cn(
"p-3 border rounded-lg space-y-2 transition-all",
isExpired
? "border-destructive/30 bg-destructive/5 opacity-75"
: "border-border"
)}
>
<div className="flex items-center gap-2">
<Link2 className={cn(
"h-4 w-4 flex-shrink-0",
isExpired ? "text-destructive/60" : "text-primary/60"
)} />
<a
href={isExpired ? undefined : getShareUrl(share.token)}
target={isExpired ? undefined : "_blank"}
rel={isExpired ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (isExpired) {
e.preventDefault()
}
}}
className={cn(
"flex-1 text-xs p-1 rounded font-mono transition-colors break-all",
isExpired
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
)}
>
{getShareUrl(share.token)}
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleCopy(share.token)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => setDeleteTarget(share)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex gap-y-4 gap-x-2 sm:gap-x-4 text-xs">
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("createdAt")}: {new Date(
typeof share.createdAt === 'number'
? share.createdAt
: share.createdAt
).toLocaleString()}
</span>
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("expiresAt")}: {
share.expiresAt
? new Date(
typeof share.expiresAt === 'number'
? share.expiresAt
: share.expiresAt
).toLocaleString()
: t("permanent")
}
</span>
{isExpired && (
<span className="text-destructive font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-destructive rounded-full"></span>
{t("expired")}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteShare(deleteTarget)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,356 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Share2, Copy, Trash2, Link2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useToast } from "@/components/ui/use-toast"
import { useCopy } from "@/hooks/use-copy"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { EXPIRY_OPTIONS } from "@/types/email"
interface ShareMessageDialogProps {
emailId: string
messageId: string
messageSubject: string
trigger?: React.ReactNode
}
interface ShareLink {
id: string
token: string
createdAt: number | string | Date
expiresAt: number | string | Date | null
enabled: boolean
}
export function ShareMessageDialog({ emailId, messageId, messageSubject, trigger }: ShareMessageDialogProps) {
const t = useTranslations("emails.shareMessage")
const { toast } = useToast()
const { copyToClipboard } = useCopy()
const [open, setOpen] = useState(false)
const [shares, setShares] = useState<ShareLink[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(null)
const fetchShares = async () => {
try {
setLoading(true)
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`)
if (!response.ok) throw new Error("Failed to fetch shares")
const data = await response.json() as { shares: ShareLink[] }
setShares(data.shares || [])
} catch (error) {
console.error("Failed to fetch shares:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setLoading(false)
}
}
const createShare = async () => {
try {
setCreating(true)
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiresIn: Number(expiryTime) })
})
if (!response.ok) throw new Error("Failed to create share")
const share = await response.json() as ShareLink
setShares(prev => [share, ...prev])
toast({
title: t("createSuccess"),
})
} catch (error) {
console.error("Failed to create share:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setCreating(false)
}
}
const deleteShare = async (share: ShareLink) => {
try {
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share/${share.id}`, {
method: "DELETE"
})
if (!response.ok) throw new Error("Failed to delete share")
setShares(prev => prev.filter(s => s.id !== share.id))
toast({
title: t("deleteSuccess"),
})
} catch (error) {
console.error("Failed to delete share:", error)
toast({
title: t("deleteFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setDeleteTarget(null)
}
}
const getShareUrl = (token: string) => {
return `${window.location.origin}/shared/message/${token}`
}
const handleCopy = async (token: string) => {
const url = getShareUrl(token)
const success = await copyToClipboard(url)
if (success) {
toast({
title: t("copied"),
})
} else {
toast({
title: t("copyFailed"),
variant: "destructive"
})
}
}
useEffect(() => {
if (open) {
fetchShares()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
)}
</DialogTrigger>
<DialogContent
className="sm:max-w-[600px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
if (deleteTarget) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Message info */}
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<p className="text-sm font-medium truncate">{messageSubject}</p>
</div>
{/* Create new share link */}
<div className="space-y-2">
<Label>{t("expiryTime")}</Label>
<div className="flex gap-2">
<Select value={expiryTime} onValueChange={setExpiryTime}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
{creating ? t("creating") : t("createLink")}
</Button>
</div>
</div>
{/* Active share links */}
<div className="space-y-2">
<Label>{t("activeLinks")}</Label>
<div className="h-[270px] overflow-y-auto">
{loading ? (
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span>{t("loading")}</span>
</div>
) : shares.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
{t("noLinks")}
</div>
) : (
<div className="space-y-2">
{shares.map(share => {
// 将expiresAt转换为时间戳进行比较
const expiresAtTime = share.expiresAt
? (typeof share.expiresAt === 'number'
? share.expiresAt
: new Date(share.expiresAt).getTime())
: null
const isExpired = expiresAtTime !== null && expiresAtTime < Date.now()
return (
<div
key={share.id}
className={cn(
"p-3 border rounded-lg space-y-2 transition-all",
isExpired
? "border-destructive/30 bg-destructive/5 opacity-75"
: "border-border"
)}
>
<div className="flex items-center gap-2">
<Link2 className={cn(
"h-4 w-4 flex-shrink-0",
isExpired ? "text-destructive/60" : "text-primary/60"
)} />
<a
href={isExpired ? undefined : getShareUrl(share.token)}
target={isExpired ? undefined : "_blank"}
rel={isExpired ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (isExpired) {
e.preventDefault()
}
}}
className={cn(
"flex-1 text-xs p-1 rounded font-mono transition-colors break-all",
isExpired
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
)}
>
{getShareUrl(share.token)}
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleCopy(share.token)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => setDeleteTarget(share)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex gap-y-4 gap-x-2 sm:gap-x-4 text-xs">
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("createdAt")}: {new Date(
typeof share.createdAt === 'number'
? share.createdAt
: share.createdAt
).toLocaleString()}
</span>
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("expiresAt")}: {
share.expiresAt
? new Date(
typeof share.expiresAt === 'number'
? share.expiresAt
: share.expiresAt
).toLocaleString()
: t("permanent")
}
</span>
{isExpired && (
<span className="text-destructive font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-destructive rounded-full"></span>
{t("expired")}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteShare(deleteTarget)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import { AlertCircle } from "lucide-react"
import { Card } from "@/components/ui/card"
import { BrandHeader } from "@/components/ui/brand-header"
import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher"
interface SharedErrorPageProps {
title: string
subtitle: string
error: string
description: string
ctaText: string
}
export function SharedErrorPage({ title, subtitle, error, description, ctaText }: SharedErrorPageProps) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-4xl">
<BrandHeader
title={title}
subtitle={subtitle}
showCta={true}
ctaText={ctaText}
/>
<div className="text-center">
<Card className="max-w-md mx-auto p-8 text-center space-y-4">
<AlertCircle className="h-12 w-12 mx-auto text-destructive" />
<h2 className="text-2xl font-bold">{error}</h2>
<p className="text-gray-500">
{description}
</p>
</Card>
</div>
</div>
<FloatingLanguageSwitcher />
</div>
)
}

View File

@@ -0,0 +1,241 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Loader2 } from "lucide-react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { useTheme } from "next-themes"
interface MessageDetail {
id: string
from_address?: string
to_address?: string
subject: string
content?: string
html?: string
received_at?: number
sent_at?: number
}
interface SharedMessageDetailProps {
message: MessageDetail | null
loading?: boolean
t: {
messageContent: string
selectMessage: string
loading: string
from: string
to: string
subject: string
time: string
htmlFormat: string
textFormat: string
}
}
type ViewMode = "html" | "text"
export function SharedMessageDetail({
message,
loading = false,
t,
}: SharedMessageDetailProps) {
const [viewMode, setViewMode] = useState<ViewMode>("html")
const iframeRef = useRef<HTMLIFrameElement>(null)
const { theme } = useTheme()
// 如果没有HTML内容默认显示文本
useEffect(() => {
if (message) {
if (!message.html && message.content) {
setViewMode("text")
} else if (message.html) {
setViewMode("html")
}
}
}, [message])
const updateIframeContent = () => {
if (viewMode === "html" && message?.html && iframeRef.current) {
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
doc.open()
doc.write(`
<!DOCTYPE html>
<html>
<head>
<base target="_blank">
<style>
html, body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: system-ui, -apple-system, sans-serif;
color: ${theme === "dark" ? "#fff" : "#000"};
background: ${theme === "dark" ? "#1a1a1a" : "#fff"};
}
body {
padding: 20px;
}
img {
max-width: 100%;
height: auto;
}
a {
color: #2563eb;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
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: ${
theme === "dark"
? "rgba(130, 109, 217, 0.5)"
: "rgba(130, 109, 217, 0.4)"
};
}
* {
scrollbar-width: thin;
scrollbar-color: ${
theme === "dark"
? "rgba(130, 109, 217, 0.3) transparent"
: "rgba(130, 109, 217, 0.2) transparent"
};
}
</style>
</head>
<body>${message.html}</body>
</html>
`)
doc.close()
const updateHeight = () => {
const container = iframe.parentElement
if (container) {
iframe.style.height = `${container.clientHeight}px`
}
}
updateHeight()
window.addEventListener("resize", updateHeight)
const resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(doc.body)
doc.querySelectorAll("img").forEach((img: HTMLImageElement) => {
img.onload = updateHeight
})
return () => {
window.removeEventListener("resize", updateHeight)
resizeObserver.disconnect()
}
}
}
}
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">{t.loading}</span>
</div>
)
}
if (!message) {
return (
<div className="flex items-center justify-center h-32 text-gray-500">
{t.selectMessage}
</div>
)
}
return (
<div className="h-full flex flex-col">
<div className="p-4 space-y-3 border-b border-primary/20">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
</div>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>
{t.from}: {message.from_address}
</p>
)}
{message.to_address && (
<p>
{t.to}: {message.to_address}
</p>
)}
<p>
{t.time}:{" "}
{new Date(
message.sent_at || message.received_at || 0
).toLocaleString()}
</p>
</div>
</div>
{message.html && message.content && (
<div className="border-b border-primary/20 p-2">
<RadioGroup
value={viewMode}
onValueChange={(value) => setViewMode(value as ViewMode)}
className="flex items-center gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="html" id="html" />
<Label htmlFor="html" className="text-xs cursor-pointer">
{t.htmlFormat}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" />
<Label htmlFor="text" className="text-xs cursor-pointer">
{t.textFormat}
</Label>
</div>
</RadioGroup>
</div>
)}
<div className="flex-1 overflow-auto relative">
{viewMode === "html" && message.html ? (
<iframe
ref={iframeRef}
className="absolute inset-0 w-full h-full border-0 bg-transparent"
sandbox="allow-same-origin allow-popups"
/>
) : message.content ? (
<div className="p-4 text-sm whitespace-pre-wrap">
{message.content}
</div>
) : (
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
{t.selectMessage}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { Mail, Calendar, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"
import { useThrottle } from "@/hooks/use-throttle"
import { Button } from "@/components/ui/button"
interface Message {
id: string
from_address?: string
to_address?: string
subject: string
received_at?: number
sent_at?: number
}
interface SharedMessageListProps {
messages: Message[]
selectedMessageId?: string | null
onMessageSelect: (messageId: string) => void
onLoadMore?: () => void
onRefresh?: () => void
loading?: boolean
loadingMore?: boolean
refreshing?: boolean
hasMore?: boolean
total?: number
t: {
received: string
noMessages: string
messageCount: string
loading: string
loadingMore: string
}
}
export function SharedMessageList({
messages,
selectedMessageId,
onMessageSelect,
onLoadMore,
onRefresh,
loading = false,
loadingMore = false,
refreshing = false,
hasMore = false,
total = 0,
t,
}: SharedMessageListProps) {
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
if (loadingMore || !hasMore || !onLoadMore) return
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
const threshold = clientHeight * 1.5
const remainingScroll = scrollHeight - scrollTop
if (remainingScroll <= threshold) {
onLoadMore()
}
}, 200)
return (
<div className="h-full flex flex-col">
<div className="p-2 flex justify-between items-center border-b border-primary/20">
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={refreshing || loading}
className={cn("h-8 w-8", refreshing && "animate-spin")}
>
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} ${t.messageCount}` : t.noMessages}
</span>
</div>
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto text-primary mb-2" />
{t.loading}
</div>
) : messages.length > 0 ? (
<div className="divide-y divide-primary/10">
{messages.map((message) => (
<div
key={message.id}
onClick={() => onMessageSelect(message.id)}
className={cn(
"p-3 hover:bg-primary/5 cursor-pointer",
selectedMessageId === message.id && "bg-primary/10"
)}
>
<div className="flex items-start gap-3">
<Mail className="w-4 h-4 text-primary/60 mt-1" />
<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 || message.to_address || ""}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(
message.received_at || message.sent_at || 0
).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
{t.loadingMore}
</div>
)}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
{t.noMessages}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,11 +1,14 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
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 {
@@ -14,9 +17,13 @@ interface Email {
}
export function ThreeColumnLayout() {
const t = useTranslations("emails.layout")
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,17 +42,29 @@ 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">
{/* 桌面端三栏布局 */}
<div className="hidden lg:grid grid-cols-12 gap-4 h-full min-h-0">
<div className={cn("col-span-3", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}></h2>
<h2 className={titleClass}>{t("myEmails")}</h2>
</div>
<div className="flex-1 overflow-auto">
<EmailList
onEmailSelect={setSelectedEmail}
onEmailSelect={(email) => {
setSelectedEmail(email)
setSelectedMessageId(null)
}}
selectedEmailId={selectedEmail?.id}
/>
</div>
@@ -55,23 +74,33 @@ 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>
) : (
"选择邮箱查看消息"
t("selectEmail")
)}
</h2>
</div>
{selectedEmail && (
<div className="flex-1 overflow-auto">
<MessageList
<MessageListContainer
email={selectedEmail}
onMessageSelect={setSelectedMessageId}
onMessageSelect={handleMessageSelect}
selectedMessageId={selectedMessageId}
refreshTrigger={refreshTrigger}
/>
</div>
)}
@@ -80,7 +109,7 @@ export function ThreeColumnLayout() {
<div className={cn("col-span-5", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}>
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
{selectedMessageId ? t("messageContent") : t("selectMessage")}
</h2>
</div>
{selectedEmail && selectedMessageId && (
@@ -88,6 +117,7 @@ export function ThreeColumnLayout() {
<MessageView
emailId={selectedEmail.id}
messageId={selectedMessageId}
messageType={selectedMessageType}
onClose={() => setSelectedMessageId(null)}
/>
</div>
@@ -101,7 +131,7 @@ export function ThreeColumnLayout() {
{mobileView === "list" && (
<>
<div className={headerClass}>
<h2 className={titleClass}></h2>
<h2 className={titleClass}>{t("myEmails")}</h2>
</div>
<div className="flex-1 overflow-auto">
<EmailList
@@ -123,20 +153,30 @@ export function ThreeColumnLayout() {
}}
className="text-sm text-primary shrink-0"
>
{t("backToEmailList")}
</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>
@@ -149,14 +189,15 @@ export function ThreeColumnLayout() {
onClick={() => setSelectedMessageId(null)}
className="text-sm text-primary"
>
{t("backToMessageList")}
</button>
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{t("messageContent")}</span>
</div>
<div className="flex-1 overflow-auto">
<MessageView
emailId={selectedEmail.id}
messageId={selectedMessageId}
messageType={selectedMessageType}
onClose={() => setSelectedMessageId(null)}
/>
</div>

View File

@@ -0,0 +1,49 @@
"use client"
import { useTranslations } from "next-intl"
import { usePathname } from "next/navigation"
import { Github } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function FloatMenu() {
const t = useTranslations("common")
const pathname = usePathname()
// 在分享页面隐藏GitHub悬浮框
if (pathname.includes("/shared/")) {
return null
}
return (
<div className="fixed bottom-6 right-6 z-50">
<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">{t("github")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<p>{t("github")}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}

View File

@@ -3,6 +3,7 @@
import { Button } from "@/components/ui/button"
import { Mail } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations, useLocale } from "next-intl"
import { SignButton } from "../auth/sign-button"
interface ActionButtonProps {
@@ -11,16 +12,18 @@ interface ActionButtonProps {
export function ActionButton({ isLoggedIn }: ActionButtonProps) {
const router = useRouter()
const locale = useLocale()
const t = useTranslations("home")
if (isLoggedIn) {
return (
<Button
size="lg"
onClick={() => router.push("/moe")}
onClick={() => router.push(`/${locale}/moe`)}
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
>
<Mail className="w-5 h-5" />
{t("actions.enterMailbox")}
</Button>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { usePathname, useRouter } from "next/navigation"
import { i18n } from "@/i18n/config"
import { Button } from "@/components/ui/button"
import { Languages } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function FloatingLanguageSwitcher() {
const t = useTranslations("common")
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const switchLocale = (newLocale: string) => {
if (newLocale === locale) return
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
const segments = pathname.split("/")
if (i18n.locales.includes(segments[1] as any)) {
segments[1] = newLocale
} else {
segments.splice(1, 0, newLocale)
}
const newPath = segments.join("/")
router.push(newPath)
router.refresh()
}
const getLanguageName = (loc: string) => {
switch (loc) {
case "en":
return "English"
case "zh-CN":
return "简体中文"
default:
return loc
}
}
return (
<div className="fixed bottom-6 right-6 z-50">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20 hover:border-primary/40 transition-all"
>
<Languages className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
<span className="sr-only">{t("lang.switch")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="mb-2">
{i18n.locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => switchLocale(loc)}
className={locale === loc ? "bg-accent" : ""}
>
{getLanguageName(loc)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { SignButton } from "@/components/auth/sign-button"
import { ThemeToggle } from "@/components/theme/theme-toggle"
import { LanguageSwitcher } from "@/components/layout/language-switcher"
import { Logo } from "@/components/ui/logo"
export function Header() {
@@ -8,7 +9,8 @@ export function Header() {
<div className="container mx-auto h-full px-4">
<div className="h-full flex items-center justify-between">
<Logo />
<div className="flex items-center gap-4">
<div className="flex items-center gap-y-4 gap-x-3 sm:gap-x-4">
<LanguageSwitcher />
<ThemeToggle />
<SignButton />
</div>

View File

@@ -0,0 +1,62 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { usePathname, useRouter } from "next/navigation"
import { i18n } from "@/i18n/config"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Languages } from "lucide-react"
export function LanguageSwitcher() {
const t = useTranslations("common")
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const switchLocale = (newLocale: string) => {
if (newLocale === locale) return
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
const segments = pathname.split("/")
if (i18n.locales.includes(segments[1] as any)) {
segments[1] = newLocale
} else {
segments.splice(1, 0, newLocale)
}
const newPath = segments.join("/")
router.push(newPath)
router.refresh()
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Languages className="h-5 w-5" />
<span className="sr-only">{t("lang.switch")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => switchLocale("en")}
className={locale === "en" ? "bg-accent" : ""}
>
{t("lang.en")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => switchLocale("zh-CN")}
className={locale === "zh-CN" ? "bg-accent" : ""}
>
{t("lang.zhCN")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -2,28 +2,32 @@
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useAdminContact } from "@/hooks/use-admin-contact"
import { useTranslations, useLocale } from "next-intl"
import { useConfig } from "@/hooks/use-config"
export function NoPermissionDialog() {
const router = useRouter()
const { adminContact } = useAdminContact()
const locale = useLocale()
const t = useTranslations("emails.noPermission")
const { config } = useConfig()
return (
<div className="fixed inset-0 bg-background/50 backdrop-blur-sm z-50">
<div className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[90%] max-w-md">
<div className="bg-background border-2 border-primary/20 rounded-lg p-6 md:p-12 shadow-lg">
<div className="text-center space-y-4">
<h1 className="text-xl md:text-2xl font-bold"></h1>
<p className="text-sm md:text-base text-muted-foreground">访</p>
<h1 className="text-xl md:text-2xl font-bold">{t("title")}</h1>
<p className="text-sm md:text-base text-muted-foreground">{t("description")}</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">{t("adminContact")}{config.adminContact}</p>
)
}
<Button
onClick={() => router.push("/")}
onClick={() => router.push(`/${locale}`)}
className="mt-4 w-full md:w-auto"
>
{t("backToHome")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Key, Plus, Loader2, Copy, Trash2, ChevronDown, ChevronUp } from "lucide-react"
@@ -20,7 +21,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
@@ -32,6 +33,10 @@ type ApiKey = {
}
export function ApiKeyPanel() {
const t = useTranslations("profile.apiKey")
const tCommon = useTranslations("common.actions")
const tNoPermission = useTranslations("emails.noPermission")
const tMessages = useTranslations("emails.messages")
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
const [loading, setLoading] = useState(false)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
@@ -47,14 +52,14 @@ export function ApiKeyPanel() {
const fetchApiKeys = async () => {
try {
const res = await fetch("/api/api-keys")
if (!res.ok) throw new Error("获取 API Keys 失败")
if (!res.ok) throw new Error(t("createFailed"))
const data = await res.json() as { apiKeys: ApiKey[] }
setApiKeys(data.apiKeys)
} catch (error) {
console.error(error)
toast({
title: "获取失败",
description: "获取 API Keys 列表失败",
title: t("createFailed"),
description: t("createFailed"),
variant: "destructive"
})
} finally {
@@ -68,7 +73,7 @@ export function ApiKeyPanel() {
}
}, [canManageApiKey])
const { adminContact } = useAdminContact()
const { config } = useConfig()
const createApiKey = async () => {
if (!newKeyName.trim()) return
@@ -81,15 +86,15 @@ export function ApiKeyPanel() {
body: JSON.stringify({ name: newKeyName })
})
if (!res.ok) throw new Error("创建 API Key 失败")
if (!res.ok) throw new Error(t("createFailed"))
const data = await res.json() as { key: string }
setNewKey(data.key)
fetchApiKeys()
} catch (error) {
toast({
title: "创建失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("createFailed"),
description: error instanceof Error ? error.message : t("createFailed"),
variant: "destructive"
})
setCreateDialogOpen(false)
@@ -112,7 +117,7 @@ export function ApiKeyPanel() {
body: JSON.stringify({ enabled })
})
if (!res.ok) throw new Error("更新失败")
if (!res.ok) throw new Error(t("createFailed"))
setApiKeys(keys =>
keys.map(key =>
@@ -122,8 +127,8 @@ export function ApiKeyPanel() {
} catch (error) {
console.error(error)
toast({
title: "更新失败",
description: "更新 API Key 状态失败",
title: t("createFailed"),
description: t("createFailed"),
variant: "destructive"
})
}
@@ -135,18 +140,18 @@ export function ApiKeyPanel() {
method: "DELETE"
})
if (!res.ok) throw new Error("删除失败")
if (!res.ok) throw new Error(t("deleteFailed"))
setApiKeys(keys => keys.filter(key => key.id !== id))
toast({
title: "删除成功",
description: "API Key 已删除"
title: t("deleteSuccess"),
description: t("deleteSuccess")
})
} catch (error) {
console.error(error)
toast({
title: "删除失败",
description: "删除 API Key 失败",
title: t("deleteFailed"),
description: t("deleteFailed"),
variant: "destructive"
})
}
@@ -157,7 +162,7 @@ export function ApiKeyPanel() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">API Keys</h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
{
canManageApiKey && (
@@ -165,17 +170,17 @@ export function ApiKeyPanel() {
<DialogTrigger asChild>
<Button className="gap-2" onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4" />
API Key
{t("create")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{newKey ? "API Key 创建成功" : "创建新的 API Key"}
{newKey ? t("createSuccess") : t("create")}
</DialogTitle>
{newKey && (
<DialogDescription className="text-destructive">
{t("description")}
</DialogDescription>
)}
</DialogHeader>
@@ -183,18 +188,18 @@ export function ApiKeyPanel() {
{!newKey ? (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label></Label>
<Label>{t("name")}</Label>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="为你的 API Key 起个名字"
placeholder={t("namePlaceholder")}
/>
</div>
</div>
) : (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>API Key</Label>
<Label>{t("key")}</Label>
<div className="flex gap-2">
<Input
value={newKey}
@@ -220,7 +225,7 @@ export function ApiKeyPanel() {
onClick={handleDialogClose}
disabled={loading}
>
{newKey ? "完成" : "取消"}
{newKey ? tCommon("ok") : tCommon("cancel")}
</Button>
</DialogClose>
{!newKey && (
@@ -231,7 +236,7 @@ export function ApiKeyPanel() {
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"创建"
t("create")
)}
</Button>
)}
@@ -245,11 +250,11 @@ export function ApiKeyPanel() {
{
!canManageApiKey ? (
<div className="text-center text-muted-foreground py-8">
<p> API Key</p>
<p className="mt-2"></p>
<p>{tNoPermission("needPermission")}</p>
<p className="mt-2">{tNoPermission("contactAdmin")}</p>
{
adminContact && (
<p className="mt-2">{adminContact}</p>
config?.adminContact && (
<p className="mt-2">{tNoPermission("adminContact")}: {config.adminContact}</p>
)
}
</div>
@@ -261,7 +266,7 @@ export function ApiKeyPanel() {
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
<div>
<p className="text-sm text-muted-foreground">...</p>
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
</div>
</div>
) : apiKeys.length === 0 ? (
@@ -270,9 +275,9 @@ export function ApiKeyPanel() {
<Key className="w-6 h-6 text-primary" />
</div>
<div>
<h3 className="text-lg font-medium"> API Keys</h3>
<h3 className="text-lg font-medium">{t("noKeys")}</h3>
<p className="text-sm text-muted-foreground mt-1">
&quot;API Key&quot; API Key
{t("description")}
</p>
</div>
</div>
@@ -286,7 +291,7 @@ export function ApiKeyPanel() {
<div className="space-y-1">
<div className="font-medium">{key.name}</div>
<div className="text-sm text-muted-foreground">
{new Date(key.createdAt).toLocaleString()}
{t("createdAt")}: {new Date(key.createdAt).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
@@ -312,14 +317,34 @@ export function ApiKeyPanel() {
onClick={() => setShowExamples(!showExamples)}
>
{showExamples ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
使
{t("viewDocs")}
</button>
{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>
<div className="text-sm font-medium">{t("docs.getConfig")}</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">{t("docs.generateEmail")}</div>
<Button
variant="ghost"
size="icon"
@@ -351,7 +376,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getEmails")}</div>
<Button
variant="ghost"
size="icon"
@@ -371,7 +396,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getMessages")}</div>
<Button
variant="ghost"
size="icon"
@@ -391,7 +416,7 @@ export function ApiKeyPanel() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("docs.getMessage")}</div>
<Button
variant="ghost"
size="icon"
@@ -409,16 +434,147 @@ export function ApiKeyPanel() {
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.createEmailShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 86400000}'`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 86400000}'`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.getEmailShares")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-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/emails/{emailId}/share \\
-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">{t("docs.deleteEmailShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
-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 -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
-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">{t("docs.createMessageShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 0}'`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 0}'`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.getMessageShares")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-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/emails/{emailId}/messages/{messageId}/share \\
-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">{t("docs.deleteMessageShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
-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 -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
-H "X-API-Key: YOUR_API_KEY"`}
</pre>
</div>
<div className="text-xs text-muted-foreground mt-4">
<p></p>
<p>{t("docs.notes")}</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li> YOUR_API_KEY API Key</li>
<li>emailId </li>
<li>messageId </li>
<li>expiryTime 3600000186400000160480000070</li>
<li>domain /api/emails/domains </li>
<li>cursor nextCursor</li>
<li> X-API-Key </li>
<li>{t("docs.note1")}</li>
<li>{t("docs.note2")}</li>
<li>{t("docs.note3")}</li>
<li>{t("docs.note4")}</li>
<li>{t("docs.note5")}</li>
<li>{t("docs.note6")}</li>
<li>{t("docs.note7")}</li>
<li>{t("docs.note8")}</li>
<li>{t("docs.note9")}</li>
<li>{t("docs.note10")}</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,265 @@
"use client"
import React, { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
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 t = useTranslations("profile.emailService")
const tCard = useTranslations("profile.card")
const tSend = useTranslations("emails.send")
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 || t("saveFailed"))
}
toast({
title: t("saveSuccess"),
description: t("saveSuccess"),
})
} catch (error) {
toast({
title: t("saveFailed"),
description: error instanceof Error ? error.message : t("saveFailed"),
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">{t("title")}</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">
{t("enable")}
</Label>
<p className="text-xs text-muted-foreground">
{t("enableDescription")}
</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">
{t("apiKey")}
</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={t("apiKeyPlaceholder")}
/>
<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">
{t("roleLimits")}
</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>
{t("fixedRoleLimits")}
</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>{tCard("roles.EMPEROR")}</strong> - {t("emperorLimit")}</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>{tCard("roles.CIVILIAN")}</strong> - {t("civilianLimit")}</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">{t("configRoleLabel")}</p>
</div>
{[
{ value: "duke", label: tCard("roles.DUKE"), key: "duke" as const },
{ value: "knight", label: tCard("roles.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 ? t("enabled") : t("disabled")}
</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">{t("dailyLimit")}</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">{tSend("dailyLimitUnit")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">0 = {t("unlimited")}</p>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</>
)}
<Button
onClick={handleSave}
disabled={loading}
className="w-full"
>
{loading ? t("saving") : t("save")}
</Button>
</div>
</div>
)
}

View File

@@ -1,16 +1,18 @@
"use client"
import { User } from "next-auth"
import { useTranslations, useLocale } from "next-intl"
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 {
@@ -18,13 +20,18 @@ interface ProfileCardProps {
}
const roleConfigs = {
emperor: { name: '皇帝', icon: Crown },
duke: { name: '公爵', icon: Gem },
knight: { name: '骑士', icon: Sword },
civilian: { name: '平民', icon: User2 },
emperor: { key: 'EMPEROR', icon: Crown },
duke: { key: 'DUKE', icon: Gem },
knight: { key: 'KNIGHT', icon: Sword },
civilian: { key: 'CIVILIAN', icon: User2 },
} as const
export function ProfileCard({ user }: ProfileCardProps) {
const t = useTranslations("profile.card")
const tAuth = useTranslations("auth.signButton")
const tWebhook = useTranslations("profile.webhook")
const tNav = useTranslations("common.nav")
const locale = useLocale()
const router = useRouter()
const { checkPermission } = useRolePermission()
const canManageWebhook = checkPermission(PERMISSIONS.MANAGE_WEBHOOK)
@@ -39,7 +46,7 @@ export function ProfileCard({ user }: ProfileCardProps) {
{user.image && (
<Image
src={user.image}
alt={user.name || "用户头像"}
alt={user.name || tAuth("userAvatar")}
width={80}
height={80}
className="rounded-full ring-2 ring-primary/20"
@@ -54,14 +61,14 @@ export function ProfileCard({ user }: ProfileCardProps) {
// 先简单实现,后续再完善
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
<Github className="w-3 h-3" />
{tAuth("linked")}
</div>
)
}
</div>
<p className="text-sm text-muted-foreground truncate mt-1">
{
user.email ? user.email : `用户名: ${user.username}`
user.email ? user.email : `${t("name")}: ${user.username}`
}
</p>
{user.roles && (
@@ -69,14 +76,15 @@ export function ProfileCard({ user }: ProfileCardProps) {
{user.roles.map(({ name }) => {
const roleConfig = roleConfigs[name as keyof typeof roleConfigs]
const Icon = roleConfig.icon
const roleName = t(`roles.${roleConfig.key}` as any)
return (
<div
key={name}
className="flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"
title={roleConfig.name}
title={roleName}
>
<Icon className="w-3 h-3" />
{roleConfig.name}
{roleName}
</div>
)
})}
@@ -90,30 +98,31 @@ export function ProfileCard({ user }: ProfileCardProps) {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">Webhook </h2>
<h2 className="text-lg font-semibold">{tWebhook("title")}</h2>
</div>
<WebhookConfig />
</div>
)}
{canManageConfig && <ConfigPanel />}
{canManageConfig && <WebsiteConfigPanel />}
{canManageConfig && <EmailServiceConfig />}
{canPromote && <PromotePanel />}
{canManageWebhook && <ApiKeyPanel />}
<div className="flex flex-col sm:flex-row gap-4 px-1">
<Button
onClick={() => router.push("/moe")}
onClick={() => router.push(`/${locale}/moe`)}
className="gap-2 flex-1"
>
<Mail className="w-4 h-4" />
{tNav("backToMailbox")}
</Button>
<Button
variant="outline"
onClick={() => signOut({ callbackUrl: "/" })}
onClick={() => signOut({ callbackUrl: `/${locale}` })}
className="flex-1"
>
退
{tAuth("logout")}
</Button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Gem, Sword, User2, Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
@@ -20,19 +21,21 @@ const roleIcons = {
[ROLES.CIVILIAN]: User2,
} as const
const roleNames = {
[ROLES.DUKE]: "公爵",
[ROLES.KNIGHT]: "骑士",
[ROLES.CIVILIAN]: "平民",
} as const
type RoleWithoutEmperor = Exclude<Role, typeof ROLES.EMPEROR>
export function PromotePanel() {
const t = useTranslations("profile.promote")
const tCard = useTranslations("profile.card")
const [searchText, setSearchText] = useState("")
const [loading, setLoading] = useState(false)
const [targetRole, setTargetRole] = useState<RoleWithoutEmperor>(ROLES.KNIGHT)
const { toast } = useToast()
const roleNames = {
[ROLES.DUKE]: tCard("roles.DUKE"),
[ROLES.KNIGHT]: tCard("roles.KNIGHT"),
[ROLES.CIVILIAN]: tCard("roles.CIVILIAN"),
} as const
const handleAction = async () => {
if (!searchText) return
@@ -59,8 +62,8 @@ export function PromotePanel() {
if (!data.user) {
toast({
title: "未找到用户",
description: "请确认用户名或邮箱地址是否正确",
title: t("noUsers"),
description: t("searchPlaceholder"),
variant: "destructive"
})
return
@@ -68,8 +71,8 @@ export function PromotePanel() {
if (data.user.role === targetRole) {
toast({
title: `用户已是${roleNames[targetRole]}`,
description: "无需重复设置",
title: t("updateSuccess"),
description: t("updateSuccess"),
})
return
}
@@ -85,18 +88,18 @@ export function PromotePanel() {
if (!promoteRes.ok) {
const error = await promoteRes.json() as { error: string }
throw new Error(error.error || "设置失败")
throw new Error(error.error || t("updateFailed"))
}
toast({
title: "设置成功",
description: `已将用户 ${data.user.username || data.user.email} 设为${roleNames[targetRole]}`,
title: t("updateSuccess"),
description: `${data.user.username || data.user.email} - ${roleNames[targetRole]}`,
})
setSearchText("")
} catch (error) {
toast({
title: "设置失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("updateFailed"),
description: error instanceof Error ? error.message : t("updateFailed"),
variant: "destructive"
})
} finally {
@@ -110,7 +113,7 @@ export function PromotePanel() {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Icon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<div className="space-y-4">
@@ -119,7 +122,7 @@ export function PromotePanel() {
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="输入用户名或邮箱"
placeholder={t("searchPlaceholder")}
/>
</div>
<Select value={targetRole} onValueChange={(value) => setTargetRole(value as RoleWithoutEmperor)}>
@@ -130,19 +133,19 @@ export function PromotePanel() {
<SelectItem value={ROLES.DUKE}>
<div className="flex items-center gap-2">
<Gem className="w-4 h-4" />
{roleNames[ROLES.DUKE]}
</div>
</SelectItem>
<SelectItem value={ROLES.KNIGHT}>
<div className="flex items-center gap-2">
<Sword className="w-4 h-4" />
{roleNames[ROLES.KNIGHT]}
</div>
</SelectItem>
<SelectItem value={ROLES.CIVILIAN}>
<div className="flex items-center gap-2">
<User2 className="w-4 h-4" />
{roleNames[ROLES.CIVILIAN]}
</div>
</SelectItem>
</SelectContent>
@@ -157,7 +160,7 @@ export function PromotePanel() {
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
`设为${roleNames[targetRole]}`
`${t("promote")} ${roleNames[targetRole]}`
)}
</Button>
</div>

View File

@@ -2,6 +2,7 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -16,6 +17,10 @@ import {
} from "@/components/ui/tooltip"
export function WebhookConfig() {
const t = useTranslations("profile.webhook")
const tCommon = useTranslations("common.actions")
const tMessages = useTranslations("emails.messages")
const tApiKey = useTranslations("profile.apiKey")
const [enabled, setEnabled] = useState(false)
const [url, setUrl] = useState("")
const [loading, setLoading] = useState(false)
@@ -42,7 +47,7 @@ export function WebhookConfig() {
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
<div>
<p className="text-sm text-muted-foreground">...</p>
<p className="text-sm text-muted-foreground">{tMessages("loading")}</p>
</div>
</div>
)
@@ -60,16 +65,16 @@ export function WebhookConfig() {
body: JSON.stringify({ url, enabled })
})
if (!res.ok) throw new Error("Failed to save")
if (!res.ok) throw new Error(t("saveFailed"))
toast({
title: "保存成功",
description: "Webhook 配置已更新"
title: t("saveSuccess"),
description: t("saveSuccess")
})
} catch (_error) {
toast({
title: "保存失败",
description: "请稍后重试",
title: t("saveFailed"),
description: t("saveFailed"),
variant: "destructive"
})
} finally {
@@ -88,16 +93,16 @@ export function WebhookConfig() {
body: JSON.stringify({ url })
})
if (!res.ok) throw new Error("测试失败")
if (!res.ok) throw new Error(t("testFailed"))
toast({
title: "测试成功",
description: "Webhook 调用成功,请检查目标服务器是否收到请求"
title: t("testSuccess"),
description: t("testSuccess")
})
} catch (_error) {
toast({
title: "测试失败",
description: "请检查 URL 是否正确且可访问",
title: t("testFailed"),
description: t("testFailed"),
variant: "destructive"
})
} finally {
@@ -109,9 +114,9 @@ export function WebhookConfig() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Webhook</Label>
<Label>{t("enable")}</Label>
<div className="text-sm text-muted-foreground">
URL
{t("description")}
</div>
</div>
<Switch
@@ -123,11 +128,11 @@ export function WebhookConfig() {
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<Label htmlFor="webhook-url">{t("url")}</Label>
<div className="flex gap-2">
<Input
id="webhook-url"
placeholder="https://example.com/webhook"
placeholder={t("urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
type="url"
@@ -137,7 +142,7 @@ export function WebhookConfig() {
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"保存"
tCommon("save")
)}
</Button>
<TooltipProvider>
@@ -157,13 +162,13 @@ export function WebhookConfig() {
</Button>
</TooltipTrigger>
<TooltipContent>
<p> Webhook</p>
<p>{t("test")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-xs text-muted-foreground">
URL POST ,
{t("description2")}
</p>
</div>
@@ -174,26 +179,26 @@ export function WebhookConfig() {
onClick={() => setShowDocs(!showDocs)}
>
{showDocs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{t("description3")}
</button>
{showDocs && (
<div className="rounded-md bg-muted p-4 text-sm space-y-3">
<p> URL POST :</p>
<p>{t("docs.intro")}</p>
<pre className="bg-background p-2 rounded text-xs">
Content-Type: application/json{'\n'}
X-Webhook-Event: new_message
</pre>
<p>:</p>
<p>{t("docs.exampleBody")}</p>
<pre className="bg-background p-2 rounded text-xs overflow-auto">
{`{
"emailId": "email-uuid",
"messageId": "message-uuid",
"fromAddress": "sender@example.com",
"subject": "邮件主题",
"content": "邮件文本内容",
"html": "邮件HTML内容",
"subject": "${t("docs.subject")}",
"content": "${t("docs.content")}",
"html": "${t("docs.html")}",
"receivedAt": "2024-01-01T12:00:00.000Z",
"toAddress": "your-email@${window.location.host}"
}`}

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Settings } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
@@ -13,11 +14,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { EMAIL_CONFIG } from "@/config"
export function ConfigPanel() {
export function WebsiteConfigPanel() {
const t = useTranslations("profile.website")
const tCard = useTranslations("profile.card")
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 +36,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,20 +56,21 @@ export function ConfigPanel() {
body: JSON.stringify({
defaultRole,
emailDomains,
adminContact
adminContact,
maxEmails: maxEmails || EMAIL_CONFIG.MAX_ACTIVE_EMAILS.toString()
}),
})
if (!res.ok) throw new Error("保存失败")
if (!res.ok) throw new Error(t("saveFailed"))
toast({
title: "保存成功",
description: "网站设置已更新",
title: t("saveSuccess"),
description: t("saveSuccess"),
})
} catch (error) {
toast({
title: "保存失败",
description: error instanceof Error ? error.message : "请稍后重试",
title: t("saveFailed"),
description: error instanceof Error ? error.message : t("saveFailed"),
variant: "destructive",
})
} finally {
@@ -72,42 +82,56 @@ export function ConfigPanel() {
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
</div>
<div className="space-y-4">
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("defaultRole")}:</span>
<Select value={defaultRole} onValueChange={setDefaultRole}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ROLES.DUKE}></SelectItem>
<SelectItem value={ROLES.KNIGHT}></SelectItem>
<SelectItem value={ROLES.CIVILIAN}></SelectItem>
<SelectItem value={ROLES.DUKE}>{tCard("roles.DUKE")}</SelectItem>
<SelectItem value={ROLES.KNIGHT}>{tCard("roles.KNIGHT")}</SelectItem>
<SelectItem value={ROLES.CIVILIAN}>{tCard("roles.CIVILIAN")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("emailDomains")}:</span>
<div className="flex-1">
<Input
value={emailDomains}
onChange={(e) => setEmailDomains(e.target.value)}
placeholder="多个域名用逗号分隔,如: moemail.app,bitibiti.com"
placeholder={t("emailDomainsPlaceholder")}
/>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">:</span>
<span className="text-sm">{t("adminContact")}:</span>
<div className="flex-1">
<Input
value={adminContact}
onChange={(e) => setAdminContact(e.target.value)}
placeholder="如: 微信号、邮箱等"
placeholder={t("adminContactPlaceholder")}
/>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">{t("maxEmails")}:</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>
@@ -117,7 +141,7 @@ export function ConfigPanel() {
disabled={loading}
className="w-full"
>
{t("save")}
</Button>
</div>
</div>

View File

@@ -0,0 +1,116 @@
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { ExternalLink, Mail } from "lucide-react"
interface BrandHeaderProps {
title?: string
subtitle?: string
showCta?: boolean
ctaText?: string
ctaHref?: string
}
export function BrandHeader({
title,
subtitle,
showCta = true,
ctaText,
ctaHref = "https://moemail.app"
}: BrandHeaderProps) {
const t = useTranslations("emails.shared.brand")
const displayTitle = title || t("title")
const displaySubtitle = subtitle || t("subtitle")
const displayCtaText = ctaText || t("cta")
return (
<div className="text-center space-y-4 lg:pb-4">
<div className="flex justify-center pt-2">
<Link
href={ctaHref}
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
>
<div className="relative w-12 h-12">
<div className="absolute inset-0 grid grid-cols-8 grid-rows-8 gap-px">
<svg
width="48"
height="48"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-primary group-hover:scale-105 transition-transform duration-200"
>
{/* 信封主体 */}
<path
d="M4 8h24v16H4V8z"
className="fill-primary/20"
/>
{/* 信封边框 */}
<path
d="M4 8h24v2H4V8zM4 22h24v2H4v-2z"
className="fill-primary"
/>
{/* @ 符号 */}
<path
d="M14 12h4v4h-4v-4zM12 14h2v4h-2v-4zM18 14h2v4h-2v-4zM14 18h4v2h-4v-2z"
className="fill-primary"
/>
{/* 折线装饰 */}
<path
d="M4 8l12 8 12-8"
className="stroke-primary stroke-2"
fill="none"
/>
{/* 装饰点 */}
<path
d="M8 18h2v2H8v-2zM22 18h2v2h-2v-2z"
className="fill-primary/60"
/>
{/* 底部装饰线 */}
<path
d="M8 14h2v2H8v-2zM22 14h2v2h-2v-2z"
className="fill-primary/40"
/>
</svg>
</div>
</div>
<span className="text-3xl font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
MoeMail
</span>
</Link>
</div>
<div className="space-y-3">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
{displayTitle}
</h1>
<p className="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
{displaySubtitle}
</p>
</div>
{showCta && (
<div className="flex justify-center">
<Button
asChild
size="lg"
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8 min-h-10 h-auto py-1"
>
<Link href={ctaHref} target="_blank" rel="noopener noreferrer">
<Mail className="w-5 h-5" />
{displayCtaText}
<ExternalLink className="w-4 h-4" />
</Link>
</Button>
</div>
)}
</div>
)
}

View 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>
)
}

View File

@@ -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 }

View 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 }

View File

@@ -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

View File

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

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

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

View File

@@ -0,0 +1,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
}
}

View File

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

11
app/i18n/config.ts Normal file
View File

@@ -0,0 +1,11 @@
export const locales = ['en', 'zh-CN'] as const
export type Locale = typeof locales[number]
export const defaultLocale: Locale = 'en'
export const i18n = {
locales,
defaultLocale,
localePrefix: 'as-needed',
}

View File

@@ -0,0 +1,43 @@
{
"signButton": {
"login": "Login / Sign Up",
"logout": "Logout",
"userAvatar": "User Avatar",
"linked": "Linked"
},
"loginForm": {
"title": "Welcome to MoeMail",
"subtitle": "Cute Temporary Email Service (。・∀・)",
"tabs": {
"login": "Login",
"register": "Sign Up"
},
"fields": {
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"actions": {
"login": "Login",
"register": "Sign Up",
"or": "OR",
"githubLogin": "Login with GitHub"
},
"errors": {
"usernameRequired": "Please enter username",
"passwordRequired": "Please enter password",
"confirmPasswordRequired": "Please confirm password",
"usernameInvalid": "Username cannot contain @ symbol",
"passwordTooShort": "Password must be at least 8 characters",
"passwordMismatch": "Passwords do not match"
},
"toast": {
"loginFailed": "Login Failed",
"loginFailedDesc": "Incorrect username or password",
"registerFailed": "Registration Failed",
"registerFailedDesc": "Please try again later",
"autoLoginFailed": "Auto-login failed, please login manually"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"app": {
"name": "MoeMail"
},
"lang": {
"en": "English",
"zhCN": "简体中文",
"switch": "Switch Language"
},
"actions": {
"ok": "OK",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
},
"nav": {
"home": "Home",
"login": "Login",
"profile": "Profile",
"logout": "Logout",
"backToMailbox": "Back to Mailbox"
},
"github": "Get Source Code"
}

View File

@@ -0,0 +1,168 @@
{
"noPermission": {
"title": "Insufficient Permission",
"description": "You don't have permission to access this page. Please contact the website administrator.",
"adminContact": "Admin Contact",
"backToHome": "Back to Home"
},
"layout": {
"myEmails": "My Emails",
"selectEmail": "Select an email to view messages",
"messageContent": "Message Content",
"selectMessage": "Select a message to view details",
"backToEmailList": "← Back to Email List",
"backToMessageList": "← Back to Message List"
},
"list": {
"emailCount": "{count}/{max} emails",
"emailCountUnlimited": "{count}/∞ emails",
"loading": "Loading...",
"loadingMore": "Loading more...",
"noEmails": "No emails yet, create one!",
"expiresAt": "Expires",
"permanent": "Permanent",
"deleteConfirm": "Confirm Delete",
"deleteDescription": "Are you sure you want to delete {email}? This will also delete all messages in this mailbox and cannot be undone.",
"deleteSuccess": "Email deleted successfully",
"deleteFailed": "Failed to delete email",
"error": "Error",
"success": "Success"
},
"create": {
"title": "Create Email",
"name": "Email Prefix",
"namePlaceholder": "Leave empty for random generation",
"domain": "Domain",
"domainPlaceholder": "Select a domain",
"expiryTime": "Validity Period",
"oneHour": "1 Hour",
"oneDay": "1 Day",
"threeDays": "3 Days",
"permanent": "Permanent",
"create": "Create",
"creating": "Creating...",
"success": "Email created successfully",
"failed": "Failed to create email"
},
"messages": {
"received": "Received",
"sent": "Sent",
"noMessages": "No messages yet",
"messageCount": "messages",
"from": "From",
"to": "To",
"subject": "Subject",
"date": "Date",
"loading": "Loading...",
"loadingMore": "Loading more..."
},
"send": {
"title": "Send Email",
"from": "From",
"to": "To",
"toPlaceholder": "Recipient email address",
"subject": "Subject",
"subjectPlaceholder": "Email subject",
"content": "Content",
"contentPlaceholder": "Email content (supports HTML)",
"send": "Send",
"sending": "Sending...",
"success": "Email sent successfully",
"failed": "Failed to send email",
"dailyLimitReached": "Daily sending limit reached",
"dailyLimit": "Daily Limit: {count}/{max}",
"dailyLimitUnit": "emails/day"
},
"messageView": {
"loading": "Loading message details...",
"loadError": "Failed to load message details",
"networkError": "Network error, please try again later",
"retry": "Click to retry",
"from": "From",
"to": "To",
"time": "Time",
"htmlFormat": "HTML Format",
"textFormat": "Plain Text Format"
},
"share": {
"title": "Share Mailbox",
"description": "Create a share link to let others view emails in this mailbox",
"createLink": "Create Link",
"creating": "Creating...",
"loading": "Loading...",
"expiryTime": "Link Expiry",
"oneHour": "1 Hour",
"oneDay": "1 Day",
"threeDays": "3 Days",
"oneWeek": "1 Week",
"permanent": "Permanent",
"activeLinks": "Active Share Links",
"noLinks": "No share links yet",
"createdAt": "Created",
"expiresAt": "Expires",
"expired": "Expired",
"copy": "Copy Link",
"copied": "Copied",
"copyFailed": "Copy failed",
"delete": "Delete",
"deleteConfirm": "Confirm delete share link?",
"deleteDescription": "This action cannot be undone. The share link will be invalidated immediately.",
"cancel": "Cancel",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"createSuccess": "Share link created successfully",
"createFailed": "Failed to create share link",
"shareButton": "Share"
},
"shareMessage": {
"title": "Share Message",
"description": "Create a share link to let others view this message",
"createLink": "Create Link",
"creating": "Creating...",
"loading": "Loading...",
"expiryTime": "Link Expiry",
"oneHour": "1 Hour",
"oneDay": "1 Day",
"threeDays": "3 Days",
"oneWeek": "1 Week",
"permanent": "Permanent",
"activeLinks": "Active Share Links",
"noLinks": "No share links yet",
"createdAt": "Created",
"expiresAt": "Expires",
"expired": "Expired",
"copy": "Copy Link",
"copied": "Copied",
"copyFailed": "Copy failed",
"delete": "Delete",
"deleteConfirm": "Confirm delete share link?",
"deleteDescription": "This action cannot be undone. The share link will be invalidated immediately.",
"cancel": "Cancel",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"createSuccess": "Share link created successfully",
"createFailed": "Failed to create share link",
"shareButton": "Share Message"
},
"shared": {
"loading": "Loading...",
"emailNotFound": "Cannot access mailbox",
"messageNotFound": "Cannot access message",
"linkExpired": "Share link does not exist or has expired",
"linkInvalid": "Invalid Link",
"linkInvalidDescription": "This share link may have expired or does not exist",
"sharedMailbox": "Shared Mailbox",
"sharedMessage": "Shared Message",
"expiresAt": "Expires at",
"permanent": "Permanent",
"createOwnEmail": "Create your own temporary email",
"brand": {
"title": "MoeMail",
"subtitle": "Cute temporary email service",
"cta": "Try Now",
"officialSite": "Official Site",
"copyright": "© 2024 MoeMail. Cute temporary email service"
}
}
}

View File

@@ -0,0 +1,27 @@
{
"title": "MoeMail",
"subtitle": "Cute Temporary Email Service",
"features": {
"privacy": {
"title": "Privacy Protection",
"description": "Protect your real email address"
},
"instant": {
"title": "Email Sharing",
"description": "Share your mailbox with others"
},
"expiry": {
"title": "Auto Expiry",
"description": "Automatically expires when due"
},
"openapi": {
"title": "Open API",
"description": "Full OpenAPI interface available"
}
},
"actions": {
"enterMailbox": "Enter Mailbox",
"getStarted": "Get Started"
}
}

View File

@@ -0,0 +1,6 @@
{
"title": "MoeMail - Cute Temporary Email Service · Open API",
"description": "Secure, fast, and disposable temporary email addresses. Protect your privacy and stay away from spam. Email sharing, instant delivery with automatic expiration. Full OpenAPI interface for developers and automation testing.",
"keywords": "temporary email, disposable email, anonymous email, email sharing, privacy protection, spam filter, instant delivery, auto expiry, secure email, verification email, temporary account, no phone required, developer tools, automation testing, email API, OpenAPI, API interface, RESTful API, API Key, developer friendly, MoeMail"
}

View File

@@ -0,0 +1,131 @@
{
"title": "Profile",
"card": {
"title": "User Information",
"name": "Name",
"role": "Role",
"roles": {
"EMPEROR": "Emperor",
"DUKE": "Duke",
"KNIGHT": "Knight",
"CIVILIAN": "Civilian"
}
},
"apiKey": {
"title": "API Key Management",
"description": "Create and manage API keys for accessing OpenAPI",
"create": "Create API Key",
"name": "Key Name",
"namePlaceholder": "Enter key name",
"key": "API Key",
"createdAt": "Created At",
"copy": "Copy",
"delete": "Delete",
"noKeys": "No API keys yet",
"createSuccess": "API key created successfully",
"createFailed": "Failed to create API key",
"deleteConfirm": "Confirm Delete",
"deleteDescription": "Are you sure you want to delete API key {name}? This action cannot be undone.",
"deleteSuccess": "API key deleted successfully",
"deleteFailed": "Failed to delete API key",
"viewDocs": "View Documentation",
"docs": {
"getConfig": "Get System Config",
"generateEmail": "Generate Temp Email",
"getEmails": "Get Email List",
"getMessages": "Get Message List",
"getMessage": "Get Single Message",
"createEmailShare": "Create Email Share Link",
"getEmailShares": "Get Email Share Links",
"deleteEmailShare": "Delete Email Share Link",
"createMessageShare": "Create Message Share Link",
"getMessageShares": "Get Message Share Links",
"deleteMessageShare": "Delete Message Share Link",
"notes": "Notes:",
"note1": "Replace YOUR_API_KEY with your actual API Key",
"note2": "/api/config endpoint provides system configuration including available email domains",
"note3": "emailId is the unique identifier for an email",
"note4": "messageId is the unique identifier for a message",
"note5": "expiryTime is the validity period in milliseconds: 3600000 (1 hour), 86400000 (1 day), 604800000 (7 days), 0 (permanent)",
"note6": "domain is the email domain, get available domains from /api/config endpoint",
"note7": "cursor is for pagination, get nextCursor from previous response",
"note8": "All requests require X-API-Key header",
"note9": "expiresIn is the share link validity period in milliseconds, 0 means permanent",
"note10": "shareId is the unique identifier for a share record"
}
},
"emailService": {
"title": "Resend Email Service Configuration",
"configRoleLabel": "Configurable Role Permissions",
"enable": "Enable Email Service",
"fixedRoleLimits": "Fixed Role Limits",
"emperorLimit": "Emperor can send unlimited emails without any restrictions",
"civilianLimit": "Cannot send emails",
"enableDescription": "When enabled, emails will be sent using Resend",
"apiKey": "Resend API Key",
"apiKeyPlaceholder": "Enter Resend API Key",
"dailyLimit": "Daily Limit",
"roleLimits": "Roles allowed to use sending feature",
"save": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"unlimited": "Unlimited",
"disabled": "Sending permission not enabled",
"enabled": "Sending permission enabled"
},
"webhook": {
"title": "Webhook Configuration",
"description": "Notify specified URL when new emails arrive",
"description2": "We will send a POST request to this URL with information about the new email",
"description3": "View data format documentation",
"enable": "Enable Webhook",
"url": "Webhook URL",
"urlPlaceholder": "Enter webhook URL",
"test": "Test",
"testing": "Testing...",
"save": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration",
"testSuccess": "Webhook test successful",
"testFailed": "Webhook test failed",
"docs": {
"intro": "When a new email is received, we will send a POST request to the configured URL with the following headers:",
"exampleBody": "Request body example:",
"subject": "Email Subject",
"content": "Email text content",
"html": "Email HTML content"
}
},
"website": {
"title": "Website Configuration",
"description": "Configure website settings (Emperor only)",
"defaultRole": "Default Role for New Users",
"emailDomains": "Email Domains",
"emailDomainsPlaceholder": "Separate multiple domains with commas",
"adminContact": "Admin Contact",
"adminContactPlaceholder": "Email or other contact method",
"maxEmails": "Max Emails per User",
"save": "Save Configuration",
"saving": "Saving...",
"saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration"
},
"promote": {
"title": "Role Management",
"description": "Manage user roles (Emperor only)",
"search": "Search Users",
"searchPlaceholder": "Enter username or email",
"username": "Username",
"email": "Email",
"role": "Role",
"actions": "Actions",
"promote": "Set as",
"noUsers": "No users found",
"loading": "Loading...",
"updateSuccess": "User role updated successfully",
"updateFailed": "Failed to update user role"
}
}

View File

@@ -0,0 +1,43 @@
{
"signButton": {
"login": "登录/注册",
"logout": "登出",
"userAvatar": "用户头像",
"linked": "已关联"
},
"loginForm": {
"title": "欢迎使用 MoeMail",
"subtitle": "萌萌哒临时邮箱服务 (。・∀・)",
"tabs": {
"login": "登录",
"register": "注册"
},
"fields": {
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码"
},
"actions": {
"login": "登录",
"register": "注册",
"or": "或者",
"githubLogin": "使用 GitHub 账号登录"
},
"errors": {
"usernameRequired": "请输入用户名",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码",
"usernameInvalid": "用户名不能包含 @ 符号",
"passwordTooShort": "密码长度必须大于等于8位",
"passwordMismatch": "两次输入的密码不一致"
},
"toast": {
"loginFailed": "登录失败",
"loginFailedDesc": "用户名或密码错误",
"registerFailed": "注册失败",
"registerFailedDesc": "请稍后重试",
"autoLoginFailed": "自动登录失败,请手动登录"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"app": {
"name": "MoeMail"
},
"lang": {
"en": "English",
"zhCN": "简体中文",
"switch": "切换语言"
},
"actions": {
"ok": "确定",
"cancel": "取消",
"save": "保存",
"delete": "删除"
},
"nav": {
"home": "首页",
"login": "登录",
"profile": "个人中心",
"logout": "退出登录",
"backToMailbox": "返回邮箱"
},
"github": "获取网站源代码"
}

View File

@@ -0,0 +1,168 @@
{
"noPermission": {
"title": "权限不足",
"description": "你没有权限访问此页面,请联系网站管理员",
"adminContact": "管理员联系方式",
"backToHome": "返回首页"
},
"layout": {
"myEmails": "我的邮箱",
"selectEmail": "选择邮箱查看消息",
"messageContent": "邮件内容",
"selectMessage": "选择邮件查看详情",
"backToEmailList": "← 返回邮箱列表",
"backToMessageList": "← 返回消息列表"
},
"list": {
"emailCount": "{count}/{max} 个邮箱",
"emailCountUnlimited": "{count}/∞ 个邮箱",
"loading": "加载中...",
"loadingMore": "加载更多...",
"noEmails": "还没有邮箱,创建一个吧!",
"expiresAt": "过期时间",
"permanent": "永久有效",
"deleteConfirm": "确认删除",
"deleteDescription": "确定要删除邮箱 {email} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。",
"deleteSuccess": "邮箱已删除",
"deleteFailed": "删除邮箱失败",
"error": "错误",
"success": "成功"
},
"create": {
"title": "创建邮箱",
"name": "邮箱前缀",
"namePlaceholder": "留空则随机生成",
"domain": "域名",
"domainPlaceholder": "选择域名",
"expiryTime": "有效期",
"oneHour": "1 小时",
"oneDay": "1 天",
"threeDays": "3 天",
"permanent": "永久",
"create": "创建",
"creating": "创建中...",
"success": "邮箱创建成功",
"failed": "创建邮箱失败"
},
"messages": {
"received": "收件箱",
"sent": "已发送",
"noMessages": "暂无邮件",
"messageCount": "封邮件",
"from": "发件人",
"to": "收件人",
"subject": "主题",
"date": "日期",
"loading": "加载中...",
"loadingMore": "加载更多..."
},
"send": {
"title": "发送邮件",
"from": "发件人",
"to": "收件人",
"toPlaceholder": "收件人邮箱地址",
"subject": "主题",
"subjectPlaceholder": "邮件主题",
"content": "内容",
"contentPlaceholder": "邮件内容(支持 HTML",
"send": "发送",
"sending": "发送中...",
"success": "邮件发送成功",
"failed": "发送邮件失败",
"dailyLimitReached": "已达每日发送上限",
"dailyLimit": "每日限额:{count}/{max}",
"dailyLimitUnit": "封/天"
},
"messageView": {
"loading": "加载邮件详情...",
"loadError": "获取邮件详情失败",
"networkError": "网络错误,请稍后重试",
"retry": "点击重试",
"from": "发件人",
"to": "收件人",
"time": "时间",
"htmlFormat": "HTML 格式",
"textFormat": "纯文本格式"
},
"share": {
"title": "分享邮箱",
"description": "创建分享链接,让其他人可以查看此邮箱中的邮件",
"createLink": "创建链接",
"creating": "创建中...",
"loading": "加载中...",
"expiryTime": "链接有效期",
"oneHour": "1 小时",
"oneDay": "1 天",
"threeDays": "3 天",
"oneWeek": "1 周",
"permanent": "永久",
"activeLinks": "当前分享链接",
"noLinks": "暂无分享链接",
"createdAt": "创建时间",
"expiresAt": "过期时间",
"expired": "已过期",
"copy": "复制链接",
"copied": "已复制",
"copyFailed": "复制失败",
"delete": "删除",
"deleteConfirm": "确认删除分享链接?",
"deleteDescription": "此操作无法撤销,分享链接将立即失效。",
"cancel": "取消",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"createSuccess": "分享链接创建成功",
"createFailed": "创建分享链接失败",
"shareButton": "分享"
},
"shareMessage": {
"title": "分享邮件",
"description": "创建分享链接,让其他人可以查看这封邮件",
"createLink": "创建链接",
"creating": "创建中...",
"loading": "加载中...",
"expiryTime": "链接有效期",
"oneHour": "1 小时",
"oneDay": "1 天",
"threeDays": "3 天",
"oneWeek": "1 周",
"permanent": "永久",
"activeLinks": "当前分享链接",
"noLinks": "暂无分享链接",
"createdAt": "创建时间",
"expiresAt": "过期时间",
"expired": "已过期",
"copy": "复制链接",
"copied": "已复制",
"copyFailed": "复制失败",
"delete": "删除",
"deleteConfirm": "确认删除分享链接?",
"deleteDescription": "此操作无法撤销,分享链接将立即失效。",
"cancel": "取消",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"createSuccess": "分享链接创建成功",
"createFailed": "创建分享链接失败",
"shareButton": "分享邮件"
},
"shared": {
"loading": "加载中...",
"emailNotFound": "无法访问邮箱",
"messageNotFound": "无法访问邮件",
"linkExpired": "分享链接不存在或已过期",
"linkInvalid": "链接无效",
"linkInvalidDescription": "此分享链接可能已过期或不存在",
"sharedMailbox": "分享邮箱",
"sharedMessage": "分享邮件",
"expiresAt": "邮箱过期时间",
"permanent": "永久有效",
"createOwnEmail": "创建自己的临时邮箱",
"brand": {
"title": "MoeMail",
"subtitle": "萌萌哒临时邮箱服务",
"cta": "立即体验",
"officialSite": "官网",
"copyright": "© 2024 MoeMail. 萌萌哒临时邮箱服务"
}
}
}

View File

@@ -0,0 +1,27 @@
{
"title": "MoeMail",
"subtitle": "萌萌哒临时邮箱服务",
"features": {
"privacy": {
"title": "隐私保护",
"description": "保护您的真实邮箱地址"
},
"instant": {
"title": "邮箱分享",
"description": "将邮箱分享给其他人使用"
},
"expiry": {
"title": "自动过期",
"description": "到期自动失效"
},
"openapi": {
"title": "开放 API",
"description": "提供完整的 OpenAPI 接口"
}
},
"actions": {
"enterMailbox": "进入邮箱",
"getStarted": "开始使用"
}
}

View File

@@ -0,0 +1,6 @@
{
"title": "MoeMail - 萌萌哒临时邮箱服务 · 开放 API",
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持邮箱分享、即时收件,到期自动失效。提供完整的 OpenAPI 接口,方便开发者集成和自动化测试。",
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 邮箱分享, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 无需手机号, 开发测试, 自动化测试, 邮件API, OpenAPI, API接口, RESTful API, API Key, 开发者工具, MoeMail"
}

View File

@@ -0,0 +1,131 @@
{
"title": "个人中心",
"card": {
"title": "用户信息",
"name": "用户名",
"role": "角色",
"roles": {
"EMPEROR": "皇帝",
"DUKE": "公爵",
"KNIGHT": "骑士",
"CIVILIAN": "平民"
}
},
"apiKey": {
"title": "API Key 管理",
"description": "创建和管理用于访问 OpenAPI 的 API 密钥",
"create": "创建 API Key",
"name": "密钥名称",
"namePlaceholder": "输入密钥名称",
"key": "API Key",
"createdAt": "创建时间",
"copy": "复制",
"delete": "删除",
"noKeys": "暂无 API Key",
"createSuccess": "API Key 创建成功",
"createFailed": "创建 API Key 失败",
"deleteConfirm": "确认删除",
"deleteDescription": "确定要删除 API Key {name} 吗?此操作不可恢复。",
"deleteSuccess": "API Key 已删除",
"deleteFailed": "删除 API Key 失败",
"viewDocs": "查看使用文档",
"docs": {
"getConfig": "获取系统配置",
"generateEmail": "生成临时邮箱",
"getEmails": "获取邮箱列表",
"getMessages": "获取邮件列表",
"getMessage": "获取单封邮件",
"createEmailShare": "创建邮箱分享链接",
"getEmailShares": "获取邮箱分享链接列表",
"deleteEmailShare": "删除邮箱分享链接",
"createMessageShare": "创建邮件分享链接",
"getMessageShares": "获取邮件分享链接列表",
"deleteMessageShare": "删除邮件分享链接",
"notes": "注意:",
"note1": "请将 YOUR_API_KEY 替换为你的实际 API Key",
"note2": "/api/config 接口可获取系统配置,包括可用的邮箱域名列表",
"note3": "emailId 是邮箱的唯一标识符",
"note4": "messageId 是邮件的唯一标识符",
"note5": "expiryTime 是邮箱的有效期毫秒可选值36000001小时、864000001天、6048000007天、0永久",
"note6": "domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表",
"note7": "cursor 用于分页,从上一次请求的响应中获取 nextCursor",
"note8": "所有请求都需要包含 X-API-Key 请求头",
"note9": "expiresIn 是分享链接的有效期毫秒0 表示永久有效",
"note10": "shareId 是分享记录的唯一标识符"
}
},
"emailService": {
"title": "Resend 发件服务配置",
"configRoleLabel": "可配置的角色权限",
"enable": "启用邮件服务",
"fixedRoleLimits": "固定权限规则",
"emperorLimit": "皇帝可以无限发件,不受任何限制",
"civilianLimit": "永远不能发件",
"enableDescription": "开启后将使用 Resend 发送邮件",
"apiKey": "Resend API Key",
"apiKeyPlaceholder": "输入 Resend API Key",
"dailyLimit": "每日限额",
"roleLimits": "允许使用发件功能的角色",
"save": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置保存成功",
"saveFailed": "保存配置失败",
"unlimited": "无限制",
"disabled": "未启用发件权限",
"enabled": "已启用发件权限"
},
"webhook": {
"title": "Webhook 配置",
"description": "当收到新邮件时通知指定的 URL",
"description2": "我们会向此 URL 发送 POST 请求,包含新邮件的相关信息",
"description3": "查看数据格式说明",
"enable": "启用 Webhook",
"url": "Webhook URL",
"urlPlaceholder": "输入 webhook URL",
"test": "测试",
"testing": "测试中...",
"save": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置保存成功",
"saveFailed": "保存配置失败",
"testSuccess": "Webhook 测试成功",
"testFailed": "Webhook 测试失败",
"docs": {
"intro": "当收到新邮件时,我们会向配置的 URL 发送 POST 请求,请求头包含:",
"exampleBody": "请求体示例:",
"subject": "邮件主题",
"content": "邮件文本内容",
"html": "邮件HTML内容"
}
},
"website": {
"title": "网站配置",
"description": "配置网站设置(仅皇帝可用)",
"defaultRole": "新用户默认角色",
"emailDomains": "邮箱域名",
"emailDomainsPlaceholder": "多个域名用逗号分隔",
"adminContact": "管理员联系方式",
"adminContactPlaceholder": "邮箱或其他联系方式",
"maxEmails": "每个用户最大邮箱数",
"save": "保存配置",
"saving": "保存中...",
"saveSuccess": "配置保存成功",
"saveFailed": "保存配置失败"
},
"promote": {
"title": "角色管理",
"description": "管理用户角色(仅皇帝可用)",
"search": "搜索用户",
"searchPlaceholder": "输入用户名或邮箱",
"username": "用户名",
"email": "邮箱",
"role": "角色",
"actions": "操作",
"promote": "设为",
"noUsers": "未找到用户",
"loading": "加载中...",
"updateSuccess": "用户角色更新成功",
"updateFailed": "更新用户角色失败"
}
}

18
app/i18n/request.ts Normal file
View File

@@ -0,0 +1,18 @@
import {getRequestConfig} from 'next-intl/server'
import {i18n} from '@/i18n/config'
export default getRequestConfig(async ({locale}) => {
const safeLocale = (i18n.locales.includes(locale as any) ? locale : i18n.defaultLocale) as string
try {
const common = (await import(`@/i18n/messages/${safeLocale}/common.json`)).default
const home = (await import(`@/i18n/messages/${safeLocale}/home.json`)).default
const auth = (await import(`@/i18n/messages/${safeLocale}/auth.json`)).default
const metadata = (await import(`@/i18n/messages/${safeLocale}/metadata.json`)).default
const emails = (await import(`@/i18n/messages/${safeLocale}/emails.json`)).default
const profile = (await import(`@/i18n/messages/${safeLocale}/profile.json`)).default
return {locale: safeLocale, messages: {common, home, auth, metadata, emails, profile}}
} catch {
return {locale: safeLocale, messages: {common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {}}}
}
})

View File

@@ -1,105 +1,7 @@
import { ThemeProvider } from "@/components/theme/theme-provider"
import { Toaster } from "@/components/ui/toaster"
import { cn } from "@/lib/utils"
import type { Metadata, Viewport } from "next"
import { zpix } from "./fonts"
import "./globals.css"
import { Providers } from "./providers"
export const metadata: Metadata = {
title: "MoeMail - 萌萌哒临时邮箱服务",
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
keywords: [
"临时邮箱",
"一次性邮箱",
"匿名邮箱",
"隐私保护",
"垃圾邮件过滤",
"即时收件",
"自动过期",
"安全邮箱",
"注册验证",
"临时账号",
"萌系邮箱",
"电子邮件",
"隐私安全",
"邮件服务",
"MoeMail"
].join(", "),
authors: [{ name: "SoftMoe Studio" }],
creator: "SoftMoe Studio",
publisher: "SoftMoe Studio",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
type: "website",
locale: "zh_CN",
url: "https://moemail.app",
title: "MoeMail - 萌萌哒临时邮箱服务",
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
siteName: "MoeMail",
},
twitter: {
card: "summary_large_image",
title: "MoeMail - 萌萌哒临时邮箱服务",
description: "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
},
manifest: '/manifest.json',
icons: [
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
],
}
export const viewport: Viewport = {
themeColor: '#826DD9',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh" suppressHydrationWarning>
<head>
<meta name="application-name" content="MoeMail" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="MoeMail" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body
className={cn(
zpix.variable,
"font-zpix min-h-screen antialiased",
"bg-background text-foreground",
"transition-colors duration-300"
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
storageKey="temp-mail-theme"
>
<Providers>
{children}
</Providers>
<Toaster />
</ThemeProvider>
</body>
</html>
)
return children
}

View File

@@ -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 }
@@ -40,8 +40,14 @@ export async function handleApiKeyAuth(apiKey: string, pathname: string) {
)
}
const response = NextResponse.next()
response.headers.set("X-User-Id", user.id)
const requestHeaders = new Headers(await headers())
requestHeaders.set("X-User-Id", user.id)
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
return response
}

View File

@@ -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 },
})

View File

@@ -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,46 @@ 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 emailShares = sqliteTable('email_share', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
emailId: text('email_id')
.notNull()
.references(() => emails.id, { onDelete: "cascade" }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
}, (table) => ({
emailIdIdx: index('email_share_email_id_idx').on(table.emailId),
tokenIdx: index('email_share_token_idx').on(table.token),
}));
export const messageShares = sqliteTable('message_share', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
messageId: text('message_id')
.notNull()
.references(() => messages.id, { onDelete: "cascade" }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
}, (table) => ({
messageIdIdx: index('message_share_message_id_idx').on(table.messageId),
tokenIdx: index('message_share_token_idx').on(table.token),
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
@@ -128,4 +171,18 @@ export const usersRelations = relations(users, ({ many }) => ({
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles),
}));
export const emailSharesRelations = relations(emailShares, ({ one }) => ({
email: one(emails, {
fields: [emailShares.emailId],
references: [emails.id],
}),
}));
export const messageSharesRelations = relations(messageShares, ({ one }) => ({
message: one(messages, {
fields: [messageShares.messageId],
references: [messages.id],
}),
}));

125
app/lib/send-permissions.ts Normal file
View 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)
}

192
app/lib/shared-data.ts Normal file
View File

@@ -0,0 +1,192 @@
import { createDb } from "@/lib/db"
import { emailShares, messageShares, messages, emails } from "@/lib/schema"
import { eq, desc, and, or, ne, isNull } from "drizzle-orm"
export interface SharedEmail {
id: string
address: string
createdAt: Date
expiresAt: Date
}
export interface SharedMessage {
id: string
from_address?: string
to_address?: string
subject: string
content?: string
html?: string
received_at?: Date
sent_at?: Date
expiresAt?: Date
emailAddress?: string
emailExpiresAt?: Date
}
export async function getSharedEmail(token: string): Promise<SharedEmail | null> {
const db = createDb()
try {
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return null
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return null
}
// 检查邮箱是否过期
if (share.email.expiresAt < new Date()) {
return null
}
return {
id: share.email.id,
address: share.email.address,
createdAt: share.email.createdAt,
expiresAt: share.email.expiresAt
}
} catch (error) {
console.error("Failed to fetch shared email:", error)
return null
}
}
export interface SharedMessagesResult {
messages: SharedMessage[]
nextCursor: string | null
total: number
}
export async function getSharedEmailMessages(token: string, limit = 20): Promise<SharedMessagesResult> {
const db = createDb()
try {
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return { messages: [], nextCursor: null, total: 0 }
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return { messages: [], nextCursor: null, total: 0 }
}
// 只显示接收的邮件,不显示发送的邮件
const baseConditions = and(
eq(messages.emailId, share.emailId),
or(
ne(messages.type, "sent"),
isNull(messages.type)
)
)
// 获取消息总数(只统计接收的邮件)
const { sql } = await import("drizzle-orm")
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(messages)
.where(baseConditions)
const totalCount = Number(totalResult[0].count)
// 获取邮箱的消息列表(多获取一条用于判断是否有更多)
const messageList = await db.query.messages.findMany({
where: baseConditions,
orderBy: [desc(messages.receivedAt), desc(messages.id)],
limit: limit + 1
})
const hasMore = messageList.length > limit
const results = hasMore ? messageList.slice(0, limit) : messageList
// 生成下一页的cursor
let nextCursor: string | null = null
if (hasMore) {
const { encodeCursor } = await import("@/lib/cursor")
const lastMessage = results[results.length - 1]
nextCursor = encodeCursor(
lastMessage.receivedAt.getTime(),
lastMessage.id
)
}
return {
messages: results.map(msg => ({
id: msg.id,
from_address: msg.fromAddress ?? undefined,
to_address: msg.toAddress ?? undefined,
subject: msg.subject,
received_at: msg.receivedAt,
sent_at: msg.sentAt
})),
nextCursor,
total: totalCount
}
} catch (error) {
console.error("Failed to fetch shared email messages:", error)
return { messages: [], nextCursor: null, total: 0 }
}
}
export async function getSharedMessage(token: string): Promise<SharedMessage | null> {
const db = createDb()
try {
const share = await db.query.messageShares.findFirst({
where: eq(messageShares.token, token)
})
if (!share) {
return null
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return null
}
// 获取消息详情
const message = await db.query.messages.findFirst({
where: eq(messages.id, share.messageId)
})
if (!message) {
return null
}
// 获取邮箱信息
const email = await db.query.emails.findFirst({
where: eq(emails.id, message.emailId)
})
return {
id: message.id,
from_address: message.fromAddress ?? undefined,
to_address: message.toAddress ?? undefined,
subject: message.subject,
content: message.content ?? undefined,
html: message.html ?? undefined,
received_at: message.receivedAt,
sent_at: message.sentAt,
expiresAt: share.expiresAt ?? undefined,
emailAddress: email?.address,
emailExpiresAt: email?.expiresAt
}
} catch (error) {
console.error("Failed to fetch shared message:", error)
return null
}
}

View File

@@ -1,59 +0,0 @@
import { Header } from "@/components/layout/header"
import { auth } from "@/lib/auth"
import { Shield, Mail, Clock } from "lucide-react"
import { ActionButton } from "@/components/home/action-button"
import { FeatureCard } from "@/components/home/feature-card"
export const runtime = "edge"
export default async function Home() {
const session = await auth()
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
<div className="container mx-auto h-full px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="h-full">
<div className="h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4 relative">
<div className="absolute inset-0 -z-10 bg-grid-primary/5" />
<div className="w-full max-w-3xl mx-auto space-y-12 py-8">
<div className="space-y-4">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
MoeMail
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 tracking-wide">
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 px-4 sm:px-0">
<FeatureCard
icon={<Shield className="w-5 h-5" />}
title="隐私保护"
description="保护您的真实邮箱地址"
/>
<FeatureCard
icon={<Mail className="w-5 h-5" />}
title="即时收件"
description="实时接收邮件通知"
/>
<FeatureCard
icon={<Clock className="w-5 h-5" />}
title="自动过期"
description="到期自动失效"
/>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 px-4 sm:px-0">
<ActionButton isLoggedIn={!!session} />
</div>
</div>
</div>
</main>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,2 @@
CREATE INDEX `email_expires_at_idx` ON `email` (`expires_at`);--> statement-breakpoint
CREATE INDEX `message_email_id_idx` ON `message` (`emailId`);

View 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`);

View File

@@ -0,0 +1,13 @@
CREATE TABLE `email_share` (
`id` text PRIMARY KEY NOT NULL,
`email_id` text NOT NULL,
`token` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
`enabled` integer DEFAULT true NOT NULL,
FOREIGN KEY (`email_id`) REFERENCES `email`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `email_share_token_unique` ON `email_share` (`token`);--> statement-breakpoint
CREATE INDEX `email_share_email_id_idx` ON `email_share` (`email_id`);--> statement-breakpoint
CREATE INDEX `email_share_token_idx` ON `email_share` (`token`);

View File

@@ -0,0 +1,13 @@
CREATE TABLE `message_share` (
`id` text PRIMARY KEY NOT NULL,
`message_id` text NOT NULL,
`token` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
`enabled` integer DEFAULT true NOT NULL,
FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `message_share_token_unique` ON `message_share` (`token`);--> statement-breakpoint
CREATE INDEX `message_share_message_id_idx` ON `message_share` (`message_id`);--> statement-breakpoint
CREATE INDEX `message_share_token_idx` ON `message_share` (`token`);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `email_share` DROP COLUMN `enabled`;--> statement-breakpoint
ALTER TABLE `message_share` DROP COLUMN `enabled`;

View File

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

View File

@@ -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": {}
}
}

View 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": {}
}
}

View File

@@ -0,0 +1,734 @@
{
"version": "6",
"dialect": "sqlite",
"id": "fd2d5830-cc75-4ec5-a404-1a340c4a5c49",
"prevId": "eb2c55e5-a514-4048-9787-e8afc4c33308",
"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_share": {
"name": "email_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email_id": {
"name": "email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"email_share_token_unique": {
"name": "email_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"email_share_email_id_idx": {
"name": "email_share_email_id_idx",
"columns": [
"email_id"
],
"isUnique": false
},
"email_share_token_idx": {
"name": "email_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"email_share_email_id_email_id_fk": {
"name": "email_share_email_id_email_id_fk",
"tableFrom": "email_share",
"tableTo": "email",
"columnsFrom": [
"email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"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": {}
}
}

View File

@@ -0,0 +1,823 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f8829008-3b07-42e3-93ac-7628453ddce4",
"prevId": "fd2d5830-cc75-4ec5-a404-1a340c4a5c49",
"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_share": {
"name": "email_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email_id": {
"name": "email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"email_share_token_unique": {
"name": "email_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"email_share_email_id_idx": {
"name": "email_share_email_id_idx",
"columns": [
"email_id"
],
"isUnique": false
},
"email_share_token_idx": {
"name": "email_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"email_share_email_id_email_id_fk": {
"name": "email_share_email_id_email_id_fk",
"tableFrom": "email_share",
"tableTo": "email",
"columnsFrom": [
"email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"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_share": {
"name": "message_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"message_share_token_unique": {
"name": "message_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"message_share_message_id_idx": {
"name": "message_share_message_id_idx",
"columns": [
"message_id"
],
"isUnique": false
},
"message_share_token_idx": {
"name": "message_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"message_share_message_id_message_id_fk": {
"name": "message_share_message_id_message_id_fk",
"tableFrom": "message_share",
"tableTo": "message",
"columnsFrom": [
"message_id"
],
"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": {}
}
}

View File

@@ -0,0 +1,807 @@
{
"version": "6",
"dialect": "sqlite",
"id": "10fa71e9-1e9e-43ef-bcfc-bfec5e46af8e",
"prevId": "f8829008-3b07-42e3-93ac-7628453ddce4",
"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_share": {
"name": "email_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email_id": {
"name": "email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": false,
"autoincrement": false
}
},
"indexes": {
"email_share_token_unique": {
"name": "email_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"email_share_email_id_idx": {
"name": "email_share_email_id_idx",
"columns": [
"email_id"
],
"isUnique": false
},
"email_share_token_idx": {
"name": "email_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"email_share_email_id_email_id_fk": {
"name": "email_share_email_id_email_id_fk",
"tableFrom": "email_share",
"tableTo": "email",
"columnsFrom": [
"email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"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_share": {
"name": "message_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"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": false,
"autoincrement": false
}
},
"indexes": {
"message_share_token_unique": {
"name": "message_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"message_share_message_id_idx": {
"name": "message_share_message_id_idx",
"columns": [
"message_id"
],
"isUnique": false
},
"message_share_token_idx": {
"name": "message_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"message_share_message_id_message_id_fk": {
"name": "message_share_message_id_message_id_fk",
"tableFrom": "message_share",
"tableTo": "message",
"columnsFrom": [
"message_id"
],
"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": {}
}
}

View File

@@ -78,6 +78,48 @@
"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
},
{
"idx": 14,
"version": "6",
"when": 1760454260207,
"tag": "0014_jazzy_gressill",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1760456754699,
"tag": "0015_majestic_chimera",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1760460028481,
"tag": "0016_hesitant_thing",
"breakpoints": true
}
]
}

Some files were not shown because too many files have changed in this diff Show More