@bobfrankston/mailx 1.0.399 → 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.
- package/client/android.html +1 -0
- package/client/app.js +15 -0
- package/client/components/message-list.js +56 -0
- package/client/components/message-viewer.js +120 -18
- package/client/compose/compose.js +113 -41
- package/client/index.html +1 -0
- package/client/styles/components.css +68 -6
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +18 -2
- package/packages/mailx-server/index.js +29 -0
- package/packages/mailx-store/db.js +8 -1
- package/packages/mailx-store-web/android-bootstrap.js +136 -96
- package/unwedge.cmd +1 -0
package/client/android.html
CHANGED
|
@@ -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
|
-
|
|
243
|
-
|
|
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 => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
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(() => {
|
|
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
|
-
|
|
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-
|
|
320
|
-
.message-list.two-line .ml-
|
|
321
|
-
.message-list.two-line .ml-
|
|
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:
|
|
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
|
@@ -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
|
-
//
|
|
2406
|
-
//
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
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
|
-
//
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|