mirror of
https://github.com/beilunyang/moemail.git
synced 2025-12-24 11:30:51 +08:00
feat(sharing): add email and message sharing functionality
This commit is contained in:
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
179
README.md
179
README.md
@@ -566,6 +566,159 @@ GET /api/emails/{emailId}/{messageId}
|
||||
- `html`: 邮件HTML内容
|
||||
- `received_at`: 接收时间(时间戳)
|
||||
|
||||
#### 创建邮箱分享链接
|
||||
```http
|
||||
POST /api/emails/{emailId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `expiresIn`: 分享链接有效期(毫秒),0 表示永久有效,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"id": "share-uuid-123",
|
||||
"emailId": "email-uuid-123",
|
||||
"token": "abc123def456",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `id`: 分享记录的唯一标识符
|
||||
- `emailId`: 关联的邮箱 ID
|
||||
- `token`: 分享链接的访问令牌
|
||||
- `expiresAt`: 分享链接过期时间,null 表示永久有效
|
||||
- `createdAt`: 创建时间
|
||||
|
||||
分享链接访问地址:`https://your-domain.com/shared/{token}`
|
||||
|
||||
#### 获取邮箱的所有分享链接
|
||||
```http
|
||||
GET /api/emails/{emailId}/share
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"shares": [
|
||||
{
|
||||
"id": "share-uuid-123",
|
||||
"emailId": "email-uuid-123",
|
||||
"token": "abc123def456",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `shares`: 分享链接列表数组
|
||||
- `total`: 分享链接总数
|
||||
|
||||
#### 删除邮箱分享链接
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/share/{shareId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `shareId`: 分享记录的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `success`: 删除操作是否成功
|
||||
|
||||
#### 创建邮件分享链接
|
||||
```http
|
||||
POST /api/emails/{emailId}/messages/{messageId}/share
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"expiresIn": 86400000
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
- `expiresIn`: 分享链接有效期(毫秒),0 表示永久有效,可选
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"id": "share-uuid-456",
|
||||
"messageId": "message-uuid-789",
|
||||
"token": "xyz789ghi012",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `id`: 分享记录的唯一标识符
|
||||
- `messageId`: 关联的邮件 ID
|
||||
- `token`: 分享链接的访问令牌
|
||||
- `expiresAt`: 分享链接过期时间,null 表示永久有效
|
||||
- `createdAt`: 创建时间
|
||||
|
||||
分享链接访问地址:`https://your-domain.com/shared/message/{token}`
|
||||
|
||||
#### 获取邮件的所有分享链接
|
||||
```http
|
||||
GET /api/emails/{emailId}/messages/{messageId}/share
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"shares": [
|
||||
{
|
||||
"id": "share-uuid-456",
|
||||
"messageId": "message-uuid-789",
|
||||
"token": "xyz789ghi012",
|
||||
"expiresAt": "2024-01-02T12:00:00.000Z",
|
||||
"createdAt": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `shares`: 分享链接列表数组
|
||||
- `total`: 分享链接总数
|
||||
|
||||
#### 删除邮件分享链接
|
||||
```http
|
||||
DELETE /api/emails/{emailId}/messages/{messageId}/share/{shareId}
|
||||
```
|
||||
参数说明:
|
||||
- `emailId`: 邮箱的唯一标识符,必填
|
||||
- `messageId`: 邮件的唯一标识符,必填
|
||||
- `shareId`: 分享记录的唯一标识符,必填
|
||||
|
||||
返回响应:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
响应字段说明:
|
||||
- `success`: 删除操作是否成功
|
||||
|
||||
### 使用示例
|
||||
|
||||
使用 curl 创建临时邮箱:
|
||||
@@ -590,6 +743,32 @@ const res = await fetch('https://your-domain.com/api/emails/your-email-id', {
|
||||
const data = await res.json();
|
||||
```
|
||||
|
||||
使用 curl 创建邮箱分享链接:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/emails/your-email-id/share \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"expiresIn": 86400000
|
||||
}'
|
||||
```
|
||||
|
||||
使用 JavaScript 创建邮件分享链接:
|
||||
```javascript
|
||||
const res = await fetch('https://your-domain.com/api/emails/your-email-id/messages/your-message-id/share', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': 'YOUR_API_KEY',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expiresIn: 0 // 永久有效
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
console.log('分享链接:', `https://your-domain.com/shared/message/${data.token}`);
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
本项目使用以下环境变量:
|
||||
|
||||
@@ -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">
|
||||
|
||||
308
app/[locale]/shared/[token]/page-client.tsx
Normal file
308
app/[locale]/shared/[token]/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
app/[locale]/shared/[token]/page.tsx
Normal file
52
app/[locale]/shared/[token]/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
72
app/[locale]/shared/message/[token]/page-client.tsx
Normal file
72
app/[locale]/shared/message/[token]/page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
app/[locale]/shared/message/[token]/page.tsx
Normal file
33
app/[locale]/shared/message/[token]/page.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
119
app/api/emails/[id]/messages/[messageId]/share/route.ts
Normal file
119
app/api/emails/[id]/messages/[messageId]/share/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
46
app/api/emails/[id]/share/[shareId]/route.ts
Normal file
46
app/api/emails/[id]/share/[shareId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
101
app/api/emails/[id]/share/route.ts
Normal file
101
app/api/emails/[id]/share/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
83
app/api/shared/[token]/messages/[messageId]/route.ts
Normal file
83
app/api/shared/[token]/messages/[messageId]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
124
app/api/shared/[token]/messages/route.ts
Normal file
124
app/api/shared/[token]/messages/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
64
app/api/shared/[token]/route.ts
Normal file
64
app/api/shared/[token]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
69
app/api/shared/message/[token]/route.ts
Normal file
69
app/api/shared/message/[token]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
347
app/components/emails/share-dialog.tsx
Normal file
347
app/components/emails/share-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
356
app/components/emails/share-message-dialog.tsx
Normal file
356
app/components/emails/share-message-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
41
app/components/emails/shared-error-page.tsx
Normal file
41
app/components/emails/shared-error-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
app/components/emails/shared-message-detail.tsx
Normal file
241
app/components/emails/shared-message-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
app/components/emails/shared-message-list.tsx
Normal file
131
app/components/emails/shared-message-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
76
app/components/layout/floating-language-switcher.tsx
Normal file
76
app/components/layout/floating-language-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
116
app/components/ui/brand-header.tsx
Normal file
116
app/components/ui/brand-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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. 萌萌哒临时邮箱服务"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
"description": "保护您的真实邮箱地址"
|
||||
},
|
||||
"instant": {
|
||||
"title": "即时收件",
|
||||
"description": "实时接收邮件通知"
|
||||
"title": "邮箱分享",
|
||||
"description": "将邮箱分享给其他人使用"
|
||||
},
|
||||
"expiry": {
|
||||
"title": "自动过期",
|
||||
"description": "到期自动失效"
|
||||
},
|
||||
"openapi": {
|
||||
"title": "开放 API",
|
||||
"description": "提供完整的 OpenAPI 接口"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "MoeMail - 萌萌哒临时邮箱服务",
|
||||
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。",
|
||||
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 萌系邮箱, 电子邮件, 隐私安全, 邮件服务, MoeMail"
|
||||
"title": "MoeMail - 萌萌哒临时邮箱服务 · 开放 API",
|
||||
"description": "安全、快速、一次性的临时邮箱地址,保护您的隐私,远离垃圾邮件。支持即时收件,到期自动失效。提供完整的 OpenAPI 接口,支持 API Key 访问。",
|
||||
"keywords": "临时邮箱, 一次性邮箱, 匿名邮箱, 隐私保护, 垃圾邮件过滤, 即时收件, 自动过期, 安全邮箱, 注册验证, 临时账号, 萌系邮箱, 电子邮件, 隐私安全, 邮件服务, OpenAPI, API接口, RESTful API, API Key, 开放接口, MoeMail"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 是邮箱的有效期(毫秒),可选值:3600000(1小时)、86400000(1天)、604800000(7天)、0(永久)",
|
||||
"note6": "domain 是邮箱域名,可通过 /api/config 接口获取可用域名列表",
|
||||
"note7": "cursor 用于分页,从上一次请求的响应中获取 nextCursor",
|
||||
"note8": "所有请求都需要包含 X-API-Key 请求头"
|
||||
"note8": "所有请求都需要包含 X-API-Key 请求头",
|
||||
"note9": "expiresIn 是分享链接的有效期(毫秒),0 表示永久有效",
|
||||
"note10": "shareId 是分享记录的唯一标识符"
|
||||
}
|
||||
},
|
||||
"emailService": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
192
app/lib/shared-data.ts
Normal 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
|
||||
}
|
||||
}
|
||||
13
drizzle/0014_jazzy_gressill.sql
Normal file
13
drizzle/0014_jazzy_gressill.sql
Normal 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`);
|
||||
13
drizzle/0015_majestic_chimera.sql
Normal file
13
drizzle/0015_majestic_chimera.sql
Normal 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`);
|
||||
2
drizzle/0016_hesitant_thing.sql
Normal file
2
drizzle/0016_hesitant_thing.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `email_share` DROP COLUMN `enabled`;--> statement-breakpoint
|
||||
ALTER TABLE `message_share` DROP COLUMN `enabled`;
|
||||
734
drizzle/meta/0014_snapshot.json
Normal file
734
drizzle/meta/0014_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
823
drizzle/meta/0015_snapshot.json
Normal file
823
drizzle/meta/0015_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
807
drizzle/meta/0016_snapshot.json
Normal file
807
drizzle/meta/0016_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user