From e85f6b04bdc433b155b76b6308bd4c82334b7eb9 Mon Sep 17 00:00:00 2001 From: beilunyang <786220806@qq.com> Date: Sat, 21 Jun 2025 23:50:46 +0800 Subject: [PATCH] feat: implement email sending functionality via Resend service --- app/api/config/email-service/route.ts | 99 +++ app/api/emails/[id]/[messageId]/route.ts | 5 +- app/api/emails/[id]/route.ts | 41 +- app/api/emails/[id]/send/route.ts | 134 +++ app/api/emails/send-permission/route.ts | 29 + .../emails/message-list-container.tsx | 76 ++ app/components/emails/message-list.tsx | 37 +- app/components/emails/message-view.tsx | 69 +- app/components/emails/send-dialog.tsx | 138 ++++ app/components/emails/three-column-layout.tsx | 62 +- .../profile/email-service-config.tsx | 261 ++++++ app/components/profile/profile-card.tsx | 8 +- ...fig-panel.tsx => website-config-panel.tsx} | 2 +- app/components/ui/checkbox.tsx | 53 ++ app/components/ui/tabs.tsx | 81 +- app/components/ui/textarea.tsx | 26 + app/config/email.ts | 6 + app/hooks/use-send-permission.ts | 52 ++ app/lib/schema.ts | 9 +- app/lib/send-permissions.ts | 125 +++ drizzle/0013_illegal_senator_kelly.sql | 20 + drizzle/meta/0013_snapshot.json | 645 +++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 5 +- pnpm-lock.yaml | 779 +++++++++--------- scripts/generate-test-data.ts | 41 +- tailwind.config.ts | 4 +- 27 files changed, 2347 insertions(+), 467 deletions(-) create mode 100644 app/api/config/email-service/route.ts create mode 100644 app/api/emails/[id]/send/route.ts create mode 100644 app/api/emails/send-permission/route.ts create mode 100644 app/components/emails/message-list-container.tsx create mode 100644 app/components/emails/send-dialog.tsx create mode 100644 app/components/profile/email-service-config.tsx rename app/components/profile/{config-panel.tsx => website-config-panel.tsx} (99%) create mode 100644 app/components/ui/checkbox.tsx create mode 100644 app/components/ui/textarea.tsx create mode 100644 app/hooks/use-send-permission.ts create mode 100644 app/lib/send-permissions.ts create mode 100644 drizzle/0013_illegal_senator_kelly.sql create mode 100644 drizzle/meta/0013_snapshot.json diff --git a/app/api/config/email-service/route.ts b/app/api/config/email-service/route.ts new file mode 100644 index 0000000..b3e8cce --- /dev/null +++ b/app/api/config/email-service/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/emails/[id]/[messageId]/route.ts b/app/api/emails/[id]/[messageId]/route.ts index 71e59cb..8a949c2 100644 --- a/app/api/emails/[id]/[messageId]/route.ts +++ b/app/api/emails/[id]/[messageId]/route.ts @@ -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) { diff --git a/app/api/emails/[id]/route.ts b/app/api/emails/[id]/route.ts index be1e706..29e70bf 100644 --- a/app/api/emails/[id]/route.ts +++ b/app/api/emails/[id]/route.ts @@ -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`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 diff --git a/app/api/emails/[id]/send/route.ts b/app/api/emails/[id]/send/route.ts new file mode 100644 index 0000000..297cd71 --- /dev/null +++ b/app/api/emails/[id]/send/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/api/emails/send-permission/route.ts b/app/api/emails/send-permission/route.ts new file mode 100644 index 0000000..c613db6 --- /dev/null +++ b/app/api/emails/send-permission/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/app/components/emails/message-list-container.tsx b/app/components/emails/message-list-container.tsx new file mode 100644 index 0000000..3214aa6 --- /dev/null +++ b/app/components/emails/message-list-container.tsx @@ -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 ( +
+ {canSendEmails ? ( + +
+ + + + 收件箱 + + + + 已发送 + + +
+ + + + + + + + +
+ ) : ( +
+ +
+ )} +
+ ) +} \ No newline at end of file diff --git a/app/components/emails/message-list.tsx b/app/components/emails/message-list.tsx index 1a4e346..9d3e2a0 100644 --- a/app/components/emails/message-list.tsx +++ b/app/components/emails/message-list.tsx @@ -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([]) 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 ( <>
@@ -210,7 +227,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa {messages.map(message => (
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

{message.subject}

- {message.from_address} + + {message.from_address || message.to_address || ''} + - {new Date(message.received_at).toLocaleString()} + {new Date(message.received_at || message.sent_at || 0).toLocaleString()}
@@ -250,7 +269,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
) : (
- 暂无邮件 + {messageType === 'sent' ? '暂无发送的邮件' : '暂无收到的邮件'}
)}
diff --git a/app/components/emails/message-view.tsx b/app/components/emails/message-view.tsx index fb229b3..b66adfa 100644 --- a/app/components/emails/message-view.tsx +++ b/app/components/emails/message-view.tsx @@ -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(null) const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const [viewMode, setViewMode] = useState("html") const iframeRef = useRef(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 (
+ 加载邮件详情... +
+ ) + } + + if (error) { + return ( +
+

{error}

+
) } @@ -162,12 +208,17 @@ export function MessageView({ emailId, messageId }: MessageViewProps) {

{message.subject}

-

发件人:{message.from_address}

-

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

+ {message.from_address && ( +

发件人:{message.from_address}

+ )} + {message.to_address && ( +

收件人:{message.to_address}

+ )} +

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

- {message.html && ( + {message.html && message.content && (
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 ( + + + + + + + + + +

使用此邮箱发送新邮件

+
+
+
+ + + 发送新邮件 + +
+
+ 发件人: {fromAddress} +
+ ) => setTo(e.target.value)} + placeholder="收件人邮箱地址" + /> + ) => setSubject(e.target.value)} + placeholder="邮件主题" + /> +