mirror of
https://github.com/beilunyang/moemail.git
synced 2025-09-27 03:46:03 +08:00
feat: Init
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
AUTH_GITHUB_ID = ""
|
||||||
|
AUTH_GITHUB_SECRET = ""
|
||||||
|
AUTH_SECRET = ""
|
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
}
|
111
.github/workflows/deploy.yml
vendored
Normal file
111
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Get full git history for checking file changes
|
||||||
|
|
||||||
|
- name: Get previous tag
|
||||||
|
id: previoustag
|
||||||
|
run: |
|
||||||
|
echo "tag=$(git describe --tags --abbrev=0 HEAD^)" >> $GITHUB_OUTPUT
|
||||||
|
continue-on-error: true # Allow failure if this is the first tag
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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
|
||||||
|
|
||||||
|
# Check if workers have changes
|
||||||
|
- name: Check workers changes
|
||||||
|
id: check_changes
|
||||||
|
run: |
|
||||||
|
# If this is the first tag, check all files
|
||||||
|
if [ -z "${{ steps.previoustag.outputs.tag }}" ]; then
|
||||||
|
if git ls-files | grep -q "workers/email-receiver.ts"; then
|
||||||
|
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
# Compare changes between two tags
|
||||||
|
if git diff ${{ steps.previoustag.outputs.tag }}..HEAD --name-only | grep -q "workers/email-receiver.ts"; then
|
||||||
|
echo "email_worker_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "email_worker_changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
- name: Deploy Email Worker
|
||||||
|
if: steps.check_changes.outputs.email_worker_changed == '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
|
||||||
|
- name: Deploy Cleanup Worker
|
||||||
|
if: steps.check_changes.outputs.cleanup_worker_changed == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: pnpm run deploy:cleanup
|
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
.wrangler
|
||||||
|
wrangler.toml
|
||||||
|
wrangler.email.toml
|
||||||
|
wrangler.cleanup.toml
|
||||||
|
|
||||||
|
public/workbox-*.js
|
||||||
|
public/sw.js
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 [BeilunYang](https://github.com/beilunyang)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
219
README.md
Normal file
219
README.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="public/icons/icon-192x192.png" alt="MoeMail Logo" width="100" height="100">
|
||||||
|
<h1 align="center">MoeMail</h1>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
一个基于 NextJS + Cloudflare 技术栈构建的可爱临时邮箱服务🎉
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#在线演示">在线演示</a> •
|
||||||
|
<a href="#特性">特性</a> •
|
||||||
|
<a href="#技术栈">技术栈</a> •
|
||||||
|
<a href="#本地运行">本地运行</a> •
|
||||||
|
<a href="#部署">部署</a> •
|
||||||
|
<a href="#贡献">贡献</a> •
|
||||||
|
<a href="#许可证">许可证</a> •
|
||||||
|
<a href="#交流群">交流群</a> •
|
||||||
|
<a href="#支持">支持</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 在线演示
|
||||||
|
[https://moemail.app](https://moemail.app)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 🔒 **隐私保护**:保护您的真实邮箱地址,远离垃圾邮件和不必要的订阅
|
||||||
|
- ⚡ **实时收件**:自动轮询,即时接收邮件通知
|
||||||
|
- ⏱️ **灵活过期**:支持 1 小时、24 小时或 3 天的过期时间选择
|
||||||
|
- 🎨 **主题切换**:支持亮色和暗色模式
|
||||||
|
- 📱 **响应式设计**:完美适配桌面和移动设备
|
||||||
|
- 🔄 **自动清理**:自动清理过期的邮箱和邮件
|
||||||
|
- 📱 **PWA 支持**:支持 PWA 安装
|
||||||
|
- 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用
|
||||||
|
- 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: [Next.js](https://nextjs.org/) (App Router)
|
||||||
|
- **平台**: [Cloudflare Pages](https://pages.cloudflare.com/)
|
||||||
|
- **数据库**: [Cloudflare D1](https://developers.cloudflare.com/d1/) (SQLite)
|
||||||
|
- **认证**: [NextAuth](https://authjs.dev/getting-started/installation?framework=Next.js) 配合 GitHub 登录
|
||||||
|
- **样式**: [Tailwind CSS](https://tailwindcss.com/)
|
||||||
|
- **UI 组件**: 基于 [Radix UI](https://www.radix-ui.com/) 的自定义组件
|
||||||
|
- **邮件处理**: [Cloudflare Email Workers](https://developers.cloudflare.com/email-routing/)
|
||||||
|
- **类型安全**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm
|
||||||
|
- Wrangler CLI
|
||||||
|
- Cloudflare 账号
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
1. 克隆仓库:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/beilunyang/moemail.git
|
||||||
|
cd moemail
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 安装依赖:
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
并设置 Cloudflare D1 数据库名以及数据库 ID
|
||||||
|
|
||||||
|
4. 设置环境变量:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
设置 AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_SECRET
|
||||||
|
|
||||||
|
5. 创建本地数据库表结构
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate-local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
|
1. 启动开发服务器:
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 测试邮件 worker:
|
||||||
|
目前无法本地运行并测试,请使用 wrangler 部署邮件 worker 并测试
|
||||||
|
```bash
|
||||||
|
pnpm deploy:email
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 测试清理 worker:
|
||||||
|
```bash
|
||||||
|
pnpm dev:cleanup
|
||||||
|
pnpm test:cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 生成 Mock 数据(邮箱以及邮件消息)
|
||||||
|
```bash
|
||||||
|
pnpm generate-test-data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
### 本地 Wrangler 部署
|
||||||
|
|
||||||
|
1. 设置 wrangler:
|
||||||
|
```bash
|
||||||
|
cp wrangler.example.toml wrangler.toml
|
||||||
|
cp wrangler.email.example.toml wrangler.email.toml
|
||||||
|
cp wrangler.cleanup.example.toml wrangler.cleanup.toml
|
||||||
|
```
|
||||||
|
设置 Cloudflare D1 数据库名以及数据库 ID
|
||||||
|
|
||||||
|
2. 创建云端 D1 数据库表结构
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate-remote
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 部署主应用到 Cloudflare Pages:
|
||||||
|
```bash
|
||||||
|
pnpm deploy:pages
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 部署邮件 worker:
|
||||||
|
```bash
|
||||||
|
pnpm deploy:email
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 部署清理 worker:
|
||||||
|
```bash
|
||||||
|
pnpm deploy:cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Github Actions 部署
|
||||||
|
|
||||||
|
本项目可使用 GitHub Actions 实现自动化部署。当推送新的 tag 时会触发部署流程。
|
||||||
|
|
||||||
|
1. 在 GitHub 仓库设置中添加以下 Secrets:
|
||||||
|
|
||||||
|
- `CLOUDFLARE_API_TOKEN`: Cloudflare API 令牌
|
||||||
|
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID
|
||||||
|
- `DATABASE_NAME`: D1 数据库名称
|
||||||
|
- `DATABASE_ID`: D1 数据库 ID
|
||||||
|
|
||||||
|
2. 创建并推送新的 tag 来触发部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建新的 tag
|
||||||
|
git tag v1.0.0
|
||||||
|
|
||||||
|
# 推送 tag 到远程仓库
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
3. GitHub Actions 会自动执行以下任务:
|
||||||
|
|
||||||
|
- 构建并部署主应用到 Cloudflare Pages
|
||||||
|
- 检测并部署更新的 Email Worker
|
||||||
|
- 检测并部署更新的 Cleanup Worker
|
||||||
|
|
||||||
|
4. 部署进度可以在仓库的 Actions 标签页查看
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
- 确保所有 Secrets 都已正确设置
|
||||||
|
- tag 必须以 `v` 开头(例如:v1.0.0)
|
||||||
|
- 只有推送 tag 才会触发部署,普通的 commit 不会触发
|
||||||
|
- 如果只修改了某个 worker,只会部署该 worker
|
||||||
|
|
||||||
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/beilunyang/moemail)
|
||||||
|
|
||||||
|
|
||||||
|
### 初次部署完成后
|
||||||
|
初次通过本地 Wrangler 或者 Github Actions 部署完成后,请登录到 Cloudflare 控制台,添加 AUTH 认证 相关 SECRETS
|
||||||
|
- 登录 [Cloudflare 控制台](https://dash.cloudflare.com/) 并选择你的账户
|
||||||
|
- 选择 Workers 和 Pages
|
||||||
|
- 在 Overview 中选择刚刚部署的 Cloudflare Pages
|
||||||
|
- 在 Settings 中选择变量和机密
|
||||||
|
- 添加 AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_SECRET
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Pull Request 或者 Issue来帮助改进这个项目
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目采用 [MIT](LICENSE) 许可证
|
||||||
|
|
||||||
|
## 交流群
|
||||||
|
<img src="https://pic.otaku.ren/20241215/AQADXcMxGyDw-FZ-.jpg" style="width: 400px;"/>
|
||||||
|
<br />
|
||||||
|
如二维码失效,请添加我的个人微信(hansenones),并备注 “MoeMail” 加入微信交流群
|
||||||
|
|
||||||
|
## 支持
|
||||||
|
|
||||||
|
如果你喜欢这个项目,欢迎给它一个 Star ⭐️
|
||||||
|
或者进行赞助
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<img src="https://pic.otaku.ren/20240212/AQADPrgxGwoIWFZ-.jpg" style="width: 400px;"/>
|
||||||
|
<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>
|
5
app/api/auth/[...auth]/route.ts
Normal file
5
app/api/auth/[...auth]/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { GET, POST } from "@/lib/auth"
|
||||||
|
|
||||||
|
export { GET, POST }
|
||||||
|
|
||||||
|
export const runtime = 'edge'
|
43
app/api/emails/[id]/[messageId]/route.ts
Normal file
43
app/api/emails/[id]/[messageId]/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { createDb } from "@/lib/db"
|
||||||
|
import { messages } from "@/lib/schema"
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string; messageId: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id, messageId } = await params
|
||||||
|
const db = createDb()
|
||||||
|
const message = await db.query.messages.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(messages.id, messageId),
|
||||||
|
eq(messages.emailId, id)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Message not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: {
|
||||||
|
id: message.id,
|
||||||
|
from_address: message.fromAddress,
|
||||||
|
subject: message.subject,
|
||||||
|
content: message.content,
|
||||||
|
html: message.html,
|
||||||
|
received_at: message.receivedAt.getTime()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch message:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch message" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
80
app/api/emails/[id]/route.ts
Normal file
80
app/api/emails/[id]/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { createDb } from "@/lib/db"
|
||||||
|
import { messages } from "@/lib/schema"
|
||||||
|
import { and, eq, lt, or, sql } from "drizzle-orm"
|
||||||
|
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const cursorStr = searchParams.get('cursor')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = createDb()
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const baseConditions = eq(messages.emailId, id)
|
||||||
|
|
||||||
|
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(messages)
|
||||||
|
.where(baseConditions)
|
||||||
|
const totalCount = Number(totalResult[0].count)
|
||||||
|
|
||||||
|
const conditions = [baseConditions]
|
||||||
|
|
||||||
|
if (cursorStr) {
|
||||||
|
const { timestamp, id } = decodeCursor(cursorStr)
|
||||||
|
conditions.push(
|
||||||
|
// @ts-expect-error "ignore the error"
|
||||||
|
or(
|
||||||
|
lt(messages.receivedAt, new Date(timestamp)),
|
||||||
|
and(
|
||||||
|
eq(messages.receivedAt, new Date(timestamp)),
|
||||||
|
lt(messages.id, id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
subject: msg.subject,
|
||||||
|
received_at: msg.receivedAt.getTime()
|
||||||
|
})),
|
||||||
|
nextCursor,
|
||||||
|
total: totalCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch messages:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch messages" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
85
app/api/emails/generate/route.ts
Normal file
85
app/api/emails/generate/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { createDb } from "@/lib/db"
|
||||||
|
import { emails } from "@/lib/schema"
|
||||||
|
import { eq, and, gt, sql } from "drizzle-orm"
|
||||||
|
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||||
|
import { EMAIL_CONFIG } from "@/config"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const db = createDb()
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check current number of active emails for user
|
||||||
|
const activeEmailsCount = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(emails)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(emails.userId, session!.user!.id!),
|
||||||
|
gt(emails.expiresAt, new Date())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Number(activeEmailsCount[0].count) >= EMAIL_CONFIG.MAX_ACTIVE_EMAILS) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Reached the maximum email limit (${EMAIL_CONFIG.MAX_ACTIVE_EMAILS})` },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, expiryTime } = await request.json<{
|
||||||
|
name: string
|
||||||
|
expiryTime: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Validate expiry time
|
||||||
|
if (!EXPIRY_OPTIONS.some(option => option.value === expiryTime)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid expiry time" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = `${name || nanoid(8)}@${EMAIL_CONFIG.DOMAIN}`
|
||||||
|
const existingEmail = await db.query.emails.findFirst({
|
||||||
|
where: eq(emails.address, address)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Email already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(now.getTime() + expiryTime)
|
||||||
|
|
||||||
|
const emailData: typeof emails.$inferInsert = {
|
||||||
|
address,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: expires,
|
||||||
|
userId: session!.user!.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.insert(emails)
|
||||||
|
.values(emailData)
|
||||||
|
.returning({ id: emails.id, address: emails.address })
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: result[0].id,
|
||||||
|
email: result[0].address
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate email:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate email" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
79
app/api/emails/route.ts
Normal file
79
app/api/emails/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { createDb } from "@/lib/db"
|
||||||
|
import { and, eq, gt, lt, or, sql } from "drizzle-orm"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { emails } from "@/lib/schema"
|
||||||
|
import { encodeCursor, decodeCursor } from "@/lib/cursor"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const cursor = searchParams.get('cursor')
|
||||||
|
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ emails: [], nextCursor: null, total: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = createDb()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseConditions = and(
|
||||||
|
eq(emails.userId, session.user.id!),
|
||||||
|
gt(emails.expiresAt, new Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalResult = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(emails)
|
||||||
|
.where(baseConditions)
|
||||||
|
const totalCount = Number(totalResult[0].count)
|
||||||
|
|
||||||
|
const conditions = [baseConditions]
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
const { timestamp, id } = decodeCursor(cursor)
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
lt(emails.createdAt, new Date(timestamp)),
|
||||||
|
and(
|
||||||
|
eq(emails.createdAt, new Date(timestamp)),
|
||||||
|
lt(emails.id, id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await db.query.emails.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: (emails, { desc }) => [
|
||||||
|
desc(emails.createdAt),
|
||||||
|
desc(emails.id)
|
||||||
|
],
|
||||||
|
limit: PAGE_SIZE + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasMore = results.length > PAGE_SIZE
|
||||||
|
const nextCursor = hasMore
|
||||||
|
? encodeCursor(
|
||||||
|
results[PAGE_SIZE - 1].createdAt.getTime(),
|
||||||
|
results[PAGE_SIZE - 1].id
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const emailList = hasMore ? results.slice(0, PAGE_SIZE) : results
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
emails: emailList,
|
||||||
|
nextCursor,
|
||||||
|
total: totalCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user emails:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch emails" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
44
app/components/auth/sign-button.tsx
Normal file
44
app/components/auth/sign-button.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { signIn, signOut, useSession } from "next-auth/react"
|
||||||
|
import { Github } from "lucide-react"
|
||||||
|
|
||||||
|
export function SignButton() {
|
||||||
|
const { data: session, status } = useSession()
|
||||||
|
const loading = status === "loading"
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="h-9" /> // 防止布局跳动
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return (
|
||||||
|
<Button onClick={() => signIn("github", { callbackUrl: "/moe" })} className="gap-2">
|
||||||
|
<Github className="w-4 h-4" />
|
||||||
|
使用 GitHub 登录
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{session.user.image && (
|
||||||
|
<Image
|
||||||
|
src={session.user.image}
|
||||||
|
alt={session.user.name || "用户头像"}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-sm">{session.user.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline">
|
||||||
|
登出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
150
app/components/emails/create-dialog.tsx
Normal file
150
app/components/emails/create-dialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
import { Plus, RefreshCw } from "lucide-react"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { EXPIRY_OPTIONS } from "@/types/email"
|
||||||
|
import { EMAIL_CONFIG } from "@/config"
|
||||||
|
|
||||||
|
interface CreateDialogProps {
|
||||||
|
onEmailCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [emailName, setEmailName] = useState("")
|
||||||
|
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString()) // Default to 24 hours
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const generateRandomName = () => setEmailName(nanoid(8))
|
||||||
|
|
||||||
|
const createEmail = async () => {
|
||||||
|
if (!emailName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: "请输入邮箱名",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/emails/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: emailName,
|
||||||
|
expiryTime: parseInt(expiryTime) // 确保转换为数字
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: "该邮箱名已被使用",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: "已达到最大邮箱数量限制",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to create email")
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "成功",
|
||||||
|
description: "已创建新的临时邮箱"
|
||||||
|
})
|
||||||
|
onEmailCreated()
|
||||||
|
setOpen(false)
|
||||||
|
setEmailName("")
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: "创建邮箱失败",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
创建新邮箱
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>创建新的临时邮箱</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={emailName}
|
||||||
|
onChange={(e) => setEmailName(e.target.value)}
|
||||||
|
placeholder="输入邮箱名"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={generateRandomName}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Label className="shrink-0 text-muted-foreground">过期时间</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>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
完整邮箱地址将为: {emailName ? `${emailName}@${EMAIL_CONFIG.DOMAIN}` : "..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={createEmail} disabled={loading}>
|
||||||
|
{loading ? "创建中..." : "创建"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
162
app/components/emails/email-list.tsx
Normal file
162
app/components/emails/email-list.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
|
import { CreateDialog } from "./create-dialog"
|
||||||
|
import { Mail, RefreshCw } 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"
|
||||||
|
|
||||||
|
interface Email {
|
||||||
|
id: string
|
||||||
|
address: string
|
||||||
|
createdAt: number
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailListProps {
|
||||||
|
onEmailSelect: (email: Email) => void
|
||||||
|
selectedEmailId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailResponse {
|
||||||
|
emails: Email[]
|
||||||
|
nextCursor: string | null
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const [emails, setEmails] = useState<Email[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
const fetchEmails = async (cursor?: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL("/api/emails", window.location.origin)
|
||||||
|
if (cursor) {
|
||||||
|
url.searchParams.set('cursor', cursor)
|
||||||
|
}
|
||||||
|
const response = await fetch(url)
|
||||||
|
const data = await response.json() as EmailResponse
|
||||||
|
|
||||||
|
if (!cursor) {
|
||||||
|
const newEmails = data.emails
|
||||||
|
const oldEmails = emails
|
||||||
|
|
||||||
|
const lastDuplicateIndex = newEmails.findIndex(
|
||||||
|
newEmail => oldEmails.some(oldEmail => oldEmail.id === newEmail.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lastDuplicateIndex === -1) {
|
||||||
|
setEmails(newEmails)
|
||||||
|
setNextCursor(data.nextCursor)
|
||||||
|
setTotal(data.total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const uniqueNewEmails = newEmails.slice(0, lastDuplicateIndex)
|
||||||
|
setEmails([...uniqueNewEmails, ...oldEmails])
|
||||||
|
setTotal(data.total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEmails(prev => [...prev, ...data.emails])
|
||||||
|
setNextCursor(data.nextCursor)
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch emails:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await fetchEmails()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
if (loadingMore) return
|
||||||
|
|
||||||
|
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
|
||||||
|
const threshold = clientHeight * 1.5
|
||||||
|
const remainingScroll = scrollHeight - scrollTop
|
||||||
|
|
||||||
|
if (remainingScroll <= threshold && nextCursor) {
|
||||||
|
setLoadingMore(true)
|
||||||
|
fetchEmails(nextCursor)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) fetchEmails()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session])
|
||||||
|
|
||||||
|
if (!session) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className={cn("h-8 w-8", refreshing && "animate-spin")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CreateDialog onEmailCreated={handleRefresh} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-sm text-gray-500">加载中...</div>
|
||||||
|
) : emails.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{emails.map(email => (
|
||||||
|
<div
|
||||||
|
key={email.id}
|
||||||
|
onClick={() => onEmailSelect(email)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 p-2 rounded cursor-pointer text-sm",
|
||||||
|
"hover:bg-primary/5",
|
||||||
|
selectedEmailId === email.id && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 text-primary/60" />
|
||||||
|
<div className="truncate flex-1">
|
||||||
|
<div className="font-medium truncate">{email.address}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
过期时间: {new Date(email.expiresAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-2">
|
||||||
|
加载更多...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
还没有邮箱,创建一个吧!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
196
app/components/emails/message-list.tsx
Normal file
196
app/components/emails/message-list.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { Mail, Calendar, RefreshCw } 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"
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
from_address: string
|
||||||
|
subject: string
|
||||||
|
received_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
email: {
|
||||||
|
id: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
onMessageSelect: (messageId: string) => void
|
||||||
|
selectedMessageId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageResponse {
|
||||||
|
messages: Message[]
|
||||||
|
nextCursor: string | null
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const pollTimeoutRef = useRef<NodeJS.Timeout>()
|
||||||
|
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
// 当 messages 改变时更新 ref
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const fetchMessages = async (cursor?: string) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(`/api/emails/${email.id}`, window.location.origin)
|
||||||
|
if (cursor) {
|
||||||
|
url.searchParams.set('cursor', cursor)
|
||||||
|
}
|
||||||
|
const response = await fetch(url)
|
||||||
|
const data = await response.json() as MessageResponse
|
||||||
|
|
||||||
|
if (!cursor) {
|
||||||
|
const newMessages = data.messages
|
||||||
|
const oldMessages = messagesRef.current
|
||||||
|
|
||||||
|
const lastDuplicateIndex = newMessages.findIndex(
|
||||||
|
newMsg => oldMessages.some(oldMsg => oldMsg.id === newMsg.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lastDuplicateIndex === -1) {
|
||||||
|
setMessages(newMessages)
|
||||||
|
setNextCursor(data.nextCursor)
|
||||||
|
setTotal(data.total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const uniqueNewMessages = newMessages.slice(0, lastDuplicateIndex)
|
||||||
|
setMessages([...uniqueNewMessages, ...oldMessages])
|
||||||
|
setTotal(data.total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, ...data.messages])
|
||||||
|
setNextCursor(data.nextCursor)
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch messages:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setRefreshing(false)
|
||||||
|
setLoadingMore(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 = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await fetchMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
if (loadingMore) return
|
||||||
|
|
||||||
|
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
|
||||||
|
const threshold = clientHeight * 1.5
|
||||||
|
const remainingScroll = scrollHeight - scrollTop
|
||||||
|
|
||||||
|
if (remainingScroll <= threshold && nextCursor) {
|
||||||
|
setLoadingMore(true)
|
||||||
|
fetchMessages(nextCursor)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!email.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setNextCursor(null)
|
||||||
|
fetchMessages()
|
||||||
|
startPolling()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [email.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="p-2 flex justify-between items-center border-b border-primary/20">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
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} 封邮件` : "暂无邮件"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">加载中...</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}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(message.received_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-2">
|
||||||
|
加载更多...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
|
暂无邮件
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
208
app/components/emails/message-view.tsx
Normal file
208
app/components/emails/message-view.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"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"
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
from_address: string
|
||||||
|
subject: string
|
||||||
|
content: string
|
||||||
|
html: string | null
|
||||||
|
received_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageViewProps {
|
||||||
|
emailId: string
|
||||||
|
messageId: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewMode = "html" | "text"
|
||||||
|
|
||||||
|
export function MessageView({ emailId, messageId }: MessageViewProps) {
|
||||||
|
const [message, setMessage] = useState<Message | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("html")
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMessage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/emails/${emailId}/${messageId}`)
|
||||||
|
const data = await response.json() as { message: Message }
|
||||||
|
setMessage(data.message)
|
||||||
|
if (!data.message.html) {
|
||||||
|
setViewMode("text")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch message:", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchMessage()
|
||||||
|
}, [emailId, messageId])
|
||||||
|
|
||||||
|
// 处理 iframe 内容
|
||||||
|
useEffect(() => {
|
||||||
|
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: ${document.documentElement.classList.contains('dark') ? '#fff' : '#000'};
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
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: ${document.documentElement.classList.contains('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')
|
||||||
|
? 'rgba(130, 109, 217, 0.5)'
|
||||||
|
: 'rgba(130, 109, 217, 0.4)'};
|
||||||
|
}
|
||||||
|
/* Firefox 滚动条 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: ${document.documentElement.classList.contains('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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [message?.html, viewMode])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
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="text-xs text-gray-500 space-y-1">
|
||||||
|
<p>发件人:{message.from_address}</p>
|
||||||
|
<p>时间:{new Date(message.received_at).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.html && (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
HTML 格式
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="text" id="text" />
|
||||||
|
<Label
|
||||||
|
htmlFor="text"
|
||||||
|
className="text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
纯文本格式
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-sm whitespace-pre-wrap">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
154
app/components/emails/three-column-layout.tsx
Normal file
154
app/components/emails/three-column-layout.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { EmailList } from "./email-list"
|
||||||
|
import { MessageList } from "./message-list"
|
||||||
|
import { MessageView } from "./message-view"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface Email {
|
||||||
|
id: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreeColumnLayout() {
|
||||||
|
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
|
||||||
|
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
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"
|
||||||
|
const titleClass = "text-sm font-bold px-2"
|
||||||
|
|
||||||
|
// 移动端视图逻辑
|
||||||
|
const getMobileView = () => {
|
||||||
|
if (selectedMessageId) return "message"
|
||||||
|
if (selectedEmail) return "emails"
|
||||||
|
return "list"
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileView = getMobileView()
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<EmailList
|
||||||
|
onEmailSelect={setSelectedEmail}
|
||||||
|
selectedEmailId={selectedEmail?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("col-span-4", columnClass)}>
|
||||||
|
<div className={headerClass}>
|
||||||
|
<h2 className={titleClass}>
|
||||||
|
{selectedEmail ? (
|
||||||
|
<span className="truncate block">{selectedEmail.address}</span>
|
||||||
|
) : (
|
||||||
|
"选择邮箱查看消息"
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{selectedEmail && (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<MessageList
|
||||||
|
email={selectedEmail}
|
||||||
|
onMessageSelect={setSelectedMessageId}
|
||||||
|
selectedMessageId={selectedMessageId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("col-span-5", columnClass)}>
|
||||||
|
<div className={headerClass}>
|
||||||
|
<h2 className={titleClass}>
|
||||||
|
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{selectedEmail && selectedMessageId && (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<MessageView
|
||||||
|
emailId={selectedEmail.id}
|
||||||
|
messageId={selectedMessageId}
|
||||||
|
onClose={() => setSelectedMessageId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端单栏布局 */}
|
||||||
|
<div className="lg:hidden h-full min-h-0">
|
||||||
|
<div className={cn("h-full", columnClass)}>
|
||||||
|
{mobileView === "list" && (
|
||||||
|
<>
|
||||||
|
<div className={headerClass}>
|
||||||
|
<h2 className={titleClass}>我的邮箱</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<EmailList
|
||||||
|
onEmailSelect={(email) => {
|
||||||
|
setSelectedEmail(email)
|
||||||
|
}}
|
||||||
|
selectedEmailId={selectedEmail?.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileView === "emails" && selectedEmail && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className={headerClass}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedEmail(null)
|
||||||
|
}}
|
||||||
|
className="text-sm text-primary"
|
||||||
|
>
|
||||||
|
← 返回邮箱列表
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{selectedEmail.address}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<MessageList
|
||||||
|
email={selectedEmail}
|
||||||
|
onMessageSelect={setSelectedMessageId}
|
||||||
|
selectedMessageId={selectedMessageId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileView === "message" && selectedEmail && selectedMessageId && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className={headerClass}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedMessageId(null)}
|
||||||
|
className="text-sm text-primary"
|
||||||
|
>
|
||||||
|
← 返回消息列表
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium">邮件内容</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<MessageView
|
||||||
|
emailId={selectedEmail.id}
|
||||||
|
messageId={selectedMessageId}
|
||||||
|
onClose={() => setSelectedMessageId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
38
app/components/home/action-button.tsx
Normal file
38
app/components/home/action-button.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Mail, Github } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { signIn } from "next-auth/react"
|
||||||
|
|
||||||
|
interface ActionButtonProps {
|
||||||
|
isLoggedIn?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionButton({ isLoggedIn }: ActionButtonProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => router.push("/moe")}
|
||||||
|
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
|
||||||
|
>
|
||||||
|
<Mail className="w-5 h-5" />
|
||||||
|
进入邮箱
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => signIn("github", { callbackUrl: "/moe" })}
|
||||||
|
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
|
||||||
|
>
|
||||||
|
<Github className="w-5 h-5" />
|
||||||
|
使用 GitHub 登录
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
21
app/components/home/feature-card.tsx
Normal file
21
app/components/home/feature-card.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
interface FeatureCardProps {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeatureCard({ icon, title, description }: FeatureCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded border-2 border-primary/20 hover:border-primary/40 transition-colors bg-white/5 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-primary/10 text-primary p-2">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-bold">{title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
19
app/components/layout/header.tsx
Normal file
19
app/components/layout/header.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { SignButton } from "@/components/auth/sign-button"
|
||||||
|
import { ThemeToggle } from "@/components/theme/theme-toggle"
|
||||||
|
import { Logo } from "@/components/ui/logo"
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 h-16 bg-background/80 backdrop-blur-sm border-b">
|
||||||
|
<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">
|
||||||
|
<ThemeToggle />
|
||||||
|
<SignButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
8
app/components/theme/theme-provider.tsx
Normal file
8
app/components/theme/theme-provider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
22
app/components/theme/theme-toggle.tsx
Normal file
22
app/components/theme/theme-toggle.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Moon, Sun } from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">切换主题</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
52
app/components/ui/button.tsx
Normal file
52
app/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
83
app/components/ui/dialog.tsx
Normal file
83
app/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose,
|
||||||
|
}
|
26
app/components/ui/input.tsx
Normal file
26
app/components/ui/input.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
22
app/components/ui/label.tsx
Normal file
22
app/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
65
app/components/ui/logo.tsx
Normal file
65
app/components/ui/logo.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export function Logo() {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="relative w-8 h-8">
|
||||||
|
<div className="absolute inset-0 grid grid-cols-8 grid-rows-8 gap-px">
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="text-primary"
|
||||||
|
>
|
||||||
|
{/* 信封主体 */}
|
||||||
|
<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="font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
|
||||||
|
MoeMail
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
44
app/components/ui/radio-group.tsx
Normal file
44
app/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
23
app/components/ui/toast-action.tsx
Normal file
23
app/components/ui/toast-action.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface ToastActionProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<HTMLButtonElement, ToastActionProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ToastAction.displayName = "ToastAction"
|
||||||
|
|
||||||
|
export { ToastAction }
|
112
app/components/ui/toast.tsx
Normal file
112
app/components/ui/toast.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { ToastAction } from "./toast-action"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
}
|
55
app/components/ui/toaster.tsx
Normal file
55
app/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "./toast"
|
||||||
|
import { useToast } from "./use-toast"
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
[key: string]: any;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
192
app/components/ui/use-toast.ts
Normal file
192
app/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
|
||||||
|
export type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_VALUE
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
5
app/config/index.ts
Normal file
5
app/config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const EMAIL_CONFIG = {
|
||||||
|
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
|
||||||
|
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
|
||||||
|
DOMAIN: 'moemail.app', // Email domain
|
||||||
|
} as const
|
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
7
app/fonts.ts
Normal file
7
app/fonts.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import localFont from 'next/font/local'
|
||||||
|
|
||||||
|
export const zpix = localFont({
|
||||||
|
src: '../public/fonts/zpix.ttf',
|
||||||
|
variable: '--font-zpix',
|
||||||
|
display: 'swap',
|
||||||
|
})
|
98
app/globals.css
Normal file
98
app/globals.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 255 65% 52%; /* #826DD9 */
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 255 65% 52%; /* #826DD9 */
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 255 65% 52%; /* #826DD9 */
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 255 65% 52%; /* #826DD9 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.bg-grid-primary {
|
||||||
|
background-image: linear-gradient(var(--primary) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--primary) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 整体滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条轨道 */
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条滑块 */
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-primary/20 rounded-full hover:bg-primary/40 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox 滚动条 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--primary) / 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式下的滚动条 */
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-primary/30 hover:bg-primary/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: hsl(var(--primary) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
}
|
19
app/hooks/use-throttle.ts
Normal file
19
app/hooks/use-throttle.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export function useThrottle<T extends (...args: any[]) => void>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): T {
|
||||||
|
const lastRun = useRef(Date.now())
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
((...args) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastRun.current >= delay) {
|
||||||
|
fn(...args)
|
||||||
|
lastRun.current = now
|
||||||
|
}
|
||||||
|
}) as T,
|
||||||
|
[fn, delay]
|
||||||
|
)
|
||||||
|
}
|
105
app/layout.tsx
Normal file
105
app/layout.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
27
app/lib/auth.ts
Normal file
27
app/lib/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import NextAuth from "next-auth"
|
||||||
|
import GitHub from "next-auth/providers/github"
|
||||||
|
import { DrizzleAdapter } from "@auth/drizzle-adapter"
|
||||||
|
import { createDb } from "./db"
|
||||||
|
import { accounts, sessions, users } from "./schema"
|
||||||
|
|
||||||
|
export const {
|
||||||
|
handlers: { GET, POST },
|
||||||
|
auth,
|
||||||
|
signIn,
|
||||||
|
signOut
|
||||||
|
} = NextAuth(() => {
|
||||||
|
return {
|
||||||
|
secret: process.env.AUTH_SECRET,
|
||||||
|
adapter: DrizzleAdapter(createDb(), {
|
||||||
|
usersTable: users,
|
||||||
|
accountsTable: accounts,
|
||||||
|
sessionsTable: sessions,
|
||||||
|
}),
|
||||||
|
providers: [
|
||||||
|
GitHub({
|
||||||
|
clientId: process.env.AUTH_GITHUB_ID,
|
||||||
|
clientSecret: process.env.AUTH_GITHUB_SECRET,
|
||||||
|
})
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
14
app/lib/cursor.ts
Normal file
14
app/lib/cursor.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
interface CursorData {
|
||||||
|
timestamp: number
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeCursor(timestamp: number, id: string): string {
|
||||||
|
const data: CursorData = { timestamp, id }
|
||||||
|
return Buffer.from(JSON.stringify(data)).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCursor(cursor: string): CursorData {
|
||||||
|
const data = JSON.parse(Buffer.from(cursor, 'base64').toString())
|
||||||
|
return data as CursorData
|
||||||
|
}
|
5
app/lib/db.ts
Normal file
5
app/lib/db.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { getRequestContext } from "@cloudflare/next-on-pages"
|
||||||
|
import { drizzle } from "drizzle-orm/d1"
|
||||||
|
import * as schema from "./schema"
|
||||||
|
|
||||||
|
export const createDb = () => drizzle(getRequestContext().env.DB, { schema })
|
69
app/lib/schema.ts
Normal file
69
app/lib/schema.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"
|
||||||
|
import type { AdapterAccountType } from "next-auth/adapters"
|
||||||
|
|
||||||
|
// https://authjs.dev/getting-started/adapters/drizzle
|
||||||
|
export const users = sqliteTable("user", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email").unique(),
|
||||||
|
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
|
||||||
|
image: text("image"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const accounts = sqliteTable(
|
||||||
|
"account",
|
||||||
|
{
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type").$type<AdapterAccountType>().notNull(),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
providerAccountId: text("providerAccountId").notNull(),
|
||||||
|
refresh_token: text("refresh_token"),
|
||||||
|
access_token: text("access_token"),
|
||||||
|
expires_at: integer("expires_at"),
|
||||||
|
token_type: text("token_type"),
|
||||||
|
scope: text("scope"),
|
||||||
|
id_token: text("id_token"),
|
||||||
|
session_state: text("session_state"),
|
||||||
|
},
|
||||||
|
(account) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [account.provider, account.providerAccountId],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sessions = sqliteTable("session", {
|
||||||
|
sessionToken: text("sessionToken").primaryKey(),
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const emails = sqliteTable("email", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
address: text("address").notNull().unique(),
|
||||||
|
userId: text("userId").references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
|
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(),
|
||||||
|
subject: text("subject").notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
html: text("html"),
|
||||||
|
receivedAt: integer("received_at", { mode: "timestamp_ms" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
})
|
6
app/lib/utils.ts
Normal file
6
app/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
25
app/moe/page.tsx
Normal file
25
app/moe/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Header } from "@/components/layout/header"
|
||||||
|
import { ThreeColumnLayout } from "@/components/emails/three-column-layout"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export const runtime = "edge"
|
||||||
|
|
||||||
|
export default async function MoePage() {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ThreeColumnLayout />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
59
app/page.tsx
Normal file
59
app/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
11
app/providers.tsx
Normal file
11
app/providers.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react"
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
)
|
||||||
|
}
|
10
app/types/email.ts
Normal file
10
app/types/email.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface ExpiryOption {
|
||||||
|
label: string
|
||||||
|
value: number // 过期时间(毫秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPIRY_OPTIONS: ExpiryOption[] = [
|
||||||
|
{ label: '1小时', value: 1000 * 60 * 60 },
|
||||||
|
{ label: '24小时', value: 1000 * 60 * 60 * 24 },
|
||||||
|
{ label: '3天', value: 1000 * 60 * 60 * 24 * 3 }
|
||||||
|
]
|
12
drizzle.config.ts
Normal file
12
drizzle.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Config } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
dialect: "sqlite",
|
||||||
|
driver: "d1-http",
|
||||||
|
schema: "./app/lib/schema.ts",
|
||||||
|
dbCredentials: {
|
||||||
|
accountId: "66c1853bb39df5aee672ecb562e702a3",
|
||||||
|
databaseId: "temp_mail_db",
|
||||||
|
token: "8832c0ab-630b-4319-8442-6d7808a99926"
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
58
drizzle/0000_hard_nick_fury.sql
Normal file
58
drizzle/0000_hard_nick_fury.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE `account` (
|
||||||
|
`userId` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`provider` text NOT NULL,
|
||||||
|
`providerAccountId` text NOT NULL,
|
||||||
|
`refresh_token` text,
|
||||||
|
`access_token` text,
|
||||||
|
`expires_at` integer,
|
||||||
|
`token_type` text,
|
||||||
|
`scope` text,
|
||||||
|
`id_token` text,
|
||||||
|
`session_state` text,
|
||||||
|
PRIMARY KEY(`provider`, `providerAccountId`),
|
||||||
|
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `email` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`address` text NOT NULL,
|
||||||
|
`userId` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `email_address_unique` ON `email` (`address`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `message` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`emailId` text NOT NULL,
|
||||||
|
`from_address` text NOT NULL,
|
||||||
|
`subject` text NOT NULL,
|
||||||
|
`content` text NOT NULL,
|
||||||
|
`received_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`emailId`) REFERENCES `email`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `session` (
|
||||||
|
`sessionToken` text PRIMARY KEY NOT NULL,
|
||||||
|
`userId` text NOT NULL,
|
||||||
|
`expires` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text,
|
||||||
|
`email` text,
|
||||||
|
`emailVerified` integer,
|
||||||
|
`image` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `verificationToken` (
|
||||||
|
`identifier` text NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`expires` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`identifier`, `token`)
|
||||||
|
);
|
1
drizzle/0001_tiresome_squadron_supreme.sql
Normal file
1
drizzle/0001_tiresome_squadron_supreme.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `message` ADD `html` text;
|
1
drizzle/0002_military_cobalt_man.sql
Normal file
1
drizzle/0002_military_cobalt_man.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE `verificationToken`;
|
397
drizzle/meta/0000_snapshot.json
Normal file
397
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "4b8667f7-6535-4de9-891b-e8889d2199e9",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"sessionToken": {
|
||||||
|
"name": "sessionToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"verificationToken": {
|
||||||
|
"name": "verificationToken",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verificationToken_identifier_token_pk": {
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"name": "verificationToken_identifier_token_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
404
drizzle/meta/0001_snapshot.json
Normal file
404
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b6fb222a-2134-41a3-a4ef-67aef0062f93",
|
||||||
|
"prevId": "4b8667f7-6535-4de9-891b-e8889d2199e9",
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"sessionToken": {
|
||||||
|
"name": "sessionToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"verificationToken": {
|
||||||
|
"name": "verificationToken",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verificationToken_identifier_token_pk": {
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"name": "verificationToken_identifier_token_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
365
drizzle/meta/0002_snapshot.json
Normal file
365
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "9f9802ad-fc03-4e1a-847e-8a73866a9f52",
|
||||||
|
"prevId": "b6fb222a-2134-41a3-a4ef-67aef0062f93",
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"sessionToken": {
|
||||||
|
"name": "sessionToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
27
drizzle/meta/_journal.json
Normal file
27
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1733032810249,
|
||||||
|
"tag": "0000_hard_nick_fury",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1733057860581,
|
||||||
|
"tag": "0001_tiresome_squadron_supreme",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1734184527968,
|
||||||
|
"tag": "0002_military_cobalt_man",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
21
middleware.ts
Normal file
21
middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function middleware() {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/api/emails/:path*",
|
||||||
|
]
|
||||||
|
}
|
30
next.config.ts
Normal file
30
next.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import withPWA from 'next-pwa'
|
||||||
|
import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
await setupDevPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup()
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'avatars.githubusercontent.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withPWA({
|
||||||
|
dest: 'public',
|
||||||
|
register: true,
|
||||||
|
skipWaiting: true,
|
||||||
|
disable: process.env.NODE_ENV === 'development',
|
||||||
|
// @ts-expect-error "ignore the error"
|
||||||
|
})(nextConfig);
|
60
package.json
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "moemail",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"build:pages": "npx @cloudflare/next-on-pages",
|
||||||
|
"db:migrate-local": "drizzle-kit generate && wrangler d1 migrations apply temp_mail_db --local",
|
||||||
|
"db:migrate-remote": "drizzle-kit generate && wrangler d1 migrations apply temp_mail_db --remote",
|
||||||
|
"generate-test-data": "wrangler dev scripts/generate-test-data.ts",
|
||||||
|
"dev:cleanup": "wrangler dev --config wrangler.cleanup.toml --test-scheduled",
|
||||||
|
"test:cleanup": "curl http://localhost:8787/__scheduled",
|
||||||
|
"deploy:email": "wrangler deploy --config wrangler.email.toml",
|
||||||
|
"deploy:cleanup": "wrangler deploy --config wrangler.cleanup.toml",
|
||||||
|
"deploy:pages": "npm run build:pages && wrangler pages deploy .vercel/output/static --branch master"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/drizzle-adapter": "^1.7.4",
|
||||||
|
"@cloudflare/next-on-pages": "^1.13.6",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.1",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
|
"lucide-react": "^0.363.0",
|
||||||
|
"nanoid": "^5.0.6",
|
||||||
|
"next": "15.0.3",
|
||||||
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"next-pwa": "^5.6.0",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
|
"postal-mime": "^2.3.2",
|
||||||
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"tailwind-merge": "^2.2.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20241127.0",
|
||||||
|
"@types/next-pwa": "^5.6.9",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"drizzle-kit": "^0.28.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "15.0.3",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vercel": "39.1.1",
|
||||||
|
"wrangler": "^3.91.0"
|
||||||
|
}
|
||||||
|
}
|
10386
pnpm-lock.yaml
generated
Normal file
10386
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
BIN
public/fonts/zpix.ttf
Normal file
BIN
public/fonts/zpix.ttf
Normal file
Binary file not shown.
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.6 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
23
public/manifest.json
Normal file
23
public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "MoeMail",
|
||||||
|
"short_name": "MoeMail",
|
||||||
|
"description": "萌萌哒临时邮箱服务",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#826DD9",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
76
scripts/generate-test-data.ts
Normal file
76
scripts/generate-test-data.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/d1'
|
||||||
|
import { emails, messages } from '../app/lib/schema'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { EMAIL_CONFIG} from '../app/config'
|
||||||
|
|
||||||
|
const TEST_USER_ID = '4e4c1d5d-a3c9-407a-8808-2a2424b38c62'
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
DB: D1Database
|
||||||
|
NEXT_PUBLIC_EMAIL_DOMAIN: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_EMAIL_COUNT = 5
|
||||||
|
const MAX_MESSAGE_COUNT = 50
|
||||||
|
const BATCH_SIZE = 10 // SQLite 变量限制
|
||||||
|
|
||||||
|
async function generateTestData(env: Env) {
|
||||||
|
const db = drizzle(env.DB)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 生成测试邮箱
|
||||||
|
const testEmails = Array.from({ length: MAX_EMAIL_COUNT }).map(() => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
address: `${nanoid(6)}@${EMAIL_CONFIG.DOMAIN}`,
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 插入测试邮箱
|
||||||
|
const emailResults = await db.insert(emails).values(testEmails).returning()
|
||||||
|
console.log('Created test emails:', emailResults)
|
||||||
|
|
||||||
|
// 为每个邮箱生成测试消息
|
||||||
|
for (const email of emailResults) {
|
||||||
|
const allMessages = Array.from({ length: MAX_MESSAGE_COUNT }).map((_, index) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
emailId: email.id,
|
||||||
|
fromAddress: `sender${index + 1}@example.com`,
|
||||||
|
subject: `Test Message ${index + 1} - ${nanoid(6)}`,
|
||||||
|
content: `This is test message ${index + 1} content.\n\nBest regards,\nSender ${index + 1}`,
|
||||||
|
html: `<div>
|
||||||
|
<h1>Test Message ${index + 1}</h1>
|
||||||
|
<p>This is test message ${index + 1} content.</p>
|
||||||
|
<p>With some <strong>HTML</strong> formatting.</p>
|
||||||
|
<br>
|
||||||
|
<p>Best regards,<br>Sender ${index + 1}</p>
|
||||||
|
</div>`,
|
||||||
|
receivedAt: new Date(now.getTime() - index * 60 * 60 * 1000),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 分批插入消息
|
||||||
|
for (let i = 0; i < allMessages.length; i += BATCH_SIZE) {
|
||||||
|
const batch = allMessages.slice(i, i + BATCH_SIZE)
|
||||||
|
await db.insert(messages).values(batch)
|
||||||
|
console.log(`Created batch of ${batch.length} messages for email ${email.address}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Test data generation completed successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate test data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env) {
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
await generateTestData(env)
|
||||||
|
return new Response('Test data generated successfully', { status: 200 })
|
||||||
|
}
|
||||||
|
return new Response('Method not allowed', { status: 405 })
|
||||||
|
}
|
||||||
|
}
|
84
tailwind.config.ts
Normal file
84
tailwind.config.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{ts,tsx}",
|
||||||
|
"./components/**/*.{ts,tsx}",
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['var(--font-zpix)'],
|
||||||
|
zpix: ['var(--font-zpix)'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||||
|
} satisfies Config;
|
||||||
|
|
||||||
|
export default config;
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./app/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
11
types.d.ts
vendored
Normal file
11
types.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface CloudflareEnv {
|
||||||
|
DB: D1Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Env = CloudflareEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Env }
|
72
workers/cleanup.ts
Normal file
72
workers/cleanup.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
interface Env {
|
||||||
|
DB: D1Database
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEANUP_CONFIG = {
|
||||||
|
// Whether to delete expired emails
|
||||||
|
DELETE_EXPIRED_EMAILS: false,
|
||||||
|
|
||||||
|
// Whether to delete messages from expired emails if not deleting the emails themselves
|
||||||
|
DELETE_MESSAGES_FROM_EXPIRED: true,
|
||||||
|
|
||||||
|
// Batch processing size
|
||||||
|
BATCH_SIZE: 100,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const main = {
|
||||||
|
async scheduled(event: ScheduledEvent, env: Env) {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find expired emails
|
||||||
|
const { results: expiredEmails } = await env.DB
|
||||||
|
.prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM email
|
||||||
|
WHERE expires_at < ?
|
||||||
|
LIMIT ?
|
||||||
|
`)
|
||||||
|
.bind(now, CLEANUP_CONFIG.BATCH_SIZE)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
if (!expiredEmails?.length) {
|
||||||
|
console.log('No expired emails found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredEmailIds = expiredEmails.map(email => email.id)
|
||||||
|
const placeholders = expiredEmailIds.map(() => '?').join(',')
|
||||||
|
|
||||||
|
if (CLEANUP_CONFIG.DELETE_EXPIRED_EMAILS) {
|
||||||
|
// First delete associated messages
|
||||||
|
await env.DB.prepare(`
|
||||||
|
DELETE FROM message
|
||||||
|
WHERE emailId IN (${placeholders})
|
||||||
|
`).bind(...expiredEmailIds).run()
|
||||||
|
|
||||||
|
// Then delete the emails
|
||||||
|
await env.DB.prepare(`
|
||||||
|
DELETE FROM email
|
||||||
|
WHERE id IN (${placeholders})
|
||||||
|
`).bind(...expiredEmailIds).run()
|
||||||
|
|
||||||
|
console.log(`Deleted ${expiredEmails.length} expired emails and their messages`)
|
||||||
|
} else if (CLEANUP_CONFIG.DELETE_MESSAGES_FROM_EXPIRED) {
|
||||||
|
// Only delete messages from expired emails
|
||||||
|
await env.DB.prepare(`
|
||||||
|
DELETE FROM message
|
||||||
|
WHERE emailId IN (${placeholders})
|
||||||
|
`).bind(...expiredEmailIds).run()
|
||||||
|
|
||||||
|
console.log(`Deleted messages from ${expiredEmails.length} expired emails`)
|
||||||
|
} else {
|
||||||
|
console.log('No cleanup actions performed (disabled in config)')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to cleanup:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default main
|
46
workers/email-receiver.ts
Normal file
46
workers/email-receiver.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Env } from '../types'
|
||||||
|
import { drizzle } from 'drizzle-orm/d1'
|
||||||
|
import { messages, emails } from '../app/lib/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import PostalMime from 'postal-mime'
|
||||||
|
|
||||||
|
|
||||||
|
const handleEmail = async (message: ForwardableEmailMessage, env: Env) => {
|
||||||
|
const db = drizzle(env.DB, { schema: { messages, emails } })
|
||||||
|
|
||||||
|
const parsedMessage = await PostalMime.parse(message.raw)
|
||||||
|
|
||||||
|
console.log("parsedMessage:", parsedMessage)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetEmail = await db.query.emails.findFirst({
|
||||||
|
where: eq(emails.address, message.to)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!targetEmail) {
|
||||||
|
console.error(`Email not found: ${message.to}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(messages).values({
|
||||||
|
// @ts-expect-error to fix
|
||||||
|
emailId: targetEmail.id,
|
||||||
|
fromAddress: message.from,
|
||||||
|
subject: parsedMessage.subject,
|
||||||
|
content: parsedMessage.text,
|
||||||
|
html: parsedMessage.html || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Email processed: ${parsedMessage.subject}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process email:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = {
|
||||||
|
async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
|
||||||
|
await handleEmail(message, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default worker
|
14
wrangler.cleanup.example.toml
Normal file
14
wrangler.cleanup.example.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "cleanup-worker"
|
||||||
|
main = "workers/cleanup.ts"
|
||||||
|
compatibility_date = "2024-03-20"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
# 每 1 小时运行一次
|
||||||
|
[triggers]
|
||||||
|
crons = ["0 * * * *"]
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
migrations_dir = "drizzle"
|
||||||
|
database_name = ""
|
||||||
|
database_id = ""
|
11
wrangler.email.example.toml
Normal file
11
wrangler.email.example.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
name = "email-receiver-worker"
|
||||||
|
compatibility_date = "2024-03-20"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
main = "workers/email-receiver.ts"
|
||||||
|
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
migrations_dir = "drizzle"
|
||||||
|
database_name = ""
|
||||||
|
database_id = ""
|
10
wrangler.example.toml
Normal file
10
wrangler.example.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name = "moemail"
|
||||||
|
compatibility_date = "2024-03-20"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
pages_build_output_dir = ".vercel/output/static"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
migrations_dir = "drizzle"
|
||||||
|
database_name = ""
|
||||||
|
database_id = ""
|
Reference in New Issue
Block a user