feat: Init

This commit is contained in:
beilunyang
2024-12-16 01:35:08 +08:00
commit cc7e5003c5
73 changed files with 15001 additions and 0 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
AUTH_GITHUB_ID = ""
AUTH_GITHUB_SECRET = ""
AUTH_SECRET = ""

6
.eslintrc.json Normal file
View 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
View 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
View 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
View 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
View 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)
![首页](https://pic.otaku.ren/20241209/AQADwsUxG9k1uVZ-.jpg "首页")
![邮箱](https://pic.otaku.ren/20241209/AQADw8UxG9k1uVZ-.jpg "邮箱")
## 特性
- 🔒 **隐私保护**:保护您的真实邮箱地址,远离垃圾邮件和不必要的订阅
-**实时收件**:自动轮询,即时接收邮件通知
- ⏱️ **灵活过期**:支持 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
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](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>

View File

@@ -0,0 +1,5 @@
import { GET, POST } from "@/lib/auth"
export { GET, POST }
export const runtime = 'edge'

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

7
app/fonts.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

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

View File

@@ -0,0 +1 @@
ALTER TABLE `message` ADD `html` text;

View File

@@ -0,0 +1 @@
DROP TABLE `verificationToken`;

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

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

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

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

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View 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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

23
public/manifest.json Normal file
View 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"
}
]
}

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

View 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 = ""

View 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
View 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 = ""