Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 72 additions & 6 deletions app/api/emails/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createDb } from "@/lib/db"
import { and, eq, gt, lt, or, sql } from "drizzle-orm"
import { NextResponse } from "next/server"
import { emails } from "@/lib/schema"
import { emails, messages } from "@/lib/schema"
import { encodeCursor, decodeCursor } from "@/lib/cursor"
import { getUserId } from "@/lib/apiKey"

Expand All @@ -14,7 +14,8 @@ export async function GET(request: Request) {

const { searchParams } = new URL(request.url)
const cursor = searchParams.get('cursor')

const search = searchParams.get('search')?.trim() || ''

const db = createDb()

try {
Expand All @@ -23,6 +24,71 @@ export async function GET(request: Request) {
gt(emails.expiresAt, new Date())
)

// When search is active, use JOIN to search across emails + messages
if (search) {
const escaped = search.toLowerCase().replace(/[%_]/g, '\\$&')
const searchPattern = `%${escaped}%`
const searchCondition = or(
sql`LOWER(${emails.address}) LIKE ${searchPattern}`,
sql`LOWER(${messages.subject}) LIKE ${searchPattern}`,
sql`LOWER(${messages.fromAddress}) LIKE ${searchPattern}`,
sql`LOWER(${messages.toAddress}) LIKE ${searchPattern}`
)

// Count matching emails
const totalResult = await db
.select({ count: sql<number>`COUNT(DISTINCT ${emails.id})` })
.from(emails)
.leftJoin(messages, eq(messages.emailId, emails.id))
.where(and(baseConditions, searchCondition))
const totalCount = Number(totalResult[0].count)

// Build cursor condition
const cursorConditions = []
if (cursor) {
const { timestamp, id } = decodeCursor(cursor)
cursorConditions.push(
or(
lt(emails.createdAt, new Date(timestamp)),
and(
eq(emails.createdAt, new Date(timestamp)),
lt(emails.id, id)
)
)
)
}

const results = await db
.selectDistinct({
id: emails.id,
address: emails.address,
userId: emails.userId,
createdAt: emails.createdAt,
expiresAt: emails.expiresAt,
})
.from(emails)
.leftJoin(messages, eq(messages.emailId, emails.id))
.where(and(baseConditions, searchCondition, ...cursorConditions))
.orderBy(sql`${emails.createdAt} DESC`, sql`${emails.id} DESC`)
.limit(PAGE_SIZE + 1)

const hasMore = results.length > PAGE_SIZE
const nextCursor = hasMore
? encodeCursor(
results[PAGE_SIZE - 1].createdAt.getTime(),
results[PAGE_SIZE - 1].id
)
: null
const emailList = hasMore ? results.slice(0, PAGE_SIZE) : results

return NextResponse.json({
emails: emailList,
nextCursor,
total: totalCount
})
}

// No search — original logic
const totalResult = await db.select({ count: sql<number>`count(*)` })
.from(emails)
.where(baseConditions)
Expand Down Expand Up @@ -51,17 +117,17 @@ export async function GET(request: Request) {
],
limit: PAGE_SIZE + 1
})

const hasMore = results.length > PAGE_SIZE
const nextCursor = hasMore
const nextCursor = hasMore
? encodeCursor(
results[PAGE_SIZE - 1].createdAt.getTime(),
results[PAGE_SIZE - 1].id
)
: null
const emailList = hasMore ? results.slice(0, PAGE_SIZE) : results

return NextResponse.json({
return NextResponse.json({
emails: emailList,
nextCursor,
total: totalCount
Expand All @@ -73,4 +139,4 @@ export async function GET(request: Request) {
{ status: 500 }
)
}
}
}
97 changes: 75 additions & 22 deletions app/components/emails/email-list.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"use client"

import { useEffect, useState } from "react"
import { useEffect, useState, useCallback, useRef } 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 { Mail, RefreshCw, Trash2, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useThrottle } from "@/hooks/use-throttle"
import { Input } from "@/components/ui/input"
import { EMAIL_CONFIG } from "@/config"
import { useToast } from "@/components/ui/use-toast"
import {
Expand Down Expand Up @@ -58,35 +59,56 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
const [emailToDelete, setEmailToDelete] = useState<Email | null>(null)
const { toast } = useToast()

const fetchEmails = async (cursor?: string) => {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(null)

const handleSearchChange = useCallback((value: string) => {
setSearch(value)
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
setDebouncedSearch(value)
}, 300)
}, [])

const clearSearch = useCallback(() => {
setSearch('')
setDebouncedSearch('')
if (debounceTimer.current) clearTimeout(debounceTimer.current)
// Reset list to show all emails
setEmails([])
setNextCursor(null)
setLoading(true)
fetchEmails(undefined, '')
}, [])

const fetchEmails = async (cursor?: string, searchQuery?: string) => {
try {
const url = new URL("/api/emails", window.location.origin)
if (cursor) {
url.searchParams.set('cursor', cursor)
}
const currentSearch = searchQuery ?? debouncedSearch
if (currentSearch) {
url.searchParams.set('search', currentSearch)
}
const response = await fetch(url)
const data = await response.json() as EmailResponse

if (!cursor) {
const newEmails = data.emails
const oldEmails = emails

const lastDuplicateIndex = newEmails.findIndex(
newEmail => oldEmails.some(oldEmail => oldEmail.id === newEmail.id)
)

if (lastDuplicateIndex === -1) {
setEmails(newEmails)
setNextCursor(data.nextCursor)
setTotal(data.total)
return
}
const uniqueNewEmails = newEmails.slice(0, lastDuplicateIndex)
setEmails([...uniqueNewEmails, ...oldEmails])
setTotal(data.total)
return
if (cursor) {
// Pagination: append
setEmails(prev => [...prev, ...data.emails])
} else if (currentSearch) {
// Search: replace entirely
setEmails(data.emails)
} else {
// Non-search refresh: merge new emails into existing list
setEmails(prev => {
const existingIds = new Set(prev.map(e => e.id))
const newEmails = data.emails.filter((e: Email) => !existingIds.has(e.id))
return [...newEmails, ...prev]
})
}
setEmails(prev => [...prev, ...data.emails])
setNextCursor(data.nextCursor)
setTotal(data.total)
} catch (error) {
Expand Down Expand Up @@ -120,6 +142,16 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
if (session) fetchEmails()
}, [session])


useEffect(() => {
if (!session) return
if (!debouncedSearch) return
setEmails([])
setNextCursor(null)
setLoading(true)
fetchEmails(undefined, debouncedSearch)
}, [debouncedSearch])

const handleDelete = async (email: Email) => {
try {
const response = await fetch(`/api/emails/${email.id}`, {
Expand Down Expand Up @@ -185,6 +217,27 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
<CreateDialog onEmailCreated={handleRefresh} />
</div>

<div className="px-2 pb-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={t("searchPlaceholder")}
className="h-8 pl-7 pr-7 text-xs"
/>
{search && (
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-8 w-8"
onClick={clearSearch}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
{loading ? (
<div className="text-center text-sm text-gray-500">{t("loading")}</div>
Expand Down
3 changes: 2 additions & 1 deletion app/i18n/messages/en/emails.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"deleteSuccess": "Email deleted successfully",
"deleteFailed": "Failed to delete email",
"error": "Error",
"success": "Success"
"success": "Success",
"searchPlaceholder": "Search address, subject, sender..."
},
"create": {
"title": "Create Email",
Expand Down
3 changes: 2 additions & 1 deletion app/i18n/messages/ja/emails.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"deleteSuccess": "メールボックスを削除しました",
"deleteFailed": "メールボックスの削除に失敗しました",
"error": "エラー",
"success": "成功"
"success": "成功",
"searchPlaceholder": "アドレス、件名、差出人を検索..."
},
"create": {
"title": "メールボックスを作成",
Expand Down
3 changes: 2 additions & 1 deletion app/i18n/messages/ko/emails.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"deleteSuccess": "이메일이 성공적으로 삭제되었습니다",
"deleteFailed": "이메일 삭제에 실패했습니다",
"error": "오류",
"success": "성공"
"success": "성공",
"searchPlaceholder": "주소, 제목, 발신자 검색..."
},
"create": {
"title": "이메일 생성",
Expand Down
3 changes: 2 additions & 1 deletion app/i18n/messages/zh-CN/emails.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"deleteSuccess": "邮箱已删除",
"deleteFailed": "删除邮箱失败",
"error": "错误",
"success": "成功"
"success": "成功",
"searchPlaceholder": "搜索地址、主题、发件人..."
},
"create": {
"title": "创建邮箱",
Expand Down
3 changes: 2 additions & 1 deletion app/i18n/messages/zh-TW/emails.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"deleteSuccess": "郵箱已刪除",
"deleteFailed": "刪除郵箱失敗",
"error": "錯誤",
"success": "成功"
"success": "成功",
"searchPlaceholder": "搜尋地址、主題、寄件人..."
},
"create": {
"title": "建立郵箱",
Expand Down