@bobfrankston/mailx 1.0.55 → 1.0.61

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.
@@ -13,9 +13,8 @@
13
13
  "@bobfrankston/mailx-store": "file:../mailx-store",
14
14
  "@bobfrankston/mailx-imap": "file:../mailx-imap",
15
15
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
16
- "express": "^4.21.0",
17
- "mailparser": "^3.7.2",
18
- "nodemailer": "^7.0.0"
16
+ "@bobfrankston/mailx-service": "file:../mailx-service",
17
+ "express": "^4.21.0"
19
18
  },
20
19
  "devDependencies": {
21
20
  "@types/express": "^5.0.0",
@@ -30,6 +30,8 @@ export declare class ImapManager extends EventEmitter {
30
30
  constructor(db: MailxDB);
31
31
  /** Get OAuth access token for an account (for SMTP auth) */
32
32
  getOAuthToken(accountId: string): Promise<string | null>;
33
+ /** Force re-authentication for an OAuth account — deletes cached token, triggers browser consent */
34
+ reauthenticate(accountId: string): Promise<boolean>;
33
35
  /** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
34
36
  deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
35
37
  /** Search messages on the IMAP server — returns matching UIDs */
@@ -103,6 +105,7 @@ export declare class ImapManager extends EventEmitter {
103
105
  /** Process Outbox — send pending messages with flag-based interlock */
104
106
  processOutbox(accountId: string): Promise<void>;
105
107
  /** Start background Outbox worker — runs immediately then every 10 seconds */
108
+ private outboxBackoff;
106
109
  startOutboxWorker(): void;
107
110
  /** Stop Outbox worker */
108
111
  stopOutboxWorker(): void;
@@ -72,6 +72,40 @@ export class ImapManager extends EventEmitter {
72
72
  return null;
73
73
  return config.tokenProvider();
74
74
  }
75
+ /** Force re-authentication for an OAuth account — deletes cached token, triggers browser consent */
76
+ async reauthenticate(accountId) {
77
+ const settings = loadSettings();
78
+ const account = settings.accounts.find(a => a.id === accountId);
79
+ if (!account)
80
+ return false;
81
+ // Delete cached tokens
82
+ const accountDir = account.imap.user.replace(/[@.]/g, "_");
83
+ const tokenDir = path.join(getConfigDir(), "tokens", accountDir);
84
+ for (const f of ["token.json", "contacts-token.json"]) {
85
+ const p = path.join(tokenDir, f);
86
+ if (fs.existsSync(p)) {
87
+ fs.unlinkSync(p);
88
+ console.log(` [reauth] Deleted ${p}`);
89
+ }
90
+ }
91
+ // Re-register the account to get a fresh config with new tokenProvider
92
+ this.configs.delete(accountId);
93
+ await this.addAccount(account);
94
+ // Trigger the OAuth flow by requesting a token
95
+ try {
96
+ const token = await this.getOAuthToken(accountId);
97
+ if (token) {
98
+ console.log(` [reauth] ${accountId}: success`);
99
+ // Trigger a sync now that auth works
100
+ this.syncInbox().catch(() => { });
101
+ return true;
102
+ }
103
+ }
104
+ catch (e) {
105
+ console.error(` [reauth] ${accountId}: ${e.message}`);
106
+ }
107
+ return false;
108
+ }
75
109
  /** Delete a message directly on the IMAP server (for stuck outbox messages not in local DB) */
76
110
  async deleteOnServer(accountId, folderPath, uid) {
77
111
  const client = this.createClient(accountId);
@@ -126,6 +160,17 @@ export class ImapManager extends EventEmitter {
126
160
  this.configs.set(account.id, config);
127
161
  // Register account in DB
128
162
  this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
163
+ // Pre-validate OAuth token (so browser consent happens now, not during a timed sync)
164
+ if (config.tokenProvider) {
165
+ try {
166
+ await config.tokenProvider();
167
+ console.log(` [auth] ${account.id}: token valid`);
168
+ }
169
+ catch (e) {
170
+ console.error(` [auth] ${account.id}: ${e.message}`);
171
+ this.emit("accountError", account.id, e.message, "Re-authenticate: click the button below or run mailx -setup");
172
+ }
173
+ }
129
174
  }
130
175
  /** Sync folder list for an account */
131
176
  async syncFolders(accountId, client) {
@@ -278,24 +323,35 @@ export class ImapManager extends EventEmitter {
278
323
  }
279
324
  if (newCount > 0)
280
325
  console.log(` stored ${newCount} new messages`);
281
- // Remove messages deleted on the server
326
+ // Remove messages deleted on the server (skip on first sync — nothing to reconcile)
282
327
  let deletedCount = 0;
283
- try {
284
- const serverUids = new Set(await client.getUids(folder.path));
285
- const localUids = this.db.getUidsForFolder(accountId, folderId);
286
- for (const uid of localUids) {
287
- if (!serverUids.has(uid)) {
288
- this.db.deleteMessage(accountId, uid);
289
- // Also remove cached body
290
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
291
- deletedCount++;
328
+ if (!firstSync) {
329
+ let delClient = null;
330
+ try {
331
+ delClient = this.createClient(accountId);
332
+ const serverUids = new Set(await delClient.getUids(folder.path));
333
+ const localUids = this.db.getUidsForFolder(accountId, folderId);
334
+ for (const uid of localUids) {
335
+ if (!serverUids.has(uid)) {
336
+ this.db.deleteMessage(accountId, uid);
337
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
338
+ deletedCount++;
339
+ }
292
340
  }
341
+ if (deletedCount > 0)
342
+ console.log(` removed ${deletedCount} deleted messages`);
343
+ await delClient.logout();
344
+ }
345
+ catch (e) {
346
+ console.error(` deletion sync error: ${e.message}`);
347
+ }
348
+ finally {
349
+ if (delClient)
350
+ try {
351
+ await delClient.logout();
352
+ }
353
+ catch { /* ignore */ }
293
354
  }
294
- if (deletedCount > 0)
295
- console.log(` removed ${deletedCount} deleted messages`);
296
- }
297
- catch (e) {
298
- console.error(` deletion sync error: ${e.message}`);
299
355
  }
300
356
  // Update folder counts from local DB (after deletions + additions)
301
357
  const result = this.db.getMessages({ accountId, folderId, page: 1, pageSize: 1 });
@@ -367,6 +423,14 @@ export class ImapManager extends EventEmitter {
367
423
  catch (e) {
368
424
  this.emit("syncError", accountId, e.message);
369
425
  console.error(`Sync error for ${accountId}: ${e.message}`);
426
+ // Classify error and emit user-facing hint
427
+ const msg = e.message || "";
428
+ if (msg.includes("token") || msg.includes("auth") || msg.includes("Command failed")) {
429
+ this.emit("accountError", accountId, msg, "Re-authenticate: mailx -setup or check credentials");
430
+ }
431
+ else if (msg.includes("timeout")) {
432
+ this.emit("accountError", accountId, msg, "Server slow or unreachable — will retry");
433
+ }
370
434
  }
371
435
  finally {
372
436
  if (client)
@@ -376,17 +440,32 @@ export class ImapManager extends EventEmitter {
376
440
  catch { /* ignore */ }
377
441
  }
378
442
  }
379
- // Phase 2: Sync remaining folders for all accounts
443
+ // Phase 2: Sync remaining folders priority order, skip Trash subfolders on first sync
444
+ const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
380
445
  for (const [accountId, folders] of accountFolders) {
446
+ // Sort: sent/drafts first, then regular, then trash subfolders last
447
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
448
+ remaining.sort((a, b) => {
449
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
450
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
451
+ return pa - pb;
452
+ });
381
453
  let client = null;
382
- for (const folder of folders) {
383
- if (folder.specialUse === "inbox")
384
- continue; // already synced
454
+ for (const folder of remaining) {
455
+ // Skip Trash subfolders on first sync — they're large and low priority
456
+ const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
457
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
458
+ if (isTrashChild && highestUid === 0) {
459
+ console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
460
+ continue;
461
+ }
462
+ // Longer timeout for folders we know are large (Trash, first sync)
463
+ const timeout = highestUid === 0 ? 180000 : 60000;
385
464
  try {
386
465
  client = this.createClient(accountId);
387
466
  await Promise.race([
388
467
  this.syncFolder(accountId, folder.id, client),
389
- new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
468
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s)`)), timeout))
390
469
  ]);
391
470
  await client.logout();
392
471
  client = null;
@@ -1054,35 +1133,33 @@ export class ImapManager extends EventEmitter {
1054
1133
  }
1055
1134
  }
1056
1135
  /** Start background Outbox worker — runs immediately then every 10 seconds */
1136
+ outboxBackoff = new Map(); // accountId → next retry time
1057
1137
  startOutboxWorker() {
1058
1138
  if (this.outboxInterval)
1059
1139
  return;
1060
- // Run once immediately on startup
1061
1140
  const processAll = async () => {
1141
+ const now = Date.now();
1062
1142
  for (const [accountId] of this.configs) {
1143
+ // Skip accounts in backoff
1144
+ const retryAfter = this.outboxBackoff.get(accountId) || 0;
1145
+ if (now < retryAfter)
1146
+ continue;
1063
1147
  try {
1064
1148
  await this.processLocalQueue(accountId);
1065
1149
  await this.processOutbox(accountId);
1150
+ this.outboxBackoff.delete(accountId); // success — clear backoff
1066
1151
  }
1067
1152
  catch (e) {
1068
- console.error(` [outbox] Error for ${accountId}: ${e.message}`);
1153
+ // Exponential backoff: 30s, 60s, 120s, max 5min
1154
+ const prev = this.outboxBackoff.get(accountId);
1155
+ const delay = prev ? Math.min((now - prev + 30000) * 2, 300000) : 30000;
1156
+ this.outboxBackoff.set(accountId, now + delay);
1157
+ console.error(` [outbox] Error for ${accountId}: ${e.message} (retry in ${Math.round(delay / 1000)}s)`);
1069
1158
  }
1070
1159
  }
1071
1160
  };
1072
- setTimeout(() => processAll(), 3000); // 3s after startup (let connections settle)
1073
- this.outboxInterval = setInterval(async () => {
1074
- for (const [accountId] of this.configs) {
1075
- try {
1076
- // First move any local queued messages to IMAP
1077
- await this.processLocalQueue(accountId);
1078
- // Then process IMAP Outbox
1079
- await this.processOutbox(accountId);
1080
- }
1081
- catch (e) {
1082
- console.error(` [outbox] Error for ${accountId}: ${e.message}`);
1083
- }
1084
- }
1085
- }, 10000);
1161
+ setTimeout(() => processAll(), 3000);
1162
+ this.outboxInterval = setInterval(processAll, 10000);
1086
1163
  }
1087
1164
  /** Stop Outbox worker */
1088
1165
  stopOutboxWorker() {
@@ -199,6 +199,9 @@ imapManager.on("folderCountsChanged", (accountId, counts) => {
199
199
  imapManager.on("syncError", (accountId, error) => {
200
200
  broadcast({ type: "error", message: `${accountId}: ${error}` });
201
201
  });
202
+ imapManager.on("accountError", (accountId, error, hint) => {
203
+ broadcast({ type: "accountError", accountId, error, hint });
204
+ });
202
205
  // ── Startup ──
203
206
  async function start() {
204
207
  console.log("mailx server starting...");
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @bobfrankston/mailx-service
3
+ * Pure business logic — no HTTP, no Express.
4
+ * Both the Express API (mailx-api) and the Android bridge call these functions.
5
+ */
6
+ import { MailxDB } from "@bobfrankston/mailx-store";
7
+ import { ImapManager } from "@bobfrankston/mailx-imap";
8
+ import type { Folder } from "@bobfrankston/mailx-types";
9
+ export declare function sanitizeHtml(html: string): {
10
+ html: string;
11
+ hasRemoteContent: boolean;
12
+ };
13
+ export declare class MailxService {
14
+ private db;
15
+ private imapManager;
16
+ constructor(db: MailxDB, imapManager: ImapManager);
17
+ getAccounts(): any[];
18
+ getFolders(accountId: string): Folder[];
19
+ getUnifiedInbox(page?: number, pageSize?: number): any;
20
+ getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string): any;
21
+ getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
22
+ updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
23
+ allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): void;
24
+ search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
25
+ rebuildSearchIndex(): number;
26
+ getSyncPending(): {
27
+ pending: number;
28
+ };
29
+ syncAll(): Promise<void>;
30
+ syncAccount(accountId: string): Promise<void>;
31
+ /** Force re-authentication for an account (deletes token, opens browser consent) */
32
+ reauthenticate(accountId: string): Promise<boolean>;
33
+ send(msg: any): Promise<void>;
34
+ deleteMessage(accountId: string, uid: number): Promise<void>;
35
+ moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
36
+ undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
37
+ deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
38
+ createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
39
+ renameFolder(accountId: string, folderId: number, newName: string): Promise<void>;
40
+ deleteFolder(accountId: string, folderId: number): Promise<void>;
41
+ markFolderRead(folderId: number): void;
42
+ emptyFolder(accountId: string, folderId: number): Promise<void>;
43
+ getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{
44
+ content: Buffer;
45
+ contentType: string;
46
+ filename: string;
47
+ }>;
48
+ saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number): Promise<number | null>;
49
+ deleteDraft(accountId: string, draftUid: number): Promise<void>;
50
+ searchContacts(query: string): any[];
51
+ syncGoogleContacts(): Promise<void>;
52
+ seedContacts(): number;
53
+ getSettings(): any;
54
+ saveSettings(settings: any): void;
55
+ getStorageInfo(): {
56
+ provider: string;
57
+ mode: string;
58
+ };
59
+ }
60
+ //# sourceMappingURL=index.d.ts.map