@bobfrankston/mailx 1.0.399 → 1.0.406

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.
@@ -145,6 +145,7 @@
145
145
  <input type="search" id="search-input" placeholder="Search..." autocomplete="off" title="Search messages">
146
146
  </search>
147
147
  <div class="ml-header">
148
+ <span class="ml-col ml-col-avatar"></span>
148
149
  <span class="ml-col ml-col-flag"></span>
149
150
  <span class="ml-col ml-col-from" data-sort="from">From</span>
150
151
  <span class="ml-col ml-col-date" data-sort="date">Date</span>
package/client/app.js CHANGED
@@ -1266,6 +1266,21 @@ window.addEventListener("message", (e) => {
1266
1266
  // with no failures), so we do the IPC from here and post the result back
1267
1267
  // to the iframe via its source. `e.source` is the iframe's window; use it
1268
1268
  // so targeting works even if the iframe moves in the DOM.
1269
+ // S61 2026-04-24: parent-relay compose close. On Android the
1270
+ // window.close() override applied in `frame.onload` doesn't always fire
1271
+ // (WebView2 / MAUI WebView dispatches close to the shell in some cases),
1272
+ // leaving the compose overlay stuck after Send. postMessage is reliable;
1273
+ // compose.ts's closeCompose() posts this, and we find-and-remove the
1274
+ // overlay whose iframe window matches e.source.
1275
+ if (e.data?.type === "mailx-compose-close") {
1276
+ const src = e.source;
1277
+ document.querySelectorAll(".compose-overlay").forEach(el => {
1278
+ const iframe = el.querySelector("iframe");
1279
+ if (!src || iframe?.contentWindow === src)
1280
+ el.remove();
1281
+ });
1282
+ return;
1283
+ }
1269
1284
  if (e.data?.type === "mailx-compose-send" && e.data.id && e.data.body) {
1270
1285
  const src = e.source;
1271
1286
  const id = e.data.id;
@@ -100,6 +100,17 @@ function clearSelection() {
100
100
  // would break if we kept a stale pointer.
101
101
  currentFocusedRow = null;
102
102
  }
103
+ /** Deterministic sender-avatar color from a seed string (typically the
104
+ * email address). Hash → hue at 12 evenly-spaced positions on the wheel.
105
+ * Saturation + lightness fixed so all colors carry the same visual weight
106
+ * regardless of hue, and so light/dark themes don't have to override. */
107
+ function senderColor(seed) {
108
+ let h = 0;
109
+ for (let i = 0; i < seed.length; i++)
110
+ h = (h * 31 + seed.charCodeAt(i)) | 0;
111
+ const hue = ((Math.abs(h) % 12) * 30) + 15; // 15, 45, 75, …, 345
112
+ return `oklch(0.62 0.14 ${hue})`;
113
+ }
103
114
  /** Exit multi-select mode (entered via touch long-press). Clears selection
104
115
  * and the sticky body flag so subsequent taps open messages again. */
105
116
  function exitMultiSelect() {
@@ -723,6 +734,41 @@ function appendMessages(body, accountId, items) {
723
734
  row.dataset.folderId = String(msg.folderId);
724
735
  if (msg.threadId)
725
736
  row.dataset.threadId = msg.threadId;
737
+ // Sender avatar \u2014 Thunderbird-style colored circle with the first
738
+ // initial of the sender's display name. Doubles as the multi-select
739
+ // affordance: in `multi-select-on` mode, the avatar swaps to a
740
+ // checkmark via CSS. Color is derived deterministically from the
741
+ // address so the same sender keeps the same color across rows.
742
+ const fromName = (showToInsteadOfFrom && msg.to?.length)
743
+ ? (msg.to[0].name || msg.to[0].address || "?")
744
+ : (msg.from?.name || msg.from?.address || "?");
745
+ const seedAddr = (msg.from?.address || msg.from?.name || "?").toLowerCase();
746
+ const initial = (fromName.replace(/^[\W_]+/, "") || "?").charAt(0).toUpperCase();
747
+ const avatar = document.createElement("span");
748
+ avatar.className = "ml-avatar";
749
+ avatar.textContent = initial;
750
+ avatar.style.background = senderColor(seedAddr);
751
+ avatar.title = msg.from?.address || "";
752
+ // Tapping the avatar enters multi-select mode (or toggles in it,
753
+ // mirroring Thunderbird/Gmail). Click bubbles to the row otherwise,
754
+ // which would open the message — stopPropagation here keeps the
755
+ // avatar a dedicated selection affordance.
756
+ avatar.addEventListener("click", (e) => {
757
+ e.stopPropagation();
758
+ const body = document.getElementById("ml-body");
759
+ if (!body)
760
+ return;
761
+ if (body.classList.contains("multi-select-on")) {
762
+ row.classList.toggle("selected");
763
+ }
764
+ else {
765
+ clearSelection();
766
+ row.classList.add("selected");
767
+ body.classList.add("multi-select-on");
768
+ }
769
+ lastClickedRow = row;
770
+ updateBulkBar();
771
+ });
726
772
  const flag = document.createElement("span");
727
773
  flag.className = "ml-flag";
728
774
  flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
@@ -751,6 +797,15 @@ function appendMessages(body, accountId, items) {
751
797
  folderTag.title = `In folder: ${msg.folderName}`;
752
798
  from.prepend(folderTag);
753
799
  }
800
+ // Unified inbox: same Message-ID exists under >=2 accounts → ⇆ badge.
801
+ // Tooltip names the count so the user knows "this appears on N".
802
+ if (msg.dupeCount >= 2) {
803
+ const dupe = document.createElement("span");
804
+ dupe.className = "ml-dupe-tag";
805
+ dupe.textContent = "⇆";
806
+ dupe.title = `Same message on ${msg.dupeCount} accounts`;
807
+ from.prepend(dupe);
808
+ }
754
809
  const subject = document.createElement("span");
755
810
  subject.className = "ml-subject";
756
811
  subject.innerHTML = escapeHtml(msg.subject);
@@ -795,6 +850,7 @@ function appendMessages(body, accountId, items) {
795
850
  }
796
851
  catch { /* ignore */ }
797
852
  });
853
+ row.appendChild(avatar);
798
854
  row.appendChild(flag);
799
855
  row.appendChild(from);
800
856
  row.appendChild(date);
@@ -191,6 +191,17 @@ function installPreviewControls(iframe) {
191
191
  const pct = Math.round(previewZoom * 100);
192
192
  const sel = doc.defaultView?.getSelection();
193
193
  const selectedText = sel?.toString().trim() || "";
194
+ const runSearch = (query) => {
195
+ const input = document.getElementById("search-input");
196
+ if (!input)
197
+ return;
198
+ input.value = query;
199
+ // Trigger the existing search path — Enter keydown hits the
200
+ // immediate branch in app.ts's handler.
201
+ input.dispatchEvent(new Event("input"));
202
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
203
+ input.focus();
204
+ };
194
205
  const items = [
195
206
  { label: "Copy", action: () => doc.execCommand("copy") },
196
207
  { label: "Select all", action: () => {
@@ -202,14 +213,35 @@ function installPreviewControls(iframe) {
202
213
  s.removeAllRanges();
203
214
  s.addRange(range);
204
215
  } },
205
- { label: "", action: () => { }, separator: true },
206
- { label: selectedText ? "Translate selection" : "Translate message",
207
- action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
208
- { label: "", action: () => { }, separator: true },
209
- { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
210
- { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
211
- { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
212
216
  ];
217
+ if (selectedText) {
218
+ items.push({ label: "", action: () => { }, separator: true },
219
+ // Truncate long selections in the label so the menu doesn't
220
+ // blow out; full string is what we search for.
221
+ { label: `Search messages for "${selectedText.length > 40 ? selectedText.slice(0, 40) + "…" : selectedText}"`,
222
+ action: () => runSearch(selectedText) }, {
223
+ label: "Copy as quoted (> prefix)",
224
+ action: async () => {
225
+ // Prefix each line with "> " (RFC 3676 reply-quote).
226
+ // Useful when pasting a snippet into a compose window
227
+ // without the usual full-message blockquote wrapping.
228
+ const quoted = selectedText.split(/\r?\n/).map(l => "> " + l).join("\n");
229
+ try {
230
+ await navigator.clipboard.writeText(quoted);
231
+ }
232
+ catch { /* */ }
233
+ },
234
+ });
235
+ }
236
+ const senderAddr = currentMessage?.from?.address || "";
237
+ if (senderAddr) {
238
+ items.push({
239
+ label: `Search messages from ${senderAddr}`,
240
+ action: () => runSearch(`from:${senderAddr}`),
241
+ });
242
+ }
243
+ items.push({ label: "", action: () => { }, separator: true }, { label: selectedText ? "Translate selection" : "Translate message",
244
+ action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) }, { label: "", action: () => { }, separator: true }, { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) }, { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) }, { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) });
213
245
  showContextMenu(x, y, items);
214
246
  });
215
247
  };
@@ -239,8 +271,24 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
239
271
  const headerEl = document.getElementById("mv-header");
240
272
  const bodyEl = document.getElementById("mv-body");
241
273
  const attEl = document.getElementById("mv-attachments");
242
- bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
243
- headerEl.hidden = true;
274
+ // Envelope-first render: the row the user just clicked already has the
275
+ // subject / from / to / cc / date / preview in the message-state. Use
276
+ // that to populate the header + a snippet placeholder IMMEDIATELY so
277
+ // tapping a message never shows just "Fetching message body..." with
278
+ // nothing actionable. The full getMessage() call (which might block on
279
+ // a slow IMAP body fetch) only fills in the body and attachments.
280
+ const cached = state.getSelected();
281
+ if (cached && cached.uid === uid && (cached.accountId || accountId) === accountId) {
282
+ try {
283
+ renderHeaderFromEnvelope(headerEl, cached);
284
+ }
285
+ catch { /* */ }
286
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body…<br><br><span style="color:var(--color-text-muted);font-size:0.9em">${escapeHtml(cached.preview || "")}</span></div>`;
287
+ }
288
+ else {
289
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
290
+ headerEl.hidden = true;
291
+ }
244
292
  attEl.hidden = true;
245
293
  try {
246
294
  const msg = await getMessage(accountId, uid, false, folderId);
@@ -697,6 +745,29 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
697
745
  console.error(`Attachment download failed: ${err.message}`);
698
746
  }
699
747
  });
748
+ // Drag the chip to an external target (Explorer / Finder / Files app)
749
+ // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer
750
+ // format: "mime:filename:blob-url". We fetch the attachment first so
751
+ // the blob URL is valid by the time the drop lands.
752
+ chip.draggable = true;
753
+ chip.addEventListener("dragstart", async (e) => {
754
+ if (!e.dataTransfer)
755
+ return;
756
+ try {
757
+ const data = await getAttachment(accountId, uid, i, msg.folderId);
758
+ const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
759
+ const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
760
+ const url = URL.createObjectURL(blob);
761
+ // Sanitize filename: no path separators, no newlines.
762
+ const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
763
+ const downloadUrl = `${data.contentType || "application/octet-stream"}:${safeName}:${url}`;
764
+ e.dataTransfer.setData("DownloadURL", downloadUrl);
765
+ e.dataTransfer.effectAllowed = "copy";
766
+ }
767
+ catch (err) {
768
+ console.error(`Attachment drag-out failed: ${err.message || err}`);
769
+ }
770
+ });
700
771
  attEl.appendChild(chip);
701
772
  }
702
773
  }
@@ -731,6 +802,40 @@ function formatAddr(addr) {
731
802
  return `${addr.name} <${addr.address}>`;
732
803
  return addr.address;
733
804
  }
805
+ /** Render the viewer header from a list-row envelope (instant — no body
806
+ * fetch awaited). Used to populate the header pane the moment a message
807
+ * is clicked so the user always sees something actionable; getMessage()
808
+ * later overwrites the same fields with the authoritative values from the
809
+ * body parse (which can add Cc, Delivered-To, etc. that the list row
810
+ * doesn't track). */
811
+ function renderHeaderFromEnvelope(headerEl, env) {
812
+ headerEl.hidden = false;
813
+ const fromEl = headerEl.querySelector(".mv-from");
814
+ const toEl = headerEl.querySelector(".mv-to");
815
+ const subjEl = headerEl.querySelector(".mv-subject");
816
+ const dateEl = headerEl.querySelector(".mv-date");
817
+ if (fromEl)
818
+ fromEl.textContent = formatAddr(env.from);
819
+ if (toEl) {
820
+ let toLine = `To: ${(env.to || []).map(formatAddr).join(", ")}`;
821
+ if (env.cc?.length)
822
+ toLine += ` Cc: ${env.cc.map(formatAddr).join(", ")}`;
823
+ toEl.textContent = toLine;
824
+ }
825
+ if (subjEl)
826
+ subjEl.textContent = env.subject || "";
827
+ if (dateEl) {
828
+ try {
829
+ dateEl.textContent = new Date(env.date).toLocaleString();
830
+ }
831
+ catch {
832
+ dateEl.textContent = "";
833
+ }
834
+ }
835
+ }
836
+ function escapeHtml(s) {
837
+ return (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
838
+ }
734
839
  /** Convert plain text URLs into clickable links, escaping HTML */
735
840
  function linkifyText(text) {
736
841
  // Escape HTML first
@@ -922,15 +1027,12 @@ ${csp}
922
1027
  document.addEventListener("touchcancel", function () {
923
1028
  lastTouchTarget = null; touchMoved = false;
924
1029
  }, { passive: true, capture: true });
925
- document.addEventListener("mouseover", function (e) {
926
- var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
927
- if (a) {
928
- var r = a.getBoundingClientRect();
929
- window.parent.postMessage({ type: "linkHover", url: a.href, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }, "*");
930
- } else {
931
- window.parent.postMessage({ type: "linkHover", url: "" }, "*");
932
- }
933
- });
1030
+ // Link hover popover removed 2026-04-24 — user feedback: it persisted
1031
+ // over the message body when the dismissers (mousedown/scroll/blur)
1032
+ // didn't fire from inside the iframe, leaving a multi-line URL hanging
1033
+ // in the middle of the reading pane. Right-click (desktop) and long-
1034
+ // press (touch) on a link still open the existing C29 menu with Open /
1035
+ // Save-as / Copy URL / Copy link-text.
934
1036
  // C29: right-click on a link → ask parent for the Open/Save/Copy menu.
935
1037
  document.addEventListener("contextmenu", function (e) {
936
1038
  var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
@@ -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) => {
@@ -499,12 +549,23 @@ async function saveDraft() {
499
549
  draftSaving = false;
500
550
  }
501
551
  }
502
- /** 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. */
503
558
  function scheduleDraftSave() {
504
559
  markComposeDirty();
505
560
  if (draftDebounceTimer)
506
561
  clearTimeout(draftDebounceTimer);
507
- 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);
508
569
  }
509
570
  // ── Initialize: local-first population.
510
571
  //
@@ -689,6 +750,17 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
689
750
  .then(() => {
690
751
  logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
691
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 { /* */ }
692
764
  // Stop autosave only after ACK — if send threw we want the draft
693
765
  // autosave to keep the message safe.
694
766
  if (draftTimer) {
package/client/index.html CHANGED
@@ -118,6 +118,7 @@
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>
@@ -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,66 @@ 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
+
903
965
  /* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
904
966
  idea but better when we have a second-stage plan strategy". Touch users
905
967
  still reach the menu via long-press → multi-select → bulk-bar, or by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.399",
3
+ "version": "1.0.406",
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();
@@ -879,7 +879,9 @@ export class MailxDB {
879
879
  const rows = this.db.prepare(`SELECT m.*, EXISTS(
880
880
  SELECT 1 FROM sync_actions sa
881
881
  WHERE sa.account_id = m.account_id AND sa.uid = m.uid
882
- ) AS pending
882
+ ) AS pending,
883
+ (SELECT COUNT(DISTINCT account_id) FROM messages m2
884
+ WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
883
885
  FROM messages m WHERE m.folder_id IN (${placeholders})
884
886
  ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
885
887
  const items = rows.map(r => ({
@@ -902,6 +904,11 @@ export class MailxDB {
902
904
  preview: r.preview,
903
905
  bodyPath: r.body_path || "",
904
906
  pending: !!r.pending,
907
+ // >=2 means the same message-id exists under another account in
908
+ // the local DB (delivered to both accounts, or a mailing-list
909
+ // Bcc). The unified-inbox UI shows a small ⇆ badge on these
910
+ // rows so the user knows "this is a copy of the same message".
911
+ dupeCount: r.dupeCount | 0,
905
912
  }));
906
913
  return { items, total, page, pageSize };
907
914
  }
@@ -174,9 +174,19 @@ class AndroidSyncManager {
174
174
  const THROTTLE_MS = 150;
175
175
  const RATE_LIMIT_PAUSE_MS = 30000;
176
176
  const ERROR_BUDGET = 10;
177
+ const CONCURRENCY = 2; // S62: 2 in-flight per account
177
178
  let totalFetched = 0;
178
179
  let errors = 0;
179
180
  let announced = false;
181
+ // S62: INBOX always first. Within each folder the DB returns rows
182
+ // most-recent-first (PRIMARY KEY order), so newest unfetched INBOX
183
+ // mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't
184
+ // starve INBOX any more.
185
+ const folderPriority = (folderId) => {
186
+ const f = this.db.getFolders(accountId).find((x) => x.id === folderId);
187
+ return f?.specialUse === "inbox" ? 0 : 1;
188
+ };
189
+ let rateLimitCooldownUntil = 0;
180
190
  while (true) {
181
191
  const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
182
192
  if (missing.length === 0)
@@ -186,46 +196,70 @@ class AndroidSyncManager {
186
196
  vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
187
197
  announced = true;
188
198
  }
199
+ // Sort this batch INBOX-first. getMessagesWithoutBody doesn't
200
+ // know the priority, and re-querying per folder would multiply
201
+ // the SELECTs. One in-memory sort is cheap.
202
+ missing.sort((a, b) => folderPriority(a.folderId) - folderPriority(b.folderId));
189
203
  let progressedThisBatch = false;
190
- for (const m of missing) {
191
- // Sync the DB path if the body is already in IndexedDB — common
192
- // when upgrading from a build that didn't set body_path on cache.
193
- if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
194
- this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
195
- progressedThisBatch = true;
196
- continue;
197
- }
198
- try {
199
- const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
200
- if (result) {
201
- totalFetched++;
202
- progressedThisBatch = true;
203
- emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
204
+ let batchAborted = false;
205
+ // Bounded-concurrency worker pool. Each worker pulls the next
206
+ // unclaimed item from `missing`. Shared flags (errors,
207
+ // rateLimitCooldownUntil, progressedThisBatch) are updated
208
+ // inside the loop — sql.js is single-threaded so there's no
209
+ // actual race on reads/writes.
210
+ let cursor = 0;
211
+ const worker = async () => {
212
+ while (cursor < missing.length) {
213
+ if (batchAborted)
214
+ return;
215
+ if (errors >= ERROR_BUDGET)
216
+ return;
217
+ const idx = cursor++;
218
+ const m = missing[idx];
219
+ // Honor rate-limit cooldown across workers.
220
+ const now = Date.now();
221
+ if (rateLimitCooldownUntil > now) {
222
+ await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now));
204
223
  }
205
- else {
206
- errors++;
224
+ if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
225
+ this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
226
+ progressedThisBatch = true;
227
+ continue;
207
228
  }
208
- }
209
- catch (e) {
210
- errors++;
211
- const msg = String(e?.message || "");
212
- if (/429|rate|too many/i.test(msg)) {
213
- console.log(`[prefetch] ${accountId}: rate-limited pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
214
- await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
229
+ try {
230
+ const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
231
+ if (result) {
232
+ totalFetched++;
233
+ progressedThisBatch = true;
234
+ emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
235
+ }
236
+ else {
237
+ errors++;
238
+ }
215
239
  }
216
- else {
217
- console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
240
+ catch (e) {
241
+ errors++;
242
+ const msg = String(e?.message || "");
243
+ if (/429|rate|too many/i.test(msg)) {
244
+ console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
245
+ rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS;
246
+ }
247
+ else {
248
+ console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
249
+ }
218
250
  }
251
+ // Throttle kept per-request to spread load on flaky
252
+ // phone networks; concurrency-2 means effective request
253
+ // rate is ~1 per THROTTLE_MS/2.
254
+ await new Promise(r => setTimeout(r, THROTTLE_MS));
219
255
  }
220
- if (errors >= ERROR_BUDGET) {
221
- console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
222
- vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
223
- return;
224
- }
225
- await new Promise(r => setTimeout(r, THROTTLE_MS));
256
+ };
257
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker()));
258
+ if (errors >= ERROR_BUDGET) {
259
+ console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
260
+ vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
261
+ return;
226
262
  }
227
- // If a full batch made no progress, bail out to avoid an infinite
228
- // loop on messages the server can't deliver.
229
263
  if (!progressedThisBatch) {
230
264
  console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
231
265
  break;
@@ -910,70 +944,12 @@ export async function initAndroid() {
910
944
  }
911
945
  await syncManager.addAccount(account);
912
946
  }
913
- // Connect to GDrive using the Gmail token read shared accounts.jsonc
914
- if (gmailTokenProvider) {
915
- setGDriveTokenProvider(gmailTokenProvider);
916
- try {
917
- console.log("[android] Looking up GDrive mailx folder...");
918
- const folderId = await findGDriveMailxFolder(gmailTokenProvider);
919
- if (!folderId) {
920
- console.warn("[android] GDrive mailx folder not found");
921
- }
922
- else {
923
- setGDriveFolderId(folderId);
924
- console.log(`[android] GDrive mailx folder: ${folderId}`);
925
- // DEBUG: list all files in the folder
926
- try {
927
- const tk = await gmailTokenProvider();
928
- const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
929
- if (lr.ok) {
930
- const ld = await lr.json();
931
- const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
932
- console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
933
- }
934
- else {
935
- console.warn(`[android] List folder failed: ${lr.status}`);
936
- }
937
- }
938
- catch (e) {
939
- console.warn(`[android] List debug: ${e.message}`);
940
- }
941
- // Read accounts directly from GDrive (bypass IndexedDB cache)
942
- console.log("[android] Reading accounts.jsonc from GDrive...");
943
- const gdriveAccounts = await loadAccountsFromCloud();
944
- console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
945
- if (gdriveAccounts.length > 0) {
946
- // Use canonical GDrive accounts (upsert handles overwrites)
947
- accounts = gdriveAccounts;
948
- for (const account of accounts) {
949
- vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
950
- if (!account.enabled) {
951
- vlog(`init: ${account.id} disabled, skipping`);
952
- continue;
953
- }
954
- const domain = account.email?.split("@")[1]?.toLowerCase() || "";
955
- if (domain === "gmail.com" || domain === "googlemail.com") {
956
- syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
957
- }
958
- await syncManager.addAccount(account);
959
- }
960
- console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
961
- }
962
- // Register this Android device in clients.jsonc
963
- await registerDeviceInGDrive(gmailTokenProvider, folderId, accounts.map(a => a.id));
964
- }
965
- }
966
- catch (e) {
967
- console.warn(`[android] GDrive access failed: ${e.message}`);
968
- }
969
- }
947
+ // Install the mailxapi bridge + drain pending queues IMMEDIATELY using
948
+ // the local-cache account list. UI shouldn't wait on GDrive (which can
949
+ // be slow on cold network) before becoming actionable. GDrive
950
+ // reconciliation (below) runs in the background and re-registers fresh
951
+ // accounts when it returns.
970
952
  installBridge();
971
- // Drain any stranded send-queue entries BEFORE first sync. A message
972
- // queued in a prior session (offline, crashed mid-send, process killed)
973
- // gets a retry as soon as we have accounts registered. Desktop parity.
974
- // Q112 (2026-04-24): also drain move/flag/trash actions here — Android
975
- // is standalone, not desktop-dependent, so it pushes state changes
976
- // directly to the server the same way desktop does.
977
953
  for (const account of accounts) {
978
954
  if (!account.enabled)
979
955
  continue;
@@ -982,9 +958,73 @@ export async function initAndroid() {
982
958
  syncManager.processSyncActions(account.id)
983
959
  .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
984
960
  }
961
+ // First sync from local accounts on a tiny delay so the UI gets to paint.
985
962
  setTimeout(() => {
986
963
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
987
964
  }, 1000);
965
+ // GDrive reconciliation runs in the background — accounts.jsonc on the
966
+ // shared cloud may have been edited from another device, so we re-pull
967
+ // and re-register if it differs from the cached copy. The user can
968
+ // already see and use mail by the time this resolves.
969
+ if (gmailTokenProvider) {
970
+ const tp = gmailTokenProvider;
971
+ (async () => {
972
+ setGDriveTokenProvider(tp);
973
+ try {
974
+ console.log("[android] Looking up GDrive mailx folder…");
975
+ const folderId = await findGDriveMailxFolder(tp);
976
+ if (!folderId) {
977
+ console.warn("[android] GDrive mailx folder not found");
978
+ }
979
+ else {
980
+ setGDriveFolderId(folderId);
981
+ console.log(`[android] GDrive mailx folder: ${folderId}`);
982
+ // DEBUG: list all files in the folder
983
+ try {
984
+ const tk = await tp();
985
+ const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
986
+ if (lr.ok) {
987
+ const ld = await lr.json();
988
+ const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
989
+ console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
990
+ }
991
+ else {
992
+ console.warn(`[android] List folder failed: ${lr.status}`);
993
+ }
994
+ }
995
+ catch (e) {
996
+ console.warn(`[android] List debug: ${e.message}`);
997
+ }
998
+ // Read accounts directly from GDrive (bypass IndexedDB cache)
999
+ console.log("[android] Reading accounts.jsonc from GDrive...");
1000
+ const gdriveAccounts = await loadAccountsFromCloud();
1001
+ console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
1002
+ if (gdriveAccounts.length > 0) {
1003
+ // Use canonical GDrive accounts (upsert handles overwrites)
1004
+ accounts = gdriveAccounts;
1005
+ for (const account of accounts) {
1006
+ vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
1007
+ if (!account.enabled) {
1008
+ vlog(`init: ${account.id} disabled, skipping`);
1009
+ continue;
1010
+ }
1011
+ const domain = account.email?.split("@")[1]?.toLowerCase() || "";
1012
+ if (domain === "gmail.com" || domain === "googlemail.com") {
1013
+ syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
1014
+ }
1015
+ await syncManager.addAccount(account);
1016
+ }
1017
+ console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
1018
+ }
1019
+ // Register this Android device in clients.jsonc
1020
+ await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
1021
+ }
1022
+ }
1023
+ catch (e) {
1024
+ console.warn(`[android] GDrive access failed: ${e.message}`);
1025
+ }
1026
+ })();
1027
+ }
988
1028
  // Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
989
1029
  const SYNC_INTERVAL_MS = 2 * 60 * 1000;
990
1030
  setInterval(() => {
package/unwedge.cmd ADDED
@@ -0,0 +1 @@
1
+ rmdir C:\Users\Bob\.claude\session-env\5a053d1d-7856-4e74-9b2b-23a4e3262aed /s /q