From c985e90a304976febe25515b695423fe3b72753c Mon Sep 17 00:00:00 2001 From: tbphp Date: Mon, 23 Feb 2026 00:09:34 +0800 Subject: [PATCH 1/2] feat: Add email search functionality (#2) * feat(api): Add email search with JOIN query on messages * feat(ui): Add search input to email list component * feat(i18n): Add search placeholder translations for all locales --- app/api/emails/route.ts | 79 +++++++++++++++++++++++++--- app/components/emails/email-list.tsx | 73 +++++++++++++++++++------ app/i18n/messages/en/emails.json | 3 +- app/i18n/messages/ja/emails.json | 3 +- app/i18n/messages/ko/emails.json | 3 +- app/i18n/messages/zh-CN/emails.json | 3 +- app/i18n/messages/zh-TW/emails.json | 3 +- 7 files changed, 139 insertions(+), 28 deletions(-) diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts index 6686dc3e..ae503d96 100644 --- a/app/api/emails/route.ts +++ b/app/api/emails/route.ts @@ -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" @@ -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 { @@ -23,6 +24,72 @@ 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 searchPattern = `%${search.toLowerCase()}%` + + 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 + .selectDistinct({ id: emails.id }) + .from(emails) + .leftJoin(messages, eq(messages.emailId, emails.id)) + .where(and(baseConditions, searchCondition)) + + const totalCount = totalResult.length + + // 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`count(*)` }) .from(emails) .where(baseConditions) @@ -51,9 +118,9 @@ 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 @@ -61,7 +128,7 @@ export async function GET(request: Request) { : null const emailList = hasMore ? results.slice(0, PAGE_SIZE) : results - return NextResponse.json({ + return NextResponse.json({ emails: emailList, nextCursor, total: totalCount @@ -73,4 +140,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/components/emails/email-list.tsx b/app/components/emails/email-list.tsx index 21b8271b..d1e5c37c 100644 --- a/app/components/emails/email-list.tsx +++ b/app/components/emails/email-list.tsx @@ -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 { @@ -58,34 +59,43 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { const [emailToDelete, setEmailToDelete] = useState(null) const { toast } = useToast() + const [search, setSearch] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const debounceTimer = useRef>(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) + }, []) + const fetchEmails = async (cursor?: string) => { try { const url = new URL("/api/emails", window.location.origin) if (cursor) { url.searchParams.set('cursor', cursor) } + if (debouncedSearch) { + url.searchParams.set('search', debouncedSearch) + } 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]) + setEmails(data.emails) + setNextCursor(data.nextCursor) setTotal(data.total) return } + setEmails(prev => [...prev, ...data.emails]) setNextCursor(data.nextCursor) setTotal(data.total) @@ -120,6 +130,14 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { if (session) fetchEmails() }, [session]) + + useEffect(() => { + setEmails([]) + setNextCursor(null) + setLoading(true) + fetchEmails() + }, [debouncedSearch]) + const handleDelete = async (email: Email) => { try { const response = await fetch(`/api/emails/${email.id}`, { @@ -185,6 +203,27 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { +
+
+ + handleSearchChange(e.target.value)} + placeholder={t("searchPlaceholder")} + className="h-8 pl-7 pr-7 text-xs" + /> + {search && ( + + )} +
+
{loading ? (
{t("loading")}
diff --git a/app/i18n/messages/en/emails.json b/app/i18n/messages/en/emails.json index 709e43c3..59a3d97b 100644 --- a/app/i18n/messages/en/emails.json +++ b/app/i18n/messages/en/emails.json @@ -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", diff --git a/app/i18n/messages/ja/emails.json b/app/i18n/messages/ja/emails.json index 468730e4..475a4eee 100644 --- a/app/i18n/messages/ja/emails.json +++ b/app/i18n/messages/ja/emails.json @@ -28,7 +28,8 @@ "deleteSuccess": "メールボックスを削除しました", "deleteFailed": "メールボックスの削除に失敗しました", "error": "エラー", - "success": "成功" + "success": "成功", + "searchPlaceholder": "アドレス、件名、差出人を検索..." }, "create": { "title": "メールボックスを作成", diff --git a/app/i18n/messages/ko/emails.json b/app/i18n/messages/ko/emails.json index 54cbfcfe..02eb8426 100644 --- a/app/i18n/messages/ko/emails.json +++ b/app/i18n/messages/ko/emails.json @@ -28,7 +28,8 @@ "deleteSuccess": "이메일이 성공적으로 삭제되었습니다", "deleteFailed": "이메일 삭제에 실패했습니다", "error": "오류", - "success": "성공" + "success": "성공", + "searchPlaceholder": "주소, 제목, 발신자 검색..." }, "create": { "title": "이메일 생성", diff --git a/app/i18n/messages/zh-CN/emails.json b/app/i18n/messages/zh-CN/emails.json index fc2eace8..27b2eb15 100644 --- a/app/i18n/messages/zh-CN/emails.json +++ b/app/i18n/messages/zh-CN/emails.json @@ -28,7 +28,8 @@ "deleteSuccess": "邮箱已删除", "deleteFailed": "删除邮箱失败", "error": "错误", - "success": "成功" + "success": "成功", + "searchPlaceholder": "搜索地址、主题、发件人..." }, "create": { "title": "创建邮箱", diff --git a/app/i18n/messages/zh-TW/emails.json b/app/i18n/messages/zh-TW/emails.json index 88a7c697..01d96d44 100644 --- a/app/i18n/messages/zh-TW/emails.json +++ b/app/i18n/messages/zh-TW/emails.json @@ -28,7 +28,8 @@ "deleteSuccess": "郵箱已刪除", "deleteFailed": "刪除郵箱失敗", "error": "錯誤", - "success": "成功" + "success": "成功", + "searchPlaceholder": "搜尋地址、主題、寄件人..." }, "create": { "title": "建立郵箱", From 640c2dd0f18b5430cfe5b8bd9331a4d3554307e1 Mon Sep 17 00:00:00 2001 From: tbphp Date: Mon, 23 Feb 2026 00:46:02 +0800 Subject: [PATCH 2/2] fix: Search boundary --- app/api/emails/route.ts | 9 ++++---- app/components/emails/email-list.tsx | 34 ++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/app/api/emails/route.ts b/app/api/emails/route.ts index ae503d96..946d9469 100644 --- a/app/api/emails/route.ts +++ b/app/api/emails/route.ts @@ -26,8 +26,8 @@ export async function GET(request: Request) { // When search is active, use JOIN to search across emails + messages if (search) { - const searchPattern = `%${search.toLowerCase()}%` - + const escaped = search.toLowerCase().replace(/[%_]/g, '\\$&') + const searchPattern = `%${escaped}%` const searchCondition = or( sql`LOWER(${emails.address}) LIKE ${searchPattern}`, sql`LOWER(${messages.subject}) LIKE ${searchPattern}`, @@ -37,12 +37,11 @@ export async function GET(request: Request) { // Count matching emails const totalResult = await db - .selectDistinct({ id: emails.id }) + .select({ count: sql`COUNT(DISTINCT ${emails.id})` }) .from(emails) .leftJoin(messages, eq(messages.emailId, emails.id)) .where(and(baseConditions, searchCondition)) - - const totalCount = totalResult.length + const totalCount = Number(totalResult[0].count) // Build cursor condition const cursorConditions = [] diff --git a/app/components/emails/email-list.tsx b/app/components/emails/email-list.tsx index d1e5c37c..ce70ae07 100644 --- a/app/components/emails/email-list.tsx +++ b/app/components/emails/email-list.tsx @@ -75,28 +75,40 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { 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) => { + const fetchEmails = async (cursor?: string, searchQuery?: string) => { try { const url = new URL("/api/emails", window.location.origin) if (cursor) { url.searchParams.set('cursor', cursor) } - if (debouncedSearch) { - url.searchParams.set('search', debouncedSearch) + 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) { + if (cursor) { + // Pagination: append + setEmails(prev => [...prev, ...data.emails]) + } else if (currentSearch) { + // Search: replace entirely setEmails(data.emails) - setNextCursor(data.nextCursor) - setTotal(data.total) - return + } 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) { @@ -132,10 +144,12 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { useEffect(() => { + if (!session) return + if (!debouncedSearch) return setEmails([]) setNextCursor(null) setLoading(true) - fetchEmails() + fetchEmails(undefined, debouncedSearch) }, [debouncedSearch]) const handleDelete = async (email: Email) => {