mirror of
https://github.com/beilunyang/moemail.git
synced 2025-09-26 19:41:22 +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