feat: implement email sending functionality via Resend service

This commit is contained in:
beilunyang
2025-06-21 23:50:46 +08:00
parent 9d55564073
commit e85f6b04bd
27 changed files with 2347 additions and 467 deletions

View File

@@ -0,0 +1,99 @@
import { NextResponse } from "next/server"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { checkPermission } from "@/lib/auth"
import { PERMISSIONS } from "@/lib/permissions"
import { EMAIL_CONFIG } from "@/config"
export const runtime = "edge"
interface EmailServiceConfig {
enabled: boolean
apiKey: string
roleLimits: {
duke?: number
knight?: number
}
}
export async function GET() {
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
if (!canAccess) {
return NextResponse.json({
error: "权限不足"
}, { status: 403 })
}
try {
const env = getRequestContext().env
const [enabled, apiKey, roleLimits] = await Promise.all([
env.SITE_CONFIG.get("EMAIL_SERVICE_ENABLED"),
env.SITE_CONFIG.get("RESEND_API_KEY"),
env.SITE_CONFIG.get("EMAIL_ROLE_LIMITS")
])
const customLimits = roleLimits ? JSON.parse(roleLimits) : {}
const finalLimits = {
duke: customLimits.duke !== undefined ? customLimits.duke : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.duke,
knight: customLimits.knight !== undefined ? customLimits.knight : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.knight,
}
return NextResponse.json({
enabled: enabled === "true",
apiKey: apiKey || "",
roleLimits: finalLimits
})
} catch (error) {
console.error("Failed to get email service config:", error)
return NextResponse.json(
{ error: "获取 Resend 发件服务配置失败" },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
const canAccess = await checkPermission(PERMISSIONS.MANAGE_CONFIG)
if (!canAccess) {
return NextResponse.json({
error: "权限不足"
}, { status: 403 })
}
try {
const config = await request.json() as EmailServiceConfig
if (config.enabled && !config.apiKey) {
return NextResponse.json(
{ error: "启用 Resend 时API Key 为必填项" },
{ status: 400 }
)
}
const env = getRequestContext().env
const customLimits: { duke?: number; knight?: number } = {}
if (config.roleLimits?.duke !== undefined) {
customLimits.duke = config.roleLimits.duke
}
if (config.roleLimits?.knight !== undefined) {
customLimits.knight = config.roleLimits.knight
}
await Promise.all([
env.SITE_CONFIG.put("EMAIL_SERVICE_ENABLED", config.enabled.toString()),
env.SITE_CONFIG.put("RESEND_API_KEY", config.apiKey),
env.SITE_CONFIG.put("EMAIL_ROLE_LIMITS", JSON.stringify(customLimits))
])
return NextResponse.json({ success: true })
} catch (error) {
console.error("Failed to save email service config:", error)
return NextResponse.json(
{ error: "保存 Resend 发件服务配置失败" },
{ status: 500 }
)
}
}

View File

@@ -93,10 +93,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
message: {
id: message.id,
from_address: message.fromAddress,
to_address: message.toAddress,
subject: message.subject,
content: message.content,
html: message.html,
received_at: message.receivedAt.getTime()
received_at: message.receivedAt.getTime(),
sent_at: message.receivedAt.getTime(),
type: message.type as 'received' | 'sent'
}
})
} catch (error) {

View File

@@ -1,9 +1,11 @@
import { NextResponse } from "next/server"
import { createDb } from "@/lib/db"
import { emails, messages } from "@/lib/schema"
import { eq, and, lt, or, sql } from "drizzle-orm"
import { eq, and, lt, or, sql, ne } from "drizzle-orm"
import { encodeCursor, decodeCursor } from "@/lib/cursor"
import { getUserId } from "@/lib/apiKey"
import { checkBasicSendPermission } from "@/lib/send-permissions"
export const runtime = "edge"
export async function DELETE(
@@ -52,12 +54,22 @@ export async function GET(
) {
const { searchParams } = new URL(request.url)
const cursorStr = searchParams.get('cursor')
const messageType = searchParams.get('type')
try {
const db = createDb()
const { id } = await params
const userId = await getUserId()
if (messageType === 'sent') {
const permissionResult = await checkBasicSendPermission(userId!)
if (!permissionResult.canSend) {
return NextResponse.json(
{ error: permissionResult.error || "您没有查看发送邮件的权限" },
{ status: 403 }
)
}
}
const email = await db.query.emails.findFirst({
where: and(
@@ -73,7 +85,10 @@ export async function GET(
)
}
const baseConditions = eq(messages.emailId, id)
const baseConditions = and(
eq(messages.emailId, id),
messageType === 'sent' ? eq(messages.type, "sent") : ne(messages.type, "sent")
)
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(messages)
@@ -84,22 +99,24 @@ export async function GET(
if (cursorStr) {
const { timestamp, id } = decodeCursor(cursorStr)
const orderByTime = messageType === 'sent' ? messages.sentAt : messages.receivedAt
conditions.push(
// @ts-expect-error "ignore the error"
or(
lt(messages.receivedAt, new Date(timestamp)),
lt(orderByTime, new Date(timestamp)),
and(
eq(messages.receivedAt, new Date(timestamp)),
eq(orderByTime, new Date(timestamp)),
lt(messages.id, id)
)
)
)
}
const orderByTime = messageType === 'sent' ? messages.sentAt : messages.receivedAt
const results = await db.query.messages.findMany({
where: and(...conditions),
orderBy: (messages, { desc }) => [
desc(messages.receivedAt),
desc(orderByTime),
desc(messages.id)
],
limit: PAGE_SIZE + 1
@@ -108,7 +125,9 @@ export async function GET(
const hasMore = results.length > PAGE_SIZE
const nextCursor = hasMore
? encodeCursor(
results[PAGE_SIZE - 1].receivedAt.getTime(),
messageType === 'sent'
? results[PAGE_SIZE - 1].sentAt!.getTime()
: results[PAGE_SIZE - 1].receivedAt.getTime(),
results[PAGE_SIZE - 1].id
)
: null
@@ -117,9 +136,13 @@ export async function GET(
return NextResponse.json({
messages: messageList.map(msg => ({
id: msg.id,
from_address: msg.fromAddress,
from_address: msg?.fromAddress,
to_address: msg?.toAddress,
subject: msg.subject,
received_at: msg.receivedAt.getTime()
content: msg.content,
html: msg.html,
sent_at: msg.sentAt?.getTime(),
received_at: msg.receivedAt?.getTime()
})),
nextCursor,
total: totalCount

View File

@@ -0,0 +1,134 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { createDb } from "@/lib/db"
import { emails, messages } from "@/lib/schema"
import { eq } from "drizzle-orm"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { checkSendPermission } from "@/lib/send-permissions"
export const runtime = "edge"
interface SendEmailRequest {
to: string
subject: string
content: string
}
async function sendWithResend(
to: string,
subject: string,
content: string,
fromEmail: string,
config: { apiKey: string }
) {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
from: fromEmail,
to: [to],
subject: subject,
html: content,
}),
})
if (!response.ok) {
const errorData = await response.json() as { message?: string }
console.error('Resend API error:', errorData)
throw new Error(errorData.message || "Resend发送失败请稍后重试")
}
return { success: true }
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未授权" },
{ status: 401 }
)
}
const { id } = await params
const db = createDb()
const permissionResult = await checkSendPermission(session.user.id)
if (!permissionResult.canSend) {
return NextResponse.json(
{ error: permissionResult.error },
{ status: 403 }
)
}
const remainingEmails = permissionResult.remainingEmails
const { to, subject, content } = await request.json() as SendEmailRequest
if (!to || !subject || !content) {
return NextResponse.json(
{ error: "收件人、主题和内容都是必填项" },
{ status: 400 }
)
}
const email = await db.query.emails.findFirst({
where: eq(emails.id, id)
})
if (!email) {
return NextResponse.json(
{ error: "邮箱不存在" },
{ status: 404 }
)
}
if (email.userId !== session.user.id) {
return NextResponse.json(
{ error: "无权访问此邮箱" },
{ status: 403 }
)
}
const env = getRequestContext().env
const apiKey = await env.SITE_CONFIG.get("RESEND_API_KEY")
if (!apiKey) {
return NextResponse.json(
{ error: "Resend 发件服务未配置,请联系管理员" },
{ status: 500 }
)
}
await sendWithResend(to, subject, content, email.address, { apiKey })
await db.insert(messages).values({
emailId: email.id,
fromAddress: email.address,
toAddress: to,
subject,
content: '',
type: "sent",
html: content
})
return NextResponse.json({
success: true,
message: "邮件发送成功",
remainingEmails
})
} catch (error) {
console.error('Failed to send email:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "发送邮件失败" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { checkSendPermission } from "@/lib/send-permissions"
export const runtime = "edge"
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({
canSend: false,
error: "未授权"
})
}
const result = await checkSendPermission(session.user.id)
return NextResponse.json(result)
} catch (error) {
console.error('Failed to check send permission:', error)
return NextResponse.json(
{
canSend: false,
error: "权限检查失败"
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useState } from "react"
import { Send, Inbox } from "lucide-react"
import { Tabs, SlidingTabsList, SlidingTabsTrigger, TabsContent } from "@/components/ui/tabs"
import { MessageList } from "./message-list"
import { useSendPermission } from "@/hooks/use-send-permission"
interface MessageListContainerProps {
email: {
id: string
address: string
}
onMessageSelect: (messageId: string | null, messageType?: 'received' | 'sent') => void
selectedMessageId?: string | null
refreshTrigger?: number
}
export function MessageListContainer({ email, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListContainerProps) {
const [activeTab, setActiveTab] = useState<'received' | 'sent'>('received')
const { canSend: canSendEmails } = useSendPermission()
const handleTabChange = (tabId: string) => {
setActiveTab(tabId as 'received' | 'sent')
onMessageSelect(null)
}
return (
<div className="h-full flex flex-col">
{canSendEmails ? (
<Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
<div className="p-2 border-b border-primary/20">
<SlidingTabsList>
<SlidingTabsTrigger value="received">
<Inbox className="h-4 w-4" />
</SlidingTabsTrigger>
<SlidingTabsTrigger value="sent">
<Send className="h-4 w-4" />
</SlidingTabsTrigger>
</SlidingTabsList>
</div>
<TabsContent value="received" className="flex-1 overflow-hidden m-0">
<MessageList
email={email}
messageType="received"
onMessageSelect={onMessageSelect}
selectedMessageId={selectedMessageId}
/>
</TabsContent>
<TabsContent value="sent" className="flex-1 overflow-hidden m-0">
<MessageList
email={email}
messageType="sent"
onMessageSelect={onMessageSelect}
selectedMessageId={selectedMessageId}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
</Tabs>
) : (
<div className="flex-1 overflow-hidden">
<MessageList
email={email}
messageType="received"
onMessageSelect={onMessageSelect}
selectedMessageId={selectedMessageId}
/>
</div>
)}
</div>
)
}

View File

@@ -20,9 +20,13 @@ import {
interface Message {
id: string
from_address: string
from_address?: string
to_address?: string
subject: string
received_at: number
received_at?: number
sent_at?: number
content?: string
html?: string
}
interface MessageListProps {
@@ -30,8 +34,10 @@ interface MessageListProps {
id: string
address: string
}
onMessageSelect: (messageId: string | null) => void
messageType: 'received' | 'sent'
onMessageSelect: (messageId: string | null, messageType?: 'received' | 'sent') => void
selectedMessageId?: string | null
refreshTrigger?: number
}
interface MessageResponse {
@@ -40,7 +46,7 @@ interface MessageResponse {
total: number
}
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
export function MessageList({ email, messageType, onMessageSelect, selectedMessageId, refreshTrigger }: MessageListProps) {
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -60,6 +66,9 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
const fetchMessages = async (cursor?: string) => {
try {
const url = new URL(`/api/emails/${email.id}`, window.location.origin)
if (messageType === 'sent') {
url.searchParams.set('type', 'sent')
}
if (cursor) {
url.searchParams.set('cursor', cursor)
}
@@ -133,7 +142,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
const handleDelete = async (message: Message) => {
try {
const response = await fetch(`/api/emails/${email.id}/${message.id}`, {
const response = await fetch(`/api/emails/${email.id}/${message.id}${messageType === 'sent' ? '?type=sent' : ''}`, {
method: "DELETE"
})
@@ -184,6 +193,14 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email.id])
useEffect(() => {
if (refreshTrigger && refreshTrigger > 0) {
setRefreshing(true)
fetchMessages()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshTrigger])
return (
<>
<div className="h-full flex flex-col">
@@ -210,7 +227,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
{messages.map(message => (
<div
key={message.id}
onClick={() => onMessageSelect(message.id)}
onClick={() => onMessageSelect(message.id, messageType)}
className={cn(
"p-3 hover:bg-primary/5 cursor-pointer group",
selectedMessageId === message.id && "bg-primary/10"
@@ -221,10 +238,12 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{message.subject}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span className="truncate">{message.from_address}</span>
<span className="truncate">
{message.from_address || message.to_address || ''}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(message.received_at).toLocaleString()}
{new Date(message.received_at || message.sent_at || 0).toLocaleString()}
</span>
</div>
</div>
@@ -250,7 +269,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
{messageType === 'sent' ? '暂无发送的邮件' : '暂无收到的邮件'}
</div>
)}
</div>

View File

@@ -5,41 +5,72 @@ import { Loader2 } from "lucide-react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { useTheme } from "next-themes"
import { useToast } from "@/components/ui/use-toast"
interface Message {
id: string
from_address: string
from_address?: string
to_address?: string
subject: string
content: string
html: string | null
received_at: number
html?: string
received_at?: number
sent_at?: number
}
interface MessageViewProps {
emailId: string
messageId: string
messageType?: 'received' | 'sent'
onClose: () => void
}
type ViewMode = "html" | "text"
export function MessageView({ emailId, messageId }: MessageViewProps) {
export function MessageView({ emailId, messageId, messageType = 'received' }: MessageViewProps) {
const [message, setMessage] = useState<Message | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>("html")
const iframeRef = useRef<HTMLIFrameElement>(null)
const { theme } = useTheme()
const { toast } = useToast()
useEffect(() => {
const fetchMessage = async () => {
try {
const response = await fetch(`/api/emails/${emailId}/${messageId}`)
setLoading(true)
setError(null)
const url = `/api/emails/${emailId}/${messageId}${messageType === 'sent' ? '?type=sent' : ''}`;
const response = await fetch(url)
if (!response.ok) {
const errorData = await response.json()
const errorMessage = (errorData as { error?: string }).error || '获取邮件详情失败'
setError(errorMessage)
toast({
title: "错误",
description: errorMessage,
variant: "destructive"
})
return
}
const data = await response.json() as { message: Message }
setMessage(data.message)
if (!data.message.html) {
setViewMode("text")
}
} catch (error) {
const errorMessage = "网络错误,请稍后重试"
setError(errorMessage)
toast({
title: "错误",
description: errorMessage,
variant: "destructive"
})
console.error("Failed to fetch message:", error)
} finally {
setLoading(false)
@@ -47,7 +78,7 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
}
fetchMessage()
}, [emailId, messageId])
}, [emailId, messageId, messageType, toast])
const updateIframeContent = () => {
if (viewMode === "html" && message?.html && iframeRef.current) {
@@ -151,6 +182,21 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
<span className="ml-2 text-sm text-gray-500">...</span>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-32 text-center">
<p className="text-sm text-destructive mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
className="text-xs text-primary hover:underline"
>
</button>
</div>
)
}
@@ -162,12 +208,17 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {
<div className="p-4 space-y-3 border-b border-primary/20">
<h3 className="text-base font-bold">{message.subject}</h3>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>{message.from_address}</p>
<p>{new Date(message.received_at).toLocaleString()}</p>
)}
{message.to_address && (
<p>{message.to_address}</p>
)}
<p>{new Date(message.sent_at || message.received_at || 0).toLocaleString()}</p>
</div>
</div>
{message.html && (
{message.html && message.content && (
<div className="border-b border-primary/20 p-2">
<RadioGroup
value={viewMode}

View File

@@ -0,0 +1,138 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Send } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface SendDialogProps {
emailId: string
fromAddress: string
onSendSuccess?: () => void
}
export function SendDialog({ emailId, fromAddress, onSendSuccess }: SendDialogProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [to, setTo] = useState("")
const [subject, setSubject] = useState("")
const [content, setContent] = useState("")
const { toast } = useToast()
const handleSend = async () => {
if (!to.trim() || !subject.trim() || !content.trim()) {
toast({
title: "错误",
description: "收件人、主题和内容都是必填项",
variant: "destructive"
})
return
}
setLoading(true)
try {
const response = await fetch(`/api/emails/${emailId}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to, subject, content })
})
if (!response.ok) {
const data = await response.json()
toast({
title: "错误",
description: (data as { error: string }).error,
variant: "destructive"
})
return
}
toast({
title: "成功",
description: "邮件已发送"
})
setOpen(false)
setTo("")
setSubject("")
setContent("")
onSendSuccess?.()
} catch {
toast({
title: "错误",
description: "发送邮件失败",
variant: "destructive"
})
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<TooltipProvider>
<Tooltip>
<DialogTrigger asChild>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 gap-2 hover:bg-primary/10 hover:text-primary transition-colors"
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
</TooltipTrigger>
</DialogTrigger>
<TooltipContent className="sm:hidden">
<p>使</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm text-muted-foreground">
: {fromAddress}
</div>
<Input
value={to}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTo(e.target.value)}
placeholder="收件人邮箱地址"
/>
<Input
value={subject}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSubject(e.target.value)}
placeholder="邮件主题"
/>
<Textarea
value={content}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
placeholder="邮件内容"
rows={6}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
</Button>
<Button onClick={handleSend} disabled={loading}>
{loading ? "发送中..." : "发送"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -2,10 +2,12 @@
import { useState } from "react"
import { EmailList } from "./email-list"
import { MessageList } from "./message-list"
import { MessageListContainer } from "./message-list-container"
import { MessageView } from "./message-view"
import { SendDialog } from "./send-dialog"
import { cn } from "@/lib/utils"
import { useCopy } from "@/hooks/use-copy"
import { useSendPermission } from "@/hooks/use-send-permission"
import { Copy } from "lucide-react"
interface Email {
@@ -16,7 +18,10 @@ interface Email {
export function ThreeColumnLayout() {
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
const [selectedMessageType, setSelectedMessageType] = useState<'received' | 'sent'>('received')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const { copyToClipboard } = useCopy()
const { canSend: canSendEmails } = useSendPermission()
const columnClass = "border-2 border-primary/20 bg-background rounded-lg overflow-hidden flex flex-col"
const headerClass = "p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0"
@@ -35,6 +40,15 @@ export function ThreeColumnLayout() {
copyToClipboard(selectedEmail?.address || "")
}
const handleMessageSelect = (messageId: string | null, messageType: 'received' | 'sent' = 'received') => {
setSelectedMessageId(messageId)
setSelectedMessageType(messageType)
}
const handleSendSuccess = () => {
setRefreshTrigger(prev => prev + 1)
}
return (
<div className="pb-5 pt-20 h-full flex flex-col">
{/* 桌面端三栏布局 */}
@@ -58,12 +72,21 @@ export function ThreeColumnLayout() {
<div className={headerClass}>
<h2 className={titleClass}>
{selectedEmail ? (
<div className="w-full flex items-center gap-2">
<div className="w-full flex justify-between items-center gap-2">
<div className="flex items-center gap-2">
<span className="truncate min-w-0">{selectedEmail.address}</span>
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
<Copy className="size-4" />
</div>
</div>
{selectedEmail && canSendEmails && (
<SendDialog
emailId={selectedEmail.id}
fromAddress={selectedEmail.address}
onSendSuccess={handleSendSuccess}
/>
)}
</div>
) : (
"选择邮箱查看消息"
)}
@@ -71,10 +94,11 @@ export function ThreeColumnLayout() {
</div>
{selectedEmail && (
<div className="flex-1 overflow-auto">
<MessageList
<MessageListContainer
email={selectedEmail}
onMessageSelect={setSelectedMessageId}
onMessageSelect={handleMessageSelect}
selectedMessageId={selectedMessageId}
refreshTrigger={refreshTrigger}
/>
</div>
)}
@@ -91,6 +115,7 @@ export function ThreeColumnLayout() {
<MessageView
emailId={selectedEmail.id}
messageId={selectedMessageId}
messageType={selectedMessageType}
onClose={() => setSelectedMessageId(null)}
/>
</div>
@@ -128,18 +153,28 @@ export function ThreeColumnLayout() {
>
</button>
<div className="flex-1 flex items-center gap-2 min-w-0">
<div className="flex-1 flex justify-between items-center gap-2 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate min-w-0 flex-1 text-right">{selectedEmail.address}</span>
<div className="shrink-0 cursor-pointer text-primary" onClick={copyEmailAddress}>
<Copy className="size-4" />
</div>
</div>
{canSendEmails && (
<SendDialog
emailId={selectedEmail.id}
fromAddress={selectedEmail.address}
onSendSuccess={handleSendSuccess}
/>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
<MessageList
<MessageListContainer
email={selectedEmail}
onMessageSelect={setSelectedMessageId}
onMessageSelect={handleMessageSelect}
selectedMessageId={selectedMessageId}
refreshTrigger={refreshTrigger}
/>
</div>
</div>
@@ -160,6 +195,7 @@ export function ThreeColumnLayout() {
<MessageView
emailId={selectedEmail.id}
messageId={selectedMessageId}
messageType={selectedMessageType}
onClose={() => setSelectedMessageId(null)}
/>
</div>

View File

@@ -0,0 +1,261 @@
"use client"
import React, { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Zap, Eye, EyeOff } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
interface EmailServiceConfig {
enabled: boolean
apiKey: string
roleLimits: {
duke: number
knight: number
}
}
export function EmailServiceConfig() {
const [config, setConfig] = useState<EmailServiceConfig>({
enabled: false,
apiKey: "",
roleLimits: {
duke: -1,
knight: -1,
}
})
const [loading, setLoading] = useState(false)
const [showToken, setShowToken] = useState(false)
const { toast } = useToast()
useEffect(() => {
fetchConfig()
}, [])
const fetchConfig = async () => {
try {
const res = await fetch("/api/config/email-service")
if (res.ok) {
const data = await res.json() as EmailServiceConfig
setConfig(data)
}
} catch (error) {
console.error("Failed to fetch email service config:", error)
}
}
const handleSave = async () => {
setLoading(true)
try {
const saveData = {
enabled: config.enabled,
apiKey: config.apiKey,
roleLimits: config.roleLimits
}
const res = await fetch("/api/config/email-service", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(saveData),
})
if (!res.ok) {
const error = await res.json() as { error: string }
throw new Error(error.error || "保存失败")
}
toast({
title: "保存成功",
description: "Resend 发件服务配置已更新",
})
} catch (error) {
toast({
title: "保存失败",
description: error instanceof Error ? error.message : "请稍后重试",
variant: "destructive",
})
} finally {
setLoading(false)
}
}
return (
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Zap className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">Resend </h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-sm font-medium">
Resend
</Label>
<p className="text-xs text-muted-foreground">
使 Resend
</p>
</div>
<Switch
id="enabled"
checked={config.enabled}
onCheckedChange={(checked: boolean) =>
setConfig((prev: EmailServiceConfig) => ({ ...prev, enabled: checked }))
}
/>
</div>
{config.enabled && (
<>
<div className="space-y-2">
<Label htmlFor="apiKey" className="text-sm font-medium">
Resend API Key
</Label>
<div className="relative">
<Input
id="apiKey"
type={showToken ? "text" : "password"}
value={config.apiKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfig((prev: EmailServiceConfig) => ({ ...prev, apiKey: e.target.value }))}
placeholder="输入 Resend API Key"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowToken(!showToken)}
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
使
</Label>
<div className="space-y-4">
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg text-sm">
<p className="font-semibold text-blue-900 mb-3 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
</p>
<div className="space-y-2 text-blue-800">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full"></div>
<span><strong>Emperor ()</strong> - </span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full"></div>
<span><strong>Civilian ()</strong> - </span>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
<p className="text-sm font-medium text-gray-900"></p>
</div>
{[
{ value: "duke", label: "Duke (公爵)", key: "duke" as const },
{ value: "knight", label: "Knight (骑士)", key: "knight" as const }
].map((role) => {
const isDisabled = config.roleLimits[role.key] === -1
const isEnabled = !isDisabled
return (
<div
key={role.value}
className={`group relative p-4 border-2 rounded-xl transition-all duration-200 ${
isEnabled
? 'border-primary/30 bg-primary/5 shadow-sm'
: 'border-gray-200 hover:border-primary/20 hover:shadow-sm'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="relative">
<Checkbox
id={`role-${role.value}`}
checked={isEnabled}
onChange={(checked: boolean) => {
setConfig((prev: EmailServiceConfig) => ({
...prev,
roleLimits: {
...prev.roleLimits,
[role.key]: checked ? 0 : -1
}
}))
}}
/>
</div>
<div>
<Label
htmlFor={`role-${role.value}`}
className="text-base font-semibold cursor-pointer select-none flex items-center gap-2"
>
<span className="text-2xl">
{role.value === 'duke' ? '🏰' : '⚔️'}
</span>
{role.label}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{isEnabled ? '已启用发件权限' : '未启用发件权限'}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="text-right">
<Label className="text-xs font-medium text-gray-600 block mb-1"></Label>
<div className="flex items-center space-x-2">
<Input
type="number"
min="-1"
value={config.roleLimits[role.key]}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfig((prev: EmailServiceConfig) => ({
...prev,
roleLimits: {
...prev.roleLimits,
[role.key]: parseInt(e.target.value) || 0
}
}))
}
className="w-20 h-9 text-center text-sm font-medium"
placeholder="0"
disabled={isDisabled}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">/</span>
</div>
<p className="text-xs text-muted-foreground mt-1">0 = </p>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</>
)}
<Button
onClick={handleSave}
disabled={loading}
className="w-full"
>
{loading ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
)
}

View File

@@ -4,13 +4,14 @@ import { User } from "next-auth"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { signOut } from "next-auth/react"
import { Github, Mail, Settings, Crown, Sword, User2, Gem } from "lucide-react"
import { Github, Settings, Crown, Sword, User2, Gem, Mail } from "lucide-react"
import { useRouter } from "next/navigation"
import { WebhookConfig } from "./webhook-config"
import { PromotePanel } from "./promote-panel"
import { EmailServiceConfig } from "./email-service-config"
import { useRolePermission } from "@/hooks/use-role-permission"
import { PERMISSIONS } from "@/lib/permissions"
import { ConfigPanel } from "./config-panel"
import { WebsiteConfigPanel } from "./website-config-panel"
import { ApiKeyPanel } from "./api-key-panel"
interface ProfileCardProps {
@@ -96,7 +97,8 @@ export function ProfileCard({ user }: ProfileCardProps) {
</div>
)}
{canManageConfig && <ConfigPanel />}
{canManageConfig && <WebsiteConfigPanel />}
{canManageConfig && <EmailServiceConfig />}
{canPromote && <PromotePanel />}
{canManageWebhook && <ApiKeyPanel />}

View File

@@ -15,7 +15,7 @@ import {
} from "@/components/ui/select"
import { EMAIL_CONFIG } from "@/config"
export function ConfigPanel() {
export function WebsiteConfigPanel() {
const [defaultRole, setDefaultRole] = useState<string>("")
const [emailDomains, setEmailDomains] = useState<string>("")
const [adminContact, setAdminContact] = useState<string>("")

View File

@@ -0,0 +1,53 @@
import React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
interface CheckboxProps {
id?: string
checked?: boolean
onChange?: (checked: boolean) => void
className?: string
disabled?: boolean
}
export const Checkbox: React.FC<CheckboxProps> = ({
id,
checked = false,
onChange,
className,
disabled = false
}) => {
const handleChange = () => {
if (!disabled && onChange) {
onChange(!checked)
}
}
return (
<div
className={cn(
"relative inline-flex items-center justify-center w-5 h-5 rounded border-2 cursor-pointer transition-all duration-200",
checked
? "bg-primary border-primary text-primary-foreground"
: "bg-background border-input hover:border-primary/50",
disabled && "opacity-50 cursor-not-allowed",
className
)}
onClick={handleChange}
>
<input
type="checkbox"
id={id}
checked={checked}
onChange={() => {}} // Controlled by div onClick
className="sr-only"
disabled={disabled}
/>
{checked && (
<Check
className="w-3 h-3 text-current animate-in fade-in-0 scale-in-95 duration-200"
/>
)}
</div>
)
}

View File

@@ -51,4 +51,83 @@ const TabsContent = React.forwardRef<
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
const SlidingTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, children, ...props }, ref) => {
const [activeIndex, setActiveIndex] = React.useState(0)
const combinedRef = React.useCallback((node: HTMLDivElement | null) => {
if (node) {
const updateActiveIndex = () => {
const triggers = node.querySelectorAll('[data-state="active"]')
if (triggers.length > 0) {
const allTriggers = node.querySelectorAll('[role="tab"]')
const activeElement = triggers[0]
const index = Array.from(allTriggers).indexOf(activeElement)
if (index >= 0) {
setActiveIndex(index)
}
}
}
setTimeout(updateActiveIndex, 0)
const observer = new MutationObserver(updateActiveIndex)
observer.observe(node, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true
})
return () => observer.disconnect()
}
if (typeof ref === 'function') {
ref(node)
}
}, [ref])
const childrenArray = React.Children.toArray(children)
const tabCount = childrenArray.length
const tabWidth = `calc(${100 / tabCount}% - ${2 * (tabCount - 1) / tabCount}px)`
const slidePosition = `calc(${(100 / tabCount) * activeIndex}% + ${activeIndex}px)`
return (
<TabsPrimitive.List
ref={combinedRef}
className={cn(
"relative flex w-full bg-muted rounded-lg p-1 h-auto",
className
)}
{...props}
>
<div
className="absolute top-1 bottom-1 bg-primary rounded-md shadow-sm transition-all duration-300 ease-in-out"
style={{
width: tabWidth,
left: slidePosition
}}
/>
{children}
</TabsPrimitive.List>
)
})
SlidingTabsList.displayName = "SlidingTabsList"
const SlidingTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"relative z-10 flex-1 h-8 gap-2 flex items-center justify-center text-sm font-medium transition-colors duration-200 rounded-md px-3 py-2 data-[state=active]:text-primary-foreground data-[state=active]:bg-transparent data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
))
SlidingTabsTrigger.displayName = "SlidingTabsTrigger"
export { Tabs, TabsList, TabsTrigger, TabsContent, SlidingTabsList, SlidingTabsTrigger }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
className?: string
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -1,6 +1,12 @@
export const EMAIL_CONFIG = {
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
DEFAULT_DAILY_SEND_LIMITS: {
emperor: 0, // 皇帝无限制
duke: 5, // 公爵每日5封
knight: 2, // 骑士每日2封
civilian: -1, // 平民禁止发件
},
} as const
export type EmailConfig = typeof EMAIL_CONFIG

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react'
interface SendPermissionResponse {
canSend: boolean
error?: string
remainingEmails?: number
}
export function useSendPermission() {
const [canSend, setCanSend] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [remainingEmails, setRemainingEmails] = useState<number | undefined>()
const checkPermission = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/emails/send-permission')
if (!response.ok) {
throw new Error('权限检查失败')
}
const data = await response.json() as SendPermissionResponse
setCanSend(data.canSend)
setRemainingEmails(data.remainingEmails)
if (!data.canSend && data.error) {
setError(data.error)
}
} catch (err) {
setCanSend(false)
setError(err instanceof Error ? err.message : '权限检查失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
checkPermission()
}, [])
return {
canSend,
loading,
error,
remainingEmails,
checkPermission
}
}

View File

@@ -55,13 +55,18 @@ export const messages = sqliteTable("message", {
emailId: text("emailId")
.notNull()
.references(() => emails.id, { onDelete: "cascade" }),
fromAddress: text("from_address").notNull(),
fromAddress: text("from_address"),
toAddress: text("to_address"),
subject: text("subject").notNull(),
content: text("content").notNull(),
html: text("html"),
type: text("type"),
receivedAt: integer("received_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
sentAt: integer("sent_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
}, (table) => ({
emailIdIdx: index("message_email_id_idx").on(table.emailId),
}))
@@ -109,6 +114,8 @@ export const apiKeys = sqliteTable('api_keys', {
nameUserIdUnique: uniqueIndex('name_user_id_unique').on(table.name, table.userId)
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],

125
app/lib/send-permissions.ts Normal file
View File

@@ -0,0 +1,125 @@
import { createDb } from "@/lib/db"
import { userRoles, roles, messages, emails } from "@/lib/schema"
import { eq, and, gte } from "drizzle-orm"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { EMAIL_CONFIG } from "@/config"
export interface SendPermissionResult {
canSend: boolean
error?: string
remainingEmails?: number
}
export async function checkSendPermission(
userId: string,
skipDailyLimitCheck = false
): Promise<SendPermissionResult> {
try {
const env = getRequestContext().env
const enabled = await env.SITE_CONFIG.get("EMAIL_SERVICE_ENABLED")
if (enabled !== "true") {
return {
canSend: false,
error: "邮件发送服务未启用"
}
}
const userDailyLimit = await getUserDailyLimit(userId)
if (userDailyLimit === -1) {
return {
canSend: false,
error: "您的角色没有发件权限"
}
}
if (skipDailyLimitCheck || userDailyLimit === 0) {
return {
canSend: true
}
}
const db = createDb()
const today = new Date()
today.setHours(0, 0, 0, 0)
const sentToday = await db
.select()
.from(messages)
.innerJoin(emails, eq(messages.emailId, emails.id))
.where(
and(
eq(emails.userId, userId),
eq(messages.type, "sent"),
gte(messages.receivedAt, today)
)
)
const remainingEmails = Math.max(0, userDailyLimit - sentToday.length)
if (sentToday.length >= userDailyLimit) {
return {
canSend: false,
error: `您今天已达到发件限制 (${userDailyLimit} 封),请明天再试`,
remainingEmails: 0
}
}
return {
canSend: true,
remainingEmails
}
} catch (error) {
console.error('Failed to check send permission:', error)
return {
canSend: false,
error: "权限检查失败"
}
}
}
async function getUserDailyLimit(userId: string): Promise<number> {
try {
const db = createDb()
const userRoleData = await db
.select({ roleName: roles.name })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId))
const userRoleNames = userRoleData.map(r => r.roleName)
const env = getRequestContext().env
const roleLimitsStr = await env.SITE_CONFIG.get("EMAIL_ROLE_LIMITS")
const customLimits = roleLimitsStr ? JSON.parse(roleLimitsStr) : {}
const finalLimits = {
emperor: EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.emperor,
duke: customLimits.duke !== undefined ? customLimits.duke : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.duke,
knight: customLimits.knight !== undefined ? customLimits.knight : EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.knight,
civilian: EMAIL_CONFIG.DEFAULT_DAILY_SEND_LIMITS.civilian,
}
if (userRoleNames.includes("emperor")) {
return finalLimits.emperor
} else if (userRoleNames.includes("duke")) {
return finalLimits.duke
} else if (userRoleNames.includes("knight")) {
return finalLimits.knight
} else if (userRoleNames.includes("civilian")) {
return finalLimits.civilian
}
return -1
} catch (error) {
console.error('Failed to get user daily limit:', error)
return -1
}
}
export async function checkBasicSendPermission(userId: string): Promise<SendPermissionResult> {
return checkSendPermission(userId, true)
}

View File

@@ -0,0 +1,20 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_message` (
`id` text PRIMARY KEY NOT NULL,
`emailId` text NOT NULL,
`from_address` text,
`to_address` text,
`subject` text NOT NULL,
`content` text NOT NULL,
`html` text,
`type` text,
`received_at` integer NOT NULL,
`sent_at` integer NOT NULL,
FOREIGN KEY (`emailId`) REFERENCES `email`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_message`("id", "emailId", "from_address", "to_address", "subject", "content", "html", "type", "received_at", "sent_at") SELECT "id", "emailId", "from_address", "to_address", "subject", "content", "html", "type", "received_at", "sent_at" FROM `message`;--> statement-breakpoint
DROP TABLE `message`;--> statement-breakpoint
ALTER TABLE `__new_message` RENAME TO `message`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `message_email_id_idx` ON `message` (`emailId`);

View File

@@ -0,0 +1,645 @@
{
"version": "6",
"dialect": "sqlite",
"id": "eb2c55e5-a514-4048-9787-e8afc4c33308",
"prevId": "6b001f75-97b4-4e3d-9025-43b977cb2619",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"columns": [
"key"
],
"isUnique": true
},
"name_user_id_unique": {
"name": "name_user_id_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"api_keys_user_id_user_id_fk": {
"name": "api_keys_user_id_user_id_fk",
"tableFrom": "api_keys",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
},
"email_expires_at_idx": {
"name": "email_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"to_address": {
"name": "to_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"message_email_id_idx": {
"name": "message_email_id_idx",
"columns": [
"emailId"
],
"isUnique": false
}
},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -92,6 +92,13 @@
"when": 1747926565177,
"tag": "0012_steady_nitro",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1750081604094,
"tag": "0013_illegal_senator_kelly",
"breakpoints": true
}
]
}

View File

@@ -33,7 +33,6 @@
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/typography": "^0.5.15",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"drizzle-orm": "^0.36.4",
@@ -56,8 +55,8 @@
"@types/bun": "^1.1.14",
"@types/next-pwa": "^5.6.9",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"bun": "^1.1.39",
"cloudflare": "^4.1.0",
"dotenv": "^16.4.7",

779
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,11 @@ interface Env {
DB: D1Database
}
const MAX_EMAIL_COUNT = 5
const MAX_MESSAGE_COUNT = 50
const MAX_EMAIL_COUNT = 10
const MAX_MESSAGE_COUNT = 100
const BATCH_SIZE = 10 // SQLite 变量限制
async function getUserId(db: ReturnType<typeof drizzle>, identifier: string): Promise<string | null> {
// 尝试通过 email 查找用户
let user = await db
.select()
.from(users)
@@ -20,7 +19,6 @@ async function getUserId(db: ReturnType<typeof drizzle>, identifier: string): Pr
.limit(1)
.then(rows => rows[0])
// 如果没找到,尝试通过 username 查找
if (!user) {
user = await db
.select()
@@ -38,13 +36,11 @@ async function generateTestData(env: Env, userIdentifier: string) {
const now = new Date()
try {
// 获取用户 ID
const userId = await getUserId(db, userIdentifier)
if (!userId) {
throw new Error(`未找到用户: ${userIdentifier}`)
}
// 生成测试邮箱
const testEmails = Array.from({ length: MAX_EMAIL_COUNT }).map(() => ({
id: crypto.randomUUID(),
address: `${nanoid(6)}@moemail.app`,
@@ -53,34 +49,49 @@ async function generateTestData(env: Env, userIdentifier: string) {
expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
}))
// 插入测试邮箱
const emailResults = await db.insert(emails).values(testEmails).returning()
console.log('Created test emails:', emailResults)
// 为每个邮箱生成测试消息
for (const email of emailResults) {
const allMessages = Array.from({ length: MAX_MESSAGE_COUNT }).map((_, index) => ({
const receivedMessages = Array.from({ length: Math.floor(MAX_MESSAGE_COUNT * 0.7) }).map((_, index) => ({
id: crypto.randomUUID(),
emailId: email.id,
fromAddress: `sender${index + 1}@example.com`,
subject: `Test Message ${index + 1} - ${nanoid(6)}`,
content: `This is test message ${index + 1} content.\n\nBest regards,\nSender ${index + 1}`,
toAddress: null,
subject: `Received Message ${index + 1} - ${nanoid(6)}`,
content: `This is received message ${index + 1} content.\n\nBest regards,\nSender ${index + 1}`,
html: `<div>
<h1>Test Message ${index + 1}</h1>
<p>This is test message ${index + 1} content.</p>
<h1>Received Message ${index + 1}</h1>
<p>This is received message ${index + 1} content.</p>
<p>With some <strong>HTML</strong> formatting.</p>
<br>
<p>Best regards,<br>Sender ${index + 1}</p>
</div>`,
type: 'received',
receivedAt: new Date(now.getTime() - index * 60 * 60 * 1000),
}))
// 分批插入消息
const sentMessages = Array.from({ length: Math.floor(MAX_MESSAGE_COUNT * 0.3) }).map((_, index) => ({
id: crypto.randomUUID(),
emailId: email.id,
fromAddress: null,
toAddress: `recipient${index + 1}@example.com`,
subject: `Sent Message ${index + 1} - ${nanoid(6)}`,
html: `This is sent message ${index + 1} content.\n\nBest regards,\n${email.address}`,
content: '',
type: 'sent',
sentAt: new Date(now.getTime() - index * 60 * 60 * 1000),
}))
const allMessages = [...receivedMessages, ...sentMessages]
for (let i = 0; i < allMessages.length; i += BATCH_SIZE) {
const batch = allMessages.slice(i, i + BATCH_SIZE)
await db.insert(messages).values(batch)
console.log(`Created batch of ${batch.length} messages for email ${email.address}`)
console.log(`Created batch of ${batch.length} messages (received + sent) for email ${email.address}`)
}
console.log(`Email ${email.address}: ${receivedMessages.length} received, ${sentMessages.length} sent messages`)
}
console.log('Test data generation completed successfully!')

View File

@@ -1,5 +1,5 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
import tailwindcssAnimate from "tailwindcss-animate";
const config = {
darkMode: ["class"],
@@ -78,7 +78,7 @@ const config = {
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
plugins: [tailwindcssAnimate],
} satisfies Config;
export default config;