@bobfrankston/mailx 1.0.244 → 1.0.251

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.
@@ -17,6 +17,8 @@ import { WebMailxService } from "./web-service.js";
17
17
  import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, getDeviceId, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
18
18
  import { GmailApiWebProvider } from "./gmail-api-web.js";
19
19
  import { ImapWebProvider } from "./imap-web-provider.js";
20
+ import { SmtpClient } from "@bobfrankston/smtp-direct";
21
+ import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
20
22
  // ── State ──
21
23
  let db;
22
24
  let bodyStore;
@@ -121,7 +123,12 @@ class AndroidSyncManager {
121
123
  return 1;
122
124
  return 0;
123
125
  });
124
- for (const folder of sorted.slice(0, 5)) {
126
+ // Sync every folder, not just the first five — the old slice(0, 5)
127
+ // meant subfolders past the cutoff (e.g. _spam, custom labels)
128
+ // never picked up moves made on other clients, and those moves
129
+ // also stayed visible in the source folder because reconcile
130
+ // (below in syncFolder) never ran for the target.
131
+ for (const folder of sorted) {
125
132
  try {
126
133
  await this.syncFolder(account.id, folder.id);
127
134
  }
@@ -185,6 +192,48 @@ class AndroidSyncManager {
185
192
  this.db.recalcFolderCounts(folderId);
186
193
  emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
187
194
  }
195
+ // Reconcile deletions — messages present locally but no longer on the
196
+ // server (moved away, deleted on another client). Without this, the
197
+ // Android client never drops removed rows: e.g., moves to _spam from
198
+ // another client showed up in _spam (next time it synced) but never
199
+ // disappeared from INBOX.
200
+ //
201
+ // Same safety guards as the desktop reconcile path:
202
+ // - Skip if the server list is empty but local has messages (likely
203
+ // a transient API failure that returned []).
204
+ // - Refuse to delete more than 50% of local in one pass — better to
205
+ // keep phantoms than to wipe a folder on a sync bug. Rebuild local
206
+ // cache fixes a stuck state.
207
+ try {
208
+ const serverUidsArr = await provider.getUids(folder.path);
209
+ const serverUids = new Set(serverUidsArr);
210
+ const localUids = this.db.getUidsForFolder(accountId, folderId);
211
+ if (serverUidsArr.length === 0 && localUids.length > 0) {
212
+ console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
213
+ }
214
+ else {
215
+ const toDelete = localUids.filter(uid => !serverUids.has(uid));
216
+ const RECONCILE_DELETE_THRESHOLD = 0.5;
217
+ if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
218
+ console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
219
+ }
220
+ else {
221
+ for (const uid of toDelete) {
222
+ this.db.deleteMessage(accountId, uid);
223
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
224
+ }
225
+ if (toDelete.length > 0) {
226
+ console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
227
+ this.db.recalcFolderCounts(folderId);
228
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
229
+ }
230
+ }
231
+ }
232
+ }
233
+ catch (e) {
234
+ console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
235
+ }
236
+ emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
188
237
  emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
189
238
  }
190
239
  storeProviderMessages(accountId, folderId, messages) {
@@ -286,8 +335,122 @@ class AndroidSyncManager {
286
335
  async undeleteMessage(accountId, uid, folderId) {
287
336
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
288
337
  }
289
- queueOutgoingLocal(accountId, _rawMessage) {
290
- console.log(`[send] Queued outgoing for ${accountId}`);
338
+ queueOutgoingLocal(accountId, rawMessage) {
339
+ // Two paths, both real (no stubs that pretend success — see programming.md
340
+ // rule "Stubs MUST NOT appear successful"):
341
+ // - Gmail accounts: POST to users.messages.send (Gmail handles SMTP +
342
+ // auto-files into Sent label).
343
+ // - Non-Gmail accounts: smtp-direct over BridgeTransport (mailxapi.tcp).
344
+ // Caller (web-service.send) is sync-returning; we kick off the network
345
+ // request and surface success/failure via events. Compose UI listens for
346
+ // sendError/sendComplete.
347
+ const provider = this.getProvider(accountId);
348
+ if (provider && typeof provider.sendRaw === "function") {
349
+ provider.sendRaw(rawMessage)
350
+ .then((result) => {
351
+ console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
352
+ emitEvent({ type: "sendComplete", accountId, messageId: result.id });
353
+ })
354
+ .catch((e) => {
355
+ console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
356
+ emitEvent({ type: "sendError", accountId, error: e.message });
357
+ });
358
+ return;
359
+ }
360
+ // Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
361
+ // stored account JSON.
362
+ const accounts = db.getAccountConfigs();
363
+ const row = accounts.find(a => a.id === accountId);
364
+ if (!row) {
365
+ const e = "Unknown account";
366
+ console.error(`[send] ${accountId}: ${e}`);
367
+ emitEvent({ type: "sendError", accountId, error: e });
368
+ throw new Error(e);
369
+ }
370
+ let account;
371
+ try {
372
+ account = JSON.parse(row.configJson);
373
+ }
374
+ catch {
375
+ const e = "Account config malformed";
376
+ emitEvent({ type: "sendError", accountId, error: e });
377
+ throw new Error(e);
378
+ }
379
+ if (!account.smtp) {
380
+ const e = "No SMTP config for this account";
381
+ console.error(`[send] ${accountId}: ${e}`);
382
+ emitEvent({ type: "sendError", accountId, error: e });
383
+ throw new Error(e);
384
+ }
385
+ // Fire async — same pattern as Gmail path above.
386
+ this.sendViaSmtpDirect(accountId, account, rawMessage)
387
+ .then((result) => {
388
+ console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
389
+ emitEvent({ type: "sendComplete", accountId });
390
+ })
391
+ .catch((e) => {
392
+ console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
393
+ emitEvent({ type: "sendError", accountId, error: e.message });
394
+ });
395
+ }
396
+ /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
397
+ async sendViaSmtpDirect(accountId, account, raw) {
398
+ const SMTP_PORT_STARTTLS = 587;
399
+ const SMTP_PORT_IMPLICIT_TLS = 465;
400
+ const smtp = account.smtp;
401
+ const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
402
+ const smtpHost = smtp.host || account.imap?.host;
403
+ if (!smtpHost)
404
+ throw new Error("No SMTP host");
405
+ // Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
406
+ const smtpUser = smtp.user || account.imap?.user || account.email;
407
+ const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
408
+ let auth;
409
+ if (authType === "password") {
410
+ const pass = smtp.password || account.imap?.password;
411
+ if (!pass)
412
+ throw new Error("SMTP password not configured");
413
+ auth = { method: "PLAIN", user: smtpUser, pass };
414
+ }
415
+ else if (authType === "oauth2") {
416
+ const tp = this.tokenProviders.get(accountId);
417
+ if (!tp)
418
+ throw new Error("OAuth token provider not registered");
419
+ const token = await tp();
420
+ auth = { method: "XOAUTH2", user: smtpUser, token };
421
+ }
422
+ // Recipients from headers
423
+ const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
424
+ const toMatch = raw.match(/^To:\s*(.+)$/mi);
425
+ const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
426
+ const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
427
+ const fromMatch = raw.match(/^From:\s*(.+)$/mi);
428
+ const recipients = [
429
+ ...(toMatch ? parseAddrs(toMatch[1]) : []),
430
+ ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
431
+ ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
432
+ ];
433
+ const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
434
+ if (recipients.length === 0)
435
+ throw new Error("No recipients");
436
+ const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
437
+ const client = new SmtpClient({
438
+ host: smtpHost,
439
+ port: smtpPort,
440
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
441
+ auth,
442
+ localname: "mailx-android",
443
+ }, () => new BridgeTcpTransport());
444
+ try {
445
+ await client.connect();
446
+ return await client.sendMail({ from: sender, to: recipients }, rawToSend);
447
+ }
448
+ finally {
449
+ try {
450
+ await client.quit();
451
+ }
452
+ catch { /* ignore */ }
453
+ }
291
454
  }
292
455
  async saveDraft(_accountId, _raw, _prevUid, _draftId) {
293
456
  return null;
@@ -618,6 +781,13 @@ export async function initAndroid() {
618
781
  setTimeout(() => {
619
782
  syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
620
783
  }, 1000);
784
+ // Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
785
+ const SYNC_INTERVAL_MS = 2 * 60 * 1000;
786
+ setInterval(() => {
787
+ console.log("[sync] periodic poll");
788
+ vlog("periodic sync poll");
789
+ syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
790
+ }, SYNC_INTERVAL_MS);
621
791
  console.log("[android] Initialization complete");
622
792
  emitEvent({ type: "connected" });
623
793
  }
@@ -24,6 +24,13 @@ export declare class GmailApiWebProvider implements MailProvider {
24
24
  fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
25
  getUids(folder: string): Promise<number[]>;
26
26
  close(): Promise<void>;
27
+ /** Send an RFC 2822 message via Gmail API users.messages.send. The server
28
+ * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
29
+ * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
30
+ sendRaw(rawRfc822: string): Promise<{
31
+ id: string;
32
+ threadId: string;
33
+ }>;
27
34
  private folderToLabel;
28
35
  private formatDate;
29
36
  }
@@ -227,6 +227,18 @@ export class GmailApiWebProvider {
227
227
  return ids.map(idToUid);
228
228
  }
229
229
  async close() { }
230
+ /** Send an RFC 2822 message via Gmail API users.messages.send. The server
231
+ * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
232
+ * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
233
+ async sendRaw(rawRfc822) {
234
+ const b64 = btoa(unescape(encodeURIComponent(rawRfc822)))
235
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
236
+ const data = await this.apiFetch("/messages/send", {
237
+ method: "POST",
238
+ body: JSON.stringify({ raw: b64 }),
239
+ });
240
+ return { id: data.id, threadId: data.threadId };
241
+ }
230
242
  folderToLabel(path) {
231
243
  const lower = path.toLowerCase();
232
244
  if (lower === "inbox")
@@ -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 {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,6 +10,9 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "file:../mailx-types",
13
+ "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
14
+ "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
15
+ "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
13
16
  "sql.js": "^1.14.1"
14
17
  },
15
18
  "repository": {
@@ -159,6 +159,13 @@ export type WsEvent = {
159
159
  total: number;
160
160
  unread: number;
161
161
  }>;
162
+ } | {
163
+ type: "folderSynced";
164
+ accountId: string;
165
+ entries: {
166
+ folderId: number;
167
+ syncedAt: number;
168
+ }[];
162
169
  } | {
163
170
  type: "syncProgress";
164
171
  accountId: string;
@@ -0,0 +1,4 @@
1
+ // Removed after one-shot smtp-direct test on 2026-04-13.
2
+ // Original sent a test message to test1@bob.ma via iecc submission.
3
+ // Result: 250 Accepted message qp 15437 (server queued for delivery).
4
+ // File overwritten because it had a plaintext password; safe to delete.