feat(sharing): add email and message sharing functionality

This commit is contained in:
beilunyang
2025-10-18 20:08:42 +08:00
parent 47d555eaf5
commit dbe8c42b11
45 changed files with 5669 additions and 38 deletions

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

179
README.md
View File

@@ -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}`);
```
## 环境变量
本项目使用以下环境变量:

View File

@@ -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({
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 px-4 sm:px-0">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 px-4 sm:px-0">
<FeatureCard
icon={<Shield className="w-5 h-5" />}
title={t("features.privacy.title")}
description={t("features.privacy.description")}
/>
<FeatureCard
icon={<Mail className="w-5 h-5" />}
icon={<Share2 className="w-5 h-5" />}
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")}
/>
<FeatureCard
icon={<Code2 className="w-5 h-5" />}
title={t("features.openapi.title")}
description={t("features.openapi.description")}
/>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 px-4 sm:px-0">

View File

@@ -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<Message[]>(initialMessages)
const [selectedMessage, setSelectedMessage] = useState<MessageDetail | null>(null)
const [messageLoading, setMessageLoading] = useState(false)
const [nextCursor, setNextCursor] = useState<string | null>(initialNextCursor)
const [loadingMore, setLoadingMore] = useState(false)
const [total, setTotal] = useState(initialTotal)
const [refreshing, setRefreshing] = useState(false)
const pollTimeoutRef = useRef<Timer | null>(null)
const messagesRef = useRef<Message[]>(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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-7xl">
<BrandHeader
title={email.address}
subtitle={new Date(email.expiresAt).getFullYear() === 9999
? tShared("permanent")
: `${tShared("expiresAt")}: ${new Date(email.expiresAt).toLocaleDateString()} ${new Date(email.expiresAt).toLocaleTimeString()}`}
showCta={true}
ctaText={tShared("createOwnEmail")}
/>
{/* 桌面端双栏布局 */}
<div className="hidden lg:grid grid-cols-2 gap-4 h-[calc(100vh-280px)] mt-6">
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden">
<SharedMessageList
messages={messages.map(msg => ({
...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")
}}
/>
</div>
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden">
<SharedMessageDetail
message={selectedMessage ? {
...selectedMessage,
received_at: selectedMessage.received_at ? new Date(selectedMessage.received_at as string).getTime() : undefined,
sent_at: selectedMessage.sent_at ? new Date(selectedMessage.sent_at as string).getTime() : undefined
} : null}
loading={messageLoading}
t={{
messageContent: t("layout.messageContent"),
selectMessage: t("layout.selectMessage"),
loading: t("messageView.loading"),
from: t("messageView.from"),
to: t("messageView.to"),
subject: t("messages.subject"),
time: t("messageView.time"),
htmlFormat: t("messageView.htmlFormat"),
textFormat: t("messageView.textFormat")
}}
/>
</div>
</div>
{/* 移动端单栏布局 */}
<div className="lg:hidden h-[calc(100vh-260px)] mt-6">
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden h-full flex flex-col">
{!selectedMessage ? (
// 消息列表视图
<SharedMessageList
messages={messages.map(msg => ({
...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")
}}
/>
) : (
// 消息详情视图
<>
<div className="p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0">
<button
onClick={() => setSelectedMessage(null)}
className="text-sm text-primary"
>
{t("layout.backToMessageList")}
</button>
<span className="text-sm font-medium">{t("layout.messageContent")}</span>
</div>
<div className="flex-1 overflow-auto">
<SharedMessageDetail
message={{
...selectedMessage,
received_at: selectedMessage.received_at ? new Date(selectedMessage.received_at as string).getTime() : undefined,
sent_at: selectedMessage.sent_at ? new Date(selectedMessage.sent_at as string).getTime() : undefined
}}
loading={messageLoading}
t={{
messageContent: t("layout.messageContent"),
selectMessage: t("layout.selectMessage"),
loading: t("messageView.loading"),
from: t("messageView.from"),
to: t("messageView.to"),
subject: t("messages.subject"),
time: t("messageView.time"),
htmlFormat: t("messageView.htmlFormat"),
textFormat: t("messageView.textFormat")
}}
/>
</div>
</>
)}
</div>
</div>
</div>
<FloatingLanguageSwitcher />
</div>
)
}

View File

@@ -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 (
<SharedErrorPage
title={tShared("emailNotFound")}
subtitle={tShared("linkExpired")}
error={tShared("linkInvalid")}
description={tShared("linkInvalidDescription")}
ctaText={tShared("createOwnEmail")}
/>
)
}
// 获取初始消息列表
const messagesResult = await getSharedEmailMessages(token)
return (
<SharedEmailPageClient
email={{
...email,
createdAt: email.createdAt.toISOString(),
expiresAt: email.expiresAt.toISOString()
}}
initialMessages={messagesResult.messages.map(msg => ({
...msg,
received_at: msg.received_at?.toISOString(),
sent_at: msg.sent_at?.toISOString()
}))}
initialNextCursor={messagesResult.nextCursor}
initialTotal={messagesResult.total}
token={token}
/>
)
}

View File

@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-7xl">
<BrandHeader
title={message.emailAddress || message.to_address || message.subject}
subtitle={message.emailExpiresAt && new Date(message.emailExpiresAt).getFullYear() === 9999
? tShared("permanent")
: message.emailExpiresAt
? `${tShared("expiresAt")}: ${new Date(message.emailExpiresAt).toLocaleString()}`
: tShared("sharedMessage")}
showCta={true}
ctaText={tShared("createOwnEmail")}
/>
<div className="mt-6">
<div className="border-2 border-primary/20 bg-background rounded-lg overflow-hidden h-[calc(100vh-260px)] lg:h-[calc(100vh-280px)]">
<SharedMessageDetail
message={{
...message,
received_at: message.received_at ? new Date(message.received_at).getTime() : undefined,
sent_at: message.sent_at ? new Date(message.sent_at).getTime() : undefined
}}
loading={false}
t={{
messageContent: t("layout.messageContent"),
selectMessage: t("layout.selectMessage"),
loading: tShared("loading"),
from: t("messageView.from"),
to: t("messageView.to"),
subject: t("messages.subject"),
time: t("messageView.time"),
htmlFormat: t("messageView.htmlFormat"),
textFormat: t("messageView.textFormat")
}}
/>
</div>
</div>
</div>
<FloatingLanguageSwitcher />
</div>
)
}

View File

@@ -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 (
<SharedErrorPage
title={tShared("messageNotFound")}
subtitle={tShared("linkExpired")}
error={tShared("linkInvalid")}
description={tShared("linkInvalidDescription")}
ctaText={tShared("createOwnEmail")}
/>
)
}
return <SharedMessagePageClient message={message} />
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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<number>`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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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) {
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setEmailToDelete(email)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
<ShareDialog emailId={email.id} emailAddress={email.address} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setEmailToDelete(email)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
{loadingMore && (

View File

@@ -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
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} ${t("noMessages")}` : t("noMessages")}
{total > 0 ? `${total} ${t("messageCount")}` : t("noMessages")}
</span>
</div>
@@ -251,17 +252,33 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
</span>
</div>
</div>
<Button
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
<ShareMessageDialog
emailId={email.id}
messageId={message.id}
messageSubject={message.subject}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Share2 className="h-4 w-4" />
</Button>
}
/>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-8 w-8"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setMessageToDelete(message)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
))}

View File

@@ -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 (
<div className="h-full flex flex-col">
<div className="p-4 space-y-3 border-b border-primary/20">
<h3 className="text-base font-bold">{message.subject}</h3>
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
<ShareMessageDialog
emailId={emailId}
messageId={message.id}
messageSubject={message.subject}
trigger={
<button className="p-1.5 hover:bg-primary/10 rounded-md transition-colors">
<Share2 className="h-4 w-4 text-gray-500" />
</button>
}
/>
</div>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>{t("from")}: {message.from_address}</p>

View File

@@ -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<ShareLink[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(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 (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-[600px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
if (deleteTarget) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Create new share link */}
<div className="space-y-2">
<Label>{t("expiryTime")}</Label>
<div className="flex gap-2">
<Select value={expiryTime} onValueChange={setExpiryTime}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
{creating ? t("creating") : t("createLink")}
</Button>
</div>
</div>
{/* Active share links */}
<div className="space-y-2">
<Label>{t("activeLinks")}</Label>
<div className="h-[270px] overflow-y-auto">
{loading ? (
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span>{t("loading")}</span>
</div>
) : shares.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
{t("noLinks")}
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={share.id}
className={cn(
"p-3 border rounded-lg space-y-2 transition-all",
isExpired
? "border-destructive/30 bg-destructive/5 opacity-75"
: "border-border"
)}
>
<div className="flex items-center gap-2">
<Link2 className={cn(
"h-4 w-4 flex-shrink-0",
isExpired ? "text-destructive/60" : "text-primary/60"
)} />
<a
href={isExpired ? undefined : getShareUrl(share.token)}
target={isExpired ? undefined : "_blank"}
rel={isExpired ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (isExpired) {
e.preventDefault()
}
}}
className={cn(
"flex-1 text-xs p-1 rounded truncate font-mono transition-colors",
isExpired
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
)}
>
{getShareUrl(share.token)}
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleCopy(share.token)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => setDeleteTarget(share)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex gap-4 text-xs">
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("createdAt")}: {new Date(
typeof share.createdAt === 'number'
? share.createdAt
: share.createdAt
).toLocaleString()}
</span>
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("expiresAt")}: {
share.expiresAt
? new Date(
typeof share.expiresAt === 'number'
? share.expiresAt
: share.expiresAt
).toLocaleString()
: t("permanent")
}
</span>
{isExpired && (
<span className="text-destructive font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-destructive rounded-full"></span>
{t("expired")}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteShare(deleteTarget)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -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<ShareLink[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(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 (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
)}
</DialogTrigger>
<DialogContent
className="sm:max-w-[600px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
if (deleteTarget) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Message info */}
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<p className="text-sm font-medium truncate">{messageSubject}</p>
</div>
{/* Create new share link */}
<div className="space-y-2">
<Label>{t("expiryTime")}</Label>
<div className="flex gap-2">
<Select value={expiryTime} onValueChange={setExpiryTime}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
{creating ? t("creating") : t("createLink")}
</Button>
</div>
</div>
{/* Active share links */}
<div className="space-y-2">
<Label>{t("activeLinks")}</Label>
<div className="h-[270px] overflow-y-auto">
{loading ? (
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span>{t("loading")}</span>
</div>
) : shares.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
{t("noLinks")}
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={share.id}
className={cn(
"p-3 border rounded-lg space-y-2 transition-all",
isExpired
? "border-destructive/30 bg-destructive/5 opacity-75"
: "border-border"
)}
>
<div className="flex items-center gap-2">
<Link2 className={cn(
"h-4 w-4 flex-shrink-0",
isExpired ? "text-destructive/60" : "text-primary/60"
)} />
<a
href={isExpired ? undefined : getShareUrl(share.token)}
target={isExpired ? undefined : "_blank"}
rel={isExpired ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (isExpired) {
e.preventDefault()
}
}}
className={cn(
"flex-1 text-xs p-1 rounded truncate font-mono transition-colors",
isExpired
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
)}
>
{getShareUrl(share.token)}
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleCopy(share.token)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => setDeleteTarget(share)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex gap-4 text-xs">
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("createdAt")}: {new Date(
typeof share.createdAt === 'number'
? share.createdAt
: share.createdAt
).toLocaleString()}
</span>
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("expiresAt")}: {
share.expiresAt
? new Date(
typeof share.expiresAt === 'number'
? share.expiresAt
: share.expiresAt
).toLocaleString()
: t("permanent")
}
</span>
{isExpired && (
<span className="text-destructive font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-destructive rounded-full"></span>
{t("expired")}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteShare(deleteTarget)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-4xl">
<div className="flex justify-end mb-4">
<LanguageSwitcher />
</div>
<BrandHeader
title={title}
subtitle={subtitle}
showCta={true}
ctaText={ctaText}
/>
<div className="text-center">
<Card className="max-w-md mx-auto p-8 text-center space-y-4">
<AlertCircle className="h-12 w-12 mx-auto text-destructive" />
<h2 className="text-2xl font-bold">{error}</h2>
<p className="text-gray-500">
{description}
</p>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -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<ViewMode>("html")
const iframeRef = useRef<HTMLIFrameElement>(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(`
<!DOCTYPE html>
<html>
<head>
<base target="_blank">
<style>
html, body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: system-ui, -apple-system, sans-serif;
color: ${theme === "dark" ? "#fff" : "#000"};
background: ${theme === "dark" ? "#1a1a1a" : "#fff"};
}
body {
padding: 20px;
}
img {
max-width: 100%;
height: auto;
}
a {
color: #2563eb;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: ${
theme === "dark"
? "rgba(130, 109, 217, 0.3)"
: "rgba(130, 109, 217, 0.2)"
};
border-radius: 9999px;
transition: background-color 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: ${
theme === "dark"
? "rgba(130, 109, 217, 0.5)"
: "rgba(130, 109, 217, 0.4)"
};
}
* {
scrollbar-width: thin;
scrollbar-color: ${
theme === "dark"
? "rgba(130, 109, 217, 0.3) transparent"
: "rgba(130, 109, 217, 0.2) transparent"
};
}
</style>
</head>
<body>${message.html}</body>
</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 (
<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">{t.loading}</span>
</div>
)
}
if (!message) {
return (
<div className="flex items-center justify-center h-32 text-gray-500">
{t.selectMessage}
</div>
)
}
return (
<div className="h-full flex flex-col">
<div className="p-4 space-y-3 border-b border-primary/20">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
</div>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>
{t.from}: {message.from_address}
</p>
)}
{message.to_address && (
<p>
{t.to}: {message.to_address}
</p>
)}
<p>
{t.time}:{" "}
{new Date(
message.sent_at || message.received_at || 0
).toLocaleString()}
</p>
</div>
</div>
{message.html && message.content && (
<div className="border-b border-primary/20 p-2">
<RadioGroup
value={viewMode}
onValueChange={(value) => setViewMode(value as ViewMode)}
className="flex items-center gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="html" id="html" />
<Label htmlFor="html" className="text-xs cursor-pointer">
{t.htmlFormat}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" />
<Label htmlFor="text" className="text-xs cursor-pointer">
{t.textFormat}
</Label>
</div>
</RadioGroup>
</div>
)}
<div className="flex-1 overflow-auto relative">
{viewMode === "html" && message.html ? (
<iframe
ref={iframeRef}
className="absolute inset-0 w-full h-full border-0 bg-transparent"
sandbox="allow-same-origin allow-popups"
/>
) : message.content ? (
<div className="p-4 text-sm whitespace-pre-wrap">
{message.content}
</div>
) : (
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
{t.selectMessage}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { Mail, Calendar, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"
import { useThrottle } from "@/hooks/use-throttle"
import { Button } from "@/components/ui/button"
interface Message {
id: string
from_address?: string
to_address?: string
subject: string
received_at?: number
sent_at?: number
}
interface SharedMessageListProps {
messages: Message[]
selectedMessageId?: string | null
onMessageSelect: (messageId: string) => void
onLoadMore?: () => void
onRefresh?: () => void
loading?: boolean
loadingMore?: boolean
refreshing?: boolean
hasMore?: boolean
total?: number
t: {
received: string
noMessages: string
messageCount: string
loading: string
loadingMore: string
}
}
export function SharedMessageList({
messages,
selectedMessageId,
onMessageSelect,
onLoadMore,
onRefresh,
loading = false,
loadingMore = false,
refreshing = false,
hasMore = false,
total = 0,
t,
}: SharedMessageListProps) {
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
if (loadingMore || !hasMore || !onLoadMore) return
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
const threshold = clientHeight * 1.5
const remainingScroll = scrollHeight - scrollTop
if (remainingScroll <= threshold) {
onLoadMore()
}
}, 200)
return (
<div className="h-full flex flex-col">
<div className="p-2 flex justify-between items-center border-b border-primary/20">
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={refreshing || loading}
className={cn("h-8 w-8", refreshing && "animate-spin")}
>
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} ${t.messageCount}` : t.noMessages}
</span>
</div>
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto text-primary mb-2" />
{t.loading}
</div>
) : messages.length > 0 ? (
<div className="divide-y divide-primary/10">
{messages.map((message) => (
<div
key={message.id}
onClick={() => onMessageSelect(message.id)}
className={cn(
"p-3 hover:bg-primary/5 cursor-pointer",
selectedMessageId === message.id && "bg-primary/10"
)}
>
<div className="flex items-start gap-3">
<Mail className="w-4 h-4 text-primary/60 mt-1" />
<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 || message.to_address || ""}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(
message.received_at || message.sent_at || 0
).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
{t.loadingMore}
</div>
)}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
{t.noMessages}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useTranslations } from "next-intl"
import { usePathname } from "next/navigation"
import { Github } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
@@ -12,9 +13,15 @@ import {
export function FloatMenu() {
const t = useTranslations("common")
const pathname = usePathname()
// 在分享页面隐藏GitHub悬浮框
if (pathname.includes("/shared/")) {
return null
}
return (
<div className="fixed bottom-6 right-6">
<div className="fixed bottom-6 right-6 z-50">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -0,0 +1,76 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { usePathname, useRouter } from "next/navigation"
import { i18n } from "@/i18n/config"
import { Button } from "@/components/ui/button"
import { Languages } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function FloatingLanguageSwitcher() {
const t = useTranslations("common")
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const switchLocale = (newLocale: string) => {
if (newLocale === locale) return
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
const segments = pathname.split("/")
if (i18n.locales.includes(segments[1] as any)) {
segments[1] = newLocale
} else {
segments.splice(1, 0, newLocale)
}
const newPath = segments.join("/")
router.push(newPath)
router.refresh()
}
const getLanguageName = (loc: string) => {
switch (loc) {
case "en":
return "English"
case "zh-CN":
return "简体中文"
default:
return loc
}
}
return (
<div className="fixed bottom-6 right-6 z-50">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20 hover:border-primary/40 transition-all"
>
<Languages className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
<span className="sr-only">{t("lang.switch")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="mb-2">
{i18n.locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => switchLocale(loc)}
className={locale === loc ? "bg-accent" : ""}
>
{getLanguageName(loc)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -434,6 +434,134 @@ export function ApiKeyPanel() {
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.createEmailShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 86400000}'`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 86400000}'`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.getEmailShares")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-H "X-API-Key: YOUR_API_KEY"`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
-H "X-API-Key: YOUR_API_KEY"`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.deleteEmailShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
-H "X-API-Key: YOUR_API_KEY"`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
-H "X-API-Key: YOUR_API_KEY"`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.createMessageShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 0}'`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"expiresIn": 0}'`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.getMessageShares")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-H "X-API-Key: YOUR_API_KEY"`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
-H "X-API-Key: YOUR_API_KEY"`}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t("docs.deleteMessageShare")}</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
-H "X-API-Key: YOUR_API_KEY"`
)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
{`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
-H "X-API-Key: YOUR_API_KEY"`}
</pre>
</div>
<div className="text-xs text-muted-foreground mt-4">
<p>{t("docs.notes")}</p>
<ul className="list-disc list-inside space-y-1 mt-2">
@@ -445,6 +573,8 @@ export function ApiKeyPanel() {
<li>{t("docs.note6")}</li>
<li>{t("docs.note7")}</li>
<li>{t("docs.note8")}</li>
<li>{t("docs.note9")}</li>
<li>{t("docs.note10")}</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,116 @@
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { ExternalLink, Mail } from "lucide-react"
interface BrandHeaderProps {
title?: string
subtitle?: string
showCta?: boolean
ctaText?: string
ctaHref?: string
}
export function BrandHeader({
title,
subtitle,
showCta = true,
ctaText,
ctaHref = "https://moemail.app"
}: BrandHeaderProps) {
const t = useTranslations("emails.shared.brand")
const displayTitle = title || t("title")
const displaySubtitle = subtitle || t("subtitle")
const displayCtaText = ctaText || t("cta")
return (
<div className="text-center space-y-4 lg:pb-4">
<div className="flex justify-center pt-2">
<Link
href={ctaHref}
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
>
<div className="relative w-12 h-12">
<div className="absolute inset-0 grid grid-cols-8 grid-rows-8 gap-px">
<svg
width="48"
height="48"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-primary group-hover:scale-105 transition-transform duration-200"
>
{/* 信封主体 */}
<path
d="M4 8h24v16H4V8z"
className="fill-primary/20"
/>
{/* 信封边框 */}
<path
d="M4 8h24v2H4V8zM4 22h24v2H4v-2z"
className="fill-primary"
/>
{/* @ 符号 */}
<path
d="M14 12h4v4h-4v-4zM12 14h2v4h-2v-4zM18 14h2v4h-2v-4zM14 18h4v2h-4v-2z"
className="fill-primary"
/>
{/* 折线装饰 */}
<path
d="M4 8l12 8 12-8"
className="stroke-primary stroke-2"
fill="none"
/>
{/* 装饰点 */}
<path
d="M8 18h2v2H8v-2zM22 18h2v2h-2v-2z"
className="fill-primary/60"
/>
{/* 底部装饰线 */}
<path
d="M8 14h2v2H8v-2zM22 14h2v2h-2v-2z"
className="fill-primary/40"
/>
</svg>
</div>
</div>
<span className="text-3xl font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
MoeMail
</span>
</Link>
</div>
<div className="space-y-3">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
{displayTitle}
</h1>
<p className="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
{displaySubtitle}
</p>
</div>
{showCta && (
<div className="flex justify-center">
<Button
asChild
size="lg"
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8 min-h-10 h-auto py-1"
>
<Link href={ctaHref} target="_blank" rel="noopener noreferrer">
<Mail className="w-5 h-5" />
{displayCtaText}
<ExternalLink className="w-4 h-4" />
</Link>
</Button>
</div>
)}
</div>
)
}

View File

@@ -48,6 +48,7 @@
"received": "Received",
"sent": "Sent",
"noMessages": "No messages yet",
"messageCount": "messages",
"from": "From",
"to": "To",
"subject": "Subject",
@@ -82,6 +83,86 @@
"time": "Time",
"htmlFormat": "HTML Format",
"textFormat": "Plain Text Format"
},
"share": {
"title": "Share Mailbox",
"description": "Create a share link to let others view emails in this mailbox",
"createLink": "Create Link",
"creating": "Creating...",
"loading": "Loading...",
"expiryTime": "Link Expiry",
"oneHour": "1 Hour",
"oneDay": "1 Day",
"threeDays": "3 Days",
"oneWeek": "1 Week",
"permanent": "Permanent",
"activeLinks": "Active Share Links",
"noLinks": "No share links yet",
"createdAt": "Created",
"expiresAt": "Expires",
"expired": "Expired",
"copy": "Copy Link",
"copied": "Copied",
"copyFailed": "Copy failed",
"delete": "Delete",
"deleteConfirm": "Confirm delete share link?",
"deleteDescription": "This action cannot be undone. The share link will be invalidated immediately.",
"cancel": "Cancel",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"createSuccess": "Share link created successfully",
"createFailed": "Failed to create share link",
"shareButton": "Share"
},
"shareMessage": {
"title": "Share Message",
"description": "Create a share link to let others view this message",
"createLink": "Create Link",
"creating": "Creating...",
"loading": "Loading...",
"expiryTime": "Link Expiry",
"oneHour": "1 Hour",
"oneDay": "1 Day",
"threeDays": "3 Days",
"oneWeek": "1 Week",
"permanent": "Permanent",
"activeLinks": "Active Share Links",
"noLinks": "No share links yet",
"createdAt": "Created",
"expiresAt": "Expires",
"expired": "Expired",
"copy": "Copy Link",
"copied": "Copied",
"copyFailed": "Copy failed",
"delete": "Delete",
"deleteConfirm": "Confirm delete share link?",
"deleteDescription": "This action cannot be undone. The share link will be invalidated immediately.",
"cancel": "Cancel",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Failed to delete",
"createSuccess": "Share link created successfully",
"createFailed": "Failed to create share link",
"shareButton": "Share Message"
},
"shared": {
"loading": "Loading...",
"emailNotFound": "Cannot access mailbox",
"messageNotFound": "Cannot access message",
"linkExpired": "Share link does not exist or has expired",
"linkInvalid": "Invalid Link",
"linkInvalidDescription": "This share link may have expired or does not exist",
"sharedMailbox": "Shared Mailbox",
"sharedMessage": "Shared Message",
"expiresAt": "Expires at",
"permanent": "Permanent",
"createOwnEmail": "Create your own temporary email",
"brand": {
"title": "MoeMail",
"subtitle": "Cute temporary email service",
"cta": "Try Now",
"officialSite": "Official Site",
"copyright": "© 2024 MoeMail. Cute temporary email service"
}
}
}

View File

@@ -7,12 +7,16 @@
"description": "Protect your real email address"
},
"instant": {
"title": "Instant Delivery",
"description": "Receive emails in real-time"
"title": "Email Sharing",
"description": "Share your mailbox with others"
},
"expiry": {
"title": "Auto Expiry",
"description": "Automatically expires when due"
},
"openapi": {
"title": "Open API",
"description": "Full OpenAPI interface available"
}
},
"actions": {

View File

@@ -1,6 +1,6 @@
{
"title": "MoeMail - Cute Temporary Email Service",
"description": "Secure, fast, and disposable temporary email addresses. Protect your privacy and stay away from spam. Instant delivery with automatic expiration.",
"keywords": "temporary email, disposable email, anonymous email, privacy protection, spam filter, instant delivery, auto expiry, secure email, verification email, temporary account, cute email, email service, privacy security, MoeMail"
"title": "MoeMail - Cute Temporary Email Service · Open API",
"description": "Secure, fast, and disposable temporary email addresses. Protect your privacy and stay away from spam. Instant delivery with automatic expiration. Full OpenAPI interface with API Key support.",
"keywords": "temporary email, disposable email, anonymous email, privacy protection, spam filter, instant delivery, auto expiry, secure email, verification email, temporary account, cute email, email service, privacy security, OpenAPI, API interface, RESTful API, API Key, open interface, MoeMail"
}

View File

@@ -35,6 +35,12 @@
"getEmails": "Get Email List",
"getMessages": "Get Message List",
"getMessage": "Get Single Message",
"createEmailShare": "Create Email Share Link",
"getEmailShares": "Get Email Share Links",
"deleteEmailShare": "Delete Email Share Link",
"createMessageShare": "Create Message Share Link",
"getMessageShares": "Get Message Share Links",
"deleteMessageShare": "Delete Message Share Link",
"notes": "Notes:",
"note1": "Replace YOUR_API_KEY with your actual API Key",
"note2": "/api/config endpoint provides system configuration including available email domains",
@@ -43,7 +49,9 @@
"note5": "expiryTime is the validity period in milliseconds: 3600000 (1 hour), 86400000 (1 day), 604800000 (7 days), 0 (permanent)",
"note6": "domain is the email domain, get available domains from /api/config endpoint",
"note7": "cursor is for pagination, get nextCursor from previous response",
"note8": "All requests require X-API-Key header"
"note8": "All requests require X-API-Key header",
"note9": "expiresIn is the share link validity period in milliseconds, 0 means permanent",
"note10": "shareId is the unique identifier for a share record"
}
},
"emailService": {

View File

@@ -48,6 +48,7 @@
"received": "收件箱",
"sent": "已发送",
"noMessages": "暂无邮件",
"messageCount": "封邮件",
"from": "发件人",
"to": "收件人",
"subject": "主题",
@@ -82,6 +83,86 @@
"time": "时间",
"htmlFormat": "HTML 格式",
"textFormat": "纯文本格式"
},
"share": {
"title": "分享邮箱",
"description": "创建分享链接,让其他人可以查看此邮箱中的邮件",
"createLink": "创建链接",
"creating": "创建中...",
"loading": "加载中...",
"expiryTime": "链接有效期",
"oneHour": "1 小时",
"oneDay": "1 天",
"threeDays": "3 天",
"oneWeek": "1 周",
"permanent": "永久",
"activeLinks": "当前分享链接",
"noLinks": "暂无分享链接",
"createdAt": "创建时间",
"expiresAt": "过期时间",
"expired": "已过期",
"copy": "复制链接",
"copied": "已复制",
"copyFailed": "复制失败",
"delete": "删除",
"deleteConfirm": "确认删除分享链接?",
"deleteDescription": "此操作无法撤销,分享链接将立即失效。",
"cancel": "取消",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"createSuccess": "分享链接创建成功",
"createFailed": "创建分享链接失败",
"shareButton": "分享"
},
"shareMessage": {
"title": "分享邮件",
"description": "创建分享链接,让其他人可以查看这封邮件",
"createLink": "创建链接",
"creating": "创建中...",
"loading": "加载中...",
"expiryTime": "链接有效期",
"oneHour": "1 小时",
"oneDay": "1 天",
"threeDays": "3 天",
"oneWeek": "1 周",
"permanent": "永久",
"activeLinks": "当前分享链接",
"noLinks": "暂无分享链接",
"createdAt": "创建时间",
"expiresAt": "过期时间",
"expired": "已过期",
"copy": "复制链接",
"copied": "已复制",
"copyFailed": "复制失败",
"delete": "删除",
"deleteConfirm": "确认删除分享链接?",
"deleteDescription": "此操作无法撤销,分享链接将立即失效。",
"cancel": "取消",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"createSuccess": "分享链接创建成功",
"createFailed": "创建分享链接失败",
"shareButton": "分享邮件"
},
"shared": {
"loading": "加载中...",
"emailNotFound": "无法访问邮箱",
"messageNotFound": "无法访问邮件",
"linkExpired": "分享链接不存在或已过期",
"linkInvalid": "链接无效",
"linkInvalidDescription": "此分享链接可能已过期或不存在",
"sharedMailbox": "分享邮箱",
"sharedMessage": "分享邮件",
"expiresAt": "邮箱过期时间",
"permanent": "永久有效",
"createOwnEmail": "创建自己的临时邮箱",
"brand": {
"title": "MoeMail",
"subtitle": "萌萌哒临时邮箱服务",
"cta": "立即体验",
"officialSite": "官网",
"copyright": "© 2024 MoeMail. 萌萌哒临时邮箱服务"
}
}
}

View File

@@ -7,12 +7,16 @@
"description": "保护您的真实邮箱地址"
},
"instant": {
"title": "即时收件",
"description": "实时接收邮件通知"
"title": "邮箱分享",
"description": "将邮箱分享给其他人使用"
},
"expiry": {
"title": "自动过期",
"description": "到期自动失效"
},
"openapi": {
"title": "开放 API",
"description": "提供完整的 OpenAPI 接口"
}
},
"actions": {

View File

@@ -1,6 +1,6 @@
{
"title": "MoeMail - 萌萌哒临时邮箱服务",
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 萌系邮箱, 电子邮件, 隐私安全, 邮件服务, MoeMail"
"title": "MoeMail - 萌萌哒临时邮箱服务 · 开放 API",
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。提供完整的 OpenAPI 接口,支持 API Key 访问。",
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 萌系邮箱, 电子邮件, 隐私安全, 邮件服务, OpenAPI, API接口, RESTful API, API Key, 开放接口, MoeMail"
}

View File

@@ -35,6 +35,12 @@
"getEmails": "获取邮箱列表",
"getMessages": "获取邮件列表",
"getMessage": "获取单封邮件",
"createEmailShare": "创建邮箱分享链接",
"getEmailShares": "获取邮箱分享链接列表",
"deleteEmailShare": "删除邮箱分享链接",
"createMessageShare": "创建邮件分享链接",
"getMessageShares": "获取邮件分享链接列表",
"deleteMessageShare": "删除邮件分享链接",
"notes": "注意:",
"note1": "请将 YOUR_API_KEY 替换为你的实际 API Key",
"note2": "/api/config 接口可获取系统配置,包括可用的邮箱域名列表",
@@ -43,7 +49,9 @@
"note5": "expiryTime 是邮箱的有效期毫秒可选值36000001小时、864000001天、6048000007天、0永久",
"note6": "domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表",
"note7": "cursor 用于分页,从上一次请求的响应中获取 nextCursor",
"note8": "所有请求都需要包含 X-API-Key 请求头"
"note8": "所有请求都需要包含 X-API-Key 请求头",
"note9": "expiresIn 是分享链接的有效期毫秒0 表示永久有效",
"note10": "shareId 是分享记录的唯一标识符"
}
},
"emailService": {

View File

@@ -40,8 +40,14 @@ export async function handleApiKeyAuth(apiKey: string, pathname: string) {
)
}
const response = NextResponse.next()
response.headers.set("X-User-Id", user.id)
const requestHeaders = new Headers(await headers())
requestHeaders.set("X-User-Id", user.id)
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
return response
}

View File

@@ -114,6 +114,36 @@ export const apiKeys = sqliteTable('api_keys', {
nameUserIdUnique: uniqueIndex('name_user_id_unique').on(table.name, table.userId)
}));
export const emailShares = sqliteTable('email_share', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
emailId: text('email_id')
.notNull()
.references(() => emails.id, { onDelete: "cascade" }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
}, (table) => ({
emailIdIdx: index('email_share_email_id_idx').on(table.emailId),
tokenIdx: index('email_share_token_idx').on(table.token),
}));
export const messageShares = sqliteTable('message_share', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
messageId: text('message_id')
.notNull()
.references(() => messages.id, { onDelete: "cascade" }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
}, (table) => ({
messageIdIdx: index('message_share_message_id_idx').on(table.messageId),
tokenIdx: index('message_share_token_idx').on(table.token),
}));
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
@@ -141,4 +171,18 @@ export const usersRelations = relations(users, ({ many }) => ({
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles),
}));
export const emailSharesRelations = relations(emailShares, ({ one }) => ({
email: one(emails, {
fields: [emailShares.emailId],
references: [emails.id],
}),
}));
export const messageSharesRelations = relations(messageShares, ({ one }) => ({
message: one(messages, {
fields: [messageShares.messageId],
references: [messages.id],
}),
}));

192
app/lib/shared-data.ts Normal file
View File

@@ -0,0 +1,192 @@
import { createDb } from "@/lib/db"
import { emailShares, messageShares, messages, emails } from "@/lib/schema"
import { eq, desc, and, or, ne, isNull } from "drizzle-orm"
export interface SharedEmail {
id: string
address: string
createdAt: Date
expiresAt: Date
}
export interface SharedMessage {
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
}
export async function getSharedEmail(token: string): Promise<SharedEmail | null> {
const db = createDb()
try {
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return null
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return null
}
// 检查邮箱是否过期
if (share.email.expiresAt < new Date()) {
return null
}
return {
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 null
}
}
export interface SharedMessagesResult {
messages: SharedMessage[]
nextCursor: string | null
total: number
}
export async function getSharedEmailMessages(token: string, limit = 20): Promise<SharedMessagesResult> {
const db = createDb()
try {
const share = await db.query.emailShares.findFirst({
where: eq(emailShares.token, token),
with: {
email: true
}
})
if (!share) {
return { messages: [], nextCursor: null, total: 0 }
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return { messages: [], nextCursor: null, total: 0 }
}
// 只显示接收的邮件,不显示发送的邮件
const baseConditions = and(
eq(messages.emailId, share.emailId),
or(
ne(messages.type, "sent"),
isNull(messages.type)
)
)
// 获取消息总数(只统计接收的邮件)
const { sql } = await import("drizzle-orm")
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(messages)
.where(baseConditions)
const totalCount = Number(totalResult[0].count)
// 获取邮箱的消息列表(多获取一条用于判断是否有更多)
const messageList = await db.query.messages.findMany({
where: baseConditions,
orderBy: [desc(messages.receivedAt), desc(messages.id)],
limit: limit + 1
})
const hasMore = messageList.length > limit
const results = hasMore ? messageList.slice(0, limit) : messageList
// 生成下一页的cursor
let nextCursor: string | null = null
if (hasMore) {
const { encodeCursor } = await import("@/lib/cursor")
const lastMessage = results[results.length - 1]
nextCursor = encodeCursor(
lastMessage.receivedAt.getTime(),
lastMessage.id
)
}
return {
messages: results.map(msg => ({
id: msg.id,
from_address: msg.fromAddress ?? undefined,
to_address: msg.toAddress ?? undefined,
subject: msg.subject,
received_at: msg.receivedAt,
sent_at: msg.sentAt
})),
nextCursor,
total: totalCount
}
} catch (error) {
console.error("Failed to fetch shared email messages:", error)
return { messages: [], nextCursor: null, total: 0 }
}
}
export async function getSharedMessage(token: string): Promise<SharedMessage | null> {
const db = createDb()
try {
const share = await db.query.messageShares.findFirst({
where: eq(messageShares.token, token)
})
if (!share) {
return null
}
// 检查分享是否过期
if (share.expiresAt && share.expiresAt < new Date()) {
return null
}
// 获取消息详情
const message = await db.query.messages.findFirst({
where: eq(messages.id, share.messageId)
})
if (!message) {
return null
}
// 获取邮箱信息
const email = await db.query.emails.findFirst({
where: eq(emails.id, message.emailId)
})
return {
id: message.id,
from_address: message.fromAddress ?? undefined,
to_address: message.toAddress ?? undefined,
subject: message.subject,
content: message.content ?? undefined,
html: message.html ?? undefined,
received_at: message.receivedAt,
sent_at: message.sentAt,
expiresAt: share.expiresAt ?? undefined,
emailAddress: email?.address,
emailExpiresAt: email?.expiresAt
}
} catch (error) {
console.error("Failed to fetch shared message:", error)
return null
}
}

View File

@@ -0,0 +1,13 @@
CREATE TABLE `email_share` (
`id` text PRIMARY KEY NOT NULL,
`email_id` text NOT NULL,
`token` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
`enabled` integer DEFAULT true NOT NULL,
FOREIGN KEY (`email_id`) REFERENCES `email`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `email_share_token_unique` ON `email_share` (`token`);--> statement-breakpoint
CREATE INDEX `email_share_email_id_idx` ON `email_share` (`email_id`);--> statement-breakpoint
CREATE INDEX `email_share_token_idx` ON `email_share` (`token`);

View File

@@ -0,0 +1,13 @@
CREATE TABLE `message_share` (
`id` text PRIMARY KEY NOT NULL,
`message_id` text NOT NULL,
`token` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
`enabled` integer DEFAULT true NOT NULL,
FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `message_share_token_unique` ON `message_share` (`token`);--> statement-breakpoint
CREATE INDEX `message_share_message_id_idx` ON `message_share` (`message_id`);--> statement-breakpoint
CREATE INDEX `message_share_token_idx` ON `message_share` (`token`);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `email_share` DROP COLUMN `enabled`;--> statement-breakpoint
ALTER TABLE `message_share` DROP COLUMN `enabled`;

View File

@@ -0,0 +1,734 @@
{
"version": "6",
"dialect": "sqlite",
"id": "fd2d5830-cc75-4ec5-a404-1a340c4a5c49",
"prevId": "eb2c55e5-a514-4048-9787-e8afc4c33308",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"columns": [
"key"
],
"isUnique": true
},
"name_user_id_unique": {
"name": "name_user_id_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"api_keys_user_id_user_id_fk": {
"name": "api_keys_user_id_user_id_fk",
"tableFrom": "api_keys",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_share": {
"name": "email_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email_id": {
"name": "email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"email_share_token_unique": {
"name": "email_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"email_share_email_id_idx": {
"name": "email_share_email_id_idx",
"columns": [
"email_id"
],
"isUnique": false
},
"email_share_token_idx": {
"name": "email_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"email_share_email_id_email_id_fk": {
"name": "email_share_email_id_email_id_fk",
"tableFrom": "email_share",
"tableTo": "email",
"columnsFrom": [
"email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
},
"email_expires_at_idx": {
"name": "email_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"to_address": {
"name": "to_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"message_email_id_idx": {
"name": "message_email_id_idx",
"columns": [
"emailId"
],
"isUnique": false
}
},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,823 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f8829008-3b07-42e3-93ac-7628453ddce4",
"prevId": "fd2d5830-cc75-4ec5-a404-1a340c4a5c49",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"columns": [
"key"
],
"isUnique": true
},
"name_user_id_unique": {
"name": "name_user_id_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"api_keys_user_id_user_id_fk": {
"name": "api_keys_user_id_user_id_fk",
"tableFrom": "api_keys",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_share": {
"name": "email_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email_id": {
"name": "email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"email_share_token_unique": {
"name": "email_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"email_share_email_id_idx": {
"name": "email_share_email_id_idx",
"columns": [
"email_id"
],
"isUnique": false
},
"email_share_token_idx": {
"name": "email_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"email_share_email_id_email_id_fk": {
"name": "email_share_email_id_email_id_fk",
"tableFrom": "email_share",
"tableTo": "email",
"columnsFrom": [
"email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
},
"email_expires_at_idx": {
"name": "email_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message_share": {
"name": "message_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"message_share_token_unique": {
"name": "message_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"message_share_message_id_idx": {
"name": "message_share_message_id_idx",
"columns": [
"message_id"
],
"isUnique": false
},
"message_share_token_idx": {
"name": "message_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"message_share_message_id_message_id_fk": {
"name": "message_share_message_id_message_id_fk",
"tableFrom": "message_share",
"tableTo": "message",
"columnsFrom": [
"message_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"to_address": {
"name": "to_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"message_email_id_idx": {
"name": "message_email_id_idx",
"columns": [
"emailId"
],
"isUnique": false
}
},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,807 @@
{
"version": "6",
"dialect": "sqlite",
"id": "10fa71e9-1e9e-43ef-bcfc-bfec5e46af8e",
"prevId": "f8829008-3b07-42e3-93ac-7628453ddce4",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"api_keys": {
"name": "api_keys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"columns": [
"key"
],
"isUnique": true
},
"name_user_id_unique": {
"name": "name_user_id_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"api_keys_user_id_user_id_fk": {
"name": "api_keys_user_id_user_id_fk",
"tableFrom": "api_keys",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_share": {
"name": "email_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email_id": {
"name": "email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"email_share_token_unique": {
"name": "email_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"email_share_email_id_idx": {
"name": "email_share_email_id_idx",
"columns": [
"email_id"
],
"isUnique": false
},
"email_share_token_idx": {
"name": "email_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"email_share_email_id_email_id_fk": {
"name": "email_share_email_id_email_id_fk",
"tableFrom": "email_share",
"tableTo": "email",
"columnsFrom": [
"email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
},
"email_expires_at_idx": {
"name": "email_expires_at_idx",
"columns": [
"expires_at"
],
"isUnique": false
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message_share": {
"name": "message_share",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"message_share_token_unique": {
"name": "message_share_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"message_share_message_id_idx": {
"name": "message_share_message_id_idx",
"columns": [
"message_id"
],
"isUnique": false
},
"message_share_token_idx": {
"name": "message_share_token_idx",
"columns": [
"token"
],
"isUnique": false
}
},
"foreignKeys": {
"message_share_message_id_message_id_fk": {
"name": "message_share_message_id_message_id_fk",
"tableFrom": "message_share",
"tableTo": "message",
"columnsFrom": [
"message_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"to_address": {
"name": "to_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"message_email_id_idx": {
"name": "message_email_id_idx",
"columns": [
"emailId"
],
"isUnique": false
}
},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -99,6 +99,27 @@
"when": 1750081604094,
"tag": "0013_illegal_senator_kelly",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1760454260207,
"tag": "0014_jazzy_gressill",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1760456754699,
"tag": "0015_majestic_chimera",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1760460028481,
"tag": "0016_hesitant_thing",
"breakpoints": true
}
]
}