mirror of
https://github.com/beilunyang/moemail.git
synced 2026-05-11 18:11:27 +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">
|
||||
<p>发件人:{message.from_address}</p>
|
||||
<p>时间:{new Date(message.received_at).toLocaleString()}</p>
|
||||
{message.from_address && (
|
||||
<p>发件人:{message.from_address}</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,11 +72,20 @@ export function ThreeColumnLayout() {
|
||||
<div className={headerClass}>
|
||||
<h2 className={titleClass}>
|
||||
{selectedEmail ? (
|
||||
<div className="w-full 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 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">
|
||||
<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 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)
|
||||
}
|
||||
Reference in New Issue
Block a user