diff --git a/README.md b/README.md index c69b8c2..14ffd7e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ 邮箱域名配置权限系统系统设置 • + 发件功能Webhook 集成OpenAPI环境变量 • @@ -47,6 +48,7 @@ - 📱 **PWA 支持**:支持 PWA 安装 - 💸 **免费自部署**:基于 Cloudflare 构建, 可实现免费自部署,无需任何费用 - 🎉 **可爱的 UI**:简洁可爱萌萌哒 UI 界面 +- 📤 **发件功能**:支持使用临时邮箱发送邮件,基于 Resend 服务 - 🔔 **Webhook 通知**:支持通过 webhook 接收新邮件通知 - 🛡️ **权限系统**:支持基于角色的权限控制系统 - 🔑 **OpenAPI**:支持通过 API Key 访问 OpenAPI @@ -284,6 +286,73 @@ pnpm dlx tsx ./scripts/deploy/index.ts **皇帝**角色可以在个人中心页面设置 +## 发件功能 + +MoeMail 支持使用临时邮箱发送邮件,基于 [Resend](https://resend.com/) 服务。 + +### 功能特性 + +- 📨 **临时邮箱发件**:可以使用创建的临时邮箱作为发件人发送邮件 +- 🎯 **角色权限控制**:不同角色有不同的每日发件限制 +- 💌 **支持 HTML**:支持发送富文本格式邮件 + +### 角色发件权限 + +| 角色 | 每日发件限制 | 说明 | +|------|-------------|------| +| 皇帝 (Emperor) | 无限制 | 网站管理员,无发件限制 | +| 公爵 (Duke) | 5封/天 | 默认每日可发送5封邮件 | +| 骑士 (Knight) | 2封/天 | 默认每日可发送2封邮件 | +| 平民 (Civilian) | 禁止发件 | 无发件权限 | + +> 💡 **提示**:皇帝可以在个人中心的邮件服务配置中自定义公爵和骑士的每日发件限制。 + +### 配置发件服务 + +1. **获取 Resend API Key** + - 访问 [Resend 官网](https://resend.com/) 注册账号 + - 在控制台中创建 API Key + - 复制 API Key 供后续配置使用 + +2. **配置发件服务** + - 皇帝角色登录 MoeMail + - 进入个人中心页面 + - 在"Resend 发件服务配置"部分: + - 启用发件服务开关 + - 填入 Resend API Key + - 设置公爵和骑士的每日发件限制(可选) + - 点击保存配置 + +3. **验证配置** + - 配置保存后,有权限的用户在邮箱列表页面会看到"发送邮件"按钮 + - 点击按钮可以打开发件对话框进行测试 + +### 使用发件功能 + +1. **创建临时邮箱** + - 在邮箱页面创建一个新的临时邮箱 + +2. **发送邮件** + - 在邮箱列表中找到要使用的邮箱 + - 点击邮箱旁边的"发送邮件"按钮 + - 在弹出的对话框中填写: + - 收件人邮箱地址 + - 邮件主题 + - 邮件内容(支持 HTML 格式) + - 点击"发送"按钮 + +3. **查看发送记录** + - 发送的邮件会自动保存到对应邮箱的消息列表中 + - 可以在邮箱详情页面查看所有发送和接收的邮件 + +### 注意事项 + +- 📋 **Resend 限制**:请注意 Resend 服务的发送限制和定价政策 +- 🔐 **域名验证**:使用自定义域名发件需要在 Resend 中验证域名 +- 🚫 **反垃圾邮件**:请遵守邮件发送规范,避免发送垃圾邮件 +- 📊 **配额监控**:系统会自动统计每日发件数量,达到限额后将无法继续发送 +- 🔄 **配额重置**:每日发件配额在每天 00:00 自动重置 + ## Webhook 集成 当收到新邮件时,系统会向用户配置并且已启用的 Webhook URL 发送 POST 请求。 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..2015849 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,13 +46,13 @@ 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) const [nextCursor, setNextCursor] = useState(null) const [loadingMore, setLoadingMore] = useState(false) - const pollTimeoutRef = useRef() + const pollTimeoutRef = useRef(null) const messagesRef = useRef([]) // 添加 ref 来追踪最新的消息列表 const [total, setTotal] = useState(0) const [messageToDelete, setMessageToDelete] = useState(null) @@ -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) } @@ -109,7 +118,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa const stopPolling = () => { if (pollTimeoutRef.current) { clearInterval(pollTimeoutRef.current) - pollTimeoutRef.current = undefined + pollTimeoutRef.current = null } } @@ -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="邮件主题" + /> +