@bobfrankston/mailx 1.0.244 → 1.0.246

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.
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":419,"y":152}
1
+ {"height":1344,"width":2151,"x":736,"y":147}
package/client/app.js CHANGED
@@ -221,9 +221,12 @@ document.getElementById("btn-back")?.addEventListener("click", () => {
221
221
  document.getElementById("message-list")?.classList.remove("narrow-hidden");
222
222
  });
223
223
  // Close folder panel when a folder is selected (narrow mode)
224
+ // Also reset narrow navigation: show message list, hide viewer
224
225
  document.getElementById("folder-tree")?.addEventListener("click", (e) => {
225
226
  if (window.innerWidth <= 768 && e.target.closest(".ft-folder")) {
226
227
  document.querySelector(".folder-panel")?.classList.remove("open");
228
+ document.getElementById("message-viewer")?.classList.remove("narrow-active");
229
+ document.getElementById("message-list")?.classList.remove("narrow-hidden");
227
230
  }
228
231
  });
229
232
  // Close folder overlay when user clicks outside it (narrow mode OR
@@ -755,10 +758,11 @@ window.addEventListener("message", (e) => {
755
758
  if (e.data?.type === "linkClick" && e.data.url) {
756
759
  const url = e.data.url;
757
760
  if (window.mailxapi?.platform === "android") {
758
- // Android: use a hidden iframe to trigger OnNavigating which opens in Chrome
761
+ // Android: use mailxapi:// bridge scheme OnNavigating intercepts it
762
+ // and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.
759
763
  const f = document.createElement("iframe");
760
764
  f.style.display = "none";
761
- f.src = url;
765
+ f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;
762
766
  document.body.appendChild(f);
763
767
  setTimeout(() => f.remove(), 500);
764
768
  }
@@ -540,13 +540,21 @@ document.addEventListener("mouseover", e => {
540
540
  window.parent.postMessage({ type: "linkHover", url: a ? a.href : "" }, "*");
541
541
  });
542
542
  // Intercept link clicks — Android WebView silently drops window.open, so forward to parent
543
- document.addEventListener("click", e => {
543
+ // Listen for both click and touchend since click may not fire on some Android WebViews
544
+ function handleLinkTap(e) {
544
545
  const a = e.target.closest("a[href]");
545
546
  if (!a) return;
546
547
  const url = a.href;
547
548
  if (!url || url.startsWith("javascript:") || url.startsWith("#")) return;
548
549
  e.preventDefault();
549
550
  window.parent.postMessage({ type: "linkClick", url: url }, "*");
551
+ }
552
+ document.addEventListener("click", handleLinkTap, true);
553
+ let lastTouchTarget = null;
554
+ document.addEventListener("touchstart", e => { lastTouchTarget = e.target; }, true);
555
+ document.addEventListener("touchend", e => {
556
+ if (lastTouchTarget && lastTouchTarget === e.target) handleLinkTap(e);
557
+ lastTouchTarget = null;
550
558
  }, true);
551
559
  </script>
552
560
  </head><body>${html}</body></html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.244",
3
+ "version": "1.0.246",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,11 +20,11 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.12",
24
- "@bobfrankston/iflow-node": "^0.1.2",
23
+ "@bobfrankston/iflow-direct": "^0.1.13",
24
+ "@bobfrankston/iflow-node": "^0.1.3",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.306",
27
+ "@bobfrankston/msger": "^0.1.307",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -74,11 +74,11 @@
74
74
  },
75
75
  ".transformedSnapshot": {
76
76
  "dependencies": {
77
- "@bobfrankston/iflow-direct": "^0.1.12",
78
- "@bobfrankston/iflow-node": "^0.1.2",
77
+ "@bobfrankston/iflow-direct": "^0.1.13",
78
+ "@bobfrankston/iflow-node": "^0.1.3",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.306",
81
+ "@bobfrankston/msger": "^0.1.307",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -103,14 +103,19 @@ export declare class ImapManager extends EventEmitter {
103
103
  private handleSyncError;
104
104
  /** Sync just INBOX for each account (fast check for new mail) */
105
105
  syncInbox(): Promise<void>;
106
- /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
107
- * If message count changed, triggers inbox sync for that account. */
108
- private lastInboxCounts;
106
+ /** Quick inbox check — per-account lightweight probe.
107
+ * If the probe value changed since last time, triggers an inbox sync.
108
+ * The marker is only advanced after a successful sync so that a failed
109
+ * sync doesn't eat the "new mail" signal and make us stop retrying. */
110
+ private lastInboxMarker;
109
111
  private quickCheckRunning;
112
+ /** Shared quick-check skeleton: probe → compare → sync-if-changed → advance marker.
113
+ * `probe` returns the current marker value; `sync` runs only when it differs
114
+ * from the previously stored value. Marker is advanced only after sync resolves. */
115
+ private quickCheck;
110
116
  /** Check a single account's inbox — uses its own connection, never blocked by sync */
111
117
  quickInboxCheckAccount(accountId: string): Promise<void>;
112
- /** Quick Gmail inbox check — one lightweight API call to check for new messages */
113
- private lastGmailInboxTop;
118
+ private quickImapCheck;
114
119
  private quickGmailCheck;
115
120
  /** Check all accounts (used by legacy callers) */
116
121
  quickInboxCheck(): Promise<void>;
@@ -1139,39 +1139,61 @@ export class ImapManager extends EventEmitter {
1139
1139
  this.inboxSyncing = false;
1140
1140
  }
1141
1141
  }
1142
- /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
1143
- * If message count changed, triggers inbox sync for that account. */
1144
- lastInboxCounts = new Map();
1142
+ /** Quick inbox check — per-account lightweight probe.
1143
+ * If the probe value changed since last time, triggers an inbox sync.
1144
+ * The marker is only advanced after a successful sync so that a failed
1145
+ * sync doesn't eat the "new mail" signal and make us stop retrying. */
1146
+ lastInboxMarker = new Map();
1145
1147
  quickCheckRunning = new Set(); // per-account guard
1146
- /** Check a single account's inbox uses its own connection, never blocked by sync */
1147
- async quickInboxCheckAccount(accountId) {
1148
+ /** Shared quick-check skeleton: probe compare sync-if-changed advance marker.
1149
+ * `probe` returns the current marker value; `sync` runs only when it differs
1150
+ * from the previously stored value. Marker is advanced only after sync resolves. */
1151
+ async quickCheck(accountId, probe, sync) {
1148
1152
  if (this.quickCheckRunning.has(accountId))
1149
1153
  return;
1150
1154
  if (this.reauthenticating.has(accountId))
1151
1155
  return;
1152
- if (this.isGmailAccount(accountId)) {
1153
- return this.quickGmailCheck(accountId);
1154
- }
1155
1156
  this.quickCheckRunning.add(accountId);
1156
- let client = null;
1157
1157
  try {
1158
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1159
- if (!inbox)
1158
+ const current = await probe();
1159
+ if (current === null || current === "")
1160
1160
  return;
1161
- client = this.newClient(accountId);
1162
- const count = await client.getMessagesCount("INBOX");
1163
- const prev = this.lastInboxCounts.get(accountId) ?? count;
1164
- this.lastInboxCounts.set(accountId, count);
1165
- if (count !== prev) {
1166
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
1167
- await this.syncFolder(accountId, inbox.id, client);
1161
+ const prev = this.lastInboxMarker.get(accountId);
1162
+ if (prev === undefined || current !== prev) {
1163
+ await sync(current, prev);
1168
1164
  }
1169
- await client.logout();
1170
- client = null;
1165
+ // Only advance after sync succeeds — a thrown error skips this line
1166
+ // and the next tick will see the same delta and retry.
1167
+ this.lastInboxMarker.set(accountId, current);
1171
1168
  }
1172
1169
  catch {
1173
1170
  // Lightweight check — silently ignore errors
1174
1171
  }
1172
+ finally {
1173
+ this.quickCheckRunning.delete(accountId);
1174
+ }
1175
+ }
1176
+ /** Check a single account's inbox — uses its own connection, never blocked by sync */
1177
+ async quickInboxCheckAccount(accountId) {
1178
+ if (this.isGmailAccount(accountId))
1179
+ return this.quickGmailCheck(accountId);
1180
+ return this.quickImapCheck(accountId);
1181
+ }
1182
+ async quickImapCheck(accountId) {
1183
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1184
+ if (!inbox)
1185
+ return;
1186
+ let client = null;
1187
+ try {
1188
+ await this.quickCheck(accountId, async () => {
1189
+ client = this.newClient(accountId);
1190
+ return await client.getMessagesCount("INBOX");
1191
+ }, async (count, prev) => {
1192
+ if (prev !== undefined)
1193
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
1194
+ await this.syncFolder(accountId, inbox.id, client);
1195
+ });
1196
+ }
1175
1197
  finally {
1176
1198
  if (client) {
1177
1199
  try {
@@ -1179,44 +1201,31 @@ export class ImapManager extends EventEmitter {
1179
1201
  }
1180
1202
  catch { /* */ }
1181
1203
  }
1182
- this.quickCheckRunning.delete(accountId);
1183
1204
  }
1184
1205
  }
1185
- /** Quick Gmail inbox check — one lightweight API call to check for new messages */
1186
- lastGmailInboxTop = new Map();
1187
1206
  async quickGmailCheck(accountId) {
1188
- if (this.quickCheckRunning.has(accountId))
1207
+ const config = this.configs.get(accountId);
1208
+ if (!config?.tokenProvider)
1189
1209
  return;
1190
- this.quickCheckRunning.add(accountId);
1191
- try {
1192
- const config = this.configs.get(accountId);
1193
- if (!config?.tokenProvider)
1194
- return;
1210
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1211
+ if (!inbox)
1212
+ return;
1213
+ await this.quickCheck(accountId, async () => {
1195
1214
  const token = await config.tokenProvider();
1196
- // Single API call: get just the first message ID
1197
1215
  const res = await globalThis.fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=in:inbox&maxResults=1`, { headers: { "Authorization": `Bearer ${token}` } });
1198
1216
  if (!res.ok)
1199
- return;
1217
+ return null;
1200
1218
  const data = await res.json();
1201
- const topId = data.messages?.[0]?.id || "";
1202
- const prev = this.lastGmailInboxTop.get(accountId) ?? topId;
1203
- this.lastGmailInboxTop.set(accountId, topId);
1204
- if (topId && topId !== prev) {
1219
+ return data.messages?.[0]?.id || null;
1220
+ }, async (_topId, prev) => {
1221
+ if (prev !== undefined)
1205
1222
  console.log(` [check] ${accountId} INBOX: new message detected`);
1206
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1207
- if (inbox) {
1208
- const api = this.getGmailProvider(accountId);
1209
- await this.syncFolderViaApi(accountId, inbox, api);
1210
- this.db.recalcFolderCounts(inbox.id);
1211
- this.emit("folderCountsChanged", accountId, {});
1212
- await api.close();
1213
- }
1214
- }
1215
- }
1216
- catch { /* lightweight — ignore errors */ }
1217
- finally {
1218
- this.quickCheckRunning.delete(accountId);
1219
- }
1223
+ const api = this.getGmailProvider(accountId);
1224
+ await this.syncFolderViaApi(accountId, inbox, api);
1225
+ this.db.recalcFolderCounts(inbox.id);
1226
+ this.emit("folderCountsChanged", accountId, {});
1227
+ await api.close();
1228
+ });
1220
1229
  }
1221
1230
  /** Check all accounts (used by legacy callers) */
1222
1231
  async quickInboxCheck() {
@@ -45,7 +45,11 @@ export class GmailApiProvider {
45
45
  }
46
46
  async fetch(path, options = {}) {
47
47
  const token = await this.tokenProvider();
48
- for (let attempt = 0; attempt < 3; attempt++) {
48
+ const maxAttempts = 6;
49
+ const baseDelayMs = 1000;
50
+ const maxDelayMs = 60_000;
51
+ let lastStatus = 0;
52
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
49
53
  const res = await globalThis.fetch(`${API}${path}`, {
50
54
  ...options,
51
55
  headers: {
@@ -55,9 +59,24 @@ export class GmailApiProvider {
55
59
  },
56
60
  });
57
61
  if (res.status === 429 || res.status >= 500) {
58
- // Rate limited or server error — back off and retry
59
- const delay = (attempt + 1) * 2000;
60
- console.log(` [gmail] ${res.status} error, waiting ${delay / 1000}s...`);
62
+ lastStatus = res.status;
63
+ // Honor Retry-After if present (seconds or HTTP-date)
64
+ const retryAfter = res.headers.get("Retry-After");
65
+ let delay = baseDelayMs * Math.pow(2, attempt);
66
+ if (retryAfter) {
67
+ const asInt = parseInt(retryAfter, 10);
68
+ if (!isNaN(asInt))
69
+ delay = asInt * 1000;
70
+ else {
71
+ const when = Date.parse(retryAfter);
72
+ if (!isNaN(when))
73
+ delay = Math.max(0, when - Date.now());
74
+ }
75
+ }
76
+ // Full jitter to avoid synchronized retries
77
+ delay = Math.min(maxDelayMs, delay);
78
+ delay = Math.floor(delay * (0.5 + Math.random() * 0.5));
79
+ console.log(` [gmail] ${res.status} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
61
80
  await new Promise(r => setTimeout(r, delay));
62
81
  continue;
63
82
  }
@@ -67,7 +86,7 @@ export class GmailApiProvider {
67
86
  }
68
87
  return res.json();
69
88
  }
70
- throw new Error("Gmail API: failed after 3 retries");
89
+ throw new Error(`Gmail API: failed after ${maxAttempts} retries (last status ${lastStatus})`);
71
90
  }
72
91
  async listFolders() {
73
92
  const data = await this.fetch("/labels");
@@ -618,6 +618,13 @@ export async function initAndroid() {
618
618
  setTimeout(() => {
619
619
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
620
620
  }, 1000);
621
+ // Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
622
+ const SYNC_INTERVAL_MS = 2 * 60 * 1000;
623
+ setInterval(() => {
624
+ console.log("[sync] periodic poll");
625
+ vlog("periodic sync poll");
626
+ syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
627
+ }, SYNC_INTERVAL_MS);
621
628
  console.log("[android] Initialization complete");
622
629
  emitEvent({ type: "connected" });
623
630
  }
@@ -5,14 +5,23 @@
5
5
  * The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it
6
6
  * to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects
7
7
  * that global name.
8
+ *
9
+ * Includes automatic retry on broken pipe / connection errors: if an operation fails
10
+ * with a connection-related error, we create a fresh client and retry once.
8
11
  */
9
12
  import { type ImapClientConfig } from "@bobfrankston/iflow-direct";
10
13
  import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./provider-types.js";
11
14
  export declare class ImapWebProvider implements MailProvider {
12
15
  private client;
16
+ private config;
17
+ private transportFactory;
13
18
  private specialFolders;
14
19
  private folderListCache;
15
20
  constructor(config: ImapClientConfig);
21
+ /** Create a fresh client (after broken pipe / connection error) */
22
+ private reconnect;
23
+ /** Run an operation with one retry on connection error */
24
+ private withRetry;
16
25
  listFolders(): Promise<ProviderFolder[]>;
17
26
  fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
18
27
  fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
@@ -5,6 +5,9 @@
5
5
  * The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it
6
6
  * to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects
7
7
  * that global name.
8
+ *
9
+ * Includes automatic retry on broken pipe / connection errors: if an operation fails
10
+ * with a connection-related error, we create a fresh client and retry once.
8
11
  */
9
12
  import { CompatImapClient, BridgeTransport } from "@bobfrankston/iflow-direct";
10
13
  /**
@@ -54,16 +57,50 @@ function toProviderMessage(m) {
54
57
  source: m.source || "",
55
58
  };
56
59
  }
60
+ /** Check if an error is a connection/broken-pipe error worth retrying */
61
+ function isConnectionError(e) {
62
+ const msg = (e?.message || "").toLowerCase();
63
+ return msg.includes("broken pipe") || msg.includes("not connected") ||
64
+ msg.includes("connection") || msg.includes("socket") ||
65
+ msg.includes("timeout") || msg.includes("econnreset") ||
66
+ msg.includes("epipe") || msg.includes("closed");
67
+ }
57
68
  export class ImapWebProvider {
58
69
  client;
70
+ config;
71
+ transportFactory;
59
72
  specialFolders = {};
60
73
  folderListCache = null;
61
74
  constructor(config) {
62
- const transportFactory = () => new BridgeTransport();
63
- this.client = new CompatImapClient(config, transportFactory);
75
+ this.config = config;
76
+ this.transportFactory = () => new BridgeTransport();
77
+ this.client = new CompatImapClient(config, this.transportFactory);
78
+ }
79
+ /** Create a fresh client (after broken pipe / connection error) */
80
+ reconnect() {
81
+ console.log("[imap-web] reconnecting after connection error");
82
+ try {
83
+ this.client.logout();
84
+ }
85
+ catch { /* ignore */ }
86
+ this.client = new CompatImapClient(this.config, this.transportFactory);
87
+ }
88
+ /** Run an operation with one retry on connection error */
89
+ async withRetry(op, label) {
90
+ try {
91
+ return await op();
92
+ }
93
+ catch (e) {
94
+ if (isConnectionError(e)) {
95
+ console.warn(`[imap-web] ${label}: ${e.message} — reconnecting and retrying`);
96
+ this.reconnect();
97
+ return await op();
98
+ }
99
+ throw e;
100
+ }
64
101
  }
65
102
  async listFolders() {
66
- const native = await this.client.getFolderList();
103
+ const native = await this.withRetry(() => this.client.getFolderList(), "listFolders");
67
104
  const special = this.client.getSpecialFolders(native);
68
105
  this.specialFolders = special;
69
106
  const result = native.map(f => toProviderFolder(f, this.specialFolders));
@@ -71,27 +108,27 @@ export class ImapWebProvider {
71
108
  return result;
72
109
  }
73
110
  async fetchSince(folder, sinceUid, options) {
74
- const msgs = await this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source });
111
+ const msgs = await this.withRetry(() => this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source }), `fetchSince(${folder})`);
75
112
  return msgs.map(toProviderMessage);
76
113
  }
77
114
  async fetchByDate(folder, since, before, options, onChunk) {
78
115
  const wrappedChunk = onChunk ? (raw) => onChunk(raw.map(toProviderMessage)) : undefined;
79
- const msgs = await this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk);
116
+ const msgs = await this.withRetry(() => this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk), `fetchByDate(${folder})`);
80
117
  return msgs.map(toProviderMessage);
81
118
  }
82
119
  async fetchByUids(folder, uids, options) {
83
120
  if (!uids.length)
84
121
  return [];
85
122
  const range = uids.join(",");
86
- const msgs = await this.client.fetchMessages(folder, range, { source: !!options?.source });
123
+ const msgs = await this.withRetry(() => this.client.fetchMessages(folder, range, { source: !!options?.source }), `fetchByUids(${folder})`);
87
124
  return msgs.map(toProviderMessage);
88
125
  }
89
126
  async fetchOne(folder, uid, options) {
90
- const msg = await this.client.fetchMessageByUid(folder, uid, { source: !!options?.source });
127
+ const msg = await this.withRetry(() => this.client.fetchMessageByUid(folder, uid, { source: !!options?.source }), `fetchOne(${folder}/${uid})`);
91
128
  return msg ? toProviderMessage(msg) : null;
92
129
  }
93
130
  async getUids(folder) {
94
- return this.client.getUids(folder);
131
+ return this.withRetry(() => this.client.getUids(folder), `getUids(${folder})`);
95
132
  }
96
133
  async close() {
97
134
  try {