@bobfrankston/mailx 1.0.395 → 1.0.405

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.
@@ -11,7 +11,18 @@ logClientEvent("compose-module-loaded", { href: location.href, version: window.m
11
11
  /** Close compose window */
12
12
  function closeCompose() {
13
13
  logClientEvent("compose-close");
14
- window.close();
14
+ // S61: Android WebView's window.close() override is unreliable inside
15
+ // iframes — compose overlay sometimes stays visible after Send. Primary
16
+ // path is a parent postMessage; window.close() is a fallback that also
17
+ // works on desktop/msger where the override DOES fire reliably.
18
+ try {
19
+ parent.postMessage({ type: "mailx-compose-close" }, "*");
20
+ }
21
+ catch { /* */ }
22
+ try {
23
+ window.close();
24
+ }
25
+ catch { /* */ }
15
26
  }
16
27
  // ── Load editor scripts dynamically ──
17
28
  function loadScript(src) {
@@ -147,20 +158,54 @@ if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !==
147
158
  function formatAccountFrom(acct) {
148
159
  return `${acct.name} <${acct.email}>`;
149
160
  }
150
- /** Populate the From <datalist> with one entry per known account and set
151
- * the input's current value to the selected account (or the first/default
152
- * account when no selection is given). */
161
+ const FROM_HISTORY_KEY = "mailx-from-history"; // up to 20 recent manual From entries
162
+ const FROM_HISTORY_MAX = 20;
163
+ function loadFromHistory() {
164
+ try {
165
+ return JSON.parse(localStorage.getItem(FROM_HISTORY_KEY) || "[]");
166
+ }
167
+ catch {
168
+ return [];
169
+ }
170
+ }
171
+ function recordFromHistory(value) {
172
+ const v = (value || "").trim();
173
+ if (!v)
174
+ return;
175
+ try {
176
+ const list = loadFromHistory().filter(x => x !== v);
177
+ list.unshift(v);
178
+ localStorage.setItem(FROM_HISTORY_KEY, JSON.stringify(list.slice(0, FROM_HISTORY_MAX)));
179
+ }
180
+ catch { /* private mode */ }
181
+ }
182
+ /** Populate the From <datalist> with one entry per known account plus any
183
+ * manually-typed addresses from localStorage history. Account entries rank
184
+ * first; history entries get an "(used before)" label so the user can tell
185
+ * which ones are real accounts vs free-form aliases. */
153
186
  function populateFromOptions(accounts, selectedId) {
154
187
  knownAccounts = accounts;
155
188
  fromOptions.innerHTML = "";
189
+ const seenValues = new Set();
156
190
  for (const acct of accounts) {
157
191
  const opt = document.createElement("option");
158
192
  opt.value = formatAccountFrom(acct);
159
- // datalist options can carry a label so the dropdown row shows the
160
- // friendly account tag ("gmail", "bob.ma") next to the address.
161
193
  const tag = acct.label || acct.name;
162
194
  opt.label = tag;
163
195
  fromOptions.appendChild(opt);
196
+ seenValues.add(opt.value);
197
+ }
198
+ // Custom From history — addresses the user has typed before that don't
199
+ // match any known account (aliases, +tag addresses, one-off identities).
200
+ // Stored in localStorage because they're inherently per-device preferences;
201
+ // moving them to an account profile would be a different feature.
202
+ for (const value of loadFromHistory()) {
203
+ if (seenValues.has(value))
204
+ continue;
205
+ const opt = document.createElement("option");
206
+ opt.value = value;
207
+ opt.label = "(used before)";
208
+ fromOptions.appendChild(opt);
164
209
  }
165
210
  if (!fromInput.value) {
166
211
  const selected = (selectedId && accounts.find(a => a.id === selectedId)) ||
@@ -249,41 +294,46 @@ function setupAutocomplete(input) {
249
294
  closeDropdown();
250
295
  return;
251
296
  }
252
- debounce = setTimeout(async () => {
253
- try {
254
- const results = await searchContacts(token);
255
- if (results.length === 0) {
297
+ debounce = setTimeout(() => {
298
+ // rAF yield before hitting the DB — S60 mitigation, same reason
299
+ // as the draft-save path. The 200 ms timer already deferred past
300
+ // the input burst; this extra frame lets the last keystroke paint.
301
+ requestAnimationFrame(async () => {
302
+ try {
303
+ const results = await searchContacts(token);
304
+ if (results.length === 0) {
305
+ closeDropdown();
306
+ return;
307
+ }
256
308
  closeDropdown();
257
- return;
258
- }
259
- closeDropdown();
260
- dropdown = document.createElement("div");
261
- dropdown.className = "ac-dropdown";
262
- activeIndex = 0; // first item highlighted by default
263
- for (let i = 0; i < results.length; i++) {
264
- const item = document.createElement("div");
265
- item.className = `ac-item${i === 0 ? " ac-active" : ""}`;
266
- const nameEl = document.createElement("span");
267
- nameEl.className = "ac-item-name";
268
- nameEl.textContent = results[i].name || results[i].email;
269
- const emailEl = document.createElement("span");
270
- emailEl.className = "ac-item-email";
271
- emailEl.textContent = results[i].email;
272
- item.appendChild(nameEl);
273
- if (results[i].name)
274
- item.appendChild(emailEl);
275
- item.addEventListener("mousedown", (e) => {
276
- e.preventDefault();
277
- const display = results[i].name
278
- ? `${results[i].name} <${results[i].email}>`
279
- : results[i].email;
280
- replaceLastToken(display);
281
- });
282
- dropdown.appendChild(item);
309
+ dropdown = document.createElement("div");
310
+ dropdown.className = "ac-dropdown";
311
+ activeIndex = 0; // first item highlighted by default
312
+ for (let i = 0; i < results.length; i++) {
313
+ const item = document.createElement("div");
314
+ item.className = `ac-item${i === 0 ? " ac-active" : ""}`;
315
+ const nameEl = document.createElement("span");
316
+ nameEl.className = "ac-item-name";
317
+ nameEl.textContent = results[i].name || results[i].email;
318
+ const emailEl = document.createElement("span");
319
+ emailEl.className = "ac-item-email";
320
+ emailEl.textContent = results[i].email;
321
+ item.appendChild(nameEl);
322
+ if (results[i].name)
323
+ item.appendChild(emailEl);
324
+ item.addEventListener("mousedown", (e) => {
325
+ e.preventDefault();
326
+ const display = results[i].name
327
+ ? `${results[i].name} <${results[i].email}>`
328
+ : results[i].email;
329
+ replaceLastToken(display);
330
+ });
331
+ dropdown.appendChild(item);
332
+ }
333
+ input.parentElement.appendChild(dropdown);
283
334
  }
284
- input.parentElement.appendChild(dropdown);
285
- }
286
- catch { /* ignore */ }
335
+ catch { /* ignore */ }
336
+ });
287
337
  }, 200);
288
338
  });
289
339
  input.addEventListener("keydown", (e) => {
@@ -361,6 +411,30 @@ function applyInit(init) {
361
411
  if (ccBtn)
362
412
  ccBtn.classList.add("active");
363
413
  }
414
+ else if (init.to && init.to.length === 1) {
415
+ // Q49: heuristic auto-expand — when replying/composing to a single
416
+ // recipient, check sent-history. If the user has previously Cc'd
417
+ // anyone on a message to this recipient, expand the Cc row (empty,
418
+ // just visible) so they're prompted to fill it. Fire-and-forget; if
419
+ // the service call fails or the user starts typing Cc manually
420
+ // before it resolves, the answer doesn't matter.
421
+ const firstEmail = init.to[0]?.address || "";
422
+ if (firstEmail) {
423
+ import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
424
+ .then(res => {
425
+ if (!res?.hasCc)
426
+ return;
427
+ const ccRowEl = document.getElementById("compose-cc-row");
428
+ const ccBtn = document.getElementById("btn-toggle-cc");
429
+ // Only expand if user hasn't already interacted
430
+ if (ccRowEl?.hidden && !ccInput.value) {
431
+ ccRowEl.hidden = false;
432
+ ccBtn?.classList.add("active");
433
+ }
434
+ })
435
+ .catch(() => { }));
436
+ }
437
+ }
364
438
  // C42: append the account's signature (if configured) BEFORE rendering
365
439
  // the body. For new mode: just signature. For reply/forward: appended
366
440
  // after the quoted block. Drafts are skipped — the signature is already
@@ -475,12 +549,23 @@ async function saveDraft() {
475
549
  draftSaving = false;
476
550
  }
477
551
  }
478
- /** Schedule a debounced save on user input — fires ~1.5s after the last keystroke. */
552
+ /** Schedule a debounced save on user input — fires ~1.5s after the last
553
+ * keystroke, then yields one animation frame before actually writing so
554
+ * the browser can paint any keystroke in the mean time. S60 mitigation:
555
+ * wa-sqlite writes are synchronous on Android; this keeps the typing
556
+ * experience responsive by never running the write in the same task as
557
+ * an input event. */
479
558
  function scheduleDraftSave() {
480
559
  markComposeDirty();
481
560
  if (draftDebounceTimer)
482
561
  clearTimeout(draftDebounceTimer);
483
- draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
562
+ draftDebounceTimer = setTimeout(() => {
563
+ draftDebounceTimer = null;
564
+ // rAF yield — lets any pending keystroke render before we block on
565
+ // the DB write. A no-op when the tab is hidden (rAF is throttled),
566
+ // which is fine because the user isn't typing then either.
567
+ requestAnimationFrame(() => { saveDraft(); });
568
+ }, DRAFT_INPUT_DEBOUNCE_MS);
484
569
  }
485
570
  // ── Initialize: local-first population.
486
571
  //
@@ -665,6 +750,17 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
665
750
  .then(() => {
666
751
  logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
667
752
  console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
753
+ // Record From-address history on successful send. Only manual
754
+ // values worth keeping — skip anything that exactly matches a
755
+ // known account (already in the dropdown), and skip obviously
756
+ // invalid inputs. Populated dropdown surfaces this next time.
757
+ try {
758
+ const raw = fromInput.value.trim();
759
+ const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
760
+ if (raw && !known && /@.+\./.test(raw))
761
+ recordFromHistory(raw);
762
+ }
763
+ catch { /* */ }
668
764
  // Stop autosave only after ACK — if send threw we want the draft
669
765
  // autosave to keep the message safe.
670
766
  if (draftTimer) {
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">
@@ -118,11 +118,22 @@
118
118
  </search>
119
119
  <div class="ml-folder-title" id="ml-folder-title"></div>
120
120
  <div class="ml-header" id="ml-header">
121
+ <span class="ml-col ml-col-avatar"></span>
121
122
  <span class="ml-col ml-col-flag"></span>
122
123
  <span class="ml-col ml-col-from ml-col-sortable" data-sort="from">From</span>
123
124
  <span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
124
125
  <span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
125
126
  </div>
127
+ <div class="ml-bulkbar" id="ml-bulkbar" hidden>
128
+ <button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
129
+ <span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
130
+ <span style="flex:1"></span>
131
+ <button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
132
+ <button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
133
+ <button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
134
+ <button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
135
+ <button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
136
+ </div>
126
137
  <div class="ml-body" id="ml-body">
127
138
  <div class="ml-empty">Select a folder to view messages</div>
128
139
  </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) {
@@ -299,7 +299,8 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
299
299
 
300
300
  .message-list {
301
301
  display: grid;
302
- grid-template-columns: 1.2em minmax(120px, 200px) auto 1fr;
302
+ /* avatar | flag | from | date | subject */
303
+ grid-template-columns: 28px 1.2em minmax(120px, 200px) auto 1fr;
303
304
  grid-template-rows: auto auto 1fr;
304
305
  column-gap: var(--gap-sm);
305
306
  overflow: hidden;
@@ -308,7 +309,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
308
309
 
309
310
  /* Two-line view */
310
311
  .message-list.two-line {
311
- grid-template-columns: 1.2em 1fr auto;
312
+ grid-template-columns: 28px 1.2em 1fr auto;
312
313
  }
313
314
 
314
315
  .message-list.two-line .ml-row {
@@ -316,11 +317,12 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
316
317
  grid-template-rows: auto auto;
317
318
  }
318
319
 
319
- .message-list.two-line .ml-flag { grid-row: 1 / 3; align-self: center; }
320
- .message-list.two-line .ml-from { grid-column: 2; }
321
- .message-list.two-line .ml-date { grid-column: 3; grid-row: 1; padding-right: var(--gap-md); }
320
+ .message-list.two-line .ml-avatar { grid-row: 1 / 3; align-self: center; grid-column: 1; }
321
+ .message-list.two-line .ml-flag { grid-row: 1 / 3; align-self: center; grid-column: 2; }
322
+ .message-list.two-line .ml-from { grid-column: 3; }
323
+ .message-list.two-line .ml-date { grid-column: 4; grid-row: 1; padding-right: var(--gap-md); }
322
324
  .message-list.two-line .ml-subject {
323
- grid-column: 2 / 4;
325
+ grid-column: 3 / 5;
324
326
  grid-row: 2;
325
327
  color: oklch(0.55 0.10 250) !important;
326
328
  font-size: var(--font-size-sm);
@@ -900,6 +902,249 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
900
902
  list empty when the selection is a singleton thread. */
901
903
  .ml-row.thread-filter-hidden { display: none; }
902
904
 
905
+ /* Sender avatar — Thunderbird-style 24-px circle with the sender's first
906
+ initial. Background color is hashed from the sender's address so the
907
+ same person keeps the same color across rows. In multi-select mode
908
+ (#ml-body.multi-select-on) the avatar swaps to a checkmark per row,
909
+ filled when selected, hollow when not — matches the Thunderbird /
910
+ Gmail / Apple Mail pattern. */
911
+ .ml-avatar {
912
+ width: 24px;
913
+ height: 24px;
914
+ border-radius: 50%;
915
+ background: oklch(0.62 0.14 250);
916
+ color: #fff;
917
+ font-size: 0.78rem;
918
+ font-weight: 600;
919
+ line-height: 24px;
920
+ text-align: center;
921
+ user-select: none;
922
+ flex-shrink: 0;
923
+ align-self: center;
924
+ cursor: pointer;
925
+ }
926
+ #ml-body.multi-select-on .ml-avatar {
927
+ background: transparent !important;
928
+ color: var(--color-text-muted);
929
+ border: 2px solid currentColor;
930
+ line-height: 20px;
931
+ }
932
+ #ml-body.multi-select-on .ml-avatar::before { content: ""; }
933
+ #ml-body.multi-select-on .ml-row.selected .ml-avatar {
934
+ background: var(--color-brand, oklch(0.55 0.18 250)) !important;
935
+ color: #fff;
936
+ border-color: var(--color-brand, oklch(0.55 0.18 250));
937
+ }
938
+ /* Replace the initial with a check when selected in multi-mode. The
939
+ `font-size: 0` trick blanks the text node we set in JS without having
940
+ to clear it; the ::after glyph then fills the space. */
941
+ #ml-body.multi-select-on .ml-avatar { font-size: 0; }
942
+ #ml-body.multi-select-on .ml-avatar::after { content: ""; }
943
+ #ml-body.multi-select-on .ml-row.selected .ml-avatar::after {
944
+ content: "✓";
945
+ font-size: 0.95rem;
946
+ line-height: 20px;
947
+ font-weight: 700;
948
+ }
949
+
950
+ /* Add an avatar slot to the header so columns line up. Header is a sibling
951
+ subgrid; first cell is empty (avatar column). */
952
+ .ml-header { grid-template-columns: subgrid; }
953
+
954
+ /* Duplicate-message-id tag in unified inbox — shown when the same
955
+ Message-ID appears across 2+ accounts (same letter delivered to both,
956
+ mailing-list Bcc, etc.). Subtle teal badge; tooltip explains. */
957
+ .ml-dupe-tag {
958
+ display: inline-block;
959
+ color: oklch(0.55 0.14 195);
960
+ font-weight: 600;
961
+ margin-right: 0.3em;
962
+ user-select: none;
963
+ }
964
+
965
+ /* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
966
+ idea but better when we have a second-stage plan strategy". Touch users
967
+ still reach the menu via long-press → multi-select → bulk-bar, or by
968
+ using a stylus/mouse for contextmenu. Revisit when we've decided what
969
+ additional per-message actions belong here. */
970
+
971
+ /* ── Alarm popup (P17 / Q104) ── */
972
+ /* Fullscreen backdrop + centered panel; mirrors .mailx-modal* patterns but
973
+ kept in its own namespace so alarm behavior (snooze/dismiss) can evolve
974
+ without dragging the whole modal system along. */
975
+ .alarm-overlay {
976
+ position: fixed;
977
+ inset: 0;
978
+ background: rgba(0, 0, 0, 0.45);
979
+ z-index: 9000;
980
+ display: flex;
981
+ align-items: center;
982
+ justify-content: center;
983
+ }
984
+ .alarm-panel {
985
+ background: var(--color-bg);
986
+ color: var(--color-text);
987
+ border: 1px solid var(--color-border);
988
+ border-radius: 8px;
989
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
990
+ min-width: 380px;
991
+ max-width: 560px;
992
+ width: 90vw;
993
+ display: flex;
994
+ flex-direction: column;
995
+ max-height: 80vh;
996
+ }
997
+ .alarm-head {
998
+ display: flex;
999
+ align-items: center;
1000
+ gap: var(--gap-sm);
1001
+ padding: var(--gap-sm) var(--gap-md);
1002
+ border-bottom: 1px solid var(--color-border);
1003
+ font-weight: 600;
1004
+ }
1005
+ .alarm-icon { font-size: 1.4em; }
1006
+ .alarm-title { flex: 1; }
1007
+ .alarm-close {
1008
+ border: 0;
1009
+ background: transparent;
1010
+ color: var(--color-text-muted);
1011
+ cursor: pointer;
1012
+ font-size: 1.4em;
1013
+ line-height: 1;
1014
+ padding: 0 0.4em;
1015
+ }
1016
+ .alarm-close:hover { color: var(--color-text); }
1017
+ .alarm-list {
1018
+ padding: var(--gap-sm) var(--gap-md);
1019
+ overflow-y: auto;
1020
+ display: flex;
1021
+ flex-direction: column;
1022
+ gap: var(--gap-xs);
1023
+ }
1024
+ .alarm-row {
1025
+ display: flex;
1026
+ align-items: center;
1027
+ gap: var(--gap-sm);
1028
+ padding: var(--gap-xs) 0;
1029
+ border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
1030
+ }
1031
+ .alarm-row:last-child { border-bottom: none; }
1032
+ .alarm-row-main {
1033
+ flex: 1;
1034
+ display: flex;
1035
+ align-items: center;
1036
+ gap: var(--gap-xs);
1037
+ min-width: 0;
1038
+ }
1039
+ .alarm-row-kind { font-size: 1.1em; }
1040
+ .alarm-row-title {
1041
+ font-weight: 500;
1042
+ overflow: hidden;
1043
+ text-overflow: ellipsis;
1044
+ white-space: nowrap;
1045
+ flex: 1;
1046
+ }
1047
+ .alarm-row-when {
1048
+ color: var(--color-text-muted);
1049
+ font-size: var(--font-size-sm);
1050
+ font-variant-numeric: tabular-nums;
1051
+ white-space: nowrap;
1052
+ }
1053
+ .alarm-row-actions { display: flex; gap: var(--gap-xs); }
1054
+ .alarm-row-link,
1055
+ .alarm-row-dismiss {
1056
+ border: 0;
1057
+ background: transparent;
1058
+ color: var(--color-text-muted);
1059
+ cursor: pointer;
1060
+ font-size: 1em;
1061
+ padding: 0 0.3em;
1062
+ border-radius: 3px;
1063
+ }
1064
+ .alarm-row-link:hover,
1065
+ .alarm-row-dismiss:hover {
1066
+ background: var(--color-bg-hover);
1067
+ color: var(--color-text);
1068
+ }
1069
+ .alarm-foot {
1070
+ display: flex;
1071
+ align-items: center;
1072
+ gap: var(--gap-sm);
1073
+ padding: var(--gap-sm) var(--gap-md);
1074
+ border-top: 1px solid var(--color-border);
1075
+ background: var(--color-bg-toolbar);
1076
+ border-radius: 0 0 8px 8px;
1077
+ }
1078
+ .alarm-snooze-label {
1079
+ font-size: var(--font-size-sm);
1080
+ color: var(--color-text-muted);
1081
+ display: flex;
1082
+ align-items: center;
1083
+ gap: var(--gap-xs);
1084
+ }
1085
+ .alarm-snooze-sel {
1086
+ background: var(--color-bg);
1087
+ color: var(--color-text);
1088
+ border: 1px solid var(--color-border);
1089
+ border-radius: 4px;
1090
+ padding: 0.25em 0.5em;
1091
+ font-size: var(--font-size-sm);
1092
+ }
1093
+ .alarm-btn {
1094
+ border: 1px solid var(--color-border);
1095
+ background: var(--color-bg);
1096
+ color: var(--color-text);
1097
+ cursor: pointer;
1098
+ padding: 0.4em 1em;
1099
+ border-radius: 4px;
1100
+ font-size: var(--font-size-sm);
1101
+ }
1102
+ .alarm-btn:hover { background: var(--color-bg-hover); }
1103
+ .alarm-btn-primary {
1104
+ background: var(--color-brand, oklch(0.65 0.14 250));
1105
+ color: #fff;
1106
+ border-color: var(--color-brand, oklch(0.65 0.14 250));
1107
+ }
1108
+ .alarm-btn-primary:hover { filter: brightness(1.1); }
1109
+
1110
+ /* Bulk-actions bar — appears over the list header when multi-select mode is
1111
+ active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
1112
+ buttons + Cancel. Kept in sibling position to ml-header so it uses the
1113
+ same horizontal space. */
1114
+ .ml-bulkbar {
1115
+ display: flex;
1116
+ align-items: center;
1117
+ gap: var(--gap-sm);
1118
+ padding: var(--gap-xs) var(--gap-sm);
1119
+ background: var(--color-brand, oklch(0.65 0.14 250));
1120
+ color: #fff;
1121
+ font-size: var(--font-size-sm);
1122
+ border-bottom: 1px solid var(--color-border);
1123
+ grid-column: 1 / -1;
1124
+ }
1125
+ .ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
1126
+ .ml-bulk-cancel,
1127
+ .ml-bulk-btn {
1128
+ border: 0;
1129
+ background: transparent;
1130
+ color: inherit;
1131
+ cursor: pointer;
1132
+ padding: 0.25em 0.55em;
1133
+ border-radius: 4px;
1134
+ font-size: 1rem;
1135
+ }
1136
+ .ml-bulk-cancel:hover,
1137
+ .ml-bulk-btn:hover {
1138
+ background: oklch(1 0 0 / 0.15);
1139
+ }
1140
+ .ml-bulk-btn.ml-bulk-danger:hover {
1141
+ background: oklch(0.65 0.22 25 / 0.4);
1142
+ }
1143
+
1144
+ /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
1145
+ desktop). Add a left-edge accent bar so it's visually clear the list is
1146
+ in selection mode, not navigation mode. */
1147
+
903
1148
  /* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
904
1149
  desktop). Add a left-edge accent bar so it's visually clear the list is
905
1150
  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.405",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -2402,14 +2402,19 @@ export class ImapManager extends EventEmitter {
2402
2402
  async updateFlagsLocal(accountId, uid, folderId, flags) {
2403
2403
  this.db.updateMessageFlags(accountId, uid, flags);
2404
2404
  this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
2405
- // Don't process immediately let the 30s timer batch actions
2406
- // (immediate processing during sync causes connection churn)
2405
+ // User-visible pink-dot pending state stays until the action drains.
2406
+ // The 30-second periodic tick was too slow — opening one message to
2407
+ // auto-mark-as-read left it pink for half a minute. Same 1-second
2408
+ // debounce as moves/deletes batches rapid flag churn without the
2409
+ // visual lag.
2410
+ this.debounceSyncActions(accountId);
2407
2411
  }
2408
2412
  /** Process pending sync actions for an account */
2409
2413
  async processSyncActions(accountId) {
2410
2414
  const actions = this.db.getPendingSyncActions(accountId);
2411
2415
  if (actions.length === 0)
2412
2416
  return;
2417
+ const startCount = actions.length;
2413
2418
  const folders = this.db.getFolders(accountId);
2414
2419
  // Gmail path: push flag/label changes through the REST provider so
2415
2420
  // they actually reach the server. Earlier this method always went
@@ -2475,6 +2480,11 @@ export class ImapManager extends EventEmitter {
2475
2480
  }
2476
2481
  catch { /* */ }
2477
2482
  }
2483
+ // Nudge the UI so rows that were pending-reconcile re-query their
2484
+ // pending state (pink dot was sticky until this event fired).
2485
+ const remaining = this.db.getPendingSyncActions(accountId).length;
2486
+ if (remaining < startCount)
2487
+ this.emit("folderCountsChanged", accountId, {});
2478
2488
  return;
2479
2489
  }
2480
2490
  await this.withConnection(accountId, async (client) => {
@@ -2546,6 +2556,12 @@ export class ImapManager extends EventEmitter {
2546
2556
  }
2547
2557
  }
2548
2558
  });
2559
+ // IMAP path: same nudge as the Gmail branch above. Any action that
2560
+ // drained (successful or gave-up-after-5) decrements the pending
2561
+ // count, which flips the pink dot off on the next re-query.
2562
+ const remaining = this.db.getPendingSyncActions(accountId).length;
2563
+ if (remaining < startCount)
2564
+ this.emit("folderCountsChanged", accountId, {});
2549
2565
  }
2550
2566
  /** Find a folder by specialUse, case-insensitive */
2551
2567
  findFolder(accountId, specialUse) {
@@ -66,6 +66,35 @@ const imapManager = new ImapManager(db, () => new NodeTransport());
66
66
  // ── Express App ──
67
67
  const app = express();
68
68
  app.use(express.json({ limit: "Infinity" }));
69
+ // Optional token gate — required whenever the server binds to anything other
70
+ // than a loopback address. Set via `MAILX_SERVER_TOKEN=<secret>` (or the
71
+ // shorter `MAILX_TOKEN`). When the server is loopback-only (the default), the
72
+ // gate is a no-op since nothing outside the machine can reach it. When bound
73
+ // externally (`MAILX_SERVER_HOST=0.0.0.0` or `--external`), connections must
74
+ // present the token in either `?t=<token>` or the `x-mailx-token` header. No
75
+ // token configured + external bind → server refuses to start (safer than
76
+ // serving open).
77
+ const SERVER_TOKEN = process.env.MAILX_SERVER_TOKEN || process.env.MAILX_TOKEN || "";
78
+ const SERVER_HOST = process.env.MAILX_SERVER_HOST || "";
79
+ const IS_EXTERNAL_BIND = SERVER_HOST && SERVER_HOST !== "127.0.0.1" && SERVER_HOST !== "localhost" && SERVER_HOST !== "::1";
80
+ if (IS_EXTERNAL_BIND && !SERVER_TOKEN) {
81
+ console.error("[server] Refusing to bind externally without MAILX_SERVER_TOKEN. Set the env var or drop the external host.");
82
+ process.exit(2);
83
+ }
84
+ app.use((req, res, next) => {
85
+ if (!IS_EXTERNAL_BIND)
86
+ return next();
87
+ // Allow the bare static file fetch so the login page can render; every
88
+ // /api/* path requires the token.
89
+ if (!req.path.startsWith("/api/"))
90
+ return next();
91
+ const provided = req.query.t || req.header("x-mailx-token") || "";
92
+ if (provided !== SERVER_TOKEN) {
93
+ res.status(401).json({ error: "missing or invalid token" });
94
+ return;
95
+ }
96
+ next();
97
+ });
69
98
  // Request logging
70
99
  app.use((req, res, next) => {
71
100
  const start = Date.now();