@bobfrankston/mailx 1.0.395 → 1.0.399

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.
@@ -24,15 +24,46 @@ let touchWasScroll = false;
24
24
  // (text columns default asc, date defaults desc).
25
25
  let currentSort = "date";
26
26
  let currentSortDir = "desc";
27
- /** Atomic focus: update shared state + notify viewer in one call.
28
- * First slice of S56 (row-objects-own-preview) — consolidates the two
29
- * parallel selection paths (state.select + onMessageSelect) so the eventual
30
- * Row-class migration touches exactly one call site. The viewer's `gen`
31
- * token still cancels stale fetches; this just makes the transition
32
- * indivisible at the caller level. */
27
+ /** S56 refactor slice per-row focus/unfocus.
28
+ *
29
+ * Every rendered message row has an associated Row record tracking the DOM
30
+ * element + account/msg pair. `focusRow` runs the atomic transition:
31
+ *
32
+ * 1. unfocus the previously-focused row (clears `.selected` class)
33
+ * 2. mark the new row `.selected`
34
+ * 3. update shared state.selected + notify viewer
35
+ *
36
+ * The viewer's `showMessageGeneration` token still cancels stale body
37
+ * fetches during the transition — that's the "async cancellation" piece of
38
+ * S56. The "atomic DOM transition" piece is now owned here.
39
+ *
40
+ * Full abort-signal plumbing through getMessage → fetchMessageBody is a
41
+ * separate follow-up; for now the gen token does the job. */
42
+ let currentFocusedRow = null;
43
+ function focusRow(row, accountId, msg) {
44
+ if (currentFocusedRow && currentFocusedRow !== row) {
45
+ // Unfocus the previous atomically — clearing .selected AND
46
+ // triggering the viewer's stale-fetch cancel (via the bump inside
47
+ // the next showMessage() call).
48
+ currentFocusedRow.classList.remove("selected");
49
+ }
50
+ if (!row.classList.contains("selected"))
51
+ row.classList.add("selected");
52
+ currentFocusedRow = row;
53
+ state.select(msg);
54
+ onMessageSelect(accountId, msg.uid, msg.folderId);
55
+ }
56
+ /** Back-compat shim — some call sites still use `focusMessage(accountId, msg)`
57
+ * without a DOM row (e.g. thread-popup click). Fall back to the previous
58
+ * behavior (state + viewer only) so nothing regresses. */
33
59
  function focusMessage(accountId, msg) {
34
60
  state.select(msg);
35
61
  onMessageSelect(accountId, msg.uid, msg.folderId);
62
+ // Clear the focused-row reference since we don't have a DOM row here
63
+ // — the next row click will unfocus whatever was selected anyway.
64
+ if (currentFocusedRow)
65
+ currentFocusedRow.classList.remove("selected");
66
+ currentFocusedRow = null;
36
67
  }
37
68
  /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
38
69
  * Called from the bodyCached service event — covers both background prefetch
@@ -64,6 +95,10 @@ function clearSelection() {
64
95
  const body = document.getElementById("ml-body");
65
96
  if (body)
66
97
  body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
98
+ // S56 seam: the focused-row invariant is "the Row whose .selected is
99
+ // currently mine". clearSelection wipes all .selected, so the invariant
100
+ // would break if we kept a stale pointer.
101
+ currentFocusedRow = null;
67
102
  }
68
103
  /** Exit multi-select mode (entered via touch long-press). Clears selection
69
104
  * and the sticky body flag so subsequent taps open messages again. */
@@ -73,6 +108,28 @@ function exitMultiSelect() {
73
108
  return;
74
109
  body.classList.remove("multi-select-on");
75
110
  clearSelection();
111
+ updateBulkBar();
112
+ }
113
+ /** Refresh the bulk-actions bar visibility + "N selected" label. Called
114
+ * whenever selection or mode changes. Visible either when 2+ rows are
115
+ * selected (desktop Ctrl/Shift-click multi-selection) OR when touch
116
+ * multi-select mode is active (even with a single row, so the user sees
117
+ * the bar as the mode indicator). */
118
+ function updateBulkBar() {
119
+ const body = document.getElementById("ml-body");
120
+ const bar = document.getElementById("ml-bulkbar");
121
+ const count = document.getElementById("ml-bulk-count");
122
+ if (!body || !bar || !count)
123
+ return;
124
+ const active = body.classList.contains("multi-select-on");
125
+ const n = body.querySelectorAll(".ml-row.selected").length;
126
+ if (n >= 2 || (active && n > 0)) {
127
+ bar.hidden = false;
128
+ count.textContent = `${n} selected`;
129
+ }
130
+ else {
131
+ bar.hidden = true;
132
+ }
76
133
  }
77
134
  // Escape key + click-outside-list exit multi-select mode. Attached once
78
135
  // (idempotent because document only has one listener scope per handler).
@@ -88,10 +145,89 @@ if (!window.__mailxMultiSelectWired) {
88
145
  return;
89
146
  const target = e.target;
90
147
  // A tap on a row is handled by the row's own click listener; only
91
- // exit when the tap is on neutral ground (outside the list entirely).
92
- if (!target.closest(".ml-row"))
148
+ // exit when the tap is on neutral ground (outside the list entirely
149
+ // and not on the bulk bar).
150
+ if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
93
151
  exitMultiSelect();
94
152
  }, true);
153
+ // Wire bulk-bar buttons — each delegates to the single-message handlers
154
+ // but iterates over every .ml-row.selected in the body.
155
+ document.addEventListener("click", async (e) => {
156
+ const target = e.target;
157
+ if (target.closest("#ml-bulk-cancel")) {
158
+ exitMultiSelect();
159
+ return;
160
+ }
161
+ const btn = target.closest(".ml-bulk-btn");
162
+ if (!btn)
163
+ return;
164
+ const op = btn.dataset.bulk;
165
+ const body = document.getElementById("ml-body");
166
+ if (!body?.classList.contains("multi-select-on"))
167
+ return;
168
+ const selected = getSelectedMessages();
169
+ if (selected.length === 0)
170
+ return;
171
+ try {
172
+ if (op === "delete") {
173
+ // Delegate to the existing Del-key handler so the same
174
+ // tombstone/undo logic runs for bulk deletes.
175
+ document.dispatchEvent(new CustomEvent("mailx-delete"));
176
+ }
177
+ else if (op === "flag") {
178
+ for (const m of selected) {
179
+ const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
180
+ if (!row)
181
+ continue;
182
+ const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
183
+ if (!msgData)
184
+ continue;
185
+ const isFlagged = msgData.flags?.includes("\\Flagged");
186
+ const newFlags = isFlagged
187
+ ? msgData.flags.filter((f) => f !== "\\Flagged")
188
+ : [...(msgData.flags || []), "\\Flagged"];
189
+ await updateFlags(m.accountId, m.uid, newFlags);
190
+ msgData.flags = newFlags;
191
+ state.updateMessageFlags(m.accountId, m.uid, newFlags);
192
+ row.classList.toggle("flagged");
193
+ }
194
+ }
195
+ else if (op === "markread") {
196
+ for (const m of selected) {
197
+ const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
198
+ if (!row)
199
+ continue;
200
+ const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
201
+ if (!msgData)
202
+ continue;
203
+ if (msgData.flags?.includes("\\Seen"))
204
+ continue;
205
+ const newFlags = [...(msgData.flags || []), "\\Seen"];
206
+ await updateFlags(m.accountId, m.uid, newFlags);
207
+ msgData.flags = newFlags;
208
+ state.updateMessageFlags(m.accountId, m.uid, newFlags);
209
+ row.classList.remove("unread");
210
+ }
211
+ }
212
+ else if (op === "move") {
213
+ // Use the first selection's account/folder for the picker scope.
214
+ const { accountId, folderId } = selected[0];
215
+ const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
216
+ if (!pick)
217
+ return;
218
+ const uids = selected.map(s => s.uid);
219
+ await apiMoveMessages(accountId, uids, pick.folderId);
220
+ state.removeMessages(uids.map(u => ({ accountId, uid: u })));
221
+ }
222
+ else if (op === "spam") {
223
+ document.getElementById("btn-spam")?.click();
224
+ }
225
+ exitMultiSelect();
226
+ }
227
+ catch (err) {
228
+ alert(`Bulk ${op} failed: ${err.message || err}`);
229
+ }
230
+ });
95
231
  }
96
232
  function selectRange(from, to) {
97
233
  const body = document.getElementById("ml-body");
@@ -380,6 +516,10 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
380
516
  export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
381
517
  searchMode = false;
382
518
  unifiedMode = false;
519
+ // Folder switch clears any in-progress multi-select — carrying a "3
520
+ // selected" state across folders would lie about what rows the bulk
521
+ // buttons would act on.
522
+ exitMultiSelect();
383
523
  // specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
384
524
  // folder path lowercased (folder-tree fallback when tag is missing — common
385
525
  // on Dovecot which doesn't advertise \Sent). Match both cases.
@@ -671,22 +811,28 @@ function appendMessages(body, accountId, items) {
671
811
  if (body?.classList.contains("multi-select-on")) {
672
812
  row.classList.toggle("selected");
673
813
  lastClickedRow = row;
814
+ updateBulkBar();
674
815
  return;
675
816
  }
676
817
  if (e.shiftKey && lastClickedRow) {
677
818
  clearSelection();
678
819
  selectRange(lastClickedRow, row);
820
+ lastClickedRow = row;
821
+ row.classList.remove("unread");
822
+ focusMessage(msgAccountId, msg);
679
823
  }
680
824
  else if (e.ctrlKey || e.metaKey) {
681
825
  row.classList.toggle("selected");
826
+ lastClickedRow = row;
682
827
  }
683
828
  else {
829
+ // Atomic unfocus-previous + focus-this.
684
830
  clearSelection();
685
- row.classList.add("selected");
831
+ focusRow(row, msgAccountId, msg);
832
+ lastClickedRow = row;
833
+ row.classList.remove("unread");
686
834
  }
687
- lastClickedRow = row;
688
- row.classList.remove("unread");
689
- focusMessage(msgAccountId, msg);
835
+ updateBulkBar();
690
836
  });
691
837
  // Q64: double-click → pop out the message in a floating overlay so
692
838
  // the user can read it without losing the selected list context.
@@ -756,6 +902,7 @@ function appendMessages(body, accountId, items) {
756
902
  body?.classList.add("multi-select-on");
757
903
  }
758
904
  lastClickedRow = row;
905
+ updateBulkBar();
759
906
  // Haptic hint if the platform supports it (Android WebView does).
760
907
  try {
761
908
  navigator.vibrate?.(20);
@@ -859,10 +1006,6 @@ function appendMessages(body, accountId, items) {
859
1006
  label: "⚠ Mark as spam",
860
1007
  action: () => document.getElementById("btn-spam")?.click(),
861
1008
  },
862
- {
863
- label: "🚫 Report spam",
864
- action: () => document.getElementById("btn-spam-report")?.click(),
865
- },
866
1009
  { label: "", action: () => { }, separator: true },
867
1010
  {
868
1011
  label: "Copy Message-ID",
@@ -361,6 +361,30 @@ function applyInit(init) {
361
361
  if (ccBtn)
362
362
  ccBtn.classList.add("active");
363
363
  }
364
+ else if (init.to && init.to.length === 1) {
365
+ // Q49: heuristic auto-expand — when replying/composing to a single
366
+ // recipient, check sent-history. If the user has previously Cc'd
367
+ // anyone on a message to this recipient, expand the Cc row (empty,
368
+ // just visible) so they're prompted to fill it. Fire-and-forget; if
369
+ // the service call fails or the user starts typing Cc manually
370
+ // before it resolves, the answer doesn't matter.
371
+ const firstEmail = init.to[0]?.address || "";
372
+ if (firstEmail) {
373
+ import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
374
+ .then(res => {
375
+ if (!res?.hasCc)
376
+ return;
377
+ const ccRowEl = document.getElementById("compose-cc-row");
378
+ const ccBtn = document.getElementById("btn-toggle-cc");
379
+ // Only expand if user hasn't already interacted
380
+ if (ccRowEl?.hidden && !ccInput.value) {
381
+ ccRowEl.hidden = false;
382
+ ccBtn?.classList.add("active");
383
+ }
384
+ })
385
+ .catch(() => { }));
386
+ }
387
+ }
364
388
  // C42: append the account's signature (if configured) BEFORE rendering
365
389
  // the body. For new mode: just signature. For reply/forward: appended
366
390
  // after the quoted block. Drafts are skipped — the signature is already
package/client/index.html CHANGED
@@ -45,7 +45,7 @@
45
45
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
46
46
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
47
47
  <hr class="tb-menu-sep">
48
- <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
48
+ <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>
49
49
  <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>
50
50
  <label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
51
51
  <hr class="tb-menu-sep">
@@ -123,6 +123,16 @@
123
123
  <span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
124
124
  <span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
125
125
  </div>
126
+ <div class="ml-bulkbar" id="ml-bulkbar" hidden>
127
+ <button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
128
+ <span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
129
+ <span style="flex:1"></span>
130
+ <button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
131
+ <button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
132
+ <button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
133
+ <button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
134
+ <button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
135
+ </div>
126
136
  <div class="ml-body" id="ml-body">
127
137
  <div class="ml-empty">Select a folder to view messages</div>
128
138
  </div>
@@ -191,6 +191,9 @@ export function cancelQueuedOutgoing(p) {
191
191
  export function searchContacts(query) {
192
192
  return ipc().searchContacts(query);
193
193
  }
194
+ export function hasCcHistoryTo(email) {
195
+ return ipc().hasCcHistoryTo(email);
196
+ }
194
197
  export function listContacts(query, page = 1, pageSize = 100) {
195
198
  return ipc().listContacts(query, page, pageSize);
196
199
  }
@@ -181,6 +181,7 @@
181
181
  cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
182
182
  reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
183
183
  reauthGoogleScopes: function() { return callNode("reauthGoogleScopes"); },
184
+ hasCcHistoryTo: function(email) { return callNode("hasCcHistoryTo", { email: email }); },
184
185
 
185
186
  // Bulk operations
186
187
  deleteMessages: function(accountId, uids) {
@@ -900,6 +900,189 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
900
900
  list empty when the selection is a singleton thread. */
901
901
  .ml-row.thread-filter-hidden { display: none; }
902
902
 
903
+ /* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
904
+ idea but better when we have a second-stage plan strategy". Touch users
905
+ still reach the menu via long-press → multi-select → bulk-bar, or by
906
+ using a stylus/mouse for contextmenu. Revisit when we've decided what
907
+ additional per-message actions belong here. */
908
+
909
+ /* ── Alarm popup (P17 / Q104) ── */
910
+ /* Fullscreen backdrop + centered panel; mirrors .mailx-modal* patterns but
911
+ kept in its own namespace so alarm behavior (snooze/dismiss) can evolve
912
+ without dragging the whole modal system along. */
913
+ .alarm-overlay {
914
+ position: fixed;
915
+ inset: 0;
916
+ background: rgba(0, 0, 0, 0.45);
917
+ z-index: 9000;
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ }
922
+ .alarm-panel {
923
+ background: var(--color-bg);
924
+ color: var(--color-text);
925
+ border: 1px solid var(--color-border);
926
+ border-radius: 8px;
927
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
928
+ min-width: 380px;
929
+ max-width: 560px;
930
+ width: 90vw;
931
+ display: flex;
932
+ flex-direction: column;
933
+ max-height: 80vh;
934
+ }
935
+ .alarm-head {
936
+ display: flex;
937
+ align-items: center;
938
+ gap: var(--gap-sm);
939
+ padding: var(--gap-sm) var(--gap-md);
940
+ border-bottom: 1px solid var(--color-border);
941
+ font-weight: 600;
942
+ }
943
+ .alarm-icon { font-size: 1.4em; }
944
+ .alarm-title { flex: 1; }
945
+ .alarm-close {
946
+ border: 0;
947
+ background: transparent;
948
+ color: var(--color-text-muted);
949
+ cursor: pointer;
950
+ font-size: 1.4em;
951
+ line-height: 1;
952
+ padding: 0 0.4em;
953
+ }
954
+ .alarm-close:hover { color: var(--color-text); }
955
+ .alarm-list {
956
+ padding: var(--gap-sm) var(--gap-md);
957
+ overflow-y: auto;
958
+ display: flex;
959
+ flex-direction: column;
960
+ gap: var(--gap-xs);
961
+ }
962
+ .alarm-row {
963
+ display: flex;
964
+ align-items: center;
965
+ gap: var(--gap-sm);
966
+ padding: var(--gap-xs) 0;
967
+ border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
968
+ }
969
+ .alarm-row:last-child { border-bottom: none; }
970
+ .alarm-row-main {
971
+ flex: 1;
972
+ display: flex;
973
+ align-items: center;
974
+ gap: var(--gap-xs);
975
+ min-width: 0;
976
+ }
977
+ .alarm-row-kind { font-size: 1.1em; }
978
+ .alarm-row-title {
979
+ font-weight: 500;
980
+ overflow: hidden;
981
+ text-overflow: ellipsis;
982
+ white-space: nowrap;
983
+ flex: 1;
984
+ }
985
+ .alarm-row-when {
986
+ color: var(--color-text-muted);
987
+ font-size: var(--font-size-sm);
988
+ font-variant-numeric: tabular-nums;
989
+ white-space: nowrap;
990
+ }
991
+ .alarm-row-actions { display: flex; gap: var(--gap-xs); }
992
+ .alarm-row-link,
993
+ .alarm-row-dismiss {
994
+ border: 0;
995
+ background: transparent;
996
+ color: var(--color-text-muted);
997
+ cursor: pointer;
998
+ font-size: 1em;
999
+ padding: 0 0.3em;
1000
+ border-radius: 3px;
1001
+ }
1002
+ .alarm-row-link:hover,
1003
+ .alarm-row-dismiss:hover {
1004
+ background: var(--color-bg-hover);
1005
+ color: var(--color-text);
1006
+ }
1007
+ .alarm-foot {
1008
+ display: flex;
1009
+ align-items: center;
1010
+ gap: var(--gap-sm);
1011
+ padding: var(--gap-sm) var(--gap-md);
1012
+ border-top: 1px solid var(--color-border);
1013
+ background: var(--color-bg-toolbar);
1014
+ border-radius: 0 0 8px 8px;
1015
+ }
1016
+ .alarm-snooze-label {
1017
+ font-size: var(--font-size-sm);
1018
+ color: var(--color-text-muted);
1019
+ display: flex;
1020
+ align-items: center;
1021
+ gap: var(--gap-xs);
1022
+ }
1023
+ .alarm-snooze-sel {
1024
+ background: var(--color-bg);
1025
+ color: var(--color-text);
1026
+ border: 1px solid var(--color-border);
1027
+ border-radius: 4px;
1028
+ padding: 0.25em 0.5em;
1029
+ font-size: var(--font-size-sm);
1030
+ }
1031
+ .alarm-btn {
1032
+ border: 1px solid var(--color-border);
1033
+ background: var(--color-bg);
1034
+ color: var(--color-text);
1035
+ cursor: pointer;
1036
+ padding: 0.4em 1em;
1037
+ border-radius: 4px;
1038
+ font-size: var(--font-size-sm);
1039
+ }
1040
+ .alarm-btn:hover { background: var(--color-bg-hover); }
1041
+ .alarm-btn-primary {
1042
+ background: var(--color-brand, oklch(0.65 0.14 250));
1043
+ color: #fff;
1044
+ border-color: var(--color-brand, oklch(0.65 0.14 250));
1045
+ }
1046
+ .alarm-btn-primary:hover { filter: brightness(1.1); }
1047
+
1048
+ /* Bulk-actions bar — appears over the list header when multi-select mode is
1049
+ active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
1050
+ buttons + Cancel. Kept in sibling position to ml-header so it uses the
1051
+ same horizontal space. */
1052
+ .ml-bulkbar {
1053
+ display: flex;
1054
+ align-items: center;
1055
+ gap: var(--gap-sm);
1056
+ padding: var(--gap-xs) var(--gap-sm);
1057
+ background: var(--color-brand, oklch(0.65 0.14 250));
1058
+ color: #fff;
1059
+ font-size: var(--font-size-sm);
1060
+ border-bottom: 1px solid var(--color-border);
1061
+ grid-column: 1 / -1;
1062
+ }
1063
+ .ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
1064
+ .ml-bulk-cancel,
1065
+ .ml-bulk-btn {
1066
+ border: 0;
1067
+ background: transparent;
1068
+ color: inherit;
1069
+ cursor: pointer;
1070
+ padding: 0.25em 0.55em;
1071
+ border-radius: 4px;
1072
+ font-size: 1rem;
1073
+ }
1074
+ .ml-bulk-cancel:hover,
1075
+ .ml-bulk-btn:hover {
1076
+ background: oklch(1 0 0 / 0.15);
1077
+ }
1078
+ .ml-bulk-btn.ml-bulk-danger:hover {
1079
+ background: oklch(0.65 0.22 25 / 0.4);
1080
+ }
1081
+
1082
+ /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
1083
+ desktop). Add a left-edge accent bar so it's visually clear the list is
1084
+ in selection mode, not navigation mode. */
1085
+
903
1086
  /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
904
1087
  desktop). Add a left-edge accent bar so it's visually clear the list is
905
1088
  in selection mode, not navigation mode. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.395",
3
+ "version": "1.0.399",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -158,6 +158,10 @@ export declare class MailxService {
158
158
  }>;
159
159
  deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
160
160
  searchContacts(query: string): any[];
161
+ /** Q49: boolean hint for compose to auto-expand Cc when replying to this
162
+ * address. True when at least one past sent message to the same recipient
163
+ * had a non-empty Cc field. */
164
+ hasCcHistoryTo(email: string): boolean;
161
165
  syncGoogleContacts(): Promise<void>;
162
166
  seedContacts(): number;
163
167
  /** Explicit add to address book — used by the right-click "Add to contacts"
@@ -1351,6 +1351,12 @@ export class MailxService {
1351
1351
  return [];
1352
1352
  return this.db.searchContacts(query);
1353
1353
  }
1354
+ /** Q49: boolean hint for compose to auto-expand Cc when replying to this
1355
+ * address. True when at least one past sent message to the same recipient
1356
+ * had a non-empty Cc field. */
1357
+ hasCcHistoryTo(email) {
1358
+ return this.db.hasCcHistoryTo(email);
1359
+ }
1354
1360
  async syncGoogleContacts() {
1355
1361
  await this.imapManager.syncAllContacts();
1356
1362
  }
@@ -140,6 +140,8 @@ async function dispatchAction(svc, action, p) {
140
140
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
141
141
  case "searchContacts":
142
142
  return svc.searchContacts(p.query);
143
+ case "hasCcHistoryTo":
144
+ return { hasCc: svc.hasCcHistoryTo(p.email) };
143
145
  case "addContact":
144
146
  return { ok: svc.addContact(p.name, p.email) };
145
147
  case "listContacts":
@@ -19,6 +19,14 @@ export declare class MailxDB {
19
19
  hasSentMessage(messageId: string): boolean;
20
20
  /** Record a successfully sent message so future attempts are skipped. */
21
21
  recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
22
+ /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
23
+ * that had a non-empty Cc field? Used by compose to auto-expand the Cc
24
+ * input when replying to someone who customarily gets Cc'd with others.
25
+ * Query scans only Sent folders (special_use='sent') and matches the
26
+ * recipient's address inside `to_json` via LIKE. No special index — the
27
+ * Sent folder's row count is typically a few thousand at most; acceptable
28
+ * on the compose-open path. */
29
+ hasCcHistoryTo(recipientEmail: string): boolean;
22
30
  /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
23
31
  * is empty (e.g. provider stripped the header) — without a stable id we
24
32
  * can't check against future sync results anyway. */
@@ -338,6 +338,32 @@ export class MailxDB {
338
338
  console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
339
339
  }
340
340
  }
341
+ /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
342
+ * that had a non-empty Cc field? Used by compose to auto-expand the Cc
343
+ * input when replying to someone who customarily gets Cc'd with others.
344
+ * Query scans only Sent folders (special_use='sent') and matches the
345
+ * recipient's address inside `to_json` via LIKE. No special index — the
346
+ * Sent folder's row count is typically a few thousand at most; acceptable
347
+ * on the compose-open path. */
348
+ hasCcHistoryTo(recipientEmail) {
349
+ const email = (recipientEmail || "").trim().toLowerCase();
350
+ if (!email)
351
+ return false;
352
+ try {
353
+ const row = this.db.prepare(`
354
+ SELECT 1 FROM messages m
355
+ JOIN folders f ON m.folder_id = f.id
356
+ WHERE f.special_use = 'sent'
357
+ AND lower(m.to_json) LIKE ?
358
+ AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
359
+ LIMIT 1
360
+ `).get(`%"${email}"%`);
361
+ return !!row;
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
341
367
  // ── Tombstones (local-delete record so server echo can't resurrect) ──
342
368
  /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
343
369
  * is empty (e.g. provider stripped the header) — without a stable id we