@bobfrankston/mailx 1.0.392 → 1.0.393

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.
@@ -0,0 +1,4 @@
1
+ setlocal
2
+ cd %~dp0android-maui
3
+ call build-apk.cmd
4
+ cls &
package/build-apk.cmd ADDED
@@ -0,0 +1,3 @@
1
+ setlocal
2
+ cd %~dp0android-maui
3
+ call build-apk.cmd
package/client/app.js CHANGED
@@ -1573,11 +1573,12 @@ onWsEvent((event) => {
1573
1573
  }
1574
1574
  break;
1575
1575
  case "folderCountsChanged": {
1576
- // Update folder badges + full tree refresh (not just counts) so
1577
- // newly-synced folders appear. Without this, the first sync after
1578
- // setup creates DB folders but the tree never picks them up because
1579
- // syncComplete fires during page reload and gets lost.
1580
- refreshFolderTree();
1576
+ // Incremental update only updateFolderCounts patches badge counts
1577
+ // in-place and falls back to a full refreshFolderTree() when the
1578
+ // folder structure has actually changed. Calling both was doing a
1579
+ // 300 ms debounced rebuild on every sync tick even when just the
1580
+ // unread count moved — visible as folder-tree flicker on Dovecot
1581
+ // accounts where STATUS polls fire frequently.
1581
1582
  updateFolderCounts();
1582
1583
  updateNewMessageCount();
1583
1584
  // Debounced silent reload — preserves scroll position, selection, and viewer
@@ -793,6 +793,22 @@ function appendMessages(body, accountId, items) {
793
793
  label: "Delete",
794
794
  action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
795
795
  },
796
+ { label: "", action: () => { }, separator: true },
797
+ {
798
+ label: "Copy Message-ID",
799
+ action: async () => {
800
+ // Useful when asking "where did my letter go?" — pair the
801
+ // Message-ID with the reconcile-delete log line.
802
+ if (!msg.messageId) {
803
+ alert("No Message-ID on this row.");
804
+ return;
805
+ }
806
+ try {
807
+ await navigator.clipboard.writeText(msg.messageId);
808
+ }
809
+ catch { /* */ }
810
+ },
811
+ },
796
812
  ];
797
813
  showContextMenu(e.clientX, e.clientY, items);
798
814
  });
@@ -888,23 +888,30 @@ fileInput?.addEventListener("change", async () => {
888
888
  const root = document.body;
889
889
  const overlay = document.createElement("div");
890
890
  overlay.id = "compose-drop-overlay";
891
- overlay.hidden = true;
892
- overlay.style.cssText = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
891
+ // Toggle `display` directly — can't use the `hidden` attribute here
892
+ // because the inline `display` property in cssText outranks it, which is
893
+ // why the overlay showed permanently when I used `overlay.hidden = true`
894
+ // (user-reported 2026-04-24 with screenshot — blue tint + dashed border
895
+ // were visible before any drag started).
896
+ const baseStyle = "position:fixed;inset:0;background:oklch(0.6 0.18 250 / 0.15);border:3px dashed oklch(0.55 0.2 250);z-index:9999;pointer-events:none;align-items:center;justify-content:center;font-size:1.5rem;font-weight:500;color:oklch(0.35 0.2 250)";
897
+ overlay.style.cssText = baseStyle + ";display:none";
893
898
  overlay.textContent = "Drop files to attach";
894
899
  root.appendChild(overlay);
900
+ const show = () => { overlay.style.display = "flex"; };
901
+ const hide = () => { overlay.style.display = "none"; };
895
902
  const hasFiles = (e) => Array.from(e.dataTransfer?.types || []).includes("Files");
896
903
  root.addEventListener("dragenter", (e) => {
897
904
  if (!hasFiles(e))
898
905
  return;
899
906
  dragDepth++;
900
- overlay.hidden = false;
907
+ show();
901
908
  });
902
909
  root.addEventListener("dragleave", (e) => {
903
910
  if (!hasFiles(e))
904
911
  return;
905
912
  dragDepth = Math.max(0, dragDepth - 1);
906
913
  if (dragDepth === 0)
907
- overlay.hidden = true;
914
+ hide();
908
915
  });
909
916
  root.addEventListener("dragover", (e) => {
910
917
  if (!hasFiles(e))
@@ -918,7 +925,7 @@ fileInput?.addEventListener("change", async () => {
918
925
  return;
919
926
  e.preventDefault();
920
927
  dragDepth = 0;
921
- overlay.hidden = true;
928
+ hide();
922
929
  const files = e.dataTransfer?.files;
923
930
  if (files && files.length > 0)
924
931
  await ingestFiles(files);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.392",
3
+ "version": "1.0.393",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -351,7 +351,8 @@ export class MailxService {
351
351
  }
352
352
  // ── Search ──
353
353
  async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
354
- if (!q.trim())
354
+ q = (q || "").trim();
355
+ if (!q)
355
356
  return { items: [], total: 0, page, pageSize };
356
357
  if (scope === "server") {
357
358
  // Parse qualifiers once; SEARCH runs per folder.
@@ -1345,6 +1346,7 @@ export class MailxService {
1345
1346
  }
1346
1347
  // ── Contacts ──
1347
1348
  searchContacts(query) {
1349
+ query = (query || "").trim();
1348
1350
  if (query.length < 1)
1349
1351
  return [];
1350
1352
  return this.db.searchContacts(query);
@@ -168,6 +168,9 @@ const SCHEMA = `
168
168
  );
169
169
  CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
170
170
  CREATE INDEX IF NOT EXISTS idx_calendar_events_dirty ON calendar_events(dirty) WHERE dirty = 1;
171
+ -- getCalendarEventByProviderId runs once per event on every Google refresh;
172
+ -- without this index each lookup is a full table scan over calendar_events.
173
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_provider ON calendar_events(account_id, provider_id);
171
174
 
172
175
  -- Tasks: two-way cache of Google Tasks / local tasks. Same shape as
173
176
  -- calendar_events minus the time range.
@@ -188,6 +191,10 @@ const SCHEMA = `
188
191
  );
189
192
  CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
190
193
  CREATE INDEX IF NOT EXISTS idx_tasks_dirty ON tasks(dirty) WHERE dirty = 1;
194
+ -- Mirror calendar_events: any provider_id lookup path needs a proper index,
195
+ -- even if today's reconcile does the dedup in memory — prevents a future
196
+ -- refactor from accidentally introducing an O(N) scan.
197
+ CREATE INDEX IF NOT EXISTS idx_tasks_provider ON tasks(account_id, provider_id);
191
198
 
192
199
  -- Generic store-sync queue for domains OTHER than messages. Messages
193
200
  -- use sync_actions above. This table queues push-to-server actions
@@ -208,6 +215,10 @@ const SCHEMA = `
208
215
  UNIQUE(kind, target_uuid, op)
209
216
  );
210
217
  CREATE INDEX IF NOT EXISTS idx_store_sync_account ON store_sync(account_id, kind);
218
+ -- UNIQUE(kind, target_uuid, op) covers queries that start with kind; lookups
219
+ -- by target_uuid alone ("is this uuid queued for any op?") would otherwise
220
+ -- table-scan. Cheap; store_sync is tiny and write-heavy.
221
+ CREATE INDEX IF NOT EXISTS idx_store_sync_target_uuid ON store_sync(target_uuid);
211
222
  `;
212
223
  export class MailxDB {
213
224
  db;
@@ -1023,6 +1034,9 @@ export class MailxDB {
1023
1034
  }
1024
1035
  /** Search contacts by name or email prefix */
1025
1036
  searchContacts(query, limit = 10) {
1037
+ query = (query || "").trim();
1038
+ if (!query)
1039
+ return [];
1026
1040
  // Ranking: prefix matches beat substring matches, then recency-weighted
1027
1041
  // use_count within a tier. Recency decay: half-life of 30 days, so a
1028
1042
  // contact used today edges out one from months ago even with a lower
@@ -1064,7 +1078,8 @@ export class MailxDB {
1064
1078
  }
1065
1079
  /** List all contacts (address-book view) with pagination + optional filter. */
1066
1080
  listContacts(query, page = 1, pageSize = 100) {
1067
- const hasQuery = !!query.trim();
1081
+ query = (query || "").trim();
1082
+ const hasQuery = !!query;
1068
1083
  const q = `%${query}%`;
1069
1084
  const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
1070
1085
  const params = hasQuery ? [q, q] : [];
@@ -1105,6 +1120,7 @@ export class MailxDB {
1105
1120
  // ── Search ──
1106
1121
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
1107
1122
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
1123
+ query = (query || "").trim();
1108
1124
  // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
1109
1125
  let ftsQuery = "";
1110
1126
  const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];