@bobfrankston/mailx 1.0.433 → 1.0.435

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.
@@ -72,20 +72,35 @@
72
72
  <label class="tb-menu-item" title="Stack From + Subject on two lines per row, date to the right — denser on narrow windows"><input type="checkbox" id="opt-two-line"> Two-line view</label>
73
73
  <label class="tb-menu-item" title="Show the reading pane below/beside the message list"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
74
74
  <label class="tb-menu-item" title="Show a short body-text preview (first ~80 chars) beneath each row's subject"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
75
+ <label class="tb-menu-item" title="Collapse reply chains to one row"><input type="checkbox" id="opt-threaded"> Group by thread</label>
76
+ <label class="tb-menu-item" title="Filter the list to rows in the selected message's thread"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
75
77
  <label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
76
78
  <label class="tb-menu-item" title="Show unread/total counts next to each folder in the tree"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
79
+ <label class="tb-menu-item" title="Show the right-side calendar/tasks sidebar"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
77
80
  </div>
78
81
  </div>
79
82
  <div class="tb-menu" id="settings-menu">
80
83
  <button class="tb-btn" id="btn-settings">Settings</button>
81
84
  <div class="tb-menu-dropdown" id="settings-dropdown" hidden>
85
+ <span class="tb-menu-label">Theme</span>
86
+ <label class="tb-menu-item"><input type="radio" name="opt-theme" value="system" id="opt-theme-system"> System</label>
87
+ <label class="tb-menu-item"><input type="radio" name="opt-theme" value="light" id="opt-theme-light"> Light</label>
88
+ <label class="tb-menu-item"><input type="radio" name="opt-theme" value="dark" id="opt-theme-dark"> Dark</label>
89
+ <hr class="tb-menu-sep">
82
90
  <span class="tb-menu-label">Editor</span>
83
91
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
84
92
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
85
93
  <hr class="tb-menu-sep">
86
- <label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
94
+ <label class="tb-menu-item" title="Ghost-text completions while composing"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
95
+ <label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
96
+ <label class="tb-menu-item" title="Right-click in compose editor → Proofread"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
97
+ <hr class="tb-menu-sep">
98
+ <button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc / contacts.jsonc">Edit config files...</button>
99
+ <button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
87
100
  </div>
88
101
  </div>
102
+ <button class="tb-btn" id="btn-tb-delete" title="Delete selected (Del)">🗑</button>
103
+ <button class="tb-btn" id="btn-tb-spam" title="Mark as spam — move to Junk">⚠</button>
89
104
  <span id="app-version" class="app-version">mailx</span>
90
105
  </div>
91
106
  <div class="toolbar-right">
@@ -143,7 +158,9 @@
143
158
  <option value="current">This folder</option>
144
159
  </select>
145
160
  <input type="search" id="search-input" placeholder="Search..." autocomplete="off" title="Search messages">
161
+ <label class="search-server-check" title="Also search the IMAP server"><input type="checkbox" id="search-server-too"> Server</label>
146
162
  </search>
163
+ <div class="ml-folder-title" id="ml-folder-title"></div>
147
164
  <div class="ml-header">
148
165
  <span class="ml-col ml-col-avatar"></span>
149
166
  <span class="ml-col ml-col-flag"></span>
@@ -192,8 +209,11 @@
192
209
  <footer class="status-bar" id="status-bar">
193
210
  <span id="status-accounts"></span>
194
211
  <span id="status-sync">Initializing...</span>
212
+ <span id="status-offline" class="status-offline" hidden title="No network — local actions queue for later">⚡ offline</span>
213
+ <span id="status-diag" class="status-diag" hidden title=""></span>
195
214
  <span id="status-pending"></span>
196
215
  <span id="status-queue"></span>
216
+ <span class="app-version" id="status-version">mailx</span>
197
217
  </footer>
198
218
 
199
219
  <div id="startup-overlay" class="startup-overlay">
package/client/app.js CHANGED
@@ -437,8 +437,13 @@ document.addEventListener("pointerdown", (e) => {
437
437
  if (!panel || !panel.classList.contains("open"))
438
438
  return;
439
439
  const target = e.target;
440
- // Ignore clicks inside the panel itself and on the hamburger toggle
441
- if (target.closest(".folder-panel") || target.closest("#btn-menu"))
440
+ // Ignore clicks inside the panel itself and on either toggle button.
441
+ // Without `#btn-folder-toggle` in this list, clicking the folder icon
442
+ // while the panel is open closed it here (capture phase) then the click
443
+ // handler reopened it — net effect: panel stuck open, "doesn't toggle".
444
+ if (target.closest(".folder-panel")
445
+ || target.closest("#btn-menu")
446
+ || target.closest("#btn-folder-toggle"))
442
447
  return;
443
448
  // Only auto-dismiss when we're in overlay mode (small or medium screens).
444
449
  // On wide screens the panel is a permanent column and the "open" class
@@ -1238,6 +1243,24 @@ searchInput?.addEventListener("keydown", (e) => {
1238
1243
  setTitle("mailx");
1239
1244
  }
1240
1245
  });
1246
+ // Re-run the active search when the scope dropdown or "server too" checkbox
1247
+ // flips. Without this, switching all/current/server after typing the query
1248
+ // left the old result set on screen — the controls looked like they did
1249
+ // nothing. Treat the change as `immediate=true` so the user sees the new
1250
+ // scope's results without having to retype Enter; clear any client-side
1251
+ // filter-hidden flags from the prior "current folder" path so the row set
1252
+ // resets cleanly.
1253
+ function rerunActiveSearch() {
1254
+ if (!searchInput || searchInput.value.trim() === "")
1255
+ return;
1256
+ const body = document.getElementById("ml-body");
1257
+ if (body)
1258
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1259
+ clearTimeout(searchTimeout);
1260
+ doSearch(true);
1261
+ }
1262
+ searchScope?.addEventListener("change", rerunActiveSearch);
1263
+ document.getElementById("search-server-too")?.addEventListener("change", rerunActiveSearch);
1241
1264
  // Message state handles move/delete — no manual event listener needed
1242
1265
  // ── Folder filter ──
1243
1266
  const ftFilterInput = document.getElementById("ft-filter-input");
@@ -160,6 +160,19 @@ function selectRange(from, to) {
160
160
  for (let i = lo; i <= hi; i++)
161
161
  rows[i].classList.add("selected");
162
162
  }
163
+ /** The row to anchor a shift-click range against. `lastClickedRow` is the
164
+ * primary anchor, but it can become a detached DOM node after a list
165
+ * re-render (folder switch, sort, search reload, paging) — `selectRange`
166
+ * would then no-op. Fall back to whichever live row is `.selected` (the
167
+ * one in the viewer) before giving up. */
168
+ function resolveShiftAnchor() {
169
+ if (lastClickedRow?.isConnected)
170
+ return lastClickedRow;
171
+ const body = document.getElementById("ml-body");
172
+ if (!body)
173
+ return null;
174
+ return body.querySelector(".ml-row.selected");
175
+ }
163
176
  const timeFmt = { hour: "2-digit", minute: "2-digit", hour12: false };
164
177
  const dateFmt = { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
165
178
  const dateFmtSameYear = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
@@ -688,6 +701,57 @@ function appendMessages(body, accountId, items) {
688
701
  lastClickedRow = row;
689
702
  updateBulkBar();
690
703
  });
704
+ // Right-click (or long-press) on the avatar → bulk-selection menu.
705
+ // Putting it on the avatar is contextually right: the avatar is the
706
+ // "select" affordance, so its menu owns operations on the selection
707
+ // set. "Select all visible" is the load-bearing item — there's no
708
+ // Ctrl-A equivalent on touch and the scope-after-search use case
709
+ // demands it.
710
+ avatar.addEventListener("contextmenu", async (e) => {
711
+ e.preventDefault();
712
+ e.stopPropagation();
713
+ const { showContextMenu } = await import("./context-menu.js");
714
+ const body = document.getElementById("ml-body");
715
+ const visibleRows = body
716
+ ? Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
717
+ : [];
718
+ const selectedCount = body
719
+ ? body.querySelectorAll(".ml-row.selected").length
720
+ : 0;
721
+ showContextMenu(e.clientX, e.clientY, [
722
+ {
723
+ label: `Select all (${visibleRows.length})`,
724
+ action: () => {
725
+ if (!body)
726
+ return;
727
+ body.classList.add("multi-select-on");
728
+ for (const r of visibleRows)
729
+ r.classList.add("selected");
730
+ lastClickedRow = visibleRows[visibleRows.length - 1] || null;
731
+ updateBulkBar();
732
+ },
733
+ disabled: visibleRows.length === 0,
734
+ },
735
+ {
736
+ label: `Clear selection${selectedCount ? ` (${selectedCount})` : ""}`,
737
+ action: () => exitMultiSelect(),
738
+ disabled: selectedCount === 0,
739
+ },
740
+ {
741
+ label: "Invert selection",
742
+ action: () => {
743
+ if (!body)
744
+ return;
745
+ body.classList.add("multi-select-on");
746
+ for (const r of visibleRows)
747
+ r.classList.toggle("selected");
748
+ lastClickedRow = visibleRows[visibleRows.length - 1] || null;
749
+ updateBulkBar();
750
+ },
751
+ disabled: visibleRows.length === 0,
752
+ },
753
+ ]);
754
+ });
691
755
  const flag = document.createElement("span");
692
756
  flag.className = "ml-flag";
693
757
  flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
@@ -789,12 +853,25 @@ function appendMessages(body, accountId, items) {
789
853
  updateBulkBar();
790
854
  return;
791
855
  }
792
- if (e.shiftKey && lastClickedRow) {
793
- clearSelection();
794
- selectRange(lastClickedRow, row);
795
- lastClickedRow = row;
796
- row.classList.remove("unread");
797
- focusMessage(msgAccountId, msg);
856
+ if (e.shiftKey) {
857
+ const anchor = resolveShiftAnchor();
858
+ if (anchor) {
859
+ clearSelection();
860
+ selectRange(anchor, row);
861
+ lastClickedRow = row;
862
+ row.classList.remove("unread");
863
+ focusMessage(msgAccountId, msg);
864
+ }
865
+ else {
866
+ // No anchor available (first click of the session, or list
867
+ // was just rebuilt with no selection). Treat as a plain
868
+ // click so the user gets visible feedback rather than a
869
+ // silent no-op.
870
+ clearSelection();
871
+ focusRow(row, msgAccountId, msg);
872
+ lastClickedRow = row;
873
+ row.classList.remove("unread");
874
+ }
798
875
  }
799
876
  else if (e.ctrlKey || e.metaKey) {
800
877
  row.classList.toggle("selected");
@@ -897,12 +974,29 @@ function appendMessages(body, accountId, items) {
897
974
  // ── Right-click context menu ──
898
975
  row.addEventListener("contextmenu", (e) => {
899
976
  e.preventDefault();
900
- // Select row if not already selected
977
+ // Selection-on-context-click semantics:
978
+ // - If the right-clicked row is already selected → keep the
979
+ // existing selection (single or multi). The menu acts on
980
+ // whatever is selected.
981
+ // - If it's NOT selected and we're in multi-select mode →
982
+ // ADD this row to the selection (don't clear). Long-press
983
+ // to open a context menu used to wipe the multi-select set,
984
+ // which was the user-reported "annoying" behavior.
985
+ // - If it's NOT selected and we're NOT in multi-select →
986
+ // single-select this row (replace prior selection).
987
+ const body = row.parentElement;
988
+ const inMulti = !!body?.classList.contains("multi-select-on");
901
989
  if (!row.classList.contains("selected")) {
902
- clearSelection();
903
- row.classList.add("selected");
904
- lastClickedRow = row;
905
- focusMessage(msgAccountId, msg);
990
+ if (inMulti) {
991
+ row.classList.add("selected");
992
+ lastClickedRow = row;
993
+ }
994
+ else {
995
+ clearSelection();
996
+ row.classList.add("selected");
997
+ lastClickedRow = row;
998
+ focusMessage(msgAccountId, msg);
999
+ }
906
1000
  }
907
1001
  const isSeen = msg.flags.includes("\\Seen");
908
1002
  const isFlagged = msg.flags.includes("\\Flagged");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.433",
3
+ "version": "1.0.435",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -11,10 +11,23 @@ export declare class MailxService {
11
11
  private imapManager;
12
12
  private _accountsCache;
13
13
  constructor(db: MailxDB, imapManager: ImapManager);
14
- /** Read contacts.jsonc from cloud + apply preferred/denylist to the DB.
15
- * No-op if the file is missing or empty. */
14
+ private _contactsFlushTimer;
15
+ private _contactsFlushInFlight;
16
+ private readonly CONTACTS_FLUSH_DEBOUNCE_MS;
17
+ /** Schedule a debounced flush of the local contacts state to GDrive.
18
+ * Multiple changes within the debounce window collapse to one write. */
19
+ markContactsDirty(): void;
20
+ /** Write current DB contacts state to GDrive contacts.jsonc. Called via
21
+ * the debounced timer; also exposed for force-flush on shutdown or
22
+ * after a manual seed. Idempotent — safe to call multiple times. */
23
+ flushContactsConfig(): Promise<void>;
24
+ /** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
25
+ * into the DB. On first run with no file, seed from message corpus and
26
+ * write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
27
+ * makes a new device useful immediately on a shared GDrive setup. */
16
28
  loadContactsConfig(): Promise<{
17
29
  preferred: number;
30
+ discovered: number;
18
31
  purged: number;
19
32
  conflicts: string[];
20
33
  } | null>;
@@ -116,21 +116,76 @@ export class MailxService {
116
116
  this.loadContactsConfig().catch(e => console.error(` [contacts] reload failed: ${e?.message || e}`));
117
117
  }
118
118
  });
119
+ // Wire DB → cloud flush. Debounced to absorb bursts (a sync run can
120
+ // call recordSentAddress hundreds of times). 30s flush window is
121
+ // long enough that the steady state is one cloud write per sync,
122
+ // short enough that quitting after a single send still flushes.
123
+ this.db.setOnContactsChanged(() => this.markContactsDirty());
119
124
  // Initial load of contacts.jsonc — fire-and-forget; missing file is fine.
120
125
  this.loadContactsConfig().catch(() => { });
121
126
  }
122
- /** Read contacts.jsonc from cloud + apply preferred/denylist to the DB.
123
- * No-op if the file is missing or empty. */
127
+ _contactsFlushTimer = null;
128
+ _contactsFlushInFlight = false;
129
+ CONTACTS_FLUSH_DEBOUNCE_MS = 30_000;
130
+ /** Schedule a debounced flush of the local contacts state to GDrive.
131
+ * Multiple changes within the debounce window collapse to one write. */
132
+ markContactsDirty() {
133
+ if (this._contactsFlushTimer)
134
+ clearTimeout(this._contactsFlushTimer);
135
+ this._contactsFlushTimer = setTimeout(() => {
136
+ this._contactsFlushTimer = null;
137
+ this.flushContactsConfig().catch(e => console.error(` [contacts] flush failed: ${e?.message || e}`));
138
+ }, this.CONTACTS_FLUSH_DEBOUNCE_MS);
139
+ }
140
+ /** Write current DB contacts state to GDrive contacts.jsonc. Called via
141
+ * the debounced timer; also exposed for force-flush on shutdown or
142
+ * after a manual seed. Idempotent — safe to call multiple times. */
143
+ async flushContactsConfig() {
144
+ if (this._contactsFlushInFlight)
145
+ return;
146
+ this._contactsFlushInFlight = true;
147
+ try {
148
+ const cfg = this.db.exportContactsConfig();
149
+ const { cloudWrite } = await import("@bobfrankston/mailx-settings");
150
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
151
+ console.log(` [contacts] flushed to cloud: ${cfg.preferred.length} preferred + ${cfg.discovered.length} discovered + ${cfg.denylist.length} denylisted`);
152
+ }
153
+ finally {
154
+ this._contactsFlushInFlight = false;
155
+ }
156
+ }
157
+ /** Read contacts.jsonc from cloud + apply (preferred + denylist + discovered)
158
+ * into the DB. On first run with no file, seed from message corpus and
159
+ * write a fresh contacts.jsonc to GDrive — that auto-bootstrap is what
160
+ * makes a new device useful immediately on a shared GDrive setup. */
124
161
  async loadContactsConfig() {
125
162
  let raw = null;
163
+ let cloudAvailable = false;
126
164
  try {
127
165
  const { cloudRead } = await import("@bobfrankston/mailx-settings");
128
166
  raw = await cloudRead("contacts.jsonc");
167
+ cloudAvailable = true;
129
168
  }
130
- catch { /* cloud unavailable — leave config empty */ }
169
+ catch { /* cloud unavailable */ }
131
170
  if (!raw) {
132
- // No file yet. Make sure the in-memory denylist is empty.
133
- this.db.applyContactsConfig({ preferred: [], denylist: [] });
171
+ // No file (yet). Reset in-memory denylist and seed discovered
172
+ // from the local message corpus so autocomplete works immediately.
173
+ this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
174
+ try {
175
+ this.db.seedContactsFromMessages();
176
+ }
177
+ catch { /* corpus may be empty */ }
178
+ // Auto-bootstrap GDrive copy if cloud is reachable. The file gets
179
+ // a header comment so a user opening it on Drive sees what it is.
180
+ if (cloudAvailable) {
181
+ try {
182
+ await this.flushContactsConfig();
183
+ console.log(" [contacts] auto-seeded contacts.jsonc on GDrive from local corpus");
184
+ }
185
+ catch (e) {
186
+ console.error(` [contacts] auto-seed flush failed: ${e?.message || e}`);
187
+ }
188
+ }
134
189
  return null;
135
190
  }
136
191
  const { parse: parseJsonc } = await import("jsonc-parser");
@@ -138,10 +193,18 @@ export class MailxService {
138
193
  const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
139
194
  if (errors.length) {
140
195
  console.error(` [contacts] contacts.jsonc has parse errors — applying empty config: ${errors.map((e) => e.error).join(", ")}`);
141
- this.db.applyContactsConfig({ preferred: [], denylist: [] });
196
+ this.db.applyContactsConfig({ preferred: [], denylist: [], discovered: [] });
142
197
  return null;
143
198
  }
144
- return this.db.applyContactsConfig(cfg || {});
199
+ const result = this.db.applyContactsConfig(cfg || {});
200
+ // Run local seeder in case this device has corpus addresses the cloud
201
+ // copy doesn't know about yet. The seeder will fire notifyContactsChanged
202
+ // if it adds anything, which schedules a flush back to GDrive.
203
+ try {
204
+ this.db.seedContactsFromMessages();
205
+ }
206
+ catch { /* corpus may be empty */ }
207
+ return result;
145
208
  }
146
209
  /** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
147
210
  * then re-apply. Mutates the file in place — preserves existing entries
@@ -201,6 +201,14 @@ export declare class MailxDB {
201
201
  private _denylist;
202
202
  isAddressDenylisted(emailLower: string): boolean;
203
203
  setContactsDenylist(emails: string[]): void;
204
+ /** Callback fired when local-DB contacts mutations happen (sends adding
205
+ * to discovered, corpus seeder finding new addresses). The service
206
+ * registers a debounced cloud flush here so the GDrive copy stays in
207
+ * sync. NOT fired from applyContactsConfig — that's the inbound path
208
+ * and would create a write loop. */
209
+ private _onContactsChanged?;
210
+ setOnContactsChanged(cb: () => void): void;
211
+ private notifyContactsChanged;
204
212
  /** Seed `discovered`-tier contacts from every address that appears in
205
213
  * any cached message — From / To / Cc / Bcc across all folders. One row
206
214
  * per email; first non-empty name observed wins. Sent-folder rows skip
@@ -213,10 +221,15 @@ export declare class MailxDB {
213
221
  * source. Recency-weighted use_count differentiates within the tier. */
214
222
  seedContactsFromMessages(): number;
215
223
  /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
216
- * with the entries in `preferred[]`, sets the in-memory denylist, and
217
- * purges any discovered rows whose email is now denylisted. Preferred
218
- * rows are *not* auto-purged on denylist hit — if the user explicitly
219
- * added them they win that conflict; we just log a warning. */
224
+ * with the entries in `preferred[]`, merges `discovered[]` into the local
225
+ * cache, sets the in-memory denylist, and purges any discovered rows
226
+ * whose email is now denylisted. Preferred rows are *not* auto-purged on
227
+ * denylist hit if the user explicitly added them they win that
228
+ * conflict; we just log a warning.
229
+ *
230
+ * Discovered rows from the file are MERGED with whatever the local
231
+ * message-corpus seeder has produced. Each device contributes its
232
+ * observed addresses; over time GDrive accumulates the union. */
220
233
  applyContactsConfig(cfg: {
221
234
  preferred?: {
222
235
  name?: string;
@@ -226,11 +239,38 @@ export declare class MailxDB {
226
239
  org?: string;
227
240
  }[];
228
241
  denylist?: string[];
242
+ discovered?: {
243
+ name?: string;
244
+ email: string;
245
+ useCount?: number;
246
+ lastUsed?: number;
247
+ }[];
229
248
  }): {
230
249
  preferred: number;
250
+ discovered: number;
231
251
  purged: number;
232
252
  conflicts: string[];
233
253
  };
254
+ /** Build the contacts.jsonc shape from current DB state — for round-trip
255
+ * to GDrive. Preferred-tier rows come from anything not in the reserved
256
+ * system sources; discovered comes from `source='discovered'` rows;
257
+ * denylist comes from the in-memory set (set by applyContactsConfig).
258
+ * Caller is responsible for actually writing the cloud copy. */
259
+ exportContactsConfig(): {
260
+ preferred: {
261
+ name: string;
262
+ email: string;
263
+ source: string;
264
+ organization?: string;
265
+ }[];
266
+ denylist: string[];
267
+ discovered: {
268
+ name: string;
269
+ email: string;
270
+ useCount: number;
271
+ lastUsed: number;
272
+ }[];
273
+ };
234
274
  /** Search contacts by name or email prefix.
235
275
  *
236
276
  * Source-tier bonus is what makes the curated address book win against
@@ -1157,6 +1157,7 @@ export class MailxDB {
1157
1157
  else {
1158
1158
  this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, 1, ?)").run(name || "", email, now, now);
1159
1159
  }
1160
+ this.notifyContactsChanged();
1160
1161
  }
1161
1162
  /** True if `email` (lowercased) appears in the active denylist. Cached
1162
1163
  * in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
@@ -1167,6 +1168,21 @@ export class MailxDB {
1167
1168
  setContactsDenylist(emails) {
1168
1169
  this._denylist = new Set(emails.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
1169
1170
  }
1171
+ /** Callback fired when local-DB contacts mutations happen (sends adding
1172
+ * to discovered, corpus seeder finding new addresses). The service
1173
+ * registers a debounced cloud flush here so the GDrive copy stays in
1174
+ * sync. NOT fired from applyContactsConfig — that's the inbound path
1175
+ * and would create a write loop. */
1176
+ _onContactsChanged;
1177
+ setOnContactsChanged(cb) {
1178
+ this._onContactsChanged = cb;
1179
+ }
1180
+ notifyContactsChanged() {
1181
+ try {
1182
+ this._onContactsChanged?.();
1183
+ }
1184
+ catch { /* ignore */ }
1185
+ }
1170
1186
  /** Seed `discovered`-tier contacts from every address that appears in
1171
1187
  * any cached message — From / To / Cc / Bcc across all folders. One row
1172
1188
  * per email; first non-empty name observed wins. Sent-folder rows skip
@@ -1275,17 +1291,24 @@ export class MailxDB {
1275
1291
  }
1276
1292
  if (added > 0 || bumped > 0) {
1277
1293
  console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
1294
+ this.notifyContactsChanged();
1278
1295
  }
1279
1296
  return added;
1280
1297
  }
1281
1298
  /** Apply the contents of contacts.jsonc — replaces all preferred-tier rows
1282
- * with the entries in `preferred[]`, sets the in-memory denylist, and
1283
- * purges any discovered rows whose email is now denylisted. Preferred
1284
- * rows are *not* auto-purged on denylist hit — if the user explicitly
1285
- * added them they win that conflict; we just log a warning. */
1299
+ * with the entries in `preferred[]`, merges `discovered[]` into the local
1300
+ * cache, sets the in-memory denylist, and purges any discovered rows
1301
+ * whose email is now denylisted. Preferred rows are *not* auto-purged on
1302
+ * denylist hit if the user explicitly added them they win that
1303
+ * conflict; we just log a warning.
1304
+ *
1305
+ * Discovered rows from the file are MERGED with whatever the local
1306
+ * message-corpus seeder has produced. Each device contributes its
1307
+ * observed addresses; over time GDrive accumulates the union. */
1286
1308
  applyContactsConfig(cfg) {
1287
1309
  const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
1288
1310
  const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
1311
+ const discovered = Array.isArray(cfg.discovered) ? cfg.discovered : [];
1289
1312
  this.setContactsDenylist(denylist);
1290
1313
  // Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
1291
1314
  // The address-book UI's legacy `upsertContact` still writes
@@ -1319,6 +1342,39 @@ export class MailxDB {
1319
1342
  }
1320
1343
  catch { /* dup row, skip */ }
1321
1344
  }
1345
+ // Merge discovered[] from cloud into local cache. For each entry:
1346
+ // existing row wins on use_count (max), name fills if empty, lastUsed
1347
+ // is max. Missing rows are inserted. Denylisted entries skipped.
1348
+ const insDiscovered = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)");
1349
+ const updDiscovered = this.db.prepare(`UPDATE contacts SET use_count = max(use_count, ?),
1350
+ last_used = max(last_used, ?),
1351
+ name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
1352
+ updated_at = ?
1353
+ WHERE id = ?`);
1354
+ let discoveredAdded = 0;
1355
+ for (const entry of discovered) {
1356
+ if (!entry)
1357
+ continue;
1358
+ const email = (entry.email || "").trim();
1359
+ if (!email || !VALID.test(email))
1360
+ continue;
1361
+ const lower = email.toLowerCase();
1362
+ if (denySet.has(lower))
1363
+ continue;
1364
+ if (isJunkContact(lower, entry.name || ""))
1365
+ continue;
1366
+ const name = (entry.name || "").trim();
1367
+ const useCount = Math.max(0, entry.useCount || 0);
1368
+ const lastUsed = Math.max(0, entry.lastUsed || 0);
1369
+ const existing = this.db.prepare("SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(lower);
1370
+ if (!existing) {
1371
+ insDiscovered.run(name, email, lastUsed, useCount, now);
1372
+ discoveredAdded++;
1373
+ }
1374
+ else {
1375
+ updDiscovered.run(useCount, lastUsed, name, name, now, existing.id);
1376
+ }
1377
+ }
1322
1378
  // Purge discovered rows for any denylisted email.
1323
1379
  const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
1324
1380
  let purged = 0;
@@ -1329,8 +1385,38 @@ export class MailxDB {
1329
1385
  if (conflicts.length > 0) {
1330
1386
  console.warn(` [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);
1331
1387
  }
1332
- console.log(` [contacts] config applied: ${inserted} preferred row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
1333
- return { preferred: inserted, purged, conflicts };
1388
+ console.log(` [contacts] config applied: ${inserted} preferred + ${discoveredAdded} discovered row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
1389
+ return { preferred: inserted, discovered: discoveredAdded, purged, conflicts };
1390
+ }
1391
+ /** Build the contacts.jsonc shape from current DB state — for round-trip
1392
+ * to GDrive. Preferred-tier rows come from anything not in the reserved
1393
+ * system sources; discovered comes from `source='discovered'` rows;
1394
+ * denylist comes from the in-memory set (set by applyContactsConfig).
1395
+ * Caller is responsible for actually writing the cloud copy. */
1396
+ exportContactsConfig() {
1397
+ const preferredRows = this.db.prepare(`SELECT name, email, source, organization
1398
+ FROM contacts
1399
+ WHERE source NOT IN ('google', 'discovered', 'manual')
1400
+ ORDER BY source, lower(email), lower(name)`).all();
1401
+ const discoveredRows = this.db.prepare(`SELECT name, email, use_count, last_used
1402
+ FROM contacts
1403
+ WHERE source = 'discovered'
1404
+ ORDER BY use_count DESC, last_used DESC, lower(email)`).all();
1405
+ return {
1406
+ preferred: preferredRows.map(r => {
1407
+ const out = { name: r.name || "", email: r.email, source: r.source };
1408
+ if (r.organization)
1409
+ out.organization = r.organization;
1410
+ return out;
1411
+ }),
1412
+ denylist: Array.from(this._denylist),
1413
+ discovered: discoveredRows.map(r => ({
1414
+ name: r.name || "",
1415
+ email: r.email,
1416
+ useCount: r.use_count,
1417
+ lastUsed: r.last_used,
1418
+ })),
1419
+ };
1334
1420
  }
1335
1421
  /** Search contacts by name or email prefix.
1336
1422
  *
package/tdview.cmd CHANGED
@@ -1 +1,2 @@
1
- call mdview todo.md -pos 100,100,1 -size 900,1400
1
+ echo call mdview %~dp0todo.md -pos 100,100,1 -size 900,1400
2
+ call mdview %~dp0todo.md -pos 100,100,1 -size 900,1400