From dbe8c42b11aeecf0736eeb07e8731558bebcda6c Mon Sep 17 00:00:00 2001 From: beilunyang <786220806@qq.com> Date: Sat, 18 Oct 2025 20:08:42 +0800 Subject: [PATCH] feat(sharing): add email and message sharing functionality --- .vscode/settings.json | 1 + README.md | 179 ++++ app/[locale]/page.tsx | 11 +- app/[locale]/shared/[token]/page-client.tsx | 308 +++++++ app/[locale]/shared/[token]/page.tsx | 52 ++ .../shared/message/[token]/page-client.tsx | 72 ++ app/[locale]/shared/message/[token]/page.tsx | 33 + .../[messageId]/share/[shareId]/route.ts | 55 ++ .../[id]/messages/[messageId]/share/route.ts | 119 +++ app/api/emails/[id]/share/[shareId]/route.ts | 46 + app/api/emails/[id]/share/route.ts | 101 +++ .../[token]/messages/[messageId]/route.ts | 83 ++ app/api/shared/[token]/messages/route.ts | 124 +++ app/api/shared/[token]/route.ts | 64 ++ app/api/shared/message/[token]/route.ts | 69 ++ app/components/emails/email-list.tsx | 26 +- app/components/emails/message-list.tsx | 31 +- app/components/emails/message-view.tsx | 17 +- app/components/emails/share-dialog.tsx | 347 ++++++++ .../emails/share-message-dialog.tsx | 356 ++++++++ app/components/emails/shared-error-page.tsx | 41 + .../emails/shared-message-detail.tsx | 241 +++++ app/components/emails/shared-message-list.tsx | 131 +++ app/components/float-menu.tsx | 9 +- .../layout/floating-language-switcher.tsx | 76 ++ app/components/profile/api-key-panel.tsx | 130 +++ app/components/ui/brand-header.tsx | 116 +++ app/i18n/messages/en/emails.json | 81 ++ app/i18n/messages/en/home.json | 8 +- app/i18n/messages/en/metadata.json | 6 +- app/i18n/messages/en/profile.json | 10 +- app/i18n/messages/zh-CN/emails.json | 81 ++ app/i18n/messages/zh-CN/home.json | 8 +- app/i18n/messages/zh-CN/metadata.json | 6 +- app/i18n/messages/zh-CN/profile.json | 10 +- app/lib/apiKey.ts | 10 +- app/lib/schema.ts | 44 + app/lib/shared-data.ts | 192 ++++ drizzle/0014_jazzy_gressill.sql | 13 + drizzle/0015_majestic_chimera.sql | 13 + drizzle/0016_hesitant_thing.sql | 2 + drizzle/meta/0014_snapshot.json | 734 ++++++++++++++++ drizzle/meta/0015_snapshot.json | 823 ++++++++++++++++++ drizzle/meta/0016_snapshot.json | 807 +++++++++++++++++ drizzle/meta/_journal.json | 21 + 45 files changed, 5669 insertions(+), 38 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/[locale]/shared/[token]/page-client.tsx create mode 100644 app/[locale]/shared/[token]/page.tsx create mode 100644 app/[locale]/shared/message/[token]/page-client.tsx create mode 100644 app/[locale]/shared/message/[token]/page.tsx create mode 100644 app/api/emails/[id]/messages/[messageId]/share/[shareId]/route.ts create mode 100644 app/api/emails/[id]/messages/[messageId]/share/route.ts create mode 100644 app/api/emails/[id]/share/[shareId]/route.ts create mode 100644 app/api/emails/[id]/share/route.ts create mode 100644 app/api/shared/[token]/messages/[messageId]/route.ts create mode 100644 app/api/shared/[token]/messages/route.ts create mode 100644 app/api/shared/[token]/route.ts create mode 100644 app/api/shared/message/[token]/route.ts create mode 100644 app/components/emails/share-dialog.tsx create mode 100644 app/components/emails/share-message-dialog.tsx create mode 100644 app/components/emails/shared-error-page.tsx create mode 100644 app/components/emails/shared-message-detail.tsx create mode 100644 app/components/emails/shared-message-list.tsx create mode 100644 app/components/layout/floating-language-switcher.tsx create mode 100644 app/components/ui/brand-header.tsx create mode 100644 app/lib/shared-data.ts create mode 100644 drizzle/0014_jazzy_gressill.sql create mode 100644 drizzle/0015_majestic_chimera.sql create mode 100644 drizzle/0016_hesitant_thing.sql create mode 100644 drizzle/meta/0014_snapshot.json create mode 100644 drizzle/meta/0015_snapshot.json create mode 100644 drizzle/meta/0016_snapshot.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/README.md b/README.md index dd7a984..68cd3ac 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,159 @@ GET /api/emails/{emailId}/{messageId} - `html`: 邮件HTML内容 - `received_at`: 接收时间(时间戳) +#### 创建邮箱分享链接 +```http +POST /api/emails/{emailId}/share +Content-Type: application/json + +{ + "expiresIn": 86400000 +} +``` +参数说明: +- `emailId`: 邮箱的唯一标识符,必填 +- `expiresIn`: 分享链接有效期(毫秒),0 表示永久有效,可选 + +返回响应: +```json +{ + "id": "share-uuid-123", + "emailId": "email-uuid-123", + "token": "abc123def456", + "expiresAt": "2024-01-02T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z" +} +``` +响应字段说明: +- `id`: 分享记录的唯一标识符 +- `emailId`: 关联的邮箱 ID +- `token`: 分享链接的访问令牌 +- `expiresAt`: 分享链接过期时间,null 表示永久有效 +- `createdAt`: 创建时间 + +分享链接访问地址:`https://your-domain.com/shared/{token}` + +#### 获取邮箱的所有分享链接 +```http +GET /api/emails/{emailId}/share +``` +参数说明: +- `emailId`: 邮箱的唯一标识符,必填 + +返回响应: +```json +{ + "shares": [ + { + "id": "share-uuid-123", + "emailId": "email-uuid-123", + "token": "abc123def456", + "expiresAt": "2024-01-02T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z" + } + ], + "total": 1 +} +``` +响应字段说明: +- `shares`: 分享链接列表数组 +- `total`: 分享链接总数 + +#### 删除邮箱分享链接 +```http +DELETE /api/emails/{emailId}/share/{shareId} +``` +参数说明: +- `emailId`: 邮箱的唯一标识符,必填 +- `shareId`: 分享记录的唯一标识符,必填 + +返回响应: +```json +{ + "success": true +} +``` +响应字段说明: +- `success`: 删除操作是否成功 + +#### 创建邮件分享链接 +```http +POST /api/emails/{emailId}/messages/{messageId}/share +Content-Type: application/json + +{ + "expiresIn": 86400000 +} +``` +参数说明: +- `emailId`: 邮箱的唯一标识符,必填 +- `messageId`: 邮件的唯一标识符,必填 +- `expiresIn`: 分享链接有效期(毫秒),0 表示永久有效,可选 + +返回响应: +```json +{ + "id": "share-uuid-456", + "messageId": "message-uuid-789", + "token": "xyz789ghi012", + "expiresAt": "2024-01-02T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z" +} +``` +响应字段说明: +- `id`: 分享记录的唯一标识符 +- `messageId`: 关联的邮件 ID +- `token`: 分享链接的访问令牌 +- `expiresAt`: 分享链接过期时间,null 表示永久有效 +- `createdAt`: 创建时间 + +分享链接访问地址:`https://your-domain.com/shared/message/{token}` + +#### 获取邮件的所有分享链接 +```http +GET /api/emails/{emailId}/messages/{messageId}/share +``` +参数说明: +- `emailId`: 邮箱的唯一标识符,必填 +- `messageId`: 邮件的唯一标识符,必填 + +返回响应: +```json +{ + "shares": [ + { + "id": "share-uuid-456", + "messageId": "message-uuid-789", + "token": "xyz789ghi012", + "expiresAt": "2024-01-02T12:00:00.000Z", + "createdAt": "2024-01-01T12:00:00.000Z" + } + ], + "total": 1 +} +``` +响应字段说明: +- `shares`: 分享链接列表数组 +- `total`: 分享链接总数 + +#### 删除邮件分享链接 +```http +DELETE /api/emails/{emailId}/messages/{messageId}/share/{shareId} +``` +参数说明: +- `emailId`: 邮箱的唯一标识符,必填 +- `messageId`: 邮件的唯一标识符,必填 +- `shareId`: 分享记录的唯一标识符,必填 + +返回响应: +```json +{ + "success": true +} +``` +响应字段说明: +- `success`: 删除操作是否成功 + ### 使用示例 使用 curl 创建临时邮箱: @@ -590,6 +743,32 @@ const res = await fetch('https://your-domain.com/api/emails/your-email-id', { const data = await res.json(); ``` +使用 curl 创建邮箱分享链接: +```bash +curl -X POST https://your-domain.com/api/emails/your-email-id/share \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "expiresIn": 86400000 + }' +``` + +使用 JavaScript 创建邮件分享链接: +```javascript +const res = await fetch('https://your-domain.com/api/emails/your-email-id/messages/your-message-id/share', { + method: 'POST', + headers: { + 'X-API-Key': 'YOUR_API_KEY', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + expiresIn: 0 // 永久有效 + }) +}); +const data = await res.json(); +console.log('分享链接:', `https://your-domain.com/shared/message/${data.token}`); +``` + ## 环境变量 本项目使用以下环境变量: diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index ffad3bd..606fa0a 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,6 +1,6 @@ import { Header } from "@/components/layout/header" import { auth } from "@/lib/auth" -import { Shield, Mail, Clock } from "lucide-react" +import { Shield, Share2, Clock, Code2 } from "lucide-react" import { ActionButton } from "@/components/home/action-button" import { FeatureCard } from "@/components/home/feature-card" import { getTranslations } from "next-intl/server" @@ -38,14 +38,14 @@ export default async function Home({

-
+
} title={t("features.privacy.title")} description={t("features.privacy.description")} /> } + icon={} title={t("features.instant.title")} description={t("features.instant.description")} /> @@ -54,6 +54,11 @@ export default async function Home({ title={t("features.expiry.title")} description={t("features.expiry.description")} /> + } + title={t("features.openapi.title")} + description={t("features.openapi.description")} + />
diff --git a/app/[locale]/shared/[token]/page-client.tsx b/app/[locale]/shared/[token]/page-client.tsx new file mode 100644 index 0000000..8d52598 --- /dev/null +++ b/app/[locale]/shared/[token]/page-client.tsx @@ -0,0 +1,308 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { useTranslations } from "next-intl" +import { BrandHeader } from "@/components/ui/brand-header" +import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher" +import { SharedMessageList } from "@/components/emails/shared-message-list" +import { SharedMessageDetail } from "@/components/emails/shared-message-detail" +import { EMAIL_CONFIG } from "@/config" + +interface Email { + id: string + address: string + createdAt: string + expiresAt: string +} + +interface Message { + id: string + from_address?: string + to_address?: string + subject: string + received_at?: string + sent_at?: string +} + +interface MessageDetail extends Message { + content?: string + html?: string +} + +interface SharedEmailPageClientProps { + email: Email + initialMessages: Message[] + initialNextCursor: string | null + initialTotal: number + token: string +} + +export function SharedEmailPageClient({ + email, + initialMessages, + initialNextCursor, + initialTotal, + token +}: SharedEmailPageClientProps) { + const t = useTranslations("emails") + const tShared = useTranslations("emails.shared") + + const [messages, setMessages] = useState(initialMessages) + const [selectedMessage, setSelectedMessage] = useState(null) + const [messageLoading, setMessageLoading] = useState(false) + const [nextCursor, setNextCursor] = useState(initialNextCursor) + const [loadingMore, setLoadingMore] = useState(false) + const [total, setTotal] = useState(initialTotal) + const [refreshing, setRefreshing] = useState(false) + const pollTimeoutRef = useRef(null) + const messagesRef = useRef(initialMessages) + + // 当 messages 改变时更新 ref + useEffect(() => { + messagesRef.current = messages + }, [messages]) + + const fetchMessages = async (cursor?: string) => { + try { + if (cursor) { + setLoadingMore(true) + } + + const url = new URL(`/api/shared/${token}/messages`, window.location.origin) + if (cursor) { + url.searchParams.set('cursor', cursor) + } + + const messagesResponse = await fetch(url) + if (messagesResponse.ok) { + const messagesData = await messagesResponse.json() as { + messages: Message[] + nextCursor: string | null + total: number + } + + if (!cursor) { + // 刷新时:合并新消息和旧消息,避免重复 + const newMessages = messagesData.messages + const oldMessages = messagesRef.current + + // 找到第一个重复的消息 + const lastDuplicateIndex = newMessages.findIndex( + newMsg => oldMessages.some(oldMsg => oldMsg.id === newMsg.id) + ) + + if (lastDuplicateIndex === -1) { + // 没有重复,直接使用新消息 + setMessages(newMessages) + setNextCursor(messagesData.nextCursor) + setTotal(messagesData.total) + return + } + // 有重复,只添加新的消息 + const uniqueNewMessages = newMessages.slice(0, lastDuplicateIndex) + setMessages([...uniqueNewMessages, ...oldMessages]) + setTotal(messagesData.total) + return + } + // 加载更多:追加到列表末尾 + setMessages(prev => [...prev, ...(messagesData.messages || [])]) + setNextCursor(messagesData.nextCursor) + setTotal(messagesData.total) + } + } catch (err) { + console.error("Failed to fetch messages:", err) + } finally { + setLoadingMore(false) + setRefreshing(false) + } + } + + const startPolling = () => { + stopPolling() + pollTimeoutRef.current = setInterval(() => { + if (!refreshing && !loadingMore) { + fetchMessages() + } + }, EMAIL_CONFIG.POLL_INTERVAL) + } + + const stopPolling = () => { + if (pollTimeoutRef.current) { + clearInterval(pollTimeoutRef.current) + pollTimeoutRef.current = null + } + } + + const handleRefresh = async () => { + setRefreshing(true) + await fetchMessages() + } + + // 启动轮询 + useEffect(() => { + startPolling() + return () => { + stopPolling() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]) + + const handleLoadMore = () => { + if (nextCursor && !loadingMore) { + fetchMessages(nextCursor) + } + } + + const fetchMessageDetail = async (messageId: string) => { + try { + setMessageLoading(true) + + const response = await fetch(`/api/shared/${token}/messages/${messageId}`) + + if (!response.ok) { + throw new Error("Failed to load message") + } + + const data = await response.json() as { message: MessageDetail } + setSelectedMessage(data.message) + } catch (err) { + console.error("Failed to fetch message:", err) + } finally { + setMessageLoading(false) + } + } + + return ( +
+
+ + + {/* 桌面端双栏布局 */} +
+
+ ({ + ...msg, + received_at: msg.received_at ? new Date(msg.received_at as string).getTime() : undefined, + sent_at: msg.sent_at ? new Date(msg.sent_at as string).getTime() : undefined + }))} + selectedMessageId={selectedMessage?.id} + onMessageSelect={fetchMessageDetail} + onLoadMore={handleLoadMore} + onRefresh={handleRefresh} + loading={false} + loadingMore={loadingMore} + refreshing={refreshing} + hasMore={!!nextCursor} + total={total} + t={{ + received: t("messages.received"), + noMessages: t("messages.noMessages"), + messageCount: t("messages.messageCount"), + loading: t("messageView.loading"), + loadingMore: t("messages.loadingMore") + }} + /> +
+ +
+ +
+
+ + {/* 移动端单栏布局 */} +
+
+ {!selectedMessage ? ( + // 消息列表视图 + ({ + ...msg, + received_at: msg.received_at ? new Date(msg.received_at as string).getTime() : undefined, + sent_at: msg.sent_at ? new Date(msg.sent_at as string).getTime() : undefined + }))} + selectedMessageId={null} + onMessageSelect={fetchMessageDetail} + onLoadMore={handleLoadMore} + onRefresh={handleRefresh} + loading={false} + loadingMore={loadingMore} + refreshing={refreshing} + hasMore={!!nextCursor} + total={total} + t={{ + received: t("messages.received"), + noMessages: t("messages.noMessages"), + messageCount: t("messages.messageCount"), + loading: t("messageView.loading"), + loadingMore: t("messages.loadingMore") + }} + /> + ) : ( + // 消息详情视图 + <> +
+ + {t("layout.messageContent")} +
+
+ +
+ + )} +
+
+
+ + +
+ ) +} diff --git a/app/[locale]/shared/[token]/page.tsx b/app/[locale]/shared/[token]/page.tsx new file mode 100644 index 0000000..627c16c --- /dev/null +++ b/app/[locale]/shared/[token]/page.tsx @@ -0,0 +1,52 @@ +import { getTranslations } from "next-intl/server" +import { getSharedEmail, getSharedEmailMessages } from "@/lib/shared-data" +import { SharedErrorPage } from "@/components/emails/shared-error-page" +import { SharedEmailPageClient } from "./page-client" + +interface PageProps { + params: Promise<{ + token: string + locale: string + }> +} + +export default async function SharedEmailPage({ params }: PageProps) { + const { token } = await params + const tShared = await getTranslations("emails.shared") + + // 服务端获取数据 + const email = await getSharedEmail(token) + + if (!email) { + return ( + + ) + } + + // 获取初始消息列表 + const messagesResult = await getSharedEmailMessages(token) + + return ( + ({ + ...msg, + received_at: msg.received_at?.toISOString(), + sent_at: msg.sent_at?.toISOString() + }))} + initialNextCursor={messagesResult.nextCursor} + initialTotal={messagesResult.total} + token={token} + /> + ) +} diff --git a/app/[locale]/shared/message/[token]/page-client.tsx b/app/[locale]/shared/message/[token]/page-client.tsx new file mode 100644 index 0000000..b75cc8e --- /dev/null +++ b/app/[locale]/shared/message/[token]/page-client.tsx @@ -0,0 +1,72 @@ +"use client" + +import { useTranslations } from "next-intl" +import { BrandHeader } from "@/components/ui/brand-header" +import { FloatingLanguageSwitcher } from "@/components/layout/floating-language-switcher" +import { SharedMessageDetail } from "@/components/emails/shared-message-detail" + +interface MessageDetail { + id: string + from_address?: string + to_address?: string + subject: string + content?: string + html?: string + received_at?: Date + sent_at?: Date + expiresAt?: Date + emailAddress?: string + emailExpiresAt?: Date +} + +interface SharedMessagePageClientProps { + message: MessageDetail +} + +export function SharedMessagePageClient({ message }: SharedMessagePageClientProps) { + const t = useTranslations("emails") + const tShared = useTranslations("emails.shared") + + return ( +
+
+ + +
+
+ +
+
+
+ + +
+ ) +} diff --git a/app/[locale]/shared/message/[token]/page.tsx b/app/[locale]/shared/message/[token]/page.tsx new file mode 100644 index 0000000..353ad7d --- /dev/null +++ b/app/[locale]/shared/message/[token]/page.tsx @@ -0,0 +1,33 @@ +import { getTranslations } from "next-intl/server" +import { getSharedMessage } from "@/lib/shared-data" +import { SharedErrorPage } from "@/components/emails/shared-error-page" +import { SharedMessagePageClient } from "./page-client" + +interface PageProps { + params: Promise<{ + token: string + locale: string + }> +} + +export default async function SharedMessagePage({ params }: PageProps) { + const { token } = await params + const tShared = await getTranslations("emails.shared") + + // 服务端获取数据 + const message = await getSharedMessage(token) + + if (!message) { + return ( + + ) + } + + return +} diff --git a/app/api/emails/[id]/messages/[messageId]/share/[shareId]/route.ts b/app/api/emails/[id]/messages/[messageId]/share/[shareId]/route.ts new file mode 100644 index 0000000..ab0e0cd --- /dev/null +++ b/app/api/emails/[id]/messages/[messageId]/share/[shareId]/route.ts @@ -0,0 +1,55 @@ +import { createDb } from "@/lib/db" +import { messageShares, messages, emails } from "@/lib/schema" +import { eq, and } from "drizzle-orm" +import { NextResponse } from "next/server" +import { getUserId } from "@/lib/apiKey" + +export const runtime = "edge" + +// 删除消息分享链接 +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; messageId: string; shareId: string }> } +) { + const userId = await getUserId() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: emailId, messageId, shareId } = await params + const db = createDb() + + try { + // 验证邮箱所有权 + const email = await db.query.emails.findFirst({ + where: and(eq(emails.id, emailId), eq(emails.userId, userId)) + }) + + if (!email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }) + } + + // 获取消息并验证 + const message = await db.query.messages.findFirst({ + where: and(eq(messages.id, messageId), eq(messages.emailId, emailId)) + }) + + if (!message) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }) + } + + // 删除分享记录 + await db.delete(messageShares).where( + and(eq(messageShares.id, shareId), eq(messageShares.messageId, messageId)) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to delete message share:", error) + return NextResponse.json( + { error: "Failed to delete share" }, + { status: 500 } + ) + } +} + diff --git a/app/api/emails/[id]/messages/[messageId]/share/route.ts b/app/api/emails/[id]/messages/[messageId]/share/route.ts new file mode 100644 index 0000000..f0c8d06 --- /dev/null +++ b/app/api/emails/[id]/messages/[messageId]/share/route.ts @@ -0,0 +1,119 @@ +import { createDb } from "@/lib/db" +import { messageShares, messages, emails } from "@/lib/schema" +import { eq, and } from "drizzle-orm" +import { NextResponse } from "next/server" +import { getUserId } from "@/lib/apiKey" +import { nanoid } from "nanoid" + +export const runtime = "edge" + +// 获取消息的所有分享链接 +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string; messageId: string }> } +) { + const userId = await getUserId() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: emailId, messageId } = await params + const db = createDb() + + try { + // 验证邮箱所有权 + const email = await db.query.emails.findFirst({ + where: and(eq(emails.id, emailId), eq(emails.userId, userId)) + }) + + if (!email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }) + } + + // 获取消息 + const message = await db.query.messages.findFirst({ + where: and(eq(messages.id, messageId), eq(messages.emailId, emailId)) + }) + + if (!message) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }) + } + + // 获取该消息的所有分享链接 + const shares = await db.query.messageShares.findMany({ + where: eq(messageShares.messageId, messageId), + orderBy: (messageShares, { desc }) => [desc(messageShares.createdAt)] + }) + + return NextResponse.json({ shares, total: shares.length }) + } catch (error) { + console.error("Failed to fetch message shares:", error) + return NextResponse.json( + { error: "Failed to fetch shares" }, + { status: 500 } + ) + } +} + +// 创建新的消息分享链接 +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string; messageId: string }> } +) { + const userId = await getUserId() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: emailId, messageId } = await params + const db = createDb() + + try { + // 验证邮箱所有权 + const email = await db.query.emails.findFirst({ + where: and(eq(emails.id, emailId), eq(emails.userId, userId)) + }) + + if (!email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }) + } + + // 获取消息并验证 + const message = await db.query.messages.findFirst({ + where: and(eq(messages.id, messageId), eq(messages.emailId, emailId)) + }) + + if (!message) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }) + } + + // 解析请求体 + const body = await request.json() as { expiresIn: number } + const { expiresIn } = body // expiresIn 单位为毫秒,0表示永久 + + // 生成简短的分享token (16个字符) + const token = nanoid(16) + + // 计算过期时间 + let expiresAt = null + if (expiresIn && expiresIn > 0) { + expiresAt = new Date(Date.now() + expiresIn) + } + + // 创建分享记录 + const [share] = await db.insert(messageShares).values({ + messageId, + token, + expiresAt + }).returning() + + return NextResponse.json(share, { status: 201 }) + } catch (error) { + console.error("Failed to create message share:", error) + return NextResponse.json( + { error: "Failed to create share" }, + { status: 500 } + ) + } +} + diff --git a/app/api/emails/[id]/share/[shareId]/route.ts b/app/api/emails/[id]/share/[shareId]/route.ts new file mode 100644 index 0000000..52078dd --- /dev/null +++ b/app/api/emails/[id]/share/[shareId]/route.ts @@ -0,0 +1,46 @@ +import { createDb } from "@/lib/db" +import { emailShares, emails } from "@/lib/schema" +import { eq, and } from "drizzle-orm" +import { NextResponse } from "next/server" +import { getUserId } from "@/lib/apiKey" + +export const runtime = "edge" + +// 删除分享链接 +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; shareId: string }> } +) { + const userId = await getUserId() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: emailId, shareId } = await params + const db = createDb() + + try { + // 验证邮箱所有权 + const email = await db.query.emails.findFirst({ + where: and(eq(emails.id, emailId), eq(emails.userId, userId)) + }) + + if (!email) { + return NextResponse.json({ error: "Email not found" }, { status: 404 }) + } + + // 删除分享记录 + await db.delete(emailShares).where( + and(eq(emailShares.id, shareId), eq(emailShares.emailId, emailId)) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Failed to delete email share:", error) + return NextResponse.json( + { error: "Failed to delete share" }, + { status: 500 } + ) + } +} + diff --git a/app/api/emails/[id]/share/route.ts b/app/api/emails/[id]/share/route.ts new file mode 100644 index 0000000..74c48cf --- /dev/null +++ b/app/api/emails/[id]/share/route.ts @@ -0,0 +1,101 @@ +import { createDb } from "@/lib/db" +import { emailShares, emails } from "@/lib/schema" +import { eq, and } from "drizzle-orm" +import { NextResponse } from "next/server" +import { getUserId } from "@/lib/apiKey" +import { nanoid } from "nanoid" + +export const runtime = "edge" + +// 获取邮箱的所有分享链接 +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const userId = await getUserId() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: emailId } = await params + const db = createDb() + + try { + // 验证邮箱所有权 + const email = await db.query.emails.findFirst({ + where: and(eq(emails.id, emailId), eq(emails.userId, userId)) + }) + + if (!email) { + return NextResponse.json({ error: "Email not found" }, { status: 404 }) + } + + // 获取该邮箱的所有分享链接 + const shares = await db.query.emailShares.findMany({ + where: eq(emailShares.emailId, emailId), + orderBy: (emailShares, { desc }) => [desc(emailShares.createdAt)] + }) + + return NextResponse.json({ shares, total: shares.length }) + } catch (error) { + console.error("Failed to fetch email shares:", error) + return NextResponse.json( + { error: "Failed to fetch shares" }, + { status: 500 } + ) + } +} + +// 创建新的分享链接 +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const userId = await getUserId() + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id: emailId } = await params + const db = createDb() + + try { + // 验证邮箱所有权 + const email = await db.query.emails.findFirst({ + where: and(eq(emails.id, emailId), eq(emails.userId, userId)) + }) + + if (!email) { + return NextResponse.json({ error: "Email not found" }, { status: 404 }) + } + + // 解析请求体 + const body = await request.json() as { expiresIn: number } + const { expiresIn } = body // expiresIn 单位为毫秒,0表示永久 + + // 生成简短的分享token (16个字符) + const token = nanoid(16) + + // 计算过期时间 + let expiresAt = null + if (expiresIn && expiresIn > 0) { + expiresAt = new Date(Date.now() + expiresIn) + } + + // 创建分享记录 + const [share] = await db.insert(emailShares).values({ + emailId, + token, + expiresAt + }).returning() + + return NextResponse.json(share, { status: 201 }) + } catch (error) { + console.error("Failed to create email share:", error) + return NextResponse.json( + { error: "Failed to create share" }, + { status: 500 } + ) + } +} + diff --git a/app/api/shared/[token]/messages/[messageId]/route.ts b/app/api/shared/[token]/messages/[messageId]/route.ts new file mode 100644 index 0000000..23bed9a --- /dev/null +++ b/app/api/shared/[token]/messages/[messageId]/route.ts @@ -0,0 +1,83 @@ +import { createDb } from "@/lib/db" +import { emailShares, messages } from "@/lib/schema" +import { eq, and } from "drizzle-orm" +import { NextResponse } from "next/server" + +export const runtime = "edge" + +// 通过分享token获取消息详情 +export async function GET( + request: Request, + { params }: { params: Promise<{ token: string; messageId: string }> } +) { + const { token, messageId } = await params + const db = createDb() + + try { + // 验证分享token + const share = await db.query.emailShares.findFirst({ + where: eq(emailShares.token, token), + with: { + email: true + } + }) + + if (!share) { + return NextResponse.json( + { error: "Share link not found or expired" }, + { status: 404 } + ) + } + + // 检查分享是否过期 + if (share.expiresAt && share.expiresAt < new Date()) { + return NextResponse.json( + { error: "Share link has expired" }, + { status: 410 } + ) + } + + // 检查邮箱是否过期 + if (share.email.expiresAt < new Date()) { + return NextResponse.json( + { error: "Email has expired" }, + { status: 410 } + ) + } + + // 获取消息详情 + const message = await db.query.messages.findFirst({ + where: and( + eq(messages.id, messageId), + eq(messages.emailId, share.email.id) + ) + }) + + if (!message) { + return NextResponse.json( + { error: "Message not found" }, + { status: 404 } + ) + } + + return NextResponse.json({ + 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, + sent_at: message.sentAt + } + }) + } catch (error) { + console.error("Failed to fetch shared message:", error) + return NextResponse.json( + { error: "Failed to fetch message" }, + { status: 500 } + ) + } +} + diff --git a/app/api/shared/[token]/messages/route.ts b/app/api/shared/[token]/messages/route.ts new file mode 100644 index 0000000..2dfacb3 --- /dev/null +++ b/app/api/shared/[token]/messages/route.ts @@ -0,0 +1,124 @@ +import { createDb } from "@/lib/db" +import { emailShares, messages } from "@/lib/schema" +import { eq, and, lt, or, sql, ne, isNull } from "drizzle-orm" +import { NextResponse } from "next/server" +import { encodeCursor, decodeCursor } from "@/lib/cursor" + +export const runtime = "edge" + +const PAGE_SIZE = 20 + +// 通过分享token获取邮箱的消息列表 +export async function GET( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params + const db = createDb() + const { searchParams } = new URL(request.url) + const cursor = searchParams.get('cursor') + + try { + // 验证分享token + const share = await db.query.emailShares.findFirst({ + where: eq(emailShares.token, token), + with: { + email: true + } + }) + + if (!share) { + return NextResponse.json( + { error: "Share link not found or expired" }, + { status: 404 } + ) + } + + // 检查分享是否过期 + if (share.expiresAt && share.expiresAt < new Date()) { + return NextResponse.json( + { error: "Share link has expired" }, + { status: 410 } + ) + } + + // 检查邮箱是否过期 + if (share.email.expiresAt < new Date()) { + return NextResponse.json( + { error: "Email has expired" }, + { status: 410 } + ) + } + + const emailId = share.email.id + + // 只显示接收的邮件,不显示发送的邮件 + const baseConditions = and( + eq(messages.emailId, emailId), + or( + ne(messages.type, "sent"), + isNull(messages.type) + ) + ) + + // 获取消息总数(只统计接收的邮件) + const totalResult = await db.select({ count: sql`count(*)` }) + .from(messages) + .where(baseConditions) + const totalCount = Number(totalResult[0].count) + + const conditions = [baseConditions] + + if (cursor) { + const { timestamp, id } = decodeCursor(cursor) + const cursorCondition = or( + lt(messages.receivedAt, new Date(timestamp)), + and( + eq(messages.receivedAt, new Date(timestamp)), + lt(messages.id, id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + + const results = await db.query.messages.findMany({ + where: and(...conditions), + orderBy: (messages, { desc }) => [ + desc(messages.receivedAt), + desc(messages.id) + ], + limit: PAGE_SIZE + 1 + }) + + const hasMore = results.length > PAGE_SIZE + const nextCursor = hasMore + ? encodeCursor( + results[PAGE_SIZE - 1].receivedAt.getTime(), + results[PAGE_SIZE - 1].id + ) + : null + const messageList = hasMore ? results.slice(0, PAGE_SIZE) : results + + return NextResponse.json({ + messages: messageList.map(msg => ({ + id: msg.id, + from_address: msg.fromAddress, + to_address: msg.toAddress, + subject: msg.subject, + received_at: msg.receivedAt, + sent_at: msg.sentAt + })), + nextCursor, + total: totalCount + }) + } catch (error) { + console.error("Failed to fetch shared messages:", error) + return NextResponse.json( + { error: "Failed to fetch messages" }, + { status: 500 } + ) + } +} + diff --git a/app/api/shared/[token]/route.ts b/app/api/shared/[token]/route.ts new file mode 100644 index 0000000..0001ece --- /dev/null +++ b/app/api/shared/[token]/route.ts @@ -0,0 +1,64 @@ +import { createDb } from "@/lib/db" +import { emailShares } from "@/lib/schema" +import { eq } from "drizzle-orm" +import { NextResponse } from "next/server" + +export const runtime = "edge" + +// 通过分享token获取邮箱信息 +export async function GET( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params + const db = createDb() + + try { + // 查找分享记录 + const share = await db.query.emailShares.findFirst({ + where: eq(emailShares.token, token), + with: { + email: true + } + }) + + if (!share) { + return NextResponse.json( + { error: "Share link not found or expired" }, + { status: 404 } + ) + } + + // 检查分享是否过期 + if (share.expiresAt && share.expiresAt < new Date()) { + return NextResponse.json( + { error: "Share link has expired" }, + { status: 410 } + ) + } + + // 检查邮箱是否过期 + if (share.email.expiresAt < new Date()) { + return NextResponse.json( + { error: "Email has expired" }, + { status: 410 } + ) + } + + return NextResponse.json({ + email: { + id: share.email.id, + address: share.email.address, + createdAt: share.email.createdAt, + expiresAt: share.email.expiresAt + } + }) + } catch (error) { + console.error("Failed to fetch shared email:", error) + return NextResponse.json( + { error: "Failed to fetch shared email" }, + { status: 500 } + ) + } +} + diff --git a/app/api/shared/message/[token]/route.ts b/app/api/shared/message/[token]/route.ts new file mode 100644 index 0000000..9af4ab5 --- /dev/null +++ b/app/api/shared/message/[token]/route.ts @@ -0,0 +1,69 @@ +import { createDb } from "@/lib/db" +import { messageShares, messages } from "@/lib/schema" +import { eq } from "drizzle-orm" +import { NextResponse } from "next/server" + +export const runtime = "edge" + +// 通过分享token获取消息详情 +export async function GET( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params + const db = createDb() + + try { + // 验证分享token + const share = await db.query.messageShares.findFirst({ + where: eq(messageShares.token, token) + }) + + if (!share) { + return NextResponse.json( + { error: "Share link not found or disabled" }, + { status: 404 } + ) + } + + // 检查分享是否过期 + if (share.expiresAt && share.expiresAt < new Date()) { + return NextResponse.json( + { error: "Share link has expired" }, + { status: 410 } + ) + } + + // 获取消息详情 + const message = await db.query.messages.findFirst({ + where: eq(messages.id, share.messageId) + }) + + if (!message) { + return NextResponse.json( + { error: "Message not found" }, + { status: 404 } + ) + } + + return NextResponse.json({ + 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, + sent_at: message.sentAt + } + }) + } catch (error) { + console.error("Failed to fetch shared message:", error) + return NextResponse.json( + { error: "Failed to fetch message" }, + { status: 500 } + ) + } +} + diff --git a/app/components/emails/email-list.tsx b/app/components/emails/email-list.tsx index b9df34d..21b8271 100644 --- a/app/components/emails/email-list.tsx +++ b/app/components/emails/email-list.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react" import { useSession } from "next-auth/react" import { useTranslations } from "next-intl" import { CreateDialog } from "./create-dialog" +import { ShareDialog } from "./share-dialog" import { Mail, RefreshCw, Trash2 } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -209,17 +210,20 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { )}
- +
e.stopPropagation()}> + + +
))} {loadingMore && ( diff --git a/app/components/emails/message-list.tsx b/app/components/emails/message-list.tsx index babf05d..ae8e532 100644 --- a/app/components/emails/message-list.tsx +++ b/app/components/emails/message-list.tsx @@ -2,12 +2,13 @@ import { useState, useEffect, useRef } from "react" import { useTranslations } from "next-intl" -import {Mail, Calendar, RefreshCw, Trash2} from "lucide-react" +import {Mail, Calendar, RefreshCw, Trash2, Share2} from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { useThrottle } from "@/hooks/use-throttle" import { EMAIL_CONFIG } from "@/config" import { useToast } from "@/components/ui/use-toast" +import { ShareMessageDialog } from "./share-message-dialog" import { AlertDialog, AlertDialogAction, @@ -219,7 +220,7 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa - {total > 0 ? `${total} ${t("noMessages")}` : t("noMessages")} + {total > 0 ? `${total} ${t("messageCount")}` : t("noMessages")} @@ -251,17 +252,33 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa - + } + /> + + > + + + ))} diff --git a/app/components/emails/message-view.tsx b/app/components/emails/message-view.tsx index 75b7f0e..1a27046 100644 --- a/app/components/emails/message-view.tsx +++ b/app/components/emails/message-view.tsx @@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from "react" import { useTranslations } from "next-intl" -import { Loader2 } from "lucide-react" +import { Loader2, Share2 } 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" +import { ShareMessageDialog } from "./share-message-dialog" interface Message { id: string @@ -209,7 +210,19 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me return (
-

{message.subject}

+
+

{message.subject}

+ + + + } + /> +
{message.from_address && (

{t("from")}: {message.from_address}

diff --git a/app/components/emails/share-dialog.tsx b/app/components/emails/share-dialog.tsx new file mode 100644 index 0000000..a5ea670 --- /dev/null +++ b/app/components/emails/share-dialog.tsx @@ -0,0 +1,347 @@ +"use client" + +import { useState, useEffect } from "react" +import { useTranslations } from "next-intl" +import { Share2, Copy, Trash2, Link2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useToast } from "@/components/ui/use-toast" +import { useCopy } from "@/hooks/use-copy" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { EXPIRY_OPTIONS } from "@/types/email" + +interface ShareDialogProps { + emailId: string + emailAddress: string +} + +interface ShareLink { + id: string + token: string + createdAt: number | string | Date + expiresAt: number | string | Date | null + enabled: boolean +} + +export function ShareDialog({ emailId }: ShareDialogProps) { + const t = useTranslations("emails.share") + const { toast } = useToast() + const { copyToClipboard } = useCopy() + + const [open, setOpen] = useState(false) + const [shares, setShares] = useState([]) + const [loading, setLoading] = useState(false) + const [creating, setCreating] = useState(false) + const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString()) + const [deleteTarget, setDeleteTarget] = useState(null) + + const fetchShares = async () => { + try { + setLoading(true) + const response = await fetch(`/api/emails/${emailId}/share`) + if (!response.ok) throw new Error("Failed to fetch shares") + + const data = await response.json() as { shares: ShareLink[] } + setShares(data.shares || []) + } catch (error) { + console.error("Failed to fetch shares:", error) + toast({ + title: t("createFailed"), + description: String(error), + variant: "destructive" + }) + } finally { + setLoading(false) + } + } + + const createShare = async () => { + try { + setCreating(true) + const response = await fetch(`/api/emails/${emailId}/share`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expiresIn: Number(expiryTime) }) + }) + + if (!response.ok) throw new Error("Failed to create share") + + const share = await response.json() as ShareLink + setShares(prev => [share, ...prev]) + + toast({ + title: t("createSuccess"), + }) + } catch (error) { + console.error("Failed to create share:", error) + toast({ + title: t("createFailed"), + description: String(error), + variant: "destructive" + }) + } finally { + setCreating(false) + } + } + + const deleteShare = async (share: ShareLink) => { + try { + const response = await fetch(`/api/emails/${emailId}/share/${share.id}`, { + method: "DELETE" + }) + + if (!response.ok) throw new Error("Failed to delete share") + + setShares(prev => prev.filter(s => s.id !== share.id)) + + toast({ + title: t("deleteSuccess"), + }) + } catch (error) { + console.error("Failed to delete share:", error) + toast({ + title: t("deleteFailed"), + description: String(error), + variant: "destructive" + }) + } finally { + setDeleteTarget(null) + } + } + + const getShareUrl = (token: string) => { + return `${window.location.origin}/shared/${token}` + } + + const handleCopy = async (token: string) => { + const url = getShareUrl(token) + const success = await copyToClipboard(url) + + if (success) { + toast({ + title: t("copied"), + }) + } else { + toast({ + title: t("copyFailed"), + variant: "destructive" + }) + } + } + + useEffect(() => { + if (open) { + fetchShares() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + return ( + <> + + + + + e.preventDefault()} + onEscapeKeyDown={(e) => { + if (deleteTarget) { + e.preventDefault() + } + }} + > + + {t("title")} + + {t("description")} + + + +
+ {/* Create new share link */} +
+ +
+ + +
+
+ + {/* Active share links */} +
+ +
+ {loading ? ( +
+
+ {t("loading")} +
+ ) : shares.length === 0 ? ( +
+ {t("noLinks")} +
+ ) : ( +
+ {shares.map(share => { + // 将expiresAt转换为时间戳进行比较 + const expiresAtTime = share.expiresAt + ? (typeof share.expiresAt === 'number' + ? share.expiresAt + : new Date(share.expiresAt).getTime()) + : null + const isExpired = expiresAtTime !== null && expiresAtTime < Date.now() + return ( +
+ +
+ + {t("createdAt")}: {new Date( + typeof share.createdAt === 'number' + ? share.createdAt + : share.createdAt + ).toLocaleString()} + + + {t("expiresAt")}: { + share.expiresAt + ? new Date( + typeof share.expiresAt === 'number' + ? share.expiresAt + : share.expiresAt + ).toLocaleString() + : t("permanent") + } + + {isExpired && ( + + + {t("expired")} + + )} +
+
+ ) + })} +
+ )} +
+
+
+ +
+ + setDeleteTarget(null)}> + + + {t("deleteConfirm")} + + {t("deleteDescription")} + + + + {t("cancel")} + deleteTarget && deleteShare(deleteTarget)} + > + {t("delete")} + + + + + + ) +} + diff --git a/app/components/emails/share-message-dialog.tsx b/app/components/emails/share-message-dialog.tsx new file mode 100644 index 0000000..4afc543 --- /dev/null +++ b/app/components/emails/share-message-dialog.tsx @@ -0,0 +1,356 @@ +"use client" + +import { useState, useEffect } from "react" +import { useTranslations } from "next-intl" +import { Share2, Copy, Trash2, Link2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useToast } from "@/components/ui/use-toast" +import { useCopy } from "@/hooks/use-copy" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { EXPIRY_OPTIONS } from "@/types/email" + +interface ShareMessageDialogProps { + emailId: string + messageId: string + messageSubject: string + trigger?: React.ReactNode +} + +interface ShareLink { + id: string + token: string + createdAt: number | string | Date + expiresAt: number | string | Date | null + enabled: boolean +} + +export function ShareMessageDialog({ emailId, messageId, messageSubject, trigger }: ShareMessageDialogProps) { + const t = useTranslations("emails.shareMessage") + const { toast } = useToast() + const { copyToClipboard } = useCopy() + + const [open, setOpen] = useState(false) + const [shares, setShares] = useState([]) + const [loading, setLoading] = useState(false) + const [creating, setCreating] = useState(false) + const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString()) + const [deleteTarget, setDeleteTarget] = useState(null) + + const fetchShares = async () => { + try { + setLoading(true) + const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`) + if (!response.ok) throw new Error("Failed to fetch shares") + + const data = await response.json() as { shares: ShareLink[] } + setShares(data.shares || []) + } catch (error) { + console.error("Failed to fetch shares:", error) + toast({ + title: t("createFailed"), + description: String(error), + variant: "destructive" + }) + } finally { + setLoading(false) + } + } + + const createShare = async () => { + try { + setCreating(true) + const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expiresIn: Number(expiryTime) }) + }) + + if (!response.ok) throw new Error("Failed to create share") + + const share = await response.json() as ShareLink + setShares(prev => [share, ...prev]) + + toast({ + title: t("createSuccess"), + }) + } catch (error) { + console.error("Failed to create share:", error) + toast({ + title: t("createFailed"), + description: String(error), + variant: "destructive" + }) + } finally { + setCreating(false) + } + } + + const deleteShare = async (share: ShareLink) => { + try { + const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share/${share.id}`, { + method: "DELETE" + }) + + if (!response.ok) throw new Error("Failed to delete share") + + setShares(prev => prev.filter(s => s.id !== share.id)) + + toast({ + title: t("deleteSuccess"), + }) + } catch (error) { + console.error("Failed to delete share:", error) + toast({ + title: t("deleteFailed"), + description: String(error), + variant: "destructive" + }) + } finally { + setDeleteTarget(null) + } + } + + const getShareUrl = (token: string) => { + return `${window.location.origin}/shared/message/${token}` + } + + const handleCopy = async (token: string) => { + const url = getShareUrl(token) + const success = await copyToClipboard(url) + + if (success) { + toast({ + title: t("copied"), + }) + } else { + toast({ + title: t("copyFailed"), + variant: "destructive" + }) + } + } + + useEffect(() => { + if (open) { + fetchShares() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + return ( + <> + + + {trigger || ( + + )} + + e.preventDefault()} + onEscapeKeyDown={(e) => { + if (deleteTarget) { + e.preventDefault() + } + }} + > + + {t("title")} + + {t("description")} + + + +
+ {/* Message info */} +
+

{messageSubject}

+
+ + {/* Create new share link */} +
+ +
+ + +
+
+ + {/* Active share links */} +
+ +
+ {loading ? ( +
+
+ {t("loading")} +
+ ) : shares.length === 0 ? ( +
+ {t("noLinks")} +
+ ) : ( +
+ {shares.map(share => { + // 将expiresAt转换为时间戳进行比较 + const expiresAtTime = share.expiresAt + ? (typeof share.expiresAt === 'number' + ? share.expiresAt + : new Date(share.expiresAt).getTime()) + : null + const isExpired = expiresAtTime !== null && expiresAtTime < Date.now() + return ( +
+ +
+ + {t("createdAt")}: {new Date( + typeof share.createdAt === 'number' + ? share.createdAt + : share.createdAt + ).toLocaleString()} + + + {t("expiresAt")}: { + share.expiresAt + ? new Date( + typeof share.expiresAt === 'number' + ? share.expiresAt + : share.expiresAt + ).toLocaleString() + : t("permanent") + } + + {isExpired && ( + + + {t("expired")} + + )} +
+
+ ) + })} +
+ )} +
+
+
+ +
+ + setDeleteTarget(null)}> + + + {t("deleteConfirm")} + + {t("deleteDescription")} + + + + {t("cancel")} + deleteTarget && deleteShare(deleteTarget)} + > + {t("delete")} + + + + + + ) +} + diff --git a/app/components/emails/shared-error-page.tsx b/app/components/emails/shared-error-page.tsx new file mode 100644 index 0000000..8c99abd --- /dev/null +++ b/app/components/emails/shared-error-page.tsx @@ -0,0 +1,41 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { Card } from "@/components/ui/card" +import { BrandHeader } from "@/components/ui/brand-header" +import { LanguageSwitcher } from "@/components/layout/language-switcher" + +interface SharedErrorPageProps { + title: string + subtitle: string + error: string + description: string + ctaText: string +} + +export function SharedErrorPage({ title, subtitle, error, description, ctaText }: SharedErrorPageProps) { + return ( +
+
+
+ +
+ +
+ + +

{error}

+

+ {description} +

+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/components/emails/shared-message-detail.tsx b/app/components/emails/shared-message-detail.tsx new file mode 100644 index 0000000..1518a82 --- /dev/null +++ b/app/components/emails/shared-message-detail.tsx @@ -0,0 +1,241 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { Loader2 } from "lucide-react" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" +import { useTheme } from "next-themes" + +interface MessageDetail { + id: string + from_address?: string + to_address?: string + subject: string + content?: string + html?: string + received_at?: number + sent_at?: number +} + +interface SharedMessageDetailProps { + message: MessageDetail | null + loading?: boolean + t: { + messageContent: string + selectMessage: string + loading: string + from: string + to: string + subject: string + time: string + htmlFormat: string + textFormat: string + } +} + +type ViewMode = "html" | "text" + +export function SharedMessageDetail({ + message, + loading = false, + t, +}: SharedMessageDetailProps) { + const [viewMode, setViewMode] = useState("html") + const iframeRef = useRef(null) + const { theme } = useTheme() + + // 如果没有HTML内容,默认显示文本 + useEffect(() => { + if (message) { + if (!message.html && message.content) { + setViewMode("text") + } else if (message.html) { + setViewMode("html") + } + } + }, [message]) + + const updateIframeContent = () => { + if (viewMode === "html" && message?.html && iframeRef.current) { + const iframe = iframeRef.current + const doc = iframe.contentDocument || iframe.contentWindow?.document + + if (doc) { + doc.open() + doc.write(` + + + + + + + ${message.html} + + `) + doc.close() + + const updateHeight = () => { + const container = iframe.parentElement + if (container) { + iframe.style.height = `${container.clientHeight}px` + } + } + + updateHeight() + window.addEventListener("resize", updateHeight) + + const resizeObserver = new ResizeObserver(updateHeight) + resizeObserver.observe(doc.body) + + doc.querySelectorAll("img").forEach((img: HTMLImageElement) => { + img.onload = updateHeight + }) + + return () => { + window.removeEventListener("resize", updateHeight) + resizeObserver.disconnect() + } + } + } + } + + useEffect(() => { + updateIframeContent() + }, [message?.html, viewMode, theme]) + + if (loading) { + return ( +
+ + {t.loading} +
+ ) + } + + if (!message) { + return ( +
+ {t.selectMessage} +
+ ) + } + + return ( +
+
+
+

{message.subject}

+
+
+ {message.from_address && ( +

+ {t.from}: {message.from_address} +

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

+ {t.to}: {message.to_address} +

+ )} +

+ {t.time}:{" "} + {new Date( + message.sent_at || message.received_at || 0 + ).toLocaleString()} +

+
+
+ + {message.html && message.content && ( +
+ setViewMode(value as ViewMode)} + className="flex items-center gap-4" + > +
+ + +
+
+ + +
+
+
+ )} + +
+ {viewMode === "html" && message.html ? ( +