diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx
index 5169f5e0..ddc83f64 100644
--- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx
@@ -274,7 +274,7 @@ export const EmailList = () => {
setSearch(ev.target.value)}
- placeholder="Search mail…"
+ placeholder="Search — try from:, subject:, has:attachment…"
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
/>
{isSearching && {total} result{total === 1 ? '' : 's'}}
diff --git a/src/servers/api/email/email-db.ts b/src/servers/api/email/email-db.ts
index 2cf0362d..a0337182 100644
--- a/src/servers/api/email/email-db.ts
+++ b/src/servers/api/email/email-db.ts
@@ -154,28 +154,98 @@ function syncFtsRow(db: Database, id: string, subject: string, sender: string, r
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(' ');
+// Gmail-style query parsing. Free text → full-text (prefix-AND). Operators:
+// from:/to:/subject:/body: → FTS5 column filters
+// has:attachment, is:unread/read, label:X, before:/after:YYYY-MM-DD → SQL filters on `emails`
+// Unknown operators fall back to free text. Quoted values (from:"a b") match as an exact phrase.
+const FTS_COLUMNS: Record = { from: 'sender', to: 'recipients', subject: 'subject', body: 'body' };
+
+const ftsTerm = (value: string, column: string | null, prefix: boolean): string => {
+ const esc = value.replace(/"/g, '""');
+ return `${column ? column + ':' : ''}"${esc}"${prefix ? '*' : ''}`;
+};
+
+const parseSearchDate = (v: string): string | null => {
+ const iso = /^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00.000Z` : v;
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+};
+
+type ParsedQuery = { fts: string; where: string[]; params: string[] };
+
+function parseEmailQuery(q: string): ParsedQuery {
+ const fts: string[] = [];
+ const where: string[] = [];
+ const params: string[] = [];
+ const re = /(\w+):("[^"]*"|\S+)|"([^"]*)"|(\S+)/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(q)) !== null) {
+ if (m[1]) {
+ const op = m[1].toLowerCase();
+ const raw = m[2]!;
+ const quoted = raw.startsWith('"') && raw.endsWith('"');
+ const val = quoted ? raw.slice(1, -1) : raw;
+ if (!val) continue;
+ const lower = val.toLowerCase();
+ if (FTS_COLUMNS[op]) {
+ fts.push(ftsTerm(val, FTS_COLUMNS[op], !quoted));
+ } else if (op === 'label') {
+ where.push('e.labels LIKE ?');
+ params.push(`%${lower}%`);
+ } else if (op === 'has' && (lower === 'attachment' || lower === 'attachments')) {
+ where.push('e.attachment_count > 0');
+ } else if (op === 'is' && (lower === 'unread' || lower === 'read')) {
+ where.push(lower === 'unread' ? 'e.read = 0' : 'e.read = 1');
+ } else if (op === 'before' || op === 'older') {
+ const d = parseSearchDate(val);
+ if (d) {
+ where.push('e.date < ?');
+ params.push(d);
+ }
+ } else if (op === 'after' || op === 'newer') {
+ const d = parseSearchDate(val);
+ if (d) {
+ where.push('e.date >= ?');
+ params.push(d);
+ }
+ } else {
+ // Unknown operator — treat the whole "op:val" token as free text.
+ fts.push(ftsTerm(`${op}:${val}`, null, !quoted));
+ }
+ } else if (m[3] !== undefined) {
+ if (m[3].trim()) fts.push(ftsTerm(m[3], null, false)); // quoted phrase → exact
+ } else if (m[4]) {
+ fts.push(ftsTerm(m[4], null, true)); // bare word → prefix
+ }
+ }
+ return { fts: fts.join(' '), where, params };
}
export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record[]; 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[];
- 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;
+ const { fts, where, params } = parseEmailQuery(q);
+ if (!fts && where.length === 0) return { rows: [], total: 0 };
+
+ const filterSql = ['e.deleted = 0', ...where].join(' AND ');
+
+ // Full-text present → join the FTS index; structured-filters-only → query `emails` directly.
+ if (fts) {
+ const rows = db
+ .query(
+ `SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id
+ WHERE emails_fts MATCH ? AND ${filterSql}
+ ORDER BY e.date DESC LIMIT ? OFFSET ?`,
+ )
+ .all(fts, ...params, limit, offset) as Record[];
+ 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 ${filterSql}`).get(fts, ...params) as {
+ c: number;
+ }
+ ).c;
+ return { rows, total };
+ }
+
+ const rows = db.query(`SELECT e.* FROM emails e WHERE ${filterSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record[];
+ const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${filterSql}`).get(...params) as { c: number }).c;
return { rows, total };
}