From cc7e5003c53a8355d04bcfaaf7da166654a0b1ac Mon Sep 17 00:00:00 2001 From: beilunyang <786220806@qq.com> Date: Mon, 16 Dec 2024 01:35:08 +0800 Subject: [PATCH] feat: Init --- .env.example | 3 + .eslintrc.json | 6 + .github/workflows/deploy.yml | 111 + .gitignore | 49 + LICENSE | 21 + README.md | 219 + app/api/auth/[...auth]/route.ts | 5 + app/api/emails/[id]/[messageId]/route.ts | 43 + app/api/emails/[id]/route.ts | 80 + app/api/emails/generate/route.ts | 85 + app/api/emails/route.ts | 79 + app/components/auth/sign-button.tsx | 44 + app/components/emails/create-dialog.tsx | 150 + app/components/emails/email-list.tsx | 162 + app/components/emails/message-list.tsx | 196 + app/components/emails/message-view.tsx | 208 + app/components/emails/three-column-layout.tsx | 154 + app/components/home/action-button.tsx | 38 + app/components/home/feature-card.tsx | 21 + app/components/layout/header.tsx | 19 + app/components/theme/theme-provider.tsx | 8 + app/components/theme/theme-toggle.tsx | 22 + app/components/ui/button.tsx | 52 + app/components/ui/dialog.tsx | 83 + app/components/ui/input.tsx | 26 + app/components/ui/label.tsx | 22 + app/components/ui/logo.tsx | 65 + app/components/ui/radio-group.tsx | 44 + app/components/ui/toast-action.tsx | 23 + app/components/ui/toast.tsx | 112 + app/components/ui/toaster.tsx | 55 + app/components/ui/use-toast.ts | 192 + app/config/index.ts | 5 + app/favicon.ico | Bin 0 -> 15406 bytes app/fonts.ts | 7 + app/globals.css | 98 + app/hooks/use-throttle.ts | 19 + app/layout.tsx | 105 + app/lib/auth.ts | 27 + app/lib/cursor.ts | 14 + app/lib/db.ts | 5 + app/lib/schema.ts | 69 + app/lib/utils.ts | 6 + app/moe/page.tsx | 25 + app/page.tsx | 59 + app/providers.tsx | 11 + app/types/email.ts | 10 + drizzle.config.ts | 12 + drizzle/0000_hard_nick_fury.sql | 58 + drizzle/0001_tiresome_squadron_supreme.sql | 1 + drizzle/0002_military_cobalt_man.sql | 1 + drizzle/meta/0000_snapshot.json | 397 + drizzle/meta/0001_snapshot.json | 404 + drizzle/meta/0002_snapshot.json | 365 + drizzle/meta/_journal.json | 27 + middleware.ts | 21 + next.config.ts | 30 + package.json | 60 + pnpm-lock.yaml | 10386 ++++++++++++++++ postcss.config.mjs | 8 + public/fonts/zpix.ttf | Bin 0 -> 7178504 bytes public/icons/icon-192x192.png | Bin 0 -> 6773 bytes public/icons/icon-512x512.png | Bin 0 -> 22033 bytes public/manifest.json | 23 + scripts/generate-test-data.ts | 76 + tailwind.config.ts | 84 + tsconfig.json | 27 + types.d.ts | 11 + workers/cleanup.ts | 72 + workers/email-receiver.ts | 46 + wrangler.cleanup.example.toml | 14 + wrangler.email.example.toml | 11 + wrangler.example.toml | 10 + 73 files changed, 15001 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/api/auth/[...auth]/route.ts create mode 100644 app/api/emails/[id]/[messageId]/route.ts create mode 100644 app/api/emails/[id]/route.ts create mode 100644 app/api/emails/generate/route.ts create mode 100644 app/api/emails/route.ts create mode 100644 app/components/auth/sign-button.tsx create mode 100644 app/components/emails/create-dialog.tsx create mode 100644 app/components/emails/email-list.tsx create mode 100644 app/components/emails/message-list.tsx create mode 100644 app/components/emails/message-view.tsx create mode 100644 app/components/emails/three-column-layout.tsx create mode 100644 app/components/home/action-button.tsx create mode 100644 app/components/home/feature-card.tsx create mode 100644 app/components/layout/header.tsx create mode 100644 app/components/theme/theme-provider.tsx create mode 100644 app/components/theme/theme-toggle.tsx create mode 100644 app/components/ui/button.tsx create mode 100644 app/components/ui/dialog.tsx create mode 100644 app/components/ui/input.tsx create mode 100644 app/components/ui/label.tsx create mode 100644 app/components/ui/logo.tsx create mode 100644 app/components/ui/radio-group.tsx create mode 100644 app/components/ui/toast-action.tsx create mode 100644 app/components/ui/toast.tsx create mode 100644 app/components/ui/toaster.tsx create mode 100644 app/components/ui/use-toast.ts create mode 100644 app/config/index.ts create mode 100644 app/favicon.ico create mode 100644 app/fonts.ts create mode 100644 app/globals.css create mode 100644 app/hooks/use-throttle.ts create mode 100644 app/layout.tsx create mode 100644 app/lib/auth.ts create mode 100644 app/lib/cursor.ts create mode 100644 app/lib/db.ts create mode 100644 app/lib/schema.ts create mode 100644 app/lib/utils.ts create mode 100644 app/moe/page.tsx create mode 100644 app/page.tsx create mode 100644 app/providers.tsx create mode 100644 app/types/email.ts create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_hard_nick_fury.sql create mode 100644 drizzle/0001_tiresome_squadron_supreme.sql create mode 100644 drizzle/0002_military_cobalt_man.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 middleware.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/fonts/zpix.ttf create mode 100644 public/icons/icon-192x192.png create mode 100644 public/icons/icon-512x512.png create mode 100644 public/manifest.json create mode 100644 scripts/generate-test-data.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types.d.ts create mode 100644 workers/cleanup.ts create mode 100644 workers/email-receiver.ts create mode 100644 wrangler.cleanup.example.toml create mode 100644 wrangler.email.example.toml create mode 100644 wrangler.example.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5191c3c --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +AUTH_GITHUB_ID = "" +AUTH_GITHUB_SECRET = "" +AUTH_SECRET = "" \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..d8d80b0 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..1993a83 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f19465b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f1c291 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b0e793 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +

+ MoeMail Logo +

MoeMail

+

+ +

+ 一个基于 NextJS + Cloudflare 技术栈构建的可爱临时邮箱服务🎉 +

+ +

+ 在线演示 • + 特性 • + 技术栈 • + 本地运行 • + 部署 • + 贡献 • + 许可证 • + 交流群 • + 支持 +

+ +## 在线演示 +[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) 许可证 + +## 交流群 + +
+如二维码失效,请添加我的个人微信(hansenones),并备注 “MoeMail” 加入微信交流群 + +## 支持 + +如果你喜欢这个项目,欢迎给它一个 Star ⭐️ +或者进行赞助 +
+
+ +
+
+Buy Me A Coffee diff --git a/app/api/auth/[...auth]/route.ts b/app/api/auth/[...auth]/route.ts new file mode 100644 index 0000000..995f3c8 --- /dev/null +++ b/app/api/auth/[...auth]/route.ts @@ -0,0 +1,5 @@ +import { GET, POST } from "@/lib/auth" + +export { GET, POST } + +export const runtime = 'edge' \ No newline at end of file diff --git a/app/api/emails/[id]/[messageId]/route.ts b/app/api/emails/[id]/[messageId]/route.ts new file mode 100644 index 0000000..52d6fea --- /dev/null +++ b/app/api/emails/[id]/[messageId]/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/emails/[id]/route.ts b/app/api/emails/[id]/route.ts new file mode 100644 index 0000000..e1316fb --- /dev/null +++ b/app/api/emails/[id]/route.ts @@ -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`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 } + ) + } +} \ No newline at end of file diff --git a/app/api/emails/generate/route.ts b/app/api/emails/generate/route.ts new file mode 100644 index 0000000..2d91531 --- /dev/null +++ b/app/api/emails/generate/route.ts @@ -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`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 } + ) + } +} \ No newline at end of file diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts new file mode 100644 index 0000000..ddad1f6 --- /dev/null +++ b/app/api/emails/route.ts @@ -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`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 } + ) + } +} \ No newline at end of file diff --git a/app/components/auth/sign-button.tsx b/app/components/auth/sign-button.tsx new file mode 100644 index 0000000..09f7a26 --- /dev/null +++ b/app/components/auth/sign-button.tsx @@ -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
// 防止布局跳动 + } + + if (!session?.user) { + return ( + + ) + } + + return ( +
+
+ {session.user.image && ( + {session.user.name + )} + {session.user.name} +
+ +
+ ) +} \ No newline at end of file diff --git a/app/components/emails/create-dialog.tsx b/app/components/emails/create-dialog.tsx new file mode 100644 index 0000000..aca9c4a --- /dev/null +++ b/app/components/emails/create-dialog.tsx @@ -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 ( + + + + + + + 创建新的临时邮箱 + +
+
+ setEmailName(e.target.value)} + placeholder="输入邮箱名" + className="flex-1" + /> + +
+ +
+ + + {EXPIRY_OPTIONS.map((option) => ( +
+ + +
+ ))} +
+
+ +
+ 完整邮箱地址将为: {emailName ? `${emailName}@${EMAIL_CONFIG.DOMAIN}` : "..."} +
+
+
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/emails/email-list.tsx b/app/components/emails/email-list.tsx new file mode 100644 index 0000000..5126b12 --- /dev/null +++ b/app/components/emails/email-list.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [nextCursor, setNextCursor] = useState(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) => { + 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 ( +
+
+
+ + + {total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱 + +
+ +
+ +
+ {loading ? ( +
加载中...
+ ) : emails.length > 0 ? ( +
+ {emails.map(email => ( +
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" + )} + > + +
+
{email.address}
+
+ 过期时间: {new Date(email.expiresAt).toLocaleString()} +
+
+
+ ))} + {loadingMore && ( +
+ 加载更多... +
+ )} +
+ ) : ( +
+ 还没有邮箱,创建一个吧! +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/app/components/emails/message-list.tsx b/app/components/emails/message-list.tsx new file mode 100644 index 0000000..7ab5054 --- /dev/null +++ b/app/components/emails/message-list.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [nextCursor, setNextCursor] = useState(null) + const [loadingMore, setLoadingMore] = useState(false) + const pollTimeoutRef = useRef() + const messagesRef = useRef([]) // 添加 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) => { + 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 ( +
+
+ + + {total > 0 ? `${total} 封邮件` : "暂无邮件"} + +
+ +
+ {loading ? ( +
加载中...
+ ) : messages.length > 0 ? ( +
+ {messages.map(message => ( +
onMessageSelect(message.id)} + className={cn( + "p-3 hover:bg-primary/5 cursor-pointer", + selectedMessageId === message.id && "bg-primary/10" + )} + > +
+ +
+

{message.subject}

+
+ {message.from_address} + + + {new Date(message.received_at).toLocaleString()} + +
+
+
+
+ ))} + {loadingMore && ( +
+ 加载更多... +
+ )} +
+ ) : ( +
+ 暂无邮件 +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/app/components/emails/message-view.tsx b/app/components/emails/message-view.tsx new file mode 100644 index 0000000..e4892af --- /dev/null +++ b/app/components/emails/message-view.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [viewMode, setViewMode] = useState("html") + const iframeRef = useRef(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(` + + + + + + + ${message.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 ( +
+ +
+ ) + } + + if (!message) return null + + return ( +
+
+

{message.subject}

+
+

发件人:{message.from_address}

+

时间:{new Date(message.received_at).toLocaleString()}

+
+
+ + {message.html && ( +
+ setViewMode(value as ViewMode)} + className="flex items-center gap-4" + > +
+ + +
+
+ + +
+
+
+ )} + +
+ {viewMode === "html" && message.html ? ( +