@bobfrankston/rmfmail 1.1.45 → 1.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/TODO.md +9 -0
  2. package/client/app.bundle.js +371 -80
  3. package/client/app.bundle.js.map +4 -4
  4. package/client/app.js +167 -32
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +153 -32
  7. package/client/components/calendar-sidebar.js +221 -88
  8. package/client/components/calendar-sidebar.js.map +1 -1
  9. package/client/components/calendar-sidebar.ts +224 -83
  10. package/client/compose/compose.bundle.js +14 -0
  11. package/client/compose/compose.bundle.js.map +2 -2
  12. package/client/compose/spellcheck.js +15 -0
  13. package/client/compose/spellcheck.js.map +1 -1
  14. package/client/compose/spellcheck.ts +14 -0
  15. package/client/help/search-help.js +75 -0
  16. package/client/help/search-help.js.map +1 -0
  17. package/client/help/search-help.ts +75 -0
  18. package/client/index.html +7 -7
  19. package/client/lib/api-client.js +5 -0
  20. package/client/lib/api-client.js.map +1 -1
  21. package/client/lib/api-client.ts +5 -0
  22. package/client/lib/mailxapi.js +1 -0
  23. package/client/styles/components.css +204 -6
  24. package/docs/search.md +5 -1
  25. package/package.json +1 -1
  26. package/packages/mailx-service/google-sync.d.ts +3 -0
  27. package/packages/mailx-service/google-sync.d.ts.map +1 -1
  28. package/packages/mailx-service/google-sync.js +1 -0
  29. package/packages/mailx-service/google-sync.js.map +1 -1
  30. package/packages/mailx-service/google-sync.ts +4 -0
  31. package/packages/mailx-service/index.d.ts +11 -0
  32. package/packages/mailx-service/index.d.ts.map +1 -1
  33. package/packages/mailx-service/index.js +99 -127
  34. package/packages/mailx-service/index.js.map +1 -1
  35. package/packages/mailx-service/index.ts +91 -122
  36. package/packages/mailx-service/jsonrpc.js +2 -0
  37. package/packages/mailx-service/jsonrpc.js.map +1 -1
  38. package/packages/mailx-service/jsonrpc.ts +2 -0
  39. package/packages/mailx-settings/index.d.ts.map +1 -1
  40. package/packages/mailx-settings/index.js +4 -1
  41. package/packages/mailx-settings/index.js.map +1 -1
  42. package/packages/mailx-settings/index.ts +4 -1
  43. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-28848 → node_modules.npmglobalize-stash-43988}/.package-lock.json +0 -0
@@ -42,6 +42,7 @@ __export(api_client_exports, {
42
42
  getAttachment: () => getAttachment,
43
43
  getAutocompleteSettings: () => getAutocompleteSettings,
44
44
  getCalendarEvents: () => getCalendarEvents,
45
+ getCalendars: () => getCalendars,
45
46
  getDeviceAccounts: () => getDeviceAccounts,
46
47
  getDiagnostics: () => getDiagnostics,
47
48
  getFolders: () => getFolders,
@@ -221,6 +222,9 @@ function getPrimaryAccount(feature) {
221
222
  function getCalendarEvents(fromMs, toMs) {
222
223
  return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);
223
224
  }
225
+ function getCalendars() {
226
+ return ipc().getCalendars?.() ?? Promise.resolve([]);
227
+ }
224
228
  function createCalendarEvent(ev) {
225
229
  return ipc().createCalendarEvent?.(ev);
226
230
  }
@@ -3793,6 +3797,106 @@ __export(calendar_sidebar_exports, {
3793
3797
  isCalendarSidebarOn: () => isCalendarSidebarOn,
3794
3798
  showCalendarSidebar: () => showCalendarSidebar
3795
3799
  });
3800
+ function calendarKind(id, primary) {
3801
+ if (primary)
3802
+ return "personal";
3803
+ const c = (id || "").toLowerCase();
3804
+ if (c.includes("addressbook#contacts"))
3805
+ return "birthday";
3806
+ if (c.includes("#holiday@") || c.includes("holiday@group")) {
3807
+ if (c.includes("usa"))
3808
+ return "usHoliday";
3809
+ if (c.includes("judaism") || c.includes("jewish"))
3810
+ return "jewishHoliday";
3811
+ return "otherHoliday";
3812
+ }
3813
+ return "other";
3814
+ }
3815
+ function calIconHtml(info) {
3816
+ const kind = calendarKind(info.id, info.primary);
3817
+ const t = escapeHtml4(info.name);
3818
+ if (kind === "personal")
3819
+ return `<span class="cal-ico cal-ico-dot" title="${t}"></span>`;
3820
+ if (kind === "usHoliday")
3821
+ return `<span class="cal-ico cal-ico-emoji" title="${t}">\u{1F1FA}\u{1F1F8}</span>`;
3822
+ if (kind === "jewishHoliday")
3823
+ return `<span class="cal-ico cal-ico-emoji" title="${t}">\u2721\uFE0F</span>`;
3824
+ if (kind === "birthday")
3825
+ return `<span class="cal-ico cal-ico-emoji" title="${t}">\u{1F382}</span>`;
3826
+ if (kind === "otherHoliday")
3827
+ return `<span class="cal-ico cal-ico-emoji" title="${t}">\u2726</span>`;
3828
+ const letter = escapeHtml4((info.name.trim()[0] || "?").toUpperCase());
3829
+ const color = info.color || "#7a7a7a";
3830
+ return `<span class="cal-ico cal-ico-mono" style="background:${escapeHtml4(color)}" title="${t}">${letter}</span>`;
3831
+ }
3832
+ function calInfoFor(calendarId) {
3833
+ const id = calendarId || "primary";
3834
+ const found = calById.get(id);
3835
+ if (found)
3836
+ return found;
3837
+ return { id, name: id.split("@")[0] || id, color: "", primary: id === "primary" };
3838
+ }
3839
+ function isCalHidden(calendarId) {
3840
+ return hiddenCalendars.has(calInfoFor(calendarId).id);
3841
+ }
3842
+ async function loadHiddenCalendars() {
3843
+ try {
3844
+ const s = await getSettings();
3845
+ const arr = s?.calendar?.hiddenCalendars;
3846
+ hiddenCalendars = new Set(Array.isArray(arr) ? arr : []);
3847
+ } catch {
3848
+ hiddenCalendars = /* @__PURE__ */ new Set();
3849
+ }
3850
+ }
3851
+ async function saveHiddenCalendars() {
3852
+ try {
3853
+ const s = await getSettings();
3854
+ s.calendar = { ...s.calendar || {}, hiddenCalendars: [...hiddenCalendars] };
3855
+ await saveSettings(s);
3856
+ } catch (e) {
3857
+ console.error("[cal] save hiddenCalendars failed:", e);
3858
+ }
3859
+ }
3860
+ async function renderCalendarList() {
3861
+ const host = document.getElementById("cal-side-calendars");
3862
+ let list = [];
3863
+ try {
3864
+ list = await getCalendars();
3865
+ } catch {
3866
+ }
3867
+ calendarList = list;
3868
+ calById.clear();
3869
+ for (const c of list) {
3870
+ calById.set(c.id, c);
3871
+ if (c.primary)
3872
+ calById.set("primary", c);
3873
+ }
3874
+ if (host) {
3875
+ if (list.length === 0) {
3876
+ host.innerHTML = "";
3877
+ } else {
3878
+ const sorted = [...list].sort((a, b) => (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
3879
+ host.innerHTML = sorted.map((c) => `<label class="cal-side-cal-row" title="${escapeHtml4(c.name)}">
3880
+ <input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml4(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
3881
+ ${calIconHtml(c)}
3882
+ <span class="cal-side-cal-name">${escapeHtml4(c.name)}</span>
3883
+ </label>`).join("");
3884
+ host.querySelectorAll(".cal-side-cal-check").forEach((cb) => {
3885
+ cb.addEventListener("change", async () => {
3886
+ const id = cb.dataset.calId || "";
3887
+ if (cb.checked)
3888
+ hiddenCalendars.delete(id);
3889
+ else
3890
+ hiddenCalendars.add(id);
3891
+ await saveHiddenCalendars();
3892
+ renderEvents(lastEvents);
3893
+ });
3894
+ });
3895
+ }
3896
+ }
3897
+ if (lastEvents.length > 0)
3898
+ renderEvents(lastEvents);
3899
+ }
3796
3900
  function getSelectedTaskUuids() {
3797
3901
  return [...selectedTaskUuids];
3798
3902
  }
@@ -3869,11 +3973,26 @@ function renderHead() {
3869
3973
  const d = new Date(viewYear, viewMonth, viewDay);
3870
3974
  dateEl.innerHTML = `<strong>${d.getDate()}</strong> ${d.toLocaleDateString(void 0, { weekday: "short" })} <span class="cal-side-date-month">${d.toLocaleDateString(void 0, { month: "short", year: "numeric" })}</span>`;
3871
3975
  }
3976
+ function overdueTaskRowHtml(t) {
3977
+ let dueLabel = "";
3978
+ if (t.dueMs) {
3979
+ const d = new Date(t.dueMs);
3980
+ const sameYear = d.getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
3981
+ dueLabel = sameYear ? `${d.getMonth() + 1}/${d.getDate()}` : d.toISOString().slice(0, 10);
3982
+ }
3983
+ return `<div class="cal-side-task cal-side-task-overdue" data-uuid="${escapeHtml4(t.uuid)}">
3984
+ <input type="checkbox" class="cal-side-overdue-check" title="Mark done">
3985
+ <span class="cal-side-task-title">${escapeHtml4(t.title)}</span>
3986
+ <span class="cal-side-task-due overdue">${escapeHtml4(dueLabel)}</span>
3987
+ </div>`;
3988
+ }
3872
3989
  function renderEvents(events) {
3873
3990
  const body = document.getElementById("cal-side-body");
3874
3991
  if (!body)
3875
3992
  return;
3876
- if (events.length === 0) {
3993
+ events = events.filter((e) => !isCalHidden(e.calendarId));
3994
+ const overdue = lastOverdueTasks;
3995
+ if (events.length === 0 && overdue.length === 0) {
3877
3996
  body.innerHTML = `<div class="cal-side-empty">No upcoming events. Click + New event to add one.</div>`;
3878
3997
  return;
3879
3998
  }
@@ -3912,12 +4031,17 @@ function renderEvents(events) {
3912
4031
  dailyHeads.push(instances.reduce((a, b) => a.start < b.start ? a : b));
3913
4032
  }
3914
4033
  let html = "";
4034
+ if (overdue.length > 0) {
4035
+ html += `<div class="cal-side-day cal-side-day-overdue">Overdue tasks</div>`;
4036
+ for (const t of overdue)
4037
+ html += overdueTaskRowHtml(t);
4038
+ }
3915
4039
  if (dailyHeads.length > 0) {
3916
4040
  html += `<div class="cal-side-day cal-side-day-daily">Daily</div>`;
3917
4041
  for (const e of dailyHeads) {
3918
4042
  const link = e.htmlLink || "";
3919
4043
  html += `<div class="cal-side-event" data-id="${e.id}" data-link="${escapeHtml4(link)}" ${link ? 'title="Click to open in Google Calendar"' : ""}>
3920
- <span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
4044
+ ${calIconHtml(calInfoFor(e.calendarId))}
3921
4045
  <span class="cal-side-event-time">${escapeHtml4(formatTime(e))}</span>
3922
4046
  <span class="cal-side-event-title">${escapeHtml4(e.title)}<span class="cal-side-event-recur" title="Daily">\u21BB</span></span>
3923
4047
  </div>`;
@@ -3929,7 +4053,10 @@ function renderEvents(events) {
3929
4053
  for (const e of events) {
3930
4054
  if (e.recurringEventId && dailyKeys.has(e.recurringEventId))
3931
4055
  continue;
3932
- if (e.isHoliday && e.start > holidayCutoff)
4056
+ const info = calInfoFor(e.calendarId);
4057
+ const kind = calendarKind(info.id, info.primary);
4058
+ const isHolidayKind = kind === "usHoliday" || kind === "jewishHoliday" || kind === "otherHoliday";
4059
+ if (isHolidayKind && e.start > holidayCutoff)
3933
4060
  continue;
3934
4061
  const d = new Date(e.start);
3935
4062
  const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
@@ -3939,35 +4066,30 @@ function renderEvents(events) {
3939
4066
  }
3940
4067
  const recurMark = e.recurringEventId ? `<span class="cal-side-event-recur" title="Recurring event">\u21BB</span>` : "";
3941
4068
  const link = e.htmlLink || "";
3942
- let holidayKind;
3943
- if (e.isHoliday) {
3944
- const cid = (e.calendarId || "").toLowerCase();
3945
- if (cid.includes("usa"))
3946
- holidayKind = "us";
3947
- else if (cid.includes("judaism") || cid.includes("jewish"))
3948
- holidayKind = "jewish";
3949
- else
3950
- holidayKind = "other";
3951
- }
3952
- const holidayAttr = e.isHoliday ? ` data-holiday="1" data-holiday-kind="${holidayKind}"` : "";
3953
4069
  const recurAttr = e.recurringEventId ? ' data-recurring="1"' : "";
3954
- const clickable = !e.isHoliday;
3955
- const titleAttr = clickable && link ? 'title="Click to open in Google Calendar"' : "";
3956
- const dataLink = clickable ? `data-link="${escapeHtml4(link)}"` : "";
3957
- if (e.isHoliday) {
3958
- const symbol = holidayKind === "us" ? "\u{1F1FA}\u{1F1F8}" : holidayKind === "jewish" ? "\u2721\uFE0F" : "\u2726";
3959
- html += `<div class="cal-side-event"${holidayAttr} data-id="${e.id}">
3960
- <span class="cal-side-event-title cal-side-event-holiday-title"><span class="cal-side-event-holiday-symbol">${symbol}</span> ${escapeHtml4(e.title)}</span>
4070
+ if (isHolidayKind || kind === "birthday") {
4071
+ html += `<div class="cal-side-event" data-holiday="1" data-holiday-kind="${kind}" data-id="${e.id}">
4072
+ <span class="cal-side-event-title cal-side-event-holiday-title">${calIconHtml(info)} ${escapeHtml4(e.title)}</span>
3961
4073
  </div>`;
3962
4074
  } else {
3963
- html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} ${dataLink} ${titleAttr}>
3964
- <span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
4075
+ const titleAttr = link ? 'title="Click to open in Google Calendar"' : "";
4076
+ html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml4(link)}" ${titleAttr}>
4077
+ ${calIconHtml(info)}
3965
4078
  <span class="cal-side-event-time">${escapeHtml4(formatTime(e))}</span>
3966
4079
  <span class="cal-side-event-title">${escapeHtml4(e.title)}${recurMark}</span>
3967
4080
  </div>`;
3968
4081
  }
3969
4082
  }
3970
4083
  body.innerHTML = html;
4084
+ body.querySelectorAll(".cal-side-task-overdue").forEach((row) => {
4085
+ const uuid = row.dataset.uuid;
4086
+ row.querySelector(".cal-side-overdue-check")?.addEventListener("change", async () => {
4087
+ await updateTask(uuid, { completedMs: Date.now() });
4088
+ lastOverdueTasks = lastOverdueTasks.filter((t) => t.uuid !== uuid);
4089
+ renderEvents(lastEvents);
4090
+ renderTasks();
4091
+ });
4092
+ });
3971
4093
  const openInCalendar = (url) => {
3972
4094
  const api = window.mailxapi;
3973
4095
  if (api?.openCalendarEvent) {
@@ -4010,10 +4132,10 @@ function renderEvents(events) {
4010
4132
  });
4011
4133
  });
4012
4134
  }
4013
- async function renderTasks() {
4135
+ async function renderTasks(prefetched) {
4014
4136
  const cb = document.getElementById("cal-side-show-done");
4015
4137
  const showDone = cb?.checked ?? false;
4016
- const tasks = await getTasks(showDone);
4138
+ const tasks = prefetched ?? await getTasks(showDone);
4017
4139
  const host = document.getElementById("cal-side-tasks");
4018
4140
  if (!host)
4019
4141
  return;
@@ -4102,15 +4224,25 @@ async function renderTasks() {
4102
4224
  async function refresh() {
4103
4225
  renderHead();
4104
4226
  const from = new Date(viewYear, viewMonth, viewDay);
4227
+ let prefetchedTasks;
4105
4228
  try {
4106
- lastEvents = await fetchUpcoming(from);
4229
+ const startOfToday = /* @__PURE__ */ new Date();
4230
+ startOfToday.setHours(0, 0, 0, 0);
4231
+ const showDone = document.getElementById("cal-side-show-done")?.checked ?? false;
4232
+ const [events, tasks] = await Promise.all([
4233
+ fetchUpcoming(from),
4234
+ getTasks(showDone).catch(() => [])
4235
+ ]);
4236
+ lastEvents = events;
4237
+ prefetchedTasks = tasks;
4238
+ lastOverdueTasks = prefetchedTasks.filter((t) => t.dueMs && !t.completedMs && t.dueMs < startOfToday.getTime()).map((t) => ({ uuid: t.uuid, title: t.title, dueMs: t.dueMs }));
4107
4239
  renderEvents(lastEvents);
4108
4240
  } catch (e) {
4109
4241
  const body = document.getElementById("cal-side-body");
4110
4242
  if (body)
4111
4243
  body.innerHTML = `<div class="cal-side-empty cal-side-quota-error">Couldn't load calendar: ${escapeHtml4(e?.message || String(e))}</div>`;
4112
4244
  }
4113
- renderTasks();
4245
+ renderTasks(prefetchedTasks);
4114
4246
  }
4115
4247
  function openNewEventDialog() {
4116
4248
  const backdrop = document.createElement("div");
@@ -4237,6 +4369,8 @@ async function showCalendarSidebar() {
4237
4369
  localStorage.setItem(SIDEBAR_PREF, "true");
4238
4370
  } catch {
4239
4371
  }
4372
+ await loadHiddenCalendars();
4373
+ void renderCalendarList();
4240
4374
  await refresh();
4241
4375
  }
4242
4376
  function hideCalendarSidebar() {
@@ -4321,7 +4455,7 @@ function initCalendarSidebar() {
4321
4455
  if (btn)
4322
4456
  btn.disabled = true;
4323
4457
  try {
4324
- await refresh();
4458
+ await Promise.all([renderCalendarList(), refresh()]);
4325
4459
  } finally {
4326
4460
  setTimeout(() => {
4327
4461
  btn?.classList.remove("cal-side-refreshing");
@@ -4357,32 +4491,6 @@ function initCalendarSidebar() {
4357
4491
  refresh();
4358
4492
  });
4359
4493
  }
4360
- const wireHolidayCheckbox = (cbId, settingsKey) => {
4361
- const cb = document.getElementById(cbId);
4362
- if (!cb || cb.__wired)
4363
- return;
4364
- cb.__wired = true;
4365
- let userTouched = false;
4366
- getSettings().then((s) => {
4367
- if (userTouched)
4368
- return;
4369
- cb.checked = !!s?.calendar?.[settingsKey];
4370
- }).catch(() => {
4371
- });
4372
- cb.addEventListener("change", async () => {
4373
- userTouched = true;
4374
- try {
4375
- const s = await getSettings();
4376
- s.calendar = { ...s.calendar || {}, [settingsKey]: cb.checked };
4377
- await saveSettings(s);
4378
- } catch (e) {
4379
- console.error(`[cal] ${settingsKey} save failed:`, e);
4380
- }
4381
- refresh();
4382
- });
4383
- };
4384
- wireHolidayCheckbox("cal-side-show-holidays", "showHolidays");
4385
- wireHolidayCheckbox("cal-side-show-jewish-holidays", "showJewishHolidays");
4386
4494
  const horizonInput = document.getElementById("cal-side-horizon");
4387
4495
  if (horizonInput && !horizonInput.__wired) {
4388
4496
  horizonInput.__wired = true;
@@ -4441,7 +4549,7 @@ function initCalendarSidebar() {
4441
4549
  }
4442
4550
  }
4443
4551
  }
4444
- var SIDEBAR_PREF, SHOW_RECURRING_PREF, SHOW_DONE_PREF, HORIZON_DAYS_PREF, HORIZON_DEFAULT_DAYS, HOLIDAY_HORIZON_MS, viewYear, viewMonth, viewDay, lastEvents, selectedTaskUuids;
4552
+ var SIDEBAR_PREF, SHOW_RECURRING_PREF, SHOW_DONE_PREF, HORIZON_DAYS_PREF, HORIZON_DEFAULT_DAYS, HOLIDAY_HORIZON_MS, viewYear, viewMonth, viewDay, lastEvents, lastOverdueTasks, calById, calendarList, hiddenCalendars, selectedTaskUuids;
4445
4553
  var init_calendar_sidebar = __esm({
4446
4554
  "client/components/calendar-sidebar.js"() {
4447
4555
  "use strict";
@@ -4457,6 +4565,10 @@ var init_calendar_sidebar = __esm({
4457
4565
  viewMonth = (/* @__PURE__ */ new Date()).getMonth();
4458
4566
  viewDay = (/* @__PURE__ */ new Date()).getDate();
4459
4567
  lastEvents = [];
4568
+ lastOverdueTasks = [];
4569
+ calById = /* @__PURE__ */ new Map();
4570
+ calendarList = [];
4571
+ hiddenCalendars = /* @__PURE__ */ new Set();
4460
4572
  selectedTaskUuids = /* @__PURE__ */ new Set();
4461
4573
  }
4462
4574
  });
@@ -5093,6 +5205,81 @@ var init_alarms = __esm({
5093
5205
  }
5094
5206
  });
5095
5207
 
5208
+ // client/help/search-help.js
5209
+ var search_help_exports = {};
5210
+ __export(search_help_exports, {
5211
+ SEARCH_HELP_HTML: () => SEARCH_HELP_HTML
5212
+ });
5213
+ var SEARCH_HELP_HTML;
5214
+ var init_search_help = __esm({
5215
+ "client/help/search-help.js"() {
5216
+ "use strict";
5217
+ SEARCH_HELP_HTML = `
5218
+ <p>Search runs in one of three modes depending on the scope you pick. The mode
5219
+ decides which operators work.</p>
5220
+ <ul>
5221
+ <li><strong>All folders</strong> (default) \u2014 searches the <em>local cache</em> with SQLite FTS5.</li>
5222
+ <li><strong>This folder</strong> \u2014 instant client-side filter on the visible rows; full FTS5 search on Enter.</li>
5223
+ <li><strong>Server</strong> (checkbox) \u2014 sends the query to the mail server (IMAP SEARCH; not yet wired for Gmail accounts).</li>
5224
+ </ul>
5225
+
5226
+ <h3>Qualifiers \u2014 work in every mode</h3>
5227
+ <table>
5228
+ <tr><th>Form</th><th>Effect</th></tr>
5229
+ <tr><td><code>from:bob</code></td><td>Sender contains</td></tr>
5230
+ <tr><td><code>to:eleanor</code></td><td>Recipient contains</td></tr>
5231
+ <tr><td><code>subject:lunch</code></td><td>Subject contains</td></tr>
5232
+ <tr><td><code>date:2026-05-01</code></td><td>On that date \u2014 also <code>date:&gt;1w</code>, <code>date:&lt;=2026-01-15</code></td></tr>
5233
+ <tr><td><code>after:1w</code> / <code>before:1m</code></td><td>Newer / older than \u2014 <code>d</code>, <code>w</code>, <code>m</code>, <code>y</code>, a date, <code>today</code>, <code>yesterday</code></td></tr>
5234
+ <tr><td><code>has:attachment</code></td><td>Has an attachment</td></tr>
5235
+ <tr><td><code>is:unread</code></td><td>Also <code>is:flagged</code>, <code>is:read</code>, <code>is:answered</code>, <code>is:draft</code></td></tr>
5236
+ <tr><td><code>folder:sent</code></td><td>Restrict to folders whose name contains the term</td></tr>
5237
+ <tr><td><code>/regex/</code></td><td>Client-side regex over the currently-visible rows. Local only \u2014 never sent to the server.</td></tr>
5238
+ </table>
5239
+ <p>Any remaining unqualified text is the free-text term.</p>
5240
+
5241
+ <h3>Local search (FTS5) \u2014 default</h3>
5242
+ <p>Full-text index over envelopes plus locally-cached bodies. Fast and indexed.</p>
5243
+ <table>
5244
+ <tr><th>Operator</th><th>Example</th><th>Meaning</th></tr>
5245
+ <tr><td>implicit AND</td><td><code>bob lunch</code></td><td>Both terms must appear</td></tr>
5246
+ <tr><td><code>OR</code></td><td><code>bob OR eleanor</code></td><td>Either term</td></tr>
5247
+ <tr><td><code>NOT</code></td><td><code>bob NOT spam</code></td><td>First without the second</td></tr>
5248
+ <tr><td><code>"phrase"</code></td><td><code>"happy birthday"</code></td><td>Exact phrase</td></tr>
5249
+ <tr><td><code>term*</code></td><td><code>lunch*</code></td><td>Prefix</td></tr>
5250
+ <tr><td><code>NEAR(a b, 5)</code></td><td><code>NEAR(bob lunch, 5)</code></td><td>Both within 5 tokens</td></tr>
5251
+ </table>
5252
+ <p>Indexed: subject, from, to, cc, a body snippet, and the full body <em>if</em> it
5253
+ has been downloaded locally (the blue dot in the list). Bodies not yet
5254
+ prefetched aren't searchable until they are.</p>
5255
+
5256
+ <h3>This-folder filter</h3>
5257
+ <p>With scope set to <strong>This folder</strong>, typing filters the rendered rows
5258
+ instantly \u2014 no server hit, no FTS5, just substring match. Press <kbd>Enter</kbd>
5259
+ to escalate to a full FTS5 search of the folder.</p>
5260
+
5261
+ <h3>Server search</h3>
5262
+ <p>Tick <strong>Server</strong> and the query goes to the mail server. On Dovecot
5263
+ (IMAP SEARCH) your <code>from:</code> / <code>to:</code> / <code>subject:</code>
5264
+ qualifiers map to IMAP keys; remaining text becomes a body search. IMAP has no
5265
+ literal <code>AND</code> keyword \u2014 keys are implicitly ANDed. Server search on
5266
+ Gmail accounts is not yet wired and currently returns local results only.</p>
5267
+
5268
+ <h3>Case sensitivity</h3>
5269
+ <p>All three modes \u2014 local FTS5, IMAP server SEARCH, and <code>/regex/</code> \u2014
5270
+ are <strong>case-insensitive</strong>. <code>Lunch</code>, <code>lunch</code>, and
5271
+ <code>LUNCH</code> match the same messages. There is no case-sensitive mode.</p>
5272
+
5273
+ <h3>Limitations</h3>
5274
+ <ul>
5275
+ <li>No regex on the server side, any provider \u2014 <code>/pattern/</code> only filters visible local rows.</li>
5276
+ <li>Server search results are stored locally (envelope-only) on first hit, so a server search also backfills those envelopes.</li>
5277
+ <li>Body search on bodies you haven't prefetched only works server-side; the local index can't search what isn't downloaded.</li>
5278
+ </ul>
5279
+ `;
5280
+ }
5281
+ });
5282
+
5096
5283
  // client/components/folder-tree.js
5097
5284
  init_api_client();
5098
5285
  init_context_menu();
@@ -6539,7 +6726,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
6539
6726
  currentFolderSpecialUse = specialUse;
6540
6727
  currentAccountId3 = accountId;
6541
6728
  currentFolderId2 = folderId;
6542
- if (searchInput) searchInput.value = "";
6729
+ if (searchInput) {
6730
+ searchInput.value = "";
6731
+ updateSearchHighlight();
6732
+ }
6543
6733
  clearSearchMode();
6544
6734
  markAsSeen();
6545
6735
  releaseFocus();
@@ -6550,7 +6740,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
6550
6740
  document.dispatchEvent(new CustomEvent("mailx-folder-changed", { detail: { accountId, folderId } }));
6551
6741
  }, () => {
6552
6742
  currentFolderSpecialUse = "inbox";
6553
- if (searchInput) searchInput.value = "";
6743
+ if (searchInput) {
6744
+ searchInput.value = "";
6745
+ updateSearchHighlight();
6746
+ }
6554
6747
  clearSearchMode();
6555
6748
  releaseFocus();
6556
6749
  loadUnifiedInbox();
@@ -6559,7 +6752,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
6559
6752
  setActiveView({ kind: "unified" }, "All Inboxes");
6560
6753
  });
6561
6754
  function applyTabView(tab) {
6562
- if (searchInput) searchInput.value = "";
6755
+ if (searchInput) {
6756
+ searchInput.value = "";
6757
+ updateSearchHighlight();
6758
+ }
6563
6759
  clearSearchMode();
6564
6760
  releaseFocus();
6565
6761
  const v = tab.view;
@@ -6576,7 +6772,10 @@ function applyTabView(tab) {
6576
6772
  setTitle(`${APP_NAME} - ${tab.title}`);
6577
6773
  setNarrowFolderTitle(tab.title);
6578
6774
  } else {
6579
- if (searchInput) searchInput.value = v.query;
6775
+ if (searchInput) {
6776
+ searchInput.value = v.query;
6777
+ updateSearchHighlight();
6778
+ }
6580
6779
  loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);
6581
6780
  setTitle(`${APP_NAME} - Search`);
6582
6781
  setNarrowFolderTitle(`Search: ${v.query}`);
@@ -7566,6 +7765,64 @@ function recordSearchHistory(query) {
7566
7765
  refreshSearchHistoryDatalist();
7567
7766
  }
7568
7767
  refreshSearchHistoryDatalist();
7768
+ var searchHl = document.getElementById("search-hl");
7769
+ var SEARCH_QUALIFIERS = /* @__PURE__ */ new Set(["from", "to", "subject", "date", "after", "before", "has", "is", "folder"]);
7770
+ var SEARCH_REGEX_SETTLE_MS = 500;
7771
+ var searchRegexSettleTimer = null;
7772
+ function escapeHl(s) {
7773
+ return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] || c);
7774
+ }
7775
+ function renderSearchHighlight(settled) {
7776
+ if (!searchHl || !searchInput) return;
7777
+ const text = searchInput.value;
7778
+ const tokens = text.match(/\s+|"[^"]*"?|\S+/g) || [];
7779
+ let html = "";
7780
+ for (const tok of tokens) {
7781
+ if (/^\s+$/.test(tok)) {
7782
+ html += escapeHl(tok);
7783
+ continue;
7784
+ }
7785
+ if (tok.startsWith("/") && tok.length > 1) {
7786
+ let bad = false;
7787
+ if (tok.endsWith("/")) {
7788
+ try {
7789
+ new RegExp(tok.slice(1, -1), "i");
7790
+ } catch {
7791
+ bad = true;
7792
+ }
7793
+ }
7794
+ const cls = bad && settled ? "sh-regex-bad" : "sh-regex";
7795
+ html += `<span class="${cls}">${escapeHl(tok)}</span>`;
7796
+ continue;
7797
+ }
7798
+ const qm = tok.match(/^([a-z]+):(.*)$/i);
7799
+ if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {
7800
+ html += `<span class="sh-key">${escapeHl(qm[1] + ":")}</span>${escapeHl(qm[2])}`;
7801
+ continue;
7802
+ }
7803
+ if (/^(OR|NOT|AND)$/.test(tok)) {
7804
+ html += `<span class="sh-op">${escapeHl(tok)}</span>`;
7805
+ continue;
7806
+ }
7807
+ if (tok.startsWith('"')) {
7808
+ html += `<span class="sh-phrase">${escapeHl(tok)}</span>`;
7809
+ continue;
7810
+ }
7811
+ html += escapeHl(tok);
7812
+ }
7813
+ searchHl.innerHTML = html;
7814
+ searchHl.scrollLeft = searchInput.scrollLeft;
7815
+ }
7816
+ function updateSearchHighlight() {
7817
+ renderSearchHighlight(false);
7818
+ if (searchRegexSettleTimer) clearTimeout(searchRegexSettleTimer);
7819
+ searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);
7820
+ }
7821
+ searchInput?.addEventListener("scroll", () => {
7822
+ if (searchHl) searchHl.scrollLeft = searchInput.scrollLeft;
7823
+ });
7824
+ searchInput?.addEventListener("change", () => updateSearchHighlight());
7825
+ updateSearchHighlight();
7569
7826
  function doSearch(immediate = false) {
7570
7827
  const query = searchInput.value.trim();
7571
7828
  if (query.length === 0) {
@@ -7602,6 +7859,7 @@ var currentFolderId2 = 0;
7602
7859
  var reloadDebounceTimer = null;
7603
7860
  searchInput?.addEventListener("input", () => {
7604
7861
  clearTimeout(searchTimeout);
7862
+ updateSearchHighlight();
7605
7863
  if (searchInput.value.trim() === "") {
7606
7864
  clearSearchMode();
7607
7865
  const body = document.getElementById("ml-body");
@@ -7619,6 +7877,7 @@ searchInput?.addEventListener("keydown", (e) => {
7619
7877
  }
7620
7878
  if (e.key === "Escape") {
7621
7879
  searchInput.value = "";
7880
+ updateSearchHighlight();
7622
7881
  clearSearchMode();
7623
7882
  const body = document.getElementById("ml-body");
7624
7883
  if (body) body.querySelectorAll(".filter-hidden").forEach((r) => r.classList.remove("filter-hidden"));
@@ -8777,34 +9036,66 @@ optSnippet?.addEventListener("change", () => {
8777
9036
  localStorage.setItem("mailx-snippet", String(optSnippet.checked));
8778
9037
  });
8779
9038
  document.getElementById("search-help")?.addEventListener("click", async () => {
8780
- const { readConfigHelp: readConfigHelp2 } = await Promise.resolve().then(() => (init_api_client(), api_client_exports));
8781
- const r = await readConfigHelp2("search").catch(() => ({ content: "" }));
8782
- const md = r?.content || "";
8783
- const backdrop = document.createElement("div");
8784
- backdrop.className = "mailx-modal-backdrop";
8785
- backdrop.style.cssText = "position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;";
9039
+ const btn = document.getElementById("search-help");
9040
+ if (!btn) return;
9041
+ const existing = document.getElementById("search-help-panel");
9042
+ if (existing) {
9043
+ existing.remove();
9044
+ btn.setAttribute("aria-expanded", "false");
9045
+ return;
9046
+ }
9047
+ const { SEARCH_HELP_HTML: SEARCH_HELP_HTML2 } = await Promise.resolve().then(() => (init_search_help(), search_help_exports));
9048
+ const searchBar = document.querySelector("search.ml-search");
8786
9049
  const panel = document.createElement("div");
8787
- panel.style.cssText = "background:var(--color-bg,#fff);color:var(--color-text,#000);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);width:min(820px,92vw);max-height:85vh;display:flex;flex-direction:column;";
9050
+ panel.id = "search-help-panel";
9051
+ panel.className = "search-help-panel";
8788
9052
  panel.innerHTML = `
8789
- <div style="padding:14px 18px;border-bottom:1px solid var(--color-border,#ddd);display:flex;justify-content:space-between;align-items:center">
8790
- <span style="font-weight:600">Search syntax</span>
8791
- <button type="button" id="search-help-close" style="background:none;border:0;font-size:18px;cursor:pointer">&times;</button>
9053
+ <div class="search-help-head">
9054
+ <span>Search syntax</span>
9055
+ <button type="button" id="search-help-close" title="Close (Esc)" aria-label="Close">&times;</button>
8792
9056
  </div>
8793
- <pre style="margin:0;padding:14px 18px;overflow:auto;font:13px/1.5 var(--font-ui);white-space:pre-wrap;word-wrap:break-word">${(md || "Help not available \u2014 check that docs/search.md is deployed.").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] || c)}</pre>
9057
+ <div class="search-help-body">${SEARCH_HELP_HTML2}</div>
8794
9058
  `;
8795
- backdrop.appendChild(panel);
8796
- document.body.appendChild(backdrop);
8797
- const close = () => backdrop.remove();
8798
- panel.querySelector("#search-help-close")?.addEventListener("click", close);
8799
- backdrop.addEventListener("click", (e) => {
8800
- if (e.target === backdrop) close();
8801
- });
8802
- document.addEventListener("keydown", function escClose(e) {
9059
+ document.body.appendChild(panel);
9060
+ btn.setAttribute("aria-expanded", "true");
9061
+ const position = () => {
9062
+ const anchor = searchBar || btn;
9063
+ const rect = anchor.getBoundingClientRect();
9064
+ const margin = 8;
9065
+ const top = rect.bottom + 2;
9066
+ const width = Math.min(640, Math.max(320, rect.width));
9067
+ let left = rect.left;
9068
+ if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;
9069
+ if (left < margin) left = margin;
9070
+ panel.style.top = `${top}px`;
9071
+ panel.style.left = `${left}px`;
9072
+ panel.style.width = `${width}px`;
9073
+ panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;
9074
+ };
9075
+ position();
9076
+ const close = () => {
9077
+ panel.remove();
9078
+ btn.setAttribute("aria-expanded", "false");
9079
+ window.removeEventListener("resize", position);
9080
+ document.removeEventListener("keydown", onKey, true);
9081
+ document.removeEventListener("mousedown", onOutside);
9082
+ };
9083
+ const onKey = (e) => {
8803
9084
  if (e.key === "Escape") {
9085
+ e.preventDefault();
9086
+ e.stopPropagation();
8804
9087
  close();
8805
- document.removeEventListener("keydown", escClose);
8806
9088
  }
8807
- });
9089
+ };
9090
+ const onOutside = (e) => {
9091
+ const t = e.target;
9092
+ if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t)) return;
9093
+ close();
9094
+ };
9095
+ panel.querySelector("#search-help-close")?.addEventListener("click", close);
9096
+ window.addEventListener("resize", position);
9097
+ document.addEventListener("keydown", onKey, true);
9098
+ document.addEventListener("mousedown", onOutside);
8808
9099
  });
8809
9100
  document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () => {
8810
9101
  const settingsDropdown2 = document.getElementById("settings-dropdown");