mirror of
https://github.com/beilunyang/moemail.git
synced 2025-09-27 03:46:03 +08:00
feat: implement email sending functionality via Resend service
This commit is contained in:
99
app/api/config/email-service/route.ts
Normal file
99
app/api/config/email-service/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
@@ -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) {
|
||||
|
@@ -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
|
||||
|
134
app/api/emails/[id]/send/route.ts
Normal file
134
app/api/emails/[id]/send/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
29
app/api/emails/send-permission/route.ts
Normal file
29
app/api/emails/send-permission/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
76
app/components/emails/message-list-container.tsx
Normal file
76
app/components/emails/message-list-container.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
|
@@ -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}
|
||||
|
138
app/components/emails/send-dialog.tsx
Normal file
138
app/components/emails/send-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
|
261
app/components/profile/email-service-config.tsx
Normal file
261
app/components/profile/email-service-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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 />}
|
||||
|
||||
|
@@ -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>("")
|
53
app/components/ui/checkbox.tsx
Normal file
53
app/components/ui/checkbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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 }
|
26
app/components/ui/textarea.tsx
Normal file
26
app/components/ui/textarea.tsx
Normal 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 }
|
@@ -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
|
52
app/hooks/use-send-permission.ts
Normal file
52
app/hooks/use-send-permission.ts
Normal 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
|
||||
}
|
||||
}
|
@@ -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
125
app/lib/send-permissions.ts
Normal 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)
|
||||
}
|
20
drizzle/0013_illegal_senator_kelly.sql
Normal file
20
drizzle/0013_illegal_senator_kelly.sql
Normal 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`);
|
645
drizzle/meta/0013_snapshot.json
Normal file
645
drizzle/meta/0013_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
@@ -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
779
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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!')
|
||||
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user