├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (main) │ ├── layout.tsx │ └── page.tsx ├── api │ └── send │ │ └── route.ts ├── favicon.ico ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── globals.css ├── layout.tsx └── mail │ └── page.tsx ├── components.json ├── components ├── layout │ └── email-layout.tsx ├── mail │ ├── mail-editor.tsx │ ├── mail-list-skeleton.tsx │ ├── mail-list.tsx │ ├── mail-view.tsx │ ├── mails-loading.tsx │ ├── mobile-mail-view.tsx │ └── trix-init.tsx ├── settings │ ├── create-address-dialog.tsx │ └── settings-dialog.tsx ├── sidebar │ ├── email-switcher.tsx │ ├── sidebar-nav.tsx │ └── sidebar.tsx └── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── label.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── toggle.tsx ├── hooks └── use-toast.ts ├── lib ├── email-parser.js ├── http-client.ts ├── mail-parser.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── favicon.ico └── logo.png ├── screenshots ├── s1.png └── s2.png ├── store ├── use-address.ts ├── use-auth.ts ├── use-mail-view.ts ├── use-mails.ts ├── use-settings.ts └── use-sidebar.ts ├── styles └── quill.css ├── tailwind.config.ts ├── tsconfig.json └── types ├── react-quill.d.ts ├── react-trix.d.ts └── trix.d.ts /.env.example: -------------------------------------------------------------------------------- 1 | # API基础URL 2 | NEXT_PUBLIC_API_BASE_URL=https://your-api-url.com 3 | 4 | # 认证密码 5 | NEXT_PUBLIC_AUTH_PASSWORD=your-password -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@typescript-eslint/no-explicit-any": "warn", 5 | "@typescript-eslint/no-unused-vars": ["error", { 6 | "argsIgnorePattern": "^_", 7 | "varsIgnorePattern": "^_" 8 | }] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare邮箱管 2 | 3 | 之前使用了[cloudflare_temp_email](https://github.com/dreamhunter2333/cloudflare_temp_email)部署了一个cloudflare邮箱,但是前端管理页面不是特别符合我的使用习惯,尤其是移动端,邮件查看基本没法用。所以用cursor撸了一个适合自己使用习惯的前端页面出来。 4 | 5 | ## 前置要求 6 | 使用cloudflare_temp_email后端,[点击这里查看部署教程](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html),部署好之后在使用本项目。 7 | 8 | ## 体验地址 9 | 10 | [https://cloudflare-email.vercel.app/](https://cloudflare-email.vercel.app/) 11 | 12 | ### 参数配置 13 | API地址: 14 | ![API地址](./screenshots/s1.png) 15 | 16 | 认证令牌: 17 | ![认证令牌](./screenshots/s2.png) 18 | 19 | ## Vercel部署 20 | 21 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/jiangnan1224/cloudflare-email) 22 | 23 | 24 | 25 | ## 特色功能 26 | 27 | ### 邮件管理 28 | - 🔄 实时邮件接收(30秒自动刷新) 29 | - 📍 新邮件红点提示 30 | - 🔍 邮件内容预览 31 | - 📎 附件查看和下载 32 | - 💨 无限滚动加载更多邮件 33 | 34 | ### 用户体验 35 | - 📱 响应式设计,支持移动端 36 | - 👆 移动端支持滑动返回 37 | - ⚡️ 快速切换邮箱账号 38 | - 🔒 安全的 HTML 内容渲染 39 | 40 | ### 支持resend发送邮件 41 | - ✨ 配置resend的api key即可发送邮件 42 | 43 | ## 部署指南 44 | 45 | ### 环境要求 46 | - Node.js 18+ 47 | - 支持 WebAssembly 的环境 48 | 49 | ### 本地开发 50 | 51 | ```bash 52 | # 安装依赖 53 | pnpm install 54 | 55 | # 开发环境运行 56 | pnpm dev 57 | 58 | # 生产环境构建 59 | pnpm build 60 | pnpm start 61 | ``` 62 | 63 | ### Vercel 部署 64 | 65 | 点击上方的 "Deploy with Vercel" 按钮,然后: 66 | 67 | 1. 连接你的 GitHub 仓库 68 | 2. 配置环境变量 69 | 3. 部署完成后即可访问 70 | 71 | ## 技术栈 72 | 73 | - Next.js 14 74 | - TypeScript 75 | - Tailwind CSS 76 | - Framer Motion 77 | - SWR 78 | - Zustand 79 | - shadcn/ui 80 | 81 | ## 开源协议 82 | 83 | MIT License 84 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import EmailLayout from "@/components/layout/email-layout" 2 | 3 | export default function MainLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }) { 8 | return {children} 9 | } -------------------------------------------------------------------------------- /app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState, useCallback, useRef } from "react" 4 | import { HttpClient, Mail, MailsResponse } from "@/lib/http-client" 5 | import { MailList } from "@/components/mail/mail-list" 6 | import { Inbox, Loader2, Settings as SettingsIcon } from "lucide-react" 7 | import { useAddressStore } from "@/store/use-address" 8 | import { motion, AnimatePresence } from "framer-motion" 9 | import MailEditor from "@/components/mail/mail-editor" 10 | import { useMailView } from "@/store/use-mail-view" 11 | import { useSettings } from "@/store/use-settings" 12 | import { Button } from "@/components/ui/button" 13 | 14 | export default function MailContent() { 15 | const { view } = useMailView() 16 | const [mails, setMails] = useState([]) 17 | const [loading, setLoading] = useState(true) 18 | const [loadingMore, setLoadingMore] = useState(false) 19 | const [error, setError] = useState(null) 20 | const [hasMore, setHasMore] = useState(true) 21 | const offsetRef = useRef(0) 22 | const currentAddress = useAddressStore(state => state.currentAddress) 23 | const { apiBaseUrl, authToken } = useSettings() 24 | 25 | const fetchMails = useCallback(async (isLoadingMore = false) => { 26 | // 如果未配置 API,不执行请求 27 | if (!apiBaseUrl || !authToken) { 28 | setLoading(false) 29 | return 30 | } 31 | 32 | try { 33 | if (isLoadingMore) { 34 | setLoadingMore(true) 35 | } else { 36 | setLoading(true) 37 | } 38 | 39 | const data: MailsResponse = currentAddress?.id === -1 40 | ? await HttpClient.getAllMails(20, isLoadingMore ? offsetRef.current : 0) 41 | : await HttpClient.getMails( 42 | currentAddress!.name, 43 | 20, 44 | isLoadingMore ? offsetRef.current : 0 45 | ) 46 | 47 | if (!data) { 48 | throw new Error('Failed to fetch mails') 49 | } 50 | 51 | const newHasMore = data.items.length === 20 52 | setHasMore(newHasMore) 53 | 54 | if (isLoadingMore) { 55 | setMails(prev => [...prev, ...data.items]) 56 | offsetRef.current += 20 57 | } else { 58 | setMails(data.items) 59 | offsetRef.current = newHasMore ? 20 : 0 60 | } 61 | 62 | } catch (error) { 63 | console.error('Failed to fetch mails:', error) 64 | setError("加载邮件失败") 65 | } finally { 66 | setLoading(false) 67 | setLoadingMore(false) 68 | } 69 | }, [currentAddress, apiBaseUrl, authToken]) 70 | 71 | useEffect(() => { 72 | if (!currentAddress) return 73 | if (!apiBaseUrl || !authToken) return 74 | 75 | setMails([]) 76 | offsetRef.current = 0 77 | setHasMore(true) 78 | setError(null) 79 | 80 | fetchMails() 81 | }, [currentAddress, fetchMails, apiBaseUrl, authToken]) 82 | 83 | // 渲染主要内容 84 | const renderContent = () => { 85 | switch (view) { 86 | case 'compose': 87 | return ( 88 |
89 |

写邮件

90 | 91 |
92 | ) 93 | case 'inbox': 94 | // 如果未配置 API,显示配置提示 95 | if (!apiBaseUrl || !authToken) { 96 | return ( 97 | 103 | 104 |

请先在设置中配置 API 地址和令牌

105 | 114 |
115 | ) 116 | } 117 | 118 | if (loading) { 119 | return ( 120 | 126 | 127 |

加载邮件中...

128 |
129 | ) 130 | } 131 | 132 | if (error) { 133 | return ( 134 | 140 | {error} 141 | 142 | ) 143 | } 144 | 145 | if (!mails || mails.length === 0) { 146 | return ( 147 | 153 | 154 |

没有邮件

155 |
156 | ) 157 | } 158 | 159 | return ( 160 | 161 | 169 | fetchMails(true)} 174 | /> 175 | 176 | 177 | ) 178 | default: 179 | return null 180 | } 181 | } 182 | 183 | return ( 184 |
185 | {renderContent()} 186 |
187 | ) 188 | } -------------------------------------------------------------------------------- /app/api/send/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { Resend } from 'resend' 3 | 4 | export async function POST(request: Request) { 5 | try { 6 | // 从环境变量或请求头中获取 API key 7 | const resendApiKey = request.headers.get('x-resend-api-key') 8 | if (!resendApiKey) { 9 | return NextResponse.json( 10 | { error: 'Resend API Key is required' }, 11 | { status: 400 } 12 | ) 13 | } 14 | 15 | const resend = new Resend(resendApiKey) 16 | const body = await request.json() 17 | 18 | try { 19 | const data = await resend.emails.send({ 20 | from: body.from, // 使用传入的发件人地址 21 | to: body.to, 22 | subject: body.subject, 23 | html: body.html, 24 | attachments: body.attachments 25 | }) 26 | 27 | console.log('Resend API response:', data) 28 | return NextResponse.json(data) 29 | } catch (sendError: any) { 30 | // 记录详细的发送错误 31 | console.error('Resend API error:', { 32 | message: sendError.message, 33 | response: sendError.response, 34 | statusCode: sendError.statusCode, 35 | data: sendError.data 36 | }) 37 | 38 | return NextResponse.json( 39 | { 40 | error: 'Failed to send email', 41 | details: sendError.message, 42 | statusCode: sendError.statusCode, 43 | data: sendError.data 44 | }, 45 | { status: sendError.statusCode || 500 } 46 | ) 47 | } 48 | } catch (error: any) { 49 | console.error('General error:', error) 50 | return NextResponse.json( 51 | { 52 | error: 'Server error', 53 | message: error.message 54 | }, 55 | { status: 500 } 56 | ) 57 | } 58 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/app/favicon.ico -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer utilities { 10 | .text-balance { 11 | text-wrap: balance; 12 | } 13 | .safe-top { 14 | padding-top: env(safe-area-inset-top, 0px); 15 | } 16 | } 17 | 18 | @layer base { 19 | :root { 20 | --background: 0 0% 100%; 21 | --foreground: 0 0% 3.9%; 22 | --card: 0 0% 100%; 23 | --card-foreground: 0 0% 3.9%; 24 | --popover: 0 0% 100%; 25 | --popover-foreground: 0 0% 3.9%; 26 | --primary: 0 0% 9%; 27 | --primary-foreground: 0 0% 98%; 28 | --secondary: 0 0% 96.1%; 29 | --secondary-foreground: 0 0% 9%; 30 | --muted: 0 0% 96.1%; 31 | --muted-foreground: 0 0% 45.1%; 32 | --accent: 0 0% 96.1%; 33 | --accent-foreground: 0 0% 9%; 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 0 0% 98%; 36 | --border: 0 0% 89.8%; 37 | --input: 0 0% 89.8%; 38 | --ring: 0 0% 3.9%; 39 | --chart-1: 12 76% 61%; 40 | --chart-2: 173 58% 39%; 41 | --chart-3: 197 37% 24%; 42 | --chart-4: 43 74% 66%; 43 | --chart-5: 27 87% 67%; 44 | --radius: 0.5rem; 45 | } 46 | .dark { 47 | --background: 0 0% 3.9%; 48 | --foreground: 0 0% 98%; 49 | --card: 0 0% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | --popover: 0 0% 3.9%; 52 | --popover-foreground: 0 0% 98%; 53 | --primary: 0 0% 98%; 54 | --primary-foreground: 0 0% 9%; 55 | --secondary: 0 0% 14.9%; 56 | --secondary-foreground: 0 0% 98%; 57 | --muted: 0 0% 14.9%; 58 | --muted-foreground: 0 0% 63.9%; 59 | --accent: 0 0% 14.9%; 60 | --accent-foreground: 0 0% 98%; 61 | --destructive: 0 62.8% 30.6%; 62 | --destructive-foreground: 0 0% 98%; 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | --chart-1: 220 70% 50%; 67 | --chart-2: 160 60% 45%; 68 | --chart-3: 30 80% 55%; 69 | --chart-4: 280 65% 60%; 70 | --chart-5: 340 75% 55%; 71 | } 72 | } 73 | 74 | @layer base { 75 | * { 76 | @apply border-border; 77 | } 78 | body { 79 | @apply bg-background text-foreground; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Toaster } from "@/components/ui/toaster" 5 | import '@/styles/quill.css' 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "CloudFlare邮件系统", 11 | description: "CloudFlare邮件系统", 12 | appleWebApp: { 13 | capable: true, 14 | statusBarStyle: 'default', 15 | title: '临时邮箱', 16 | startupImage: [ 17 | // 可以为不同设备尺寸提供启动图 18 | // '/splash/launch.png', 19 | ], 20 | }, 21 | }; 22 | 23 | export const viewport: Viewport = { 24 | width: 'device-width', 25 | initialScale: 1, 26 | maximumScale: 1, 27 | userScalable: false, 28 | viewportFit: 'cover', 29 | themeColor: '#ffffff', 30 | }; 31 | 32 | export default function RootLayout({ 33 | children, 34 | }: Readonly<{ 35 | children: React.ReactNode 36 | }>) { 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 52 | 53 | 54 | {/* 根据路径条件渲染布局 */} 55 | {children} 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/mail/page.tsx: -------------------------------------------------------------------------------- 1 | import MailEditor from '@/components/mail/mail-editor' 2 | 3 | export default function MailPage() { 4 | return ( 5 |
6 |

写邮件

7 | 8 |
9 | ) 10 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/layout/email-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Sidebar } from "@/components/sidebar/sidebar" 4 | import { MenuIcon } from "lucide-react" 5 | import { Button } from "@/components/ui/button" 6 | import { useSidebar } from "@/store/use-sidebar" 7 | import { cn } from "@/lib/utils" 8 | import Image from "next/image" 9 | 10 | interface EmailLayoutProps { 11 | children: React.ReactNode 12 | } 13 | 14 | export default function EmailLayout({ children }: EmailLayoutProps) { 15 | const { isOpen, toggle } = useSidebar() 16 | 17 | return ( 18 |
19 | {/* 统一的顶部区域 */} 20 |
21 | 29 |
30 |
31 | Logo 38 |
39 |

CloudFlare邮件系统

40 |
41 |
42 | 43 | {/* 主体内容区域 */} 44 |
45 | {/* 侧边栏 */} 46 |
52 | 53 |
54 | 55 | {/* 遮罩层 */} 56 | {isOpen && ( 57 |
61 | )} 62 | 63 | {/* 主内容区 */} 64 |
65 | {children} 66 |
67 |
68 |
69 | ) 70 | } -------------------------------------------------------------------------------- /components/mail/mail-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { Input } from '@/components/ui/input' 5 | import { useState, useRef } from 'react' 6 | import dynamic from 'next/dynamic' 7 | import { Label } from '@/components/ui/label' 8 | import { Paperclip, Send } from 'lucide-react' 9 | import { useSettings } from "@/store/use-settings" 10 | import { useToast } from "@/hooks/use-toast" 11 | import 'react-quill/dist/quill.snow.css' 12 | import { useAddressStore } from "@/store/use-address" 13 | 14 | // 动态导入 ReactQuill 以避免 SSR 问题 15 | const ReactQuill = dynamic(() => import('react-quill'), { 16 | ssr: false, 17 | loading: () =>
18 | }) 19 | 20 | const modules = { 21 | toolbar: { 22 | container: [ 23 | [{ 'size': ['small', false, 'large', 'huge'] }], 24 | ['bold', 'italic', 'underline', 'strike'], 25 | [{ 'list': 'ordered'}, { 'list': 'bullet' }], 26 | [{ 'align': [] }], 27 | [{ 'color': [] }, { 'background': [] }], 28 | ['link', 'image'], 29 | ['clean'] 30 | ], 31 | handlers: { 32 | image: function(this: { quill: any }) { 33 | const quill = this.quill; 34 | const input = document.createElement('input'); 35 | input.setAttribute('type', 'file'); 36 | input.setAttribute('accept', 'image/*'); 37 | input.click(); 38 | 39 | input.onchange = () => { 40 | const file = input.files?.[0]; 41 | if (file) { 42 | const reader = new FileReader(); 43 | reader.onload = (e) => { 44 | const range = quill.getSelection(true); 45 | quill.insertEmbed(range.index, 'image', e.target?.result); 46 | }; 47 | reader.readAsDataURL(file); 48 | } 49 | }; 50 | } 51 | } 52 | } 53 | } 54 | 55 | const formats = [ 56 | 'header', 57 | 'bold', 'italic', 'underline', 'strike', 58 | 'size', 59 | 'list', 'bullet', 60 | 'align', 61 | 'color', 'background', 62 | 'link', 'image' 63 | ] 64 | 65 | export default function MailEditor() { 66 | const [content, setContent] = useState('') 67 | const [to, setTo] = useState('') 68 | const [subject, setSubject] = useState('') 69 | const [files, setFiles] = useState([]) 70 | const [sending, setSending] = useState(false) 71 | const fileInputRef = useRef(null) 72 | const { resendApiKey } = useSettings() 73 | const { toast } = useToast() 74 | const currentAddress = useAddressStore(state => state.currentAddress) 75 | 76 | const handleSend = async () => { 77 | // 表单验证 78 | if (!to) { 79 | toast({ title: "错误", description: "请输入收件人邮箱", variant: "destructive" }) 80 | return 81 | } 82 | if (!subject) { 83 | toast({ title: "错误", description: "请输入邮件主题", variant: "destructive" }) 84 | return 85 | } 86 | if (!content) { 87 | toast({ title: "错误", description: "请输入邮件内容", variant: "destructive" }) 88 | return 89 | } 90 | if (!resendApiKey) { 91 | toast({ title: "错误", description: "请先在设置中配置 Resend API Key", variant: "destructive" }) 92 | return 93 | } 94 | if (!currentAddress) { 95 | toast({ title: "错误", description: "请先选择发件人邮箱", variant: "destructive" }) 96 | return 97 | } 98 | if (currentAddress.id === -1) { 99 | toast({ title: "错误", description: "请选择一个具体的发件邮箱", variant: "destructive" }) 100 | return 101 | } 102 | 103 | try { 104 | setSending(true) 105 | 106 | // 处理 Quill HTML 内容 107 | const emailContent = ` 108 | 109 | 110 | 111 | 112 | 203 | 204 | 205 |
206 | ${content} 207 |
208 | 209 | 210 | ` 211 | 212 | // 处理附件 213 | const attachments = await Promise.all( 214 | files.map(async (file) => { 215 | const base64 = await new Promise((resolve) => { 216 | const reader = new FileReader() 217 | reader.onloadend = () => { 218 | const base64String = reader.result as string 219 | resolve(base64String.split(',')[1]) // 移除 data:image/jpeg;base64, 前缀 220 | } 221 | reader.readAsDataURL(file) 222 | }) 223 | 224 | return { 225 | filename: file.name, 226 | content: base64 227 | } 228 | }) 229 | ) 230 | 231 | // 发送邮件 232 | const response = await fetch('/api/send', { 233 | method: 'POST', 234 | headers: { 235 | 'Content-Type': 'application/json', 236 | 'x-resend-api-key': resendApiKey 237 | }, 238 | body: JSON.stringify({ 239 | from: currentAddress.name, 240 | to, 241 | subject, 242 | html: emailContent, // 使用处理后的 HTML 243 | attachments 244 | }), 245 | }) 246 | 247 | const data = await response.json() 248 | 249 | if (!response.ok) { 250 | console.error('Send email response:', data) 251 | throw new Error(data.details || data.error || '发送失败') 252 | } 253 | 254 | toast({ title: "成功", description: "邮件已发送" }) 255 | 256 | // 清空表单 257 | setTo('') 258 | setSubject('') 259 | setContent('') 260 | setFiles([]) 261 | 262 | } catch (error: any) { 263 | console.error('发送邮件失败:', error) 264 | toast({ 265 | title: "错误", 266 | description: error.message || "发送邮件失败,请稍后重试", 267 | variant: "destructive" 268 | }) 269 | } finally { 270 | setSending(false) 271 | } 272 | } 273 | 274 | const handleFileSelect = (e: React.ChangeEvent) => { 275 | if (e.target.files) { 276 | setFiles(Array.from(e.target.files)) 277 | } 278 | } 279 | 280 | return ( 281 |
282 |
283 | {/* 发件人 */} 284 |
285 | 286 | 292 |
293 | 294 | {/* 收件人 */} 295 |
296 | 297 | setTo(e.target.value)} 302 | /> 303 |
304 | 305 | {/* 主题 */} 306 |
307 | 308 | setSubject(e.target.value)} 313 | /> 314 |
315 |
316 | 317 | {/* 编辑器 */} 318 |
319 | 332 |
333 | 334 | {/* 操作按钮 */} 335 |
336 |
337 | 344 | 352 | {files.length > 0 && ( 353 | 354 | 已选择 {files.length} 个文件 355 | 356 | )} 357 |
358 | 365 |
366 |
367 | ) 368 | } -------------------------------------------------------------------------------- /components/mail/mail-list-skeleton.tsx: -------------------------------------------------------------------------------- 1 | export function MailListSkeleton() { 2 | return ( 3 |
4 | {Array.from({ length: 5 }).map((_, i) => ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ))} 13 |
14 | ) 15 | } -------------------------------------------------------------------------------- /components/mail/mail-list.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Mail, HttpClient } from "@/lib/http-client" 4 | import { formatDistanceToNow, addHours } from "date-fns" 5 | import { zhCN } from "date-fns/locale" 6 | import { Loader2 } from "lucide-react" 7 | import { useEffect, useRef, useState } from "react" 8 | import { MailView } from "./mail-view" 9 | import { MobileMailView } from "./mobile-mail-view" 10 | import { cn } from "@/lib/utils" 11 | import { motion, AnimatePresence } from "framer-motion" 12 | import { Bell } from "lucide-react" 13 | import { useToast } from "@/hooks/use-toast" 14 | import { useAddressStore } from "@/store/use-address" 15 | 16 | interface MailListProps { 17 | mails: Mail[] 18 | hasMore: boolean 19 | loadingMore: boolean 20 | onLoadMore: () => void 21 | } 22 | 23 | export function MailList({ mails: initialMails, hasMore, loadingMore, onLoadMore }: MailListProps) { 24 | const { currentAddress } = useAddressStore() 25 | const [mails, setMails] = useState(initialMails) 26 | const [newMailIds, setNewMailIds] = useState>(new Set()) 27 | const { toast } = useToast() 28 | const lastMailId = useRef(mails[0]?.id || 0) 29 | const [selectedMail, setSelectedMail] = useState(null) 30 | const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024 31 | const listRef = useRef(null) 32 | const loadingRef = useRef(null) 33 | 34 | useEffect(() => { 35 | setMails(initialMails) 36 | }, [initialMails]) 37 | 38 | useEffect(() => { 39 | if (!hasMore || loadingMore) return 40 | 41 | const observer = new IntersectionObserver( 42 | ([entry]) => { 43 | if (entry.isIntersecting) { 44 | onLoadMore() 45 | } 46 | }, 47 | { 48 | root: listRef.current, 49 | rootMargin: '100px', 50 | threshold: 0.1 51 | } 52 | ) 53 | 54 | if (loadingRef.current) { 55 | observer.observe(loadingRef.current) 56 | } 57 | 58 | return () => observer.disconnect() 59 | }, [hasMore, loadingMore, onLoadMore]) 60 | 61 | useEffect(() => { 62 | const checkNewMails = async () => { 63 | try { 64 | const response = currentAddress?.id === -1 65 | ? await HttpClient.getAllMails(20, 0) 66 | : await HttpClient.getMails(currentAddress?.id.toString() || '0', 20, 0) 67 | 68 | const newMails = response.items 69 | 70 | if (newMails[0]?.id > lastMailId.current) { 71 | const newMailsToAdd = newMails.filter(mail => mail.id > lastMailId.current) 72 | 73 | lastMailId.current = newMails[0].id 74 | 75 | const newIds = new Set(Array.from(newMailIds)) 76 | newMailsToAdd.forEach(mail => newIds.add(mail.id)) 77 | setNewMailIds(newIds) 78 | 79 | setMails(prev => [...newMailsToAdd, ...prev]) 80 | 81 | if (newMailsToAdd.length > 0) { 82 | toast({ 83 | description: `收到 ${newMailsToAdd.length} 封新邮件`, 84 | }) 85 | } 86 | } 87 | } catch (error) { 88 | console.error('Failed to check new mails:', error) 89 | } 90 | } 91 | 92 | const interval = setInterval(checkNewMails, 30000) 93 | return () => clearInterval(interval) 94 | }, [toast, newMailIds, currentAddress]) 95 | 96 | const handleMailClick = (mail: Mail) => { 97 | setSelectedMail(mail) 98 | if (newMailIds.has(mail.id)) { 99 | const updatedIds = new Set(Array.from(newMailIds)) 100 | updatedIds.delete(mail.id) 101 | setNewMailIds(updatedIds) 102 | } 103 | } 104 | 105 | return ( 106 |
107 | {/* 邮件列表 */} 108 | 118 |
119 | 120 | {mails.map((mail) => ( 121 | 128 |
handleMailClick(mail)} 131 | > 132 | 153 | {newMailIds.has(mail.id) && ( 154 |
155 | )} 156 |
157 | 158 | ))} 159 | 160 | 161 | {hasMore && ( 162 |
166 | {loadingMore ? ( 167 |
168 | 169 | 加载更多... 170 |
171 | ) : ( 172 | 上滑加载更多 173 | )} 174 |
175 | )} 176 |
177 |
178 | 179 | {/* 邮件详情 */} 180 | 181 | {selectedMail && ( 182 | 191 | {isMobile ? ( 192 | setSelectedMail(null)} 195 | /> 196 | ) : ( 197 | setSelectedMail(null)} 200 | /> 201 | )} 202 | 203 | )} 204 | 205 | 206 | {/* 空状态提示 */} 207 | {!selectedMail && ( 208 |
209 |

选择一封邮件查看详情

210 |
211 | )} 212 |
213 | ) 214 | } -------------------------------------------------------------------------------- /components/mail/mail-view.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Mail } from "@/lib/http-client" 4 | import { Button } from "../ui/button" 5 | import { X } from "lucide-react" 6 | import { format, addHours } from "date-fns" 7 | import { zhCN } from "date-fns/locale" 8 | import DOMPurify from 'dompurify' 9 | import { useMemo, useRef, useEffect } from "react" 10 | 11 | interface MailViewProps { 12 | mail: Mail 13 | onClose: () => void 14 | } 15 | 16 | export function MailView({ mail, onClose }: MailViewProps) { 17 | const sanitizedHtml = useMemo(() => { 18 | if (!mail.message) return '' 19 | return DOMPurify.sanitize(mail.message, { 20 | ALLOWED_TAGS: [ 21 | // 基础文本格式 22 | 'p', 'br', 'b', 'i', 'em', 'strong', 'u', 'small', 'sub', 'sup', 23 | // 标题 24 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 25 | // 列表 26 | 'ul', 'ol', 'li', 27 | // 表格 28 | 'table', 'thead', 'tbody', 'tr', 'td', 'th', 29 | // 其他常用元素 30 | 'div', 'span', 'a', 'img', 'blockquote', 'pre', 'code', 31 | // 样式和布局 32 | 'style', 'font', 'center', 'hr' 33 | ], 34 | ALLOWED_ATTR: [ 35 | // 通用属性 36 | 'class', 'id', 'style', 37 | // 链接属性 38 | 'href', 'target', 'rel', 39 | // 图片属性 40 | 'src', 'alt', 'width', 'height', 41 | // 表格属性 42 | 'border', 'cellpadding', 'cellspacing', 43 | // 字体和颜色 44 | 'face', 'color', 'size' 45 | ], 46 | ALLOW_DATA_ATTR: false, 47 | ADD_TAGS: ['style'], 48 | ADD_ATTR: ['target'], // 允许链接在新窗口打开 49 | }) 50 | }, [mail.message]) 51 | 52 | const contentRef = useRef(null) 53 | 54 | // 监听 mail 变化,滚动到顶部 55 | useEffect(() => { 56 | if (contentRef.current) { 57 | contentRef.current.scrollTop = 0 58 | } 59 | }, [mail.id]) // 只在邮件 ID 变化时触发 60 | 61 | return ( 62 |
63 |
64 |

{mail.subject || '无主题'}

65 | 68 |
69 | 70 |
71 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | {(mail.from?.[0] || 'U').toUpperCase()} 82 |
83 |
84 |
85 | {mail.source || mail.from} 86 |
87 |
88 | 发送至: {mail.address} 89 |
90 |
91 |
92 |
93 | {format(addHours(new Date(mail.created_at), 8), 'PPpp', { locale: zhCN })} 94 |
95 |
96 |
97 |
98 | 99 |
100 |
101 | {mail.message ? ( 102 |
103 |
116 |
117 | ) : ( 118 |
119 | {mail.text} 120 |
121 | )} 122 |
123 |
124 | 125 | {mail.attachments && mail.attachments.length > 0 && ( 126 |
127 |
128 |

附件

129 |
130 | {mail.attachments.map((attachment) => ( 131 |
135 |
136 | {attachment.filename} 137 | 138 | {attachment.size} 139 | 140 |
141 | 146 |
147 | ))} 148 |
149 |
150 |
151 | )} 152 |
153 |
154 |
155 |
156 | ) 157 | } -------------------------------------------------------------------------------- /components/mail/mails-loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton" 2 | 3 | export function MailsLoading() { 4 | return ( 5 |
6 | {Array.from({ length: 5 }).map((_, i) => ( 7 |
8 |
9 | 10 | 11 |
12 | 13 | 14 |
15 | ))} 16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /components/mail/mobile-mail-view.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Mail } from "@/lib/http-client" 4 | import { Button } from "../ui/button" 5 | import { ArrowLeft } from "lucide-react" 6 | import { format, addHours } from "date-fns" 7 | import { zhCN } from "date-fns/locale" 8 | import { motion, useMotionValue, useTransform, useAnimation, PanInfo } from "framer-motion" 9 | import { useEffect, useMemo, useRef } from "react" 10 | import DOMPurify from 'dompurify' 11 | 12 | interface MobileMailViewProps { 13 | mail: Mail 14 | onClose: () => void 15 | } 16 | 17 | export function MobileMailView({ mail, onClose }: MobileMailViewProps) { 18 | const x = useMotionValue(0) 19 | const controls = useAnimation() 20 | const dragStartX = useRef(0) 21 | 22 | // 根据滑动距离计算不同的动画值 23 | const opacity = useTransform(x, [0, 100, 200], [1, 0.8, 0]) 24 | const scale = useTransform(x, [0, 100], [1, 0.95]) 25 | const borderRadius = useTransform(x, [0, 100], [0, 20]) 26 | const backgroundColor = useTransform( 27 | x, 28 | [0, 200], 29 | ["hsl(var(--background))", "hsl(var(--muted))"] 30 | ) 31 | 32 | const handleDragStart = (event: any) => { 33 | dragStartX.current = event.clientX || event.touches?.[0]?.clientX || 0 34 | } 35 | 36 | const handleDragEnd = async (event: any, info: PanInfo) => { 37 | const velocity = info.velocity.x 38 | const offset = info.offset.x 39 | 40 | // 放宽边缘区域和触发条件 41 | if (dragStartX.current <= 50) { // 增加边缘区域到 50px 42 | if (velocity > 300 || offset > 80) { // 降低速度和距离阈值 43 | await controls.start({ 44 | x: 300, 45 | opacity: 0, 46 | transition: { duration: 0.2 } 47 | }) 48 | onClose() 49 | } else { 50 | controls.start({ 51 | x: 0, 52 | transition: { 53 | type: "spring", 54 | stiffness: 300, 55 | damping: 30 56 | } 57 | }) 58 | } 59 | } else { 60 | x.set(0) 61 | } 62 | } 63 | 64 | // 同步修改滑动限制 65 | useEffect(() => { 66 | const unsubscribe = x.onChange((latest) => { 67 | if (latest < 0) { 68 | x.set(0) 69 | } else if (dragStartX.current > 50 && latest > 0) { // 同步修改边缘区域判断 70 | x.set(0) 71 | } 72 | }) 73 | return () => unsubscribe() 74 | }, [x]) 75 | 76 | const sanitizedHtml = useMemo(() => { 77 | if (!mail.message) return '' 78 | return DOMPurify.sanitize(mail.message, { 79 | ALLOWED_TAGS: [ 80 | // 基础文本格式 81 | 'p', 'br', 'b', 'i', 'em', 'strong', 'u', 'small', 'sub', 'sup', 82 | // 标题 83 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 84 | // 列表 85 | 'ul', 'ol', 'li', 86 | // 表格 87 | 'table', 'thead', 'tbody', 'tr', 'td', 'th', 88 | // 其他常用元素 89 | 'div', 'span', 'a', 'img', 'blockquote', 'pre', 'code', 90 | // 样式和布局 91 | 'style', 'font', 'center', 'hr' 92 | ], 93 | ALLOWED_ATTR: [ 94 | // 通用属性 95 | 'class', 'id', 'style', 96 | // 链接属性 97 | 'href', 'target', 'rel', 98 | // 图片属性 99 | 'src', 'alt', 'width', 'height', 100 | // 表格属性 101 | 'border', 'cellpadding', 'cellspacing', 102 | // 字体和颜色 103 | 'face', 'color', 'size' 104 | ], 105 | ALLOW_DATA_ATTR: false, 106 | ADD_TAGS: ['style'], 107 | ADD_ATTR: ['target'], 108 | }) 109 | }, [mail.message]) 110 | 111 | return ( 112 | 126 | 130 | {/* 顶部导航 */} 131 |
132 | 135 |

邮件详情

136 |
137 | 138 | {/* 内容区域 - 移除多余的滚动容器 */} 139 |
140 | {/* 邮件信息卡片 */} 141 |
142 |
143 |
144 |

145 | {mail.subject || '无主题'} 146 |

147 | 148 |
149 |
150 | {(mail.from?.[0] || 'U').toUpperCase()} 151 |
152 |
153 |
154 | {mail.source || mail.from} 155 |
156 |
157 | 发送至: {mail.address} 158 |
159 |
160 | {format(addHours(new Date(mail.created_at), 8), 'PPpp', { locale: zhCN })} 161 |
162 |
163 |
164 |
165 |
166 |
167 | 168 | {/* 邮件内容 */} 169 |
170 |
171 | {mail.message ? ( 172 |
186 | ) : ( 187 |
188 | {mail.text} 189 |
190 | )} 191 |
192 |
193 | 194 | {/* 附件 */} 195 | {mail.attachments && mail.attachments.length > 0 && ( 196 |
197 |
198 |

附件

199 |
200 | {mail.attachments.map((attachment) => ( 201 |
205 |
206 | {attachment.filename} 207 | 208 | {attachment.size} 209 | 210 |
211 | 216 |
217 | ))} 218 |
219 |
220 |
221 | )} 222 |
223 | 224 | 225 | ) 226 | } -------------------------------------------------------------------------------- /components/mail/trix-init.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, type RefObject } from 'react' 4 | import "trix" 5 | import "trix/dist/trix.css" 6 | 7 | type Props = { 8 | editorRef: RefObject 9 | } 10 | 11 | export default function TrixInit({ editorRef }: Props) { 12 | useEffect(() => { 13 | const trixElement = editorRef.current?.querySelector('trix-editor') 14 | 15 | if (trixElement) { 16 | // 移除不需要的按钮 17 | const toolbar = editorRef.current?.querySelector('trix-toolbar') 18 | const unwantedButtons = toolbar?.querySelectorAll('.trix-button--icon-quote, .trix-button--icon-code, .trix-button--icon-decrease-nesting-level, .trix-button--icon-increase-nesting-level, .trix-button--icon-strike') 19 | unwantedButtons?.forEach(button => button.remove()) 20 | 21 | // 添加对齐按钮 22 | const buttonGroup = toolbar?.querySelector('.trix-button-group--block-tools') 23 | if (buttonGroup) { 24 | const alignButtons = ` 25 | 28 | 31 | 34 | ` 35 | buttonGroup.insertAdjacentHTML('beforeend', alignButtons) 36 | } 37 | 38 | // 监听对齐按钮点击 39 | toolbar?.addEventListener('click', (event) => { 40 | const target = event.target as HTMLElement 41 | const button = target.closest('button[data-trix-action="align"]') 42 | if (button) { 43 | event.preventDefault() 44 | const alignment = button.getAttribute('data-align') 45 | const editor = (trixElement as any).editor 46 | 47 | // 使用 insertLineBreak 和 insertString 来实现对齐 48 | const currentAttributes = editor.getSelectedRange() 49 | editor.setSelectedRange(currentAttributes) 50 | editor.activateAttribute('textAlign', alignment) 51 | } 52 | }) 53 | 54 | // 自定义文字大小样式 55 | const editor = (trixElement as any).editor 56 | editor.composition.delegate.textAttributeConfig = { 57 | ...editor.composition.delegate.textAttributeConfig, 58 | large: { 59 | tagName: 'span', 60 | style: { fontSize: '4.0em' } 61 | }, 62 | small: { 63 | tagName: 'span', 64 | style: { fontSize: '0.8em' } 65 | } 66 | } 67 | } 68 | }, [editorRef]) 69 | 70 | return ( 71 | <> 72 | 82 | 83 | 88 | 89 | ) 90 | } -------------------------------------------------------------------------------- /components/settings/create-address-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect } from "react" 4 | import { HttpClient } from "@/lib/http-client" 5 | import { useAddressStore } from "@/store/use-address" 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "@/components/ui/dialog" 12 | import { Input } from "@/components/ui/input" 13 | import { Button } from "@/components/ui/button" 14 | import { 15 | Select, 16 | SelectContent, 17 | SelectItem, 18 | SelectTrigger, 19 | SelectValue, 20 | } from "@/components/ui/select" 21 | import { Loader2 } from "lucide-react" 22 | import { useToast } from "@/hooks/use-toast" 23 | 24 | interface CreateAddressDialogProps { 25 | open: boolean 26 | onOpenChange: (open: boolean) => void 27 | } 28 | 29 | export function CreateAddressDialog({ open, onOpenChange }: CreateAddressDialogProps) { 30 | const [prefix, setPrefix] = useState("") 31 | const [domain, setDomain] = useState("") 32 | const [domains, setDomains] = useState([]) 33 | const [loading, setLoading] = useState(false) 34 | const [error, setError] = useState("") 35 | const { setAddresses } = useAddressStore() 36 | const { toast } = useToast() 37 | 38 | useEffect(() => { 39 | const fetchDomains = async () => { 40 | try { 41 | const settings = await HttpClient.getSettings() 42 | setDomains(settings.domains) 43 | if (settings.domains.length > 0) { 44 | setDomain(settings.domains[0]) 45 | } 46 | } catch (error) { 47 | console.error('Failed to fetch domains:', error) 48 | setError("获取域名列表失败") 49 | } 50 | } 51 | 52 | if (open) { 53 | fetchDomains() 54 | } 55 | }, [open]) 56 | 57 | const handleSubmit = async (e: React.FormEvent) => { 58 | e.preventDefault() 59 | if (!prefix || !domain) { 60 | setError("请填写完整信息") 61 | return 62 | } 63 | 64 | setLoading(true) 65 | setError("") 66 | 67 | try { 68 | await HttpClient.createAddress(prefix, domain) 69 | const addresses = await HttpClient.getAddresses() 70 | setAddresses(addresses) 71 | onOpenChange(false) 72 | setPrefix("") 73 | 74 | toast({ 75 | title: "创建成功", 76 | description: `邮箱 ${prefix}@${domain} 已创建`, 77 | variant: "default", 78 | }) 79 | } catch (error) { 80 | console.error('Failed to create address:', error) 81 | setError("创建邮箱失败") 82 | 83 | toast({ 84 | title: "创建失败", 85 | description: "创建邮箱时出现错误", 86 | variant: "destructive", 87 | }) 88 | } finally { 89 | setLoading(false) 90 | } 91 | } 92 | 93 | return ( 94 | 95 | 96 | 97 | 新建邮箱地址 98 | 99 |
100 |
101 |
102 | setPrefix(e.target.value)} 106 | disabled={loading} 107 | autoFocus={false} 108 | /> 109 |
110 |
@
111 |
112 | 128 |
129 |
130 | 131 | {error && ( 132 |

{error}

133 | )} 134 | 135 |
136 | 144 | 148 |
149 |
150 |
151 |
152 | ) 153 | } -------------------------------------------------------------------------------- /components/settings/settings-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog" 10 | import { Input } from "@/components/ui/input" 11 | import { Label } from "@/components/ui/label" 12 | import { useSettings } from "@/store/use-settings" 13 | import { useState, useEffect } from "react" 14 | 15 | interface SettingsDialogProps { 16 | open: boolean 17 | onOpenChange: (open: boolean) => void 18 | } 19 | 20 | export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { 21 | const { 22 | apiBaseUrl, 23 | authToken, 24 | resendApiKey, 25 | setApiBaseUrl, 26 | setAuthToken, 27 | setResendApiKey 28 | } = useSettings() 29 | 30 | const [url, setUrl] = useState('') 31 | const [token, setToken] = useState('') 32 | const [apiKey, setApiKey] = useState('') 33 | 34 | useEffect(() => { 35 | setUrl(apiBaseUrl || process.env.NEXT_PUBLIC_API_BASE_URL || '') 36 | setToken(authToken || process.env.NEXT_PUBLIC_AUTH_TOKEN || '') 37 | setApiKey(resendApiKey || '') 38 | }, [apiBaseUrl, authToken, resendApiKey, open]) 39 | 40 | const handleSave = () => { 41 | if (url) setApiBaseUrl(url.trim()) 42 | if (token) setAuthToken(token.trim()) 43 | if (apiKey) setResendApiKey(apiKey.trim()) 44 | onOpenChange(false) 45 | } 46 | 47 | return ( 48 | 49 | 50 | 51 | 系统设置 52 | 53 |
54 |
55 | 56 | setUrl(e.target.value)} 60 | /> 61 |
62 |
63 | 64 | setToken(e.target.value)} 69 | /> 70 |
71 |
72 | 73 | setApiKey(e.target.value)} 78 | /> 79 |
80 |
81 |
82 | 83 |
84 |
85 |
86 | ) 87 | } -------------------------------------------------------------------------------- /components/sidebar/email-switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog" 13 | import { useEffect, useState, useRef, useMemo } from "react" 14 | import { ChevronDown, Search, Trash2, Settings as SettingsIcon } from "lucide-react" 15 | import { useAddressStore } from "@/store/use-address" 16 | import { HttpClient } from "@/lib/http-client" 17 | import { Input } from "@/components/ui/input" 18 | import { ScrollArea } from "@/components/ui/scroll-area" 19 | import { useSidebar } from "@/store/use-sidebar" 20 | import { cn } from "@/lib/utils" 21 | import { useToast } from "@/hooks/use-toast" 22 | import useSWR from 'swr' 23 | import { EmailAddress } from "@/lib/http-client" 24 | import { useSettings } from "@/store/use-settings" 25 | 26 | export function EmailSwitcher() { 27 | const { addresses, currentAddress, setAddresses, setCurrentAddress } = useAddressStore() 28 | const { toggle } = useSidebar() 29 | const { data: settings } = useSWR('/api/settings', () => HttpClient.getSettings()) 30 | const domain = settings?.domains[0] || '' 31 | const [search, setSearch] = useState("") 32 | const [open, setOpen] = useState(false) 33 | const [deleting, setDeleting] = useState(null) 34 | const inputRef = useRef(null) 35 | const { toast } = useToast() 36 | const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) 37 | const [addressToDelete, setAddressToDelete] = useState(null) 38 | const { mutate } = useSWR('/api/addresses') 39 | const { apiBaseUrl, authToken } = useSettings() 40 | 41 | useEffect(() => { 42 | const fetchAddresses = async () => { 43 | try { 44 | const addresses = await HttpClient.getAddresses() 45 | setAddresses(addresses) 46 | } catch (error) { 47 | console.error('Failed to fetch addresses:', error) 48 | } 49 | } 50 | 51 | if (addresses.length === 0) { 52 | fetchAddresses() 53 | } 54 | }, [addresses.length, setAddresses]) 55 | 56 | useEffect(() => { 57 | if (!currentAddress && addresses.length > 0) { 58 | setCurrentAddress(addresses[0]) 59 | } 60 | }, [addresses, currentAddress, setCurrentAddress]) 61 | 62 | useEffect(() => { 63 | if (!open) { 64 | setSearch("") 65 | } 66 | }, [open]) 67 | 68 | const allMailsOption = useMemo(() => ({ 69 | id: -1, 70 | name: '所有邮件', 71 | created_at: '', 72 | updated_at: '', 73 | mail_count: undefined, 74 | send_count: 0, 75 | }), []) 76 | 77 | const filteredAddresses = useMemo(() => { 78 | const filtered = addresses.filter(address => 79 | address.name.toLowerCase().includes(search.toLowerCase()) 80 | ) 81 | return [allMailsOption, ...filtered] 82 | }, [addresses, search, allMailsOption]) 83 | 84 | if (!apiBaseUrl || !authToken) { 85 | return ( 86 | 103 | ) 104 | } 105 | 106 | if (!currentAddress) return null 107 | 108 | const handleDelete = (address: EmailAddress) => { 109 | setAddressToDelete(address) 110 | setDeleteDialogOpen(true) 111 | } 112 | 113 | const confirmDelete = async () => { 114 | if (!addressToDelete) return 115 | 116 | try { 117 | await HttpClient.deleteAddress(addressToDelete.id) 118 | 119 | // 更新本地邮箱列表 120 | const newAddresses = addresses.filter(addr => addr.id !== addressToDelete.id) 121 | setAddresses(newAddresses) 122 | 123 | // 如果删除的是当前选中的邮箱,切换到第一个邮箱 124 | if (currentAddress?.id === addressToDelete.id && newAddresses.length > 0) { 125 | setCurrentAddress(newAddresses[0]) 126 | } 127 | 128 | toast({ 129 | description: "邮箱已删除", 130 | }) 131 | setDeleteDialogOpen(false) 132 | setAddressToDelete(null) 133 | 134 | // 刷新邮箱列表 135 | mutate('/api/addresses') 136 | } catch (error) { 137 | toast({ 138 | variant: "destructive", 139 | description: "删除失败,请重试", 140 | }) 141 | } 142 | } 143 | 144 | return ( 145 | 146 | 147 | 161 | 162 | 163 | 164 | 选择邮箱账号 165 | 166 |
inputRef.current?.focus()} 169 | > 170 | 176 | setSearch(e.target.value)} 181 | className={cn( 182 | "mb-2", 183 | search ? "pl-4" : "pl-9" 184 | )} 185 | autoFocus={false} 186 | /> 187 |
188 | 189 |
190 | {filteredAddresses.map((address) => ( 191 |
198 | 221 | 237 | {currentAddress?.id === address.id && ( 238 |
239 | )} 240 |
241 | ))} 242 |
243 | 244 | 245 | 246 | 247 | 248 | 249 | 确认删除 250 | 251 | 确定要删除邮箱 {addressToDelete?.name}@{domain} 吗?此操作无法撤销。 252 | 253 | 254 | 255 | 261 | 267 | 268 | 269 | 270 |
271 | ) 272 | } -------------------------------------------------------------------------------- /components/sidebar/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { Separator } from "@/components/ui/separator" 5 | import { 6 | Inbox, 7 | Send, 8 | MailOpen, 9 | PenLine, 10 | Settings 11 | } from "lucide-react" 12 | import { cn } from "@/lib/utils" 13 | import { useState } from "react" 14 | import { CreateAddressDialog } from "../settings/create-address-dialog" 15 | import { useMailView } from "@/store/use-mail-view" 16 | import { SettingsDialog } from "../settings/settings-dialog" 17 | 18 | const navItems = [ 19 | { id: 'inbox', icon: Inbox, label: "收件箱" }, 20 | { id: 'sent', icon: Send, label: "发件箱", disabled: true }, 21 | ] 22 | 23 | interface SidebarNavProps { 24 | onMobileClose?: () => void 25 | } 26 | 27 | export function SidebarNav({ onMobileClose }: SidebarNavProps) { 28 | const [showCreateDialog, setShowCreateDialog] = useState(false) 29 | const [showSettingsDialog, setShowSettingsDialog] = useState(false) 30 | const { view, setView } = useMailView() 31 | 32 | const handleNavClick = (id: string) => { 33 | setView(id) 34 | // 移动端自动收起 35 | onMobileClose?.() 36 | } 37 | 38 | return ( 39 |
40 | 41 | 97 | 98 | {/* 底部设置按钮 */} 99 |
100 | 112 |
113 | 114 | 118 | 122 |
123 | ) 124 | } -------------------------------------------------------------------------------- /components/sidebar/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { EmailSwitcher } from "./email-switcher" 2 | import { SidebarNav } from "./sidebar-nav" 3 | import { Button } from "@/components/ui/button" 4 | import { X } from "lucide-react" 5 | import { useSidebar } from "@/store/use-sidebar" 6 | import Image from "next/image" 7 | 8 | interface SidebarProps { 9 | showHeader?: boolean 10 | } 11 | 12 | export function Sidebar({ showHeader }: SidebarProps) { 13 | const { toggle } = useSidebar() 14 | 15 | return ( 16 |
17 | {/* 移动端显示的顶部 */} 18 | {showHeader && ( 19 |
20 |
21 |
22 | Logo 29 |
30 |

CloudFlare邮件系统

31 |
32 | 40 |
41 | )} 42 | 43 | {/* 邮箱切换器 */} 44 |
45 | 46 |
47 | 48 | {/* 导航菜单和设置按钮的容器 */} 49 |
50 | {/* 导航菜单(可滚动区域) */} 51 |
52 | 53 |
54 |
55 |
56 | ) 57 | } -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLDivElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |
64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | {children} 133 | 134 | )) 135 | SelectItem.displayName = SelectPrimitive.Item.displayName 136 | 137 | const SelectSeparator = React.forwardRef< 138 | React.ElementRef, 139 | React.ComponentPropsWithoutRef 140 | >(({ className, ...props }, ref) => ( 141 | 146 | )) 147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 148 | 149 | export { 150 | Select, 151 | SelectGroup, 152 | SelectValue, 153 | SelectTrigger, 154 | SelectContent, 155 | SelectLabel, 156 | SelectItem, 157 | SelectSeparator, 158 | SelectScrollUpButton, 159 | SelectScrollDownButton, 160 | } 161 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/sheet.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SheetPrimitive from "@radix-ui/react-dialog" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const Sheet = SheetPrimitive.Root 11 | 12 | const SheetTrigger = SheetPrimitive.Trigger 13 | 14 | const SheetClose = SheetPrimitive.Close 15 | 16 | const SheetPortal = SheetPrimitive.Portal 17 | 18 | const SheetOverlay = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, ...props }, ref) => ( 22 | 30 | )) 31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 32 | 33 | const sheetVariants = cva( 34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 35 | { 36 | variants: { 37 | side: { 38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 39 | bottom: 40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 42 | right: 43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 44 | }, 45 | }, 46 | defaultVariants: { 47 | side: "right", 48 | }, 49 | } 50 | ) 51 | 52 | interface SheetContentProps 53 | extends React.ComponentPropsWithoutRef, 54 | VariantProps {} 55 | 56 | const SheetContent = React.forwardRef< 57 | React.ElementRef, 58 | SheetContentProps 59 | >(({ side = "right", className, children, ...props }, ref) => ( 60 | 61 | 62 | 67 | {children} 68 | 69 | 70 | Close 71 | 72 | 73 | 74 | )) 75 | SheetContent.displayName = SheetPrimitive.Content.displayName 76 | 77 | const SheetHeader = ({ 78 | className, 79 | ...props 80 | }: React.HTMLAttributes) => ( 81 |
88 | ) 89 | SheetHeader.displayName = "SheetHeader" 90 | 91 | const SheetFooter = ({ 92 | className, 93 | ...props 94 | }: React.HTMLAttributes) => ( 95 |
102 | ) 103 | SheetFooter.displayName = "SheetFooter" 104 | 105 | const SheetTitle = React.forwardRef< 106 | React.ElementRef, 107 | React.ComponentPropsWithoutRef 108 | >(({ className, ...props }, ref) => ( 109 | 114 | )) 115 | SheetTitle.displayName = SheetPrimitive.Title.displayName 116 | 117 | const SheetDescription = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, ...props }, ref) => ( 121 | 126 | )) 127 | SheetDescription.displayName = SheetPrimitive.Description.displayName 128 | 129 | export { 130 | Sheet, 131 | SheetPortal, 132 | SheetOverlay, 133 | SheetTrigger, 134 | SheetClose, 135 | SheetContent, 136 | SheetHeader, 137 | SheetFooter, 138 | SheetTitle, 139 | SheetDescription, 140 | } 141 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToastPrimitives from "@radix-ui/react-toast" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 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", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TogglePrimitive from "@radix-ui/react-toggle" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const toggleVariants = cva( 10 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", 17 | }, 18 | size: { 19 | default: "h-10 px-3 min-w-10", 20 | sm: "h-9 px-2.5 min-w-9", 21 | lg: "h-11 px-5 min-w-11", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | } 29 | ) 30 | 31 | const Toggle = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef & 34 | VariantProps 35 | >(({ className, variant, size, ...props }, ref) => ( 36 | 41 | )) 42 | 43 | Toggle.displayName = TogglePrimitive.Root.displayName 44 | 45 | export { Toggle, toggleVariants } 46 | -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /lib/email-parser.js: -------------------------------------------------------------------------------- 1 | import PostalMime from 'postal-mime'; 2 | 3 | function humanFileSize(size) { 4 | const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); 5 | return parseFloat((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i]; 6 | } 7 | 8 | export async function processItem(item) { 9 | try { 10 | const parsedEmail = await PostalMime.parse(item.raw); 11 | item.source = parsedEmail.from.address || item.source; 12 | if (parsedEmail.from.address && parsedEmail.from.name) { 13 | item.source = `${parsedEmail.from.name} <${parsedEmail.from.address}>`; 14 | } 15 | item.subject = parsedEmail.subject || 'No Subject'; 16 | item.message = parsedEmail.html || parsedEmail.text || item.raw; 17 | item.text = parsedEmail.text || ''; 18 | item.attachments = parsedEmail.attachments?.map((a_item) => { 19 | const blob = new Blob( 20 | [a_item.content], 21 | { type: a_item.mimeType || 'application/octet-stream' } 22 | ); 23 | const blob_url = URL.createObjectURL(blob) 24 | if (a_item.contentId && a_item.contentId.length > 0) { 25 | item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url); 26 | } 27 | return { 28 | id: a_item.contentId || Math.random().toString(36).substring(2, 15), 29 | filename: a_item.filename || a_item.contentId || "", 30 | size: humanFileSize(a_item.content?.length || 0), 31 | url: blob_url, 32 | blob: blob 33 | } 34 | }) || []; 35 | } catch (error) { 36 | console.log('Error parsing email with PostalMime'); 37 | console.error(error); 38 | item.subject = 'No Subject'; 39 | item.message = item.raw; 40 | } 41 | return item; 42 | } 43 | 44 | export function getDownloadEmlUrl(raw) { 45 | return URL.createObjectURL( 46 | new Blob([raw], { type: 'text/plain' } 47 | )) 48 | } 49 | -------------------------------------------------------------------------------- /lib/http-client.ts: -------------------------------------------------------------------------------- 1 | import { processItem } from './email-parser' 2 | import { useSettings } from "@/store/use-settings" 3 | 4 | type RequestOptions = { 5 | method?: string 6 | headers?: Record 7 | body?: Record 8 | } 9 | 10 | export interface EmailAddress { 11 | id: number 12 | name: string 13 | created_at: string 14 | updated_at: string 15 | mail_count?: number 16 | send_count: number 17 | } 18 | 19 | interface AddressResponse { 20 | results: EmailAddress[] 21 | count: number 22 | } 23 | 24 | interface SettingsResponse { 25 | domains: string[] 26 | needAuth: boolean 27 | enableUserCreateEmail: boolean 28 | enableUserDeleteEmail: boolean 29 | version: string 30 | } 31 | 32 | export interface MailsResponse { 33 | items: Mail[] 34 | total: number 35 | } 36 | 37 | export class HttpClient { 38 | private static mailCounts: Record = {} 39 | private static mailCache: Map = new Map() 40 | 41 | private static async request(endpoint: string, options: RequestOptions = {}): Promise { 42 | const settings = useSettings.getState() 43 | const baseUrl = settings.apiBaseUrl || process.env.NEXT_PUBLIC_API_BASE_URL 44 | const authToken = settings.authToken || process.env.NEXT_PUBLIC_AUTH_TOKEN 45 | 46 | // 确保 baseUrl 存在 47 | if (!baseUrl) { 48 | throw new Error('API base URL is not configured') 49 | } 50 | 51 | const url = `${baseUrl}${endpoint}` 52 | const headers = { 53 | 'Content-Type': 'application/json', 54 | ...(authToken && { 'x-admin-auth': authToken }), // 只在有 token 时添加 55 | ...options.headers, 56 | } 57 | 58 | try { 59 | const response = await fetch(url, { 60 | method: options.method || 'GET', 61 | headers, 62 | body: options.body ? JSON.stringify(options.body) : undefined, 63 | }) 64 | 65 | if (!response.ok) { 66 | throw new Error(`HTTP error! status: ${response.status}`) 67 | } 68 | 69 | return await response.json() 70 | } catch (error) { 71 | console.error('Request failed:', error) 72 | throw error 73 | } 74 | } 75 | 76 | static async getMails(address: string, limit = 20, offset = 0): Promise { 77 | const cacheKey = `${address}-${offset}-${limit}` 78 | const cached = this.mailCache.get(cacheKey) 79 | if (cached) { 80 | return cached 81 | } 82 | 83 | try { 84 | const response = await this.request( 85 | `/admin/mails?limit=${limit}&offset=${offset}&address=${address}` 86 | ) 87 | 88 | if (!response || !response.results) { 89 | throw new Error('Invalid response structure') 90 | } 91 | 92 | const processedItems = await Promise.all( 93 | response.results.map(async (mail) => { 94 | const processed = await processItem(mail) 95 | return processed 96 | }) 97 | ) 98 | 99 | // 如果返回了 count,更新存储的总数 100 | if (response.count !== undefined) { 101 | this.mailCounts[address] = response.count 102 | } 103 | 104 | const result = { 105 | items: processedItems, 106 | total: this.mailCounts[address] || 0 107 | } 108 | 109 | this.mailCache.set(cacheKey, result) 110 | return result 111 | } catch (error) { 112 | console.error('HttpClient.getMails: Error:', error) 113 | throw error 114 | } 115 | } 116 | 117 | static async getMail(id: string, address: string) { 118 | try { 119 | const response = await this.getMails(address) 120 | const mail = response.items.find(item => item.id.toString() === id) 121 | 122 | if (!mail) { 123 | throw new Error('Mail not found') 124 | } 125 | 126 | return mail 127 | } catch (error) { 128 | console.error('HttpClient.getMail: Error:', error) 129 | throw error 130 | } 131 | } 132 | 133 | static async getAddresses() { 134 | try { 135 | const response = await this.request( 136 | '/admin/address?limit=100&offset=0' 137 | ) 138 | return response.results 139 | } catch (error) { 140 | console.error('HttpClient.getAddresses: Error:', error) 141 | throw error 142 | } 143 | } 144 | 145 | static async getSettings() { 146 | try { 147 | const response = await this.request('/open_api/settings') 148 | return response 149 | } catch (error) { 150 | console.error('HttpClient.getSettings: Error:', error) 151 | throw error 152 | } 153 | } 154 | 155 | static async createAddress(prefix: string, domain: string) { 156 | try { 157 | const response = await this.request( 158 | '/admin/new_address', 159 | { 160 | method: 'POST', 161 | body: { 162 | enablePrefix: true, 163 | name: prefix, 164 | domain: domain 165 | } 166 | } 167 | ) 168 | return response 169 | } catch (error) { 170 | console.error('HttpClient.createAddress: Error:', error) 171 | throw error 172 | } 173 | } 174 | 175 | static async deleteAddress(id: number) { 176 | try { 177 | await this.request( 178 | `/admin/delete_address/${id}`, 179 | { 180 | method: 'DELETE' 181 | } 182 | ) 183 | } catch (error) { 184 | console.error('HttpClient.deleteAddress: Error:', error) 185 | throw error 186 | } 187 | } 188 | 189 | static async getAllMails(limit = 20, offset = 0): Promise { 190 | try { 191 | const response = await this.request( 192 | `/admin/mails?limit=${limit}&offset=${offset}` 193 | ) 194 | 195 | if (!response || !response.results) { 196 | throw new Error('Invalid response structure') 197 | } 198 | 199 | const processedItems = await Promise.all( 200 | response.results.map(async (mail) => { 201 | const processed = await processItem(mail) 202 | return processed 203 | }) 204 | ) 205 | 206 | return { 207 | items: processedItems, 208 | total: response.count || 0 209 | } 210 | } catch (error) { 211 | console.error('HttpClient.getAllMails: Error:', error) 212 | throw error 213 | } 214 | } 215 | } 216 | 217 | // 类型定义 218 | export interface Mail { 219 | id: number 220 | message_id: string 221 | source: string 222 | address: string 223 | raw: string 224 | created_at: string 225 | subject?: string 226 | from?: string 227 | to?: string[] 228 | cc?: string[] 229 | content?: string 230 | text?: string 231 | message?: string 232 | attachments?: Array<{ 233 | id: string 234 | filename: string 235 | size: string | number 236 | url: string 237 | blob: Blob 238 | }> 239 | } 240 | 241 | interface MailResponse { 242 | results: Mail[] 243 | count?: number // 修改为可选属性 244 | } -------------------------------------------------------------------------------- /lib/mail-parser.ts: -------------------------------------------------------------------------------- 1 | import { parse_message } from 'mail-parser-wasm' 2 | 3 | export async function parseMessage(rawMessage: string) { 4 | try { 5 | const result = parse_message(rawMessage) 6 | return result 7 | } catch (error) { 8 | console.error('mail-parser: Error parsing message:', error) 9 | return null 10 | } 11 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import type { NextRequest } from 'next/server' 3 | 4 | export function middleware(request: NextRequest) { 5 | return NextResponse.next() 6 | } 7 | 8 | export const config = { 9 | matcher: [] 10 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | eslint: { 4 | // 在生产构建时忽略 ESLint 错误 5 | ignoreDuringBuilds: true, 6 | }, 7 | } 8 | 9 | module.exports = nextConfig -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-mail", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-avatar": "^1.1.2", 13 | "@radix-ui/react-dialog": "^1.1.5", 14 | "@radix-ui/react-label": "^2.1.1", 15 | "@radix-ui/react-scroll-area": "^1.2.2", 16 | "@radix-ui/react-select": "^2.1.4", 17 | "@radix-ui/react-separator": "^1.1.1", 18 | "@radix-ui/react-slot": "^1.1.1", 19 | "@radix-ui/react-toast": "^1.2.4", 20 | "@radix-ui/react-toggle": "^1.1.1", 21 | "@types/dompurify": "^3.0.5", 22 | "@use-gesture/react": "^10.3.1", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "date-fns": "^4.1.0", 26 | "dompurify": "^3.2.3", 27 | "framer-motion": "^11.18.1", 28 | "js-cookie": "^3.0.5", 29 | "lucide-react": "^0.471.1", 30 | "mail-parser-wasm": "^0.2.1", 31 | "next": "14.2.16", 32 | "postal-mime": "^2.4.1", 33 | "react": "^18", 34 | "react-dom": "^18", 35 | "react-quill": "^2.0.0", 36 | "react-trix": "^0.10.2", 37 | "resend": "^4.1.1", 38 | "swr": "^2.3.0", 39 | "tailwind-merge": "^2.6.0", 40 | "tailwindcss-animate": "^1.0.7", 41 | "zustand": "^5.0.3" 42 | }, 43 | "devDependencies": { 44 | "@types/js-cookie": "^3.0.6", 45 | "@types/node": "^20", 46 | "@types/react": "^18", 47 | "@types/react-dom": "^18", 48 | "eslint": "^8", 49 | "eslint-config-next": "14.2.16", 50 | "postcss": "^8", 51 | "tailwindcss": "^3.4.1", 52 | "typescript": "^5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/public/logo.png -------------------------------------------------------------------------------- /screenshots/s1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/screenshots/s1.png -------------------------------------------------------------------------------- /screenshots/s2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiangnan1224/cloudflare-email/5ca7d854b6b491a97e8f2ae98fe59e05a55120db/screenshots/s2.png -------------------------------------------------------------------------------- /store/use-address.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | import { persist } from "zustand/middleware" 3 | import { EmailAddress } from "@/lib/http-client" 4 | import { useMailView } from './use-mail-view' 5 | 6 | interface AddressStore { 7 | addresses: EmailAddress[] 8 | currentAddress: EmailAddress | null 9 | setAddresses: (addresses: EmailAddress[]) => void 10 | setCurrentAddress: (address: EmailAddress | null) => void 11 | } 12 | 13 | export const useAddressStore = create()( 14 | persist( 15 | (set) => ({ 16 | addresses: [], 17 | currentAddress: null, 18 | setAddresses: (addresses) => set({ addresses }), 19 | setCurrentAddress: (address) => { 20 | set({ currentAddress: address }) 21 | // 切换邮箱时自动跳转到收件箱 22 | useMailView.getState().setView('inbox') 23 | }, 24 | }), 25 | { 26 | name: 'address-storage', 27 | } 28 | ) 29 | ) -------------------------------------------------------------------------------- /store/use-auth.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | import { persist, createJSONStorage } from "zustand/middleware" 3 | import Cookies from 'js-cookie' 4 | 5 | interface AuthStore { 6 | token: string | null 7 | setToken: (token: string) => void 8 | clearToken: () => void 9 | hydrated: boolean 10 | setHydrated: (state: boolean) => void 11 | } 12 | 13 | // 检查是否在浏览器环境 14 | const isBrowser = typeof window !== 'undefined' 15 | 16 | // 创建一个自定义storage来同时处理localStorage和cookie 17 | const customStorage = { 18 | getItem: (name: string) => { 19 | if (!isBrowser) return null 20 | const value = localStorage.getItem(name) 21 | if (value) { 22 | Cookies.set(name, value, { expires: 7 }) 23 | } 24 | return value 25 | }, 26 | setItem: (name: string, value: string) => { 27 | if (!isBrowser) return 28 | localStorage.setItem(name, value) 29 | Cookies.set(name, value, { expires: 7 }) 30 | }, 31 | removeItem: (name: string) => { 32 | if (!isBrowser) return 33 | localStorage.removeItem(name) 34 | Cookies.remove(name) 35 | }, 36 | } 37 | 38 | export const useAuthStore = create()( 39 | persist( 40 | (set) => ({ 41 | token: null, 42 | hydrated: false, 43 | setToken: (token) => set({ token }), 44 | clearToken: () => set({ token: null }), 45 | setHydrated: (state) => set({ hydrated: state }), 46 | }), 47 | { 48 | name: 'auth-storage', 49 | storage: createJSONStorage(() => customStorage), 50 | onRehydrateStorage: () => (state) => { 51 | state?.setHydrated(true) 52 | }, 53 | } 54 | ) 55 | ) -------------------------------------------------------------------------------- /store/use-mail-view.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | 3 | interface MailViewState { 4 | view: string 5 | setView: (view: string) => void 6 | } 7 | 8 | export const useMailView = create((set) => ({ 9 | view: 'inbox', 10 | setView: (view) => set({ view }) 11 | })) -------------------------------------------------------------------------------- /store/use-mails.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | import { Mail } from "@/lib/http-client" 3 | 4 | interface MailsStore { 5 | mails: Mail[] 6 | setMails: (mails: Mail[]) => void 7 | getMail: (id: string) => Mail | undefined 8 | } 9 | 10 | export const useMailsStore = create((set, get) => ({ 11 | mails: [], 12 | setMails: (mails) => set({ mails }), 13 | getMail: (id) => get().mails.find(mail => mail.id.toString() === id) 14 | })) -------------------------------------------------------------------------------- /store/use-settings.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { persist } from 'zustand/middleware' 3 | 4 | interface SettingsState { 5 | apiBaseUrl: string | null 6 | authToken: string | null 7 | resendApiKey: string | null 8 | setApiBaseUrl: (url: string) => void 9 | setAuthToken: (token: string) => void 10 | setResendApiKey: (key: string) => void 11 | } 12 | 13 | export const useSettings = create()( 14 | persist( 15 | (set) => ({ 16 | apiBaseUrl: null, 17 | authToken: null, 18 | resendApiKey: null, 19 | setApiBaseUrl: (url) => set({ apiBaseUrl: url }), 20 | setAuthToken: (token) => set({ authToken: token }), 21 | setResendApiKey: (key) => set({ resendApiKey: key }), 22 | }), 23 | { 24 | name: 'settings-storage', 25 | } 26 | ) 27 | ) -------------------------------------------------------------------------------- /store/use-sidebar.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | 3 | type SidebarStore = { 4 | isOpen: boolean 5 | toggle: () => void 6 | } 7 | 8 | export const useSidebar = create((set) => ({ 9 | isOpen: false, 10 | toggle: () => set((state) => ({ isOpen: !state.isOpen })), 11 | })) -------------------------------------------------------------------------------- /styles/quill.css: -------------------------------------------------------------------------------- 1 | .ql-container { 2 | font-size: 16px !important; 3 | font-family: var(--font-sans) !important; 4 | flex: 1 1 auto !important; 5 | overflow: auto !important; 6 | } 7 | 8 | .ql-toolbar { 9 | flex: 0 0 auto !important; 10 | border-top-left-radius: 6px; 11 | border-top-right-radius: 6px; 12 | background-color: hsl(var(--muted)); 13 | border-color: hsl(var(--border)) !important; 14 | } 15 | 16 | .ql-container { 17 | border-bottom-left-radius: 6px; 18 | border-bottom-right-radius: 6px; 19 | border-color: hsl(var(--border)) !important; 20 | background-color: hsl(var(--background)); 21 | } 22 | 23 | .ql-editor { 24 | min-height: 100%; 25 | } 26 | 27 | .ql-snow.ql-toolbar button:hover, 28 | .ql-snow .ql-toolbar button:hover, 29 | .ql-snow.ql-toolbar button:focus, 30 | .ql-snow .ql-toolbar button:focus, 31 | .ql-snow.ql-toolbar button.ql-active, 32 | .ql-snow .ql-toolbar button.ql-active, 33 | .ql-snow.ql-toolbar .ql-picker-label:hover, 34 | .ql-snow .ql-toolbar .ql-picker-label:hover, 35 | .ql-snow.ql-toolbar .ql-picker-label.ql-active, 36 | .ql-snow .ql-toolbar .ql-picker-label.ql-active, 37 | .ql-snow.ql-toolbar .ql-picker-item:hover, 38 | .ql-snow .ql-toolbar .ql-picker-item:hover, 39 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected, 40 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected { 41 | color: hsl(var(--primary)) !important; 42 | } 43 | 44 | .ql-snow.ql-toolbar button:hover .ql-fill, 45 | .ql-snow .ql-toolbar button:hover .ql-fill, 46 | .ql-snow.ql-toolbar button:focus .ql-fill, 47 | .ql-snow .ql-toolbar button:focus .ql-fill, 48 | .ql-snow.ql-toolbar button.ql-active .ql-fill, 49 | .ql-snow .ql-toolbar button.ql-active .ql-fill, 50 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill, 51 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill, 52 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill, 53 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill, 54 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill, 55 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill, 56 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill, 57 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill, 58 | .ql-snow.ql-toolbar button:hover .ql-stroke, 59 | .ql-snow .ql-toolbar button:hover .ql-stroke, 60 | .ql-snow.ql-toolbar button:focus .ql-stroke, 61 | .ql-snow .ql-toolbar button:focus .ql-stroke, 62 | .ql-snow.ql-toolbar button.ql-active .ql-stroke, 63 | .ql-snow .ql-toolbar button.ql-active .ql-stroke, 64 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke, 65 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke, 66 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke, 67 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke, 68 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke, 69 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke, 70 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 71 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke { 72 | stroke: hsl(var(--primary)) !important; 73 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | }; 63 | export default config; 64 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/auth/page.tsx"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /types/react-quill.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-quill' { 2 | import React from 'react' 3 | 4 | export interface ReactQuillProps { 5 | theme?: string 6 | value?: string 7 | onChange?: (value: string) => void 8 | modules?: any 9 | formats?: string[] 10 | className?: string 11 | style?: React.CSSProperties 12 | } 13 | 14 | const ReactQuill: React.FC 15 | export default ReactQuill 16 | } -------------------------------------------------------------------------------- /types/react-trix.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-trix' { 2 | import { ComponentType } from 'react' 3 | 4 | interface TrixEditorProps { 5 | onChange?: (html: string, text: string) => void 6 | onEditorReady?: (editor: any) => void 7 | placeholder?: string 8 | value?: string 9 | uploadURL?: string 10 | uploadData?: Record 11 | className?: string 12 | [key: string]: any 13 | } 14 | 15 | export const TrixEditor: ComponentType 16 | } -------------------------------------------------------------------------------- /types/trix.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | interface IntrinsicElements { 3 | 'trix-editor': React.DetailedHTMLProps, HTMLElement> & { 4 | input?: string 5 | placeholder?: string 6 | class?: string 7 | } 8 | } 9 | } --------------------------------------------------------------------------------