mirror of
https://github.com/beilunyang/moemail.git
synced 2025-09-27 03:46:03 +08:00
feat: profile page & webhook notification
This commit is contained in:
69
app/api/webhook/route.ts
Normal file
69
app/api/webhook/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
39
app/api/webhook/test/route.ts
Normal file
39
app/api/webhook/test/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { signIn, signOut, useSession } from "next-auth/react"
|
import { signIn, signOut, useSession } from "next-auth/react"
|
||||||
import { Github } from "lucide-react"
|
import { Github } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
export function SignButton() {
|
export function SignButton() {
|
||||||
const { data: session, status } = useSession()
|
const { data: session, status } = useSession()
|
||||||
@@ -24,7 +25,10 @@ export function SignButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<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 && (
|
{session.user.image && (
|
||||||
<Image
|
<Image
|
||||||
src={session.user.image}
|
src={session.user.image}
|
||||||
@@ -35,7 +39,7 @@ export function SignButton() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm">{session.user.name}</span>
|
<span className="text-sm">{session.user.name}</span>
|
||||||
</div>
|
</Link>
|
||||||
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline">
|
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline">
|
||||||
登出
|
登出
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -35,7 +35,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const pollTimeoutRef = useRef<NodeJS.Timeout>()
|
const pollTimeoutRef = useRef<Timer>()
|
||||||
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function MessageList({ email, onMessageSelect, selectedMessageId }: Messa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
stopPolling() // 先清除之前的轮询
|
stopPolling()
|
||||||
pollTimeoutRef.current = setInterval(() => {
|
pollTimeoutRef.current = setInterval(() => {
|
||||||
if (!refreshing && !loadingMore) {
|
if (!refreshing && !loadingMore) {
|
||||||
fetchMessages()
|
fetchMessages()
|
||||||
|
77
app/components/profile/profile-card.tsx
Normal file
77
app/components/profile/profile-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
155
app/components/profile/webhook-config.tsx
Normal file
155
app/components/profile/webhook-config.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
28
app/components/ui/switch.tsx
Normal file
28
app/components/ui/switch.tsx
Normal 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 }
|
29
app/components/ui/tooltip.tsx
Normal file
29
app/components/ui/tooltip.tsx
Normal 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 }
|
@@ -2,4 +2,13 @@ export const EMAIL_CONFIG = {
|
|||||||
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
|
MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails
|
||||||
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
|
POLL_INTERVAL: 10_000, // Polling interval in milliseconds
|
||||||
DOMAIN: 'moemail.app', // Email domain
|
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
|
} as const
|
@@ -66,4 +66,19 @@ export const messages = sqliteTable("message", {
|
|||||||
receivedAt: integer("received_at", { mode: "timestamp_ms" })
|
receivedAt: integer("received_at", { mode: "timestamp_ms" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date()),
|
.$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
54
app/lib/webhook.ts
Normal 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
27
app/profile/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
9
drizzle/0003_dashing_dust.sql
Normal file
9
drizzle/0003_dashing_dust.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `webhook` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
432
drizzle/meta/0003_snapshot.json
Normal file
432
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "c65780fc-6545-438d-a3b6-fbdafa9b1276",
|
||||||
|
"prevId": "9f9802ad-fc03-4e1a-847e-8a73866a9f52",
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": true,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"received_at": {
|
||||||
|
"name": "received_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"sessionToken": {
|
||||||
|
"name": "sessionToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
@@ -22,6 +22,13 @@
|
|||||||
"when": 1734184527968,
|
"when": 1734184527968,
|
||||||
"tag": "0002_military_cobalt_man",
|
"tag": "0002_military_cobalt_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1734454698466,
|
||||||
|
"tag": "0003_dashing_dust",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
@@ -4,7 +4,7 @@ import { NextResponse } from "next/server"
|
|||||||
export async function middleware() {
|
export async function middleware() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Unauthorized" },
|
{ error: "Unauthorized" },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
@@ -17,5 +17,6 @@ export async function middleware() {
|
|||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/api/emails/:path*",
|
"/api/emails/:path*",
|
||||||
|
"/api/webhook/:path*",
|
||||||
]
|
]
|
||||||
}
|
}
|
19
package.json
19
package.json
@@ -8,8 +8,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"build:pages": "npx @cloudflare/next-on-pages",
|
"build:pages": "npx @cloudflare/next-on-pages",
|
||||||
"db:migrate-local": "tsx scripts/migrate.ts local",
|
"db:migrate-local": "bun run scripts/migrate.ts local",
|
||||||
"db:migrate-remote": "tsx scripts/migrate.ts remote",
|
"db:migrate-remote": "bun run scripts/migrate.ts remote",
|
||||||
|
"webhook-test-server": "bun run scripts/webhook-test-server.ts",
|
||||||
"generate-test-data": "wrangler dev scripts/generate-test-data.ts",
|
"generate-test-data": "wrangler dev scripts/generate-test-data.ts",
|
||||||
"dev:cleanup": "wrangler dev --config wrangler.cleanup.toml --test-scheduled",
|
"dev:cleanup": "wrangler dev --config wrangler.cleanup.toml --test-scheduled",
|
||||||
"test:cleanup": "curl http://localhost:8787/__scheduled",
|
"test:cleanup": "curl http://localhost:8787/__scheduled",
|
||||||
@@ -22,10 +23,12 @@
|
|||||||
"@auth/drizzle-adapter": "^1.7.4",
|
"@auth/drizzle-adapter": "^1.7.4",
|
||||||
"@cloudflare/next-on-pages": "^1.13.6",
|
"@cloudflare/next-on-pages": "^1.13.6",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-radio-group": "^1.2.1",
|
"@radix-ui/react-radio-group": "^1.2.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
@@ -40,14 +43,18 @@
|
|||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20241127.0",
|
"@cloudflare/workers-types": "^4.20241127.0",
|
||||||
|
"@iarna/toml": "^3.0.0",
|
||||||
|
"@types/bun": "^1.1.14",
|
||||||
"@types/next-pwa": "^5.6.9",
|
"@types/next-pwa": "^5.6.9",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"bun": "^1.1.39",
|
||||||
"drizzle-kit": "^0.28.1",
|
"drizzle-kit": "^0.28.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "15.0.3",
|
"eslint-config-next": "15.0.3",
|
||||||
@@ -55,8 +62,6 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vercel": "39.1.1",
|
"vercel": "39.1.1",
|
||||||
"wrangler": "^3.91.0",
|
"wrangler": "^3.91.0"
|
||||||
"@iarna/toml": "^3.0.0",
|
|
||||||
"tsx": "^4.7.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
755
pnpm-lock.yaml
generated
755
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
43
scripts/webhook-test-server.ts
Normal file
43
scripts/webhook-test-server.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { EmailMessage } from "../app/lib/webhook"
|
||||||
|
import Bun from 'bun'
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3001,
|
||||||
|
async fetch(request: Request) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response("Method not allowed", { status: 405 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await request.json() as EmailMessage
|
||||||
|
|
||||||
|
console.log("\n=== Webhook Received ===")
|
||||||
|
console.log("Event:", request.headers.get("X-Webhook-Event"))
|
||||||
|
console.log("Received At:", data.receivedAt)
|
||||||
|
console.log("\nEmail Details:")
|
||||||
|
console.log("From:", data.fromAddress)
|
||||||
|
console.log("To:", data.toAddress)
|
||||||
|
console.log("Subject:", data.subject)
|
||||||
|
console.log("Raw Content:", data.content)
|
||||||
|
console.log("HTML Content:", data.html)
|
||||||
|
console.log("Message ID:", data.messageId)
|
||||||
|
console.log("Email ID:", data.emailId)
|
||||||
|
console.log("=== End ===\n")
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing webhook:", error)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid request" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Webhook test server listening on http://localhost:${server.port}`)
|
@@ -1,12 +1,13 @@
|
|||||||
import { Env } from '../types'
|
import { Env } from '../types'
|
||||||
import { drizzle } from 'drizzle-orm/d1'
|
import { drizzle } from 'drizzle-orm/d1'
|
||||||
import { messages, emails } from '../app/lib/schema'
|
import { messages, emails, webhooks } from '../app/lib/schema'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import PostalMime from 'postal-mime'
|
import PostalMime from 'postal-mime'
|
||||||
|
import { WEBHOOK_CONFIG } from '../app/config'
|
||||||
|
import { EmailMessage } from '../app/lib/webhook'
|
||||||
|
|
||||||
const handleEmail = async (message: ForwardableEmailMessage, env: Env) => {
|
const handleEmail = async (message: ForwardableEmailMessage, env: Env) => {
|
||||||
const db = drizzle(env.DB, { schema: { messages, emails } })
|
const db = drizzle(env.DB, { schema: { messages, emails, webhooks } })
|
||||||
|
|
||||||
const parsedMessage = await PostalMime.parse(message.raw)
|
const parsedMessage = await PostalMime.parse(message.raw)
|
||||||
|
|
||||||
@@ -22,15 +23,43 @@ const handleEmail = async (message: ForwardableEmailMessage, env: Env) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(messages).values({
|
const savedMessage = await db.insert(messages).values({
|
||||||
// @ts-expect-error to fix
|
// @ts-expect-error "ignore"
|
||||||
emailId: targetEmail.id,
|
emailId: targetEmail.id,
|
||||||
fromAddress: message.from,
|
fromAddress: message.from,
|
||||||
subject: parsedMessage.subject,
|
subject: parsedMessage.subject,
|
||||||
content: parsedMessage.text,
|
content: parsedMessage.text,
|
||||||
html: parsedMessage.html || null,
|
html: parsedMessage.html || null,
|
||||||
|
}).returning().get()
|
||||||
|
|
||||||
|
const webhook = await db.query.webhooks.findFirst({
|
||||||
|
where: eq(webhooks.userId, targetEmail!.userId!)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (webhook?.enabled) {
|
||||||
|
try {
|
||||||
|
await fetch(webhook.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Webhook-Event': WEBHOOK_CONFIG.EVENTS.NEW_MESSAGE
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
emailId: targetEmail.id,
|
||||||
|
messageId: savedMessage.id,
|
||||||
|
fromAddress: savedMessage.fromAddress,
|
||||||
|
subject: savedMessage.subject,
|
||||||
|
content: savedMessage.content,
|
||||||
|
html: savedMessage.html,
|
||||||
|
receivedAt: savedMessage.receivedAt.toISOString(),
|
||||||
|
toAddress: targetEmail.address
|
||||||
|
} as EmailMessage)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send webhook:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Email processed: ${parsedMessage.subject}`)
|
console.log(`Email processed: ${parsedMessage.subject}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to process email:', error)
|
console.error('Failed to process email:', error)
|
||||||
|
Reference in New Issue
Block a user