feat: profile page & webhook notification

This commit is contained in:
beilunyang
2024-12-17 13:26:34 +08:00
parent e0bd04818e
commit c69947ceae
20 changed files with 1533 additions and 288 deletions

69
app/api/webhook/route.ts Normal file
View File

@@ -0,0 +1,69 @@
import { auth } from "@/lib/auth"
import { createDb } from "@/lib/db"
import { webhooks } from "@/lib/schema"
import { eq } from "drizzle-orm"
import { z } from "zod"
export const runtime = "edge"
const webhookSchema = z.object({
url: z.string().url(),
enabled: z.boolean()
})
export async function GET() {
const session = await auth()
const db = createDb()
const webhook = await db.query.webhooks.findFirst({
where: eq(webhooks.userId, session!.user!.id!)
})
return Response.json(webhook || { enabled: false, url: "" })
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const { url, enabled } = webhookSchema.parse(body)
const db = createDb()
const now = new Date()
const existingWebhook = await db.query.webhooks.findFirst({
where: eq(webhooks.userId, session.user.id)
})
if (existingWebhook) {
await db
.update(webhooks)
.set({
url,
enabled,
updatedAt: now
})
.where(eq(webhooks.userId, session.user.id))
} else {
await db
.insert(webhooks)
.values({
userId: session.user.id,
url,
enabled,
})
}
return Response.json({ success: true })
} catch (error) {
console.error("Failed to save webhook:", error)
return Response.json(
{ error: "Invalid request" },
{ status: 400 }
)
}
}

View File

@@ -0,0 +1,39 @@
import { callWebhook } from "@/lib/webhook"
import { WEBHOOK_CONFIG } from "@/config"
import { z } from "zod"
import { EmailMessage } from "@/lib/webhook"
export const runtime = "edge"
const testSchema = z.object({
url: z.string().url()
})
export async function POST(request: Request) {
try {
const body = await request.json()
const { url } = testSchema.parse(body)
await callWebhook(url, {
event: WEBHOOK_CONFIG.EVENTS.NEW_MESSAGE,
data: {
emailId: "123456789",
messageId: '987654321',
fromAddress: "sender@example.com",
subject: "Test Email",
content: "This is a test email.",
html: "<p>This is a <strong>test</strong> email.</p>",
receivedAt: "2023-03-01T12:00:00Z",
toAddress: "recipient@example.com"
} as EmailMessage
})
return Response.json({ success: true })
} catch (error) {
console.error("Failed to test webhook:", error)
return Response.json(
{ error: "Failed to test webhook" },
{ status: 400 }
)
}
}

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"
import Image from "next/image"
import { signIn, signOut, useSession } from "next-auth/react"
import { Github } from "lucide-react"
import Link from "next/link"
export function SignButton() {
const { data: session, status } = useSession()
@@ -24,7 +25,10 @@ export function SignButton() {
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Link
href="/profile"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
{session.user.image && (
<Image
src={session.user.image}
@@ -35,7 +39,7 @@ export function SignButton() {
/>
)}
<span className="text-sm">{session.user.name}</span>
</div>
</Link>
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline">
</Button>

View File

@@ -35,7 +35,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
const [refreshing, setRefreshing] = useState(false)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const pollTimeoutRef = useRef<NodeJS.Timeout>()
const pollTimeoutRef = useRef<Timer>()
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
const [total, setTotal] = useState(0)
@@ -85,7 +85,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
}
const startPolling = () => {
stopPolling() // 先清除之前的轮询
stopPolling()
pollTimeoutRef.current = setInterval(() => {
if (!refreshing && !loadingMore) {
fetchMessages()

View File

@@ -0,0 +1,77 @@
"use client"
import { User } from "next-auth"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { signOut } from "next-auth/react"
import { Github, Mail, Settings } from "lucide-react"
import { useRouter } from "next/navigation"
import { WebhookConfig } from "./webhook-config"
interface ProfileCardProps {
user: User
}
export function ProfileCard({ user }: ProfileCardProps) {
const router = useRouter()
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* 用户信息卡片 */}
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-6">
<div className="relative">
{user.image && (
<Image
src={user.image}
alt={user.name || "用户头像"}
width={80}
height={80}
className="rounded-full ring-2 ring-primary/20"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold truncate">{user.name}</h2>
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
<Github className="w-3 h-3" />
</div>
</div>
<p className="text-sm text-muted-foreground truncate mt-1">
{user.email}
</p>
</div>
</div>
</div>
{/* Webhook 配置卡片 */}
<div className="bg-background rounded-lg border-2 border-primary/20 p-6">
<div className="flex items-center gap-2 mb-6">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">Webhook </h2>
</div>
<WebhookConfig />
</div>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-4 px-1">
<Button
onClick={() => router.push("/moe")}
className="gap-2 flex-1"
>
<Mail className="w-4 h-4" />
</Button>
<Button
variant="outline"
onClick={() => signOut({ callbackUrl: "/" })}
className="flex-1"
>
退
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useToast } from "@/components/ui/use-toast"
import { Loader2, Send } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export function WebhookConfig() {
const [enabled, setEnabled] = useState(false)
const [url, setUrl] = useState("")
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState(false)
const { toast } = useToast()
useEffect(() => {
fetch("/api/webhook")
.then(res => res.json() as Promise<{ enabled: boolean; url: string }>)
.then(data => {
setEnabled(data.enabled)
setUrl(data.url)
})
.catch(console.error)
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!url) return
setLoading(true)
try {
const res = await fetch("/api/webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, enabled })
})
if (!res.ok) throw new Error("Failed to save")
toast({
title: "保存成功",
description: "Webhook 配置已更新"
})
} catch (_error) {
toast({
title: "保存失败",
description: "请稍后重试",
variant: "destructive"
})
} finally {
setLoading(false)
}
}
const handleTest = async () => {
if (!url) return
setTesting(true)
try {
const res = await fetch("/api/webhook/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
})
if (!res.ok) throw new Error("测试失败")
toast({
title: "测试成功",
description: "Webhook 调用成功,请检查目标服务器是否收到请求"
})
} catch (_error) {
toast({
title: "测试失败",
description: "请检查 URL 是否正确且可访问",
variant: "destructive"
})
} finally {
setTesting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> Webhook</Label>
<div className="text-sm text-muted-foreground">
URL
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
{enabled && (
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<div className="flex gap-2">
<Input
id="webhook-url"
placeholder="https://example.com/webhook"
value={url}
onChange={(e) => setUrl(e.target.value)}
type="url"
required
/>
<Button type="submit" disabled={loading} className="w-20">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"保存"
)}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testing || !url}
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p> Webhook</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-xs text-muted-foreground">
URL POST ,
</p>
</div>
)}
</form>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -2,4 +2,13 @@ export const EMAIL_CONFIG = {
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
DOMAIN: 'moemail.app', // Email domain
} as const
export const WEBHOOK_CONFIG = {
MAX_RETRIES: 3, // Maximum retry count
TIMEOUT: 10_000, // Timeout time (milliseconds)
RETRY_DELAY: 1000, // Retry delay (milliseconds)
EVENTS: {
NEW_MESSAGE: 'new_message',
}
} as const

View File

@@ -66,4 +66,19 @@ export const messages = sqliteTable("message", {
receivedAt: integer("received_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
})
export const webhooks = sqliteTable('webhook', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
url: text('url').notNull(),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
})

54
app/lib/webhook.ts Normal file
View File

@@ -0,0 +1,54 @@
import { WEBHOOK_CONFIG } from "@/config"
export interface EmailMessage {
emailId: string
messageId: string
fromAddress: string
subject: string
content: string
html: string
receivedAt: string
toAddress: string
}
export interface WebhookPayload {
event: typeof WEBHOOK_CONFIG.EVENTS[keyof typeof WEBHOOK_CONFIG.EVENTS]
data: EmailMessage
}
export async function callWebhook(url: string, payload: WebhookPayload) {
let lastError: Error | null = null
for (let i = 0; i < WEBHOOK_CONFIG.MAX_RETRIES; i++) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.TIMEOUT)
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Event": payload.event,
},
body: JSON.stringify(payload.data),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (response.ok) {
return true
}
lastError = new Error(`HTTP error! status: ${response.status}`)
} catch (error) {
lastError = error as Error
if (i < WEBHOOK_CONFIG.MAX_RETRIES - 1) {
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.RETRY_DELAY))
}
}
}
throw lastError
}

27
app/profile/page.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Header } from "@/components/layout/header"
import { ProfileCard } from "@/components/profile/profile-card"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
export const runtime = "edge"
export default async function ProfilePage() {
const session = await auth()
if (!session?.user) {
redirect("/")
}
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
<div className="container mx-auto h-full px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="h-full">
<div className="pt-20 pb-5 h-full">
<ProfileCard user={session.user} />
</div>
</main>
</div>
</div>
)
}