email: full-text search (FTS5) — backend index + /email/search + search box in the list
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Send, ShieldAlert, Trash2 } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, Trash2, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -41,6 +41,17 @@ export const EmailList = () => {
|
|||||||
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||||
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const isSearching = debouncedSearch.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
||||||
queryKey: ['email-accounts'],
|
queryKey: ['email-accounts'],
|
||||||
queryFn: () => client.get<EmailAccountRow[]>('/email/accounts'),
|
queryFn: () => client.get<EmailAccountRow[]>('/email/accounts'),
|
||||||
@@ -50,23 +61,25 @@ export const EmailList = () => {
|
|||||||
const hasAccounts = emailAccounts.length > 0;
|
const hasAccounts = emailAccounts.length > 0;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['email-messages', page, folder],
|
queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
isSearching
|
||||||
|
? client.get<{ messages: EmailSummary[]; total: number }>(`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`)
|
||||||
|
: client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-switch to "all" if inbox filter returns nothing but emails exist
|
// Auto-switch to "all" if inbox filter returns nothing but emails exist (not while searching)
|
||||||
const { data: allCount } = useQuery({
|
const { data: allCount } = useQuery({
|
||||||
queryKey: ['email-messages-all-count'],
|
queryKey: ['email-messages-all-count'],
|
||||||
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'),
|
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'),
|
||||||
enabled: folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0,
|
enabled: !isSearching && folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
|
||||||
setFolder('all');
|
setFolder('all');
|
||||||
}
|
}
|
||||||
}, [folder, data?.total, allCount]);
|
}, [isSearching, folder, data?.total, allCount]);
|
||||||
|
|
||||||
// Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch.
|
// Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch.
|
||||||
// The EventSource closes automatically when this component unmounts (i.e. when you leave /email).
|
// The EventSource closes automatically when this component unmounts (i.e. when you leave /email).
|
||||||
@@ -163,8 +176,8 @@ export const EmailList = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show onboarding empty state only when no emails exist at all
|
// Show onboarding empty state only when no emails exist at all (never while searching)
|
||||||
const hasNoEmails = total === 0 && folder === 'inbox' && !allCount?.total;
|
const hasNoEmails = !isSearching && total === 0 && folder === 'inbox' && !allCount?.total;
|
||||||
if (hasNoEmails && !isLoading && page === 1) {
|
if (hasNoEmails && !isLoading && page === 1) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
|
||||||
@@ -252,8 +265,26 @@ export const EmailList = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
||||||
|
<Search className="h-3.5 w-3.5 shrink-0 opacity-40" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
|
placeholder="Search mail…"
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
|
||||||
|
/>
|
||||||
|
{isSearching && <span className="text-xs opacity-50 shrink-0">{total} result{total === 1 ? '' : 's'}</span>}
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer" title="Clear search">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="flex flex-1 items-center justify-center text-sm opacity-40">No emails in this folder</div>
|
<div className="flex flex-1 items-center justify-center text-sm opacity-40">
|
||||||
|
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
|
||||||
{messages.map((msg: EmailSummary) => {
|
{messages.map((msg: EmailSummary) => {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function openEmailDb(email: string): Database {
|
|||||||
db.exec(SCHEMA_TABLES);
|
db.exec(SCHEMA_TABLES);
|
||||||
migrate(db);
|
migrate(db);
|
||||||
db.exec(SCHEMA_INDEXES);
|
db.exec(SCHEMA_INDEXES);
|
||||||
|
ensureFts(db);
|
||||||
// Try to chmod, but don't crash if permission denied (e.g., file owned by different user)
|
// Try to chmod, but don't crash if permission denied (e.g., file owned by different user)
|
||||||
try {
|
try {
|
||||||
chmodSync(dbPath, 0o666);
|
chmodSync(dbPath, 0o666);
|
||||||
@@ -119,6 +120,65 @@ function migrate(db: Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full-text search (FTS5) ──
|
||||||
|
// A standalone FTS5 index kept in sync with `emails` on every upsert. unicode61 + diacritic folding
|
||||||
|
// gives accent-insensitive matching; per-term prefix queries make it feel incremental.
|
||||||
|
|
||||||
|
function ensureFts(db: Database): void {
|
||||||
|
db.exec(
|
||||||
|
`CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
|
||||||
|
id UNINDEXED, subject, sender, recipients, snippet, body,
|
||||||
|
tokenize = 'unicode61 remove_diacritics 2'
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
const fts = (db.query('SELECT count(*) AS c FROM emails_fts').get() as { c: number }).c;
|
||||||
|
const total = (db.query('SELECT count(*) AS c FROM emails').get() as { c: number }).c;
|
||||||
|
if (fts === 0 && total > 0) {
|
||||||
|
// Backfill existing mail (first run after this feature ships).
|
||||||
|
db.exec(
|
||||||
|
`INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body)
|
||||||
|
SELECT id, COALESCE(subject, ''),
|
||||||
|
TRIM(COALESCE(from_name, '') || ' ' || COALESCE(from_address, '')),
|
||||||
|
TRIM(COALESCE(to_address, '') || ' ' || COALESCE(cc, '')),
|
||||||
|
COALESCE(snippet, ''), COALESCE(text_body, '')
|
||||||
|
FROM emails`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ftsDeleteStmt = 'DELETE FROM emails_fts WHERE id = ?';
|
||||||
|
const ftsInsertStmt = 'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)';
|
||||||
|
|
||||||
|
function syncFtsRow(db: Database, id: string, subject: string, sender: string, recipients: string, snippet: string, body: string): void {
|
||||||
|
db.run(ftsDeleteStmt, [id]);
|
||||||
|
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-term prefix match, ANDed. Each term is quoted (so FTS operators in user input are literal) then
|
||||||
|
// suffixed with * for prefix matching.
|
||||||
|
function toFtsQuery(q: string): string {
|
||||||
|
const terms = q.trim().split(/\s+/).filter(Boolean);
|
||||||
|
return terms.map((t) => `"${t.replace(/"/g, '""')}"*`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record<string, unknown>[]; total: number } {
|
||||||
|
const match = toFtsQuery(q);
|
||||||
|
if (!match) return { rows: [], total: 0 };
|
||||||
|
const rows = db
|
||||||
|
.query(
|
||||||
|
`SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id
|
||||||
|
WHERE emails_fts MATCH ? AND e.deleted = 0
|
||||||
|
ORDER BY e.date DESC LIMIT ? OFFSET ?`,
|
||||||
|
)
|
||||||
|
.all(match, limit, offset) as Record<string, unknown>[];
|
||||||
|
const total = (
|
||||||
|
db.query('SELECT count(*) AS c FROM emails_fts JOIN emails e ON e.id = emails_fts.id WHERE emails_fts MATCH ? AND e.deleted = 0').get(match) as {
|
||||||
|
c: number;
|
||||||
|
}
|
||||||
|
).c;
|
||||||
|
return { rows, total };
|
||||||
|
}
|
||||||
|
|
||||||
type ParsedEmail = {
|
type ParsedEmail = {
|
||||||
id: string;
|
id: string;
|
||||||
integration: string;
|
integration: string;
|
||||||
@@ -173,6 +233,8 @@ export function upsertEmail(db: Database, email: ParsedEmail): void {
|
|||||||
db.run(insertAttachmentStmt, [email.id, i, att.filename, att.size, att.contentType, att.content]);
|
db.run(insertAttachmentStmt, [email.id, i, att.filename, att.size, att.contentType, att.content]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncFtsRow(db, email.id, email.subject ?? '', `${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(), `${email.to ?? ''} ${email.cc ?? ''}`.trim(), email.snippet ?? '', email.text ?? '');
|
||||||
|
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
@@ -215,6 +277,8 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
|
|||||||
db.run(insertAttachmentStmt, [id, i, att.filename, att.size, att.contentType, att.content]);
|
db.run(insertAttachmentStmt, [id, i, att.filename, att.size, att.contentType, att.content]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncFtsRow(db, id, subject, `${name} ${address}`.trim(), `${to} ${cc ?? ''}`.trim(), snippet, text ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert a db row to an EmailSummary for the API */
|
/** Convert a db row to an EmailSummary for the API */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|||||||
import type { EmailMessage } from 'types';
|
import type { EmailMessage } from 'types';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { DATA_PATH } from '@@/data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { openEmailDb, rowToSummary, getSyncMeta } from './email-db';
|
import { openEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
|
||||||
import { accountsRouter } from './accounts';
|
import { accountsRouter } from './accounts';
|
||||||
|
|
||||||
export const emailRouter = createRouter();
|
export const emailRouter = createRouter();
|
||||||
@@ -64,6 +64,24 @@ emailRouter.get('/events', (ctx) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Full-text search across all mail (subject / sender / recipients / snippet / body), newest first.
|
||||||
|
emailRouter.get('/search', async (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const q = (ctx.req.query('q') ?? '').trim();
|
||||||
|
const page = Math.max(1, Number(ctx.req.query('page') ?? '1') || 1);
|
||||||
|
const limit = Math.min(100, Math.max(1, Number(ctx.req.query('limit') ?? '50') || 50));
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
if (!q) return ctx.json({ messages: [], total: 0 });
|
||||||
|
|
||||||
|
const db = openEmailDb(email);
|
||||||
|
try {
|
||||||
|
const { rows, total } = searchEmails(db, q, limit, offset);
|
||||||
|
return ctx.json({ messages: rows.map(rowToSummary), total });
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
emailRouter.get('/messages', async (ctx) => {
|
emailRouter.get('/messages', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const page = Number(ctx.req.query('page') ?? '1');
|
const page = Number(ctx.req.query('page') ?? '1');
|
||||||
|
|||||||
Reference in New Issue
Block a user