@bobfrankston/mailx 1.0.172 → 1.0.173

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.
@@ -82,6 +82,8 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
82
82
  if (unsubUrl) {
83
83
  unsubBtn.hidden = false;
84
84
  unsubBtn.href = unsubUrl;
85
+ unsubBtn.textContent = "Unsubscribe";
86
+ unsubBtn.title = unsubUrl;
85
87
  unsubBtn.target = "_blank";
86
88
  unsubBtn.rel = "noopener noreferrer";
87
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.172",
3
+ "version": "1.0.173",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.1",
25
25
  "@bobfrankston/miscinfo": "^1.0.7",
26
26
  "@bobfrankston/oauthsupport": "^1.0.20",
27
- "@bobfrankston/msger": "^0.1.221",
27
+ "@bobfrankston/msger": "^0.1.222",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -129,6 +129,8 @@ export declare class ImapManager extends EventEmitter {
129
129
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
130
130
  /** Fetch message body via Gmail/Outlook API */
131
131
  private fetchMessageBodyViaApi;
132
+ /** Background body prefetch — download bodies for messages that don't have them */
133
+ private prefetchBodies;
132
134
  /** Get the body store for direct access */
133
135
  getBodyStore(): FileMessageStore;
134
136
  /** Bulk trash messages — local-first, single IMAP connection for all */
@@ -731,6 +731,12 @@ export class ImapManager extends EventEmitter {
731
731
  // Sync all accounts in parallel — each manages its own connection
732
732
  const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
733
733
  await Promise.allSettled(syncPromises);
734
+ // Background body prefetch — after sync, fetch bodies for messages that don't have them
735
+ if (getPrefetch()) {
736
+ for (const accountId of this.configs.keys()) {
737
+ this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
738
+ }
739
+ }
734
740
  }
735
741
  /** Sync a single account — manages its own connection lifecycle */
736
742
  async syncAccount(accountId, priorityOrder) {
@@ -1230,6 +1236,27 @@ export class ImapManager extends EventEmitter {
1230
1236
  return null;
1231
1237
  }
1232
1238
  }
1239
+ /** Background body prefetch — download bodies for messages that don't have them */
1240
+ async prefetchBodies(accountId) {
1241
+ const missing = this.db.getMessagesWithoutBody(accountId, 25);
1242
+ if (missing.length === 0)
1243
+ return;
1244
+ console.log(` [prefetch] ${accountId}: ${missing.length} bodies to fetch`);
1245
+ let fetched = 0;
1246
+ for (const msg of missing) {
1247
+ try {
1248
+ const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
1249
+ if (result)
1250
+ fetched++;
1251
+ }
1252
+ catch (e) {
1253
+ console.error(` [prefetch] ${accountId}/${msg.uid}: ${e.message}`);
1254
+ break; // Stop on error — don't hammer a broken connection
1255
+ }
1256
+ }
1257
+ if (fetched > 0)
1258
+ console.log(` [prefetch] ${accountId}: ${fetched} bodies cached`);
1259
+ }
1233
1260
  /** Get the body store for direct access */
1234
1261
  getBodyStore() {
1235
1262
  return this.bodyStore;
@@ -45,19 +45,29 @@ export class GmailApiProvider {
45
45
  }
46
46
  async fetch(path, options = {}) {
47
47
  const token = await this.tokenProvider();
48
- const res = await globalThis.fetch(`${API}${path}`, {
49
- ...options,
50
- headers: {
51
- "Authorization": `Bearer ${token}`,
52
- "Content-Type": "application/json",
53
- ...options.headers,
54
- },
55
- });
56
- if (!res.ok) {
57
- const err = await res.text().catch(() => "");
58
- throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
48
+ for (let attempt = 0; attempt < 3; attempt++) {
49
+ const res = await globalThis.fetch(`${API}${path}`, {
50
+ ...options,
51
+ headers: {
52
+ "Authorization": `Bearer ${token}`,
53
+ "Content-Type": "application/json",
54
+ ...options.headers,
55
+ },
56
+ });
57
+ if (res.status === 429) {
58
+ // Rate limited back off and retry
59
+ const delay = (attempt + 1) * 2000;
60
+ console.log(` [gmail] Rate limited, waiting ${delay / 1000}s...`);
61
+ await new Promise(r => setTimeout(r, delay));
62
+ continue;
63
+ }
64
+ if (!res.ok) {
65
+ const err = await res.text().catch(() => "");
66
+ throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
67
+ }
68
+ return res.json();
59
69
  }
60
- return res.json();
70
+ throw new Error("Gmail API: rate limited after 3 retries");
61
71
  }
62
72
  async listFolders() {
63
73
  const data = await this.fetch("/labels");
@@ -106,15 +116,19 @@ export class GmailApiProvider {
106
116
  const all = [];
107
117
  const chunkSize = options.source ? 10 : 50; // Smaller chunks for full bodies
108
118
  const format = options.source ? "raw" : "metadata";
109
- const metadataHeaders = "From,To,Cc,Subject,Message-ID,Date";
110
119
  for (let i = 0; i < ids.length; i += chunkSize) {
111
120
  const chunk = ids.slice(i, i + chunkSize);
112
- const messages = await Promise.all(chunk.map(id => {
121
+ // Sequential fetches to avoid Gmail 429 rate limits
122
+ const messages = [];
123
+ for (const id of chunk) {
113
124
  const params = new URLSearchParams({ format });
114
- if (format === "metadata")
115
- params.set("metadataHeaders", metadataHeaders);
116
- return this.fetch(`/messages/${id}?${params}`);
117
- }));
125
+ if (format === "metadata") {
126
+ for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
127
+ params.append("metadataHeaders", h);
128
+ }
129
+ }
130
+ messages.push(await this.fetch(`/messages/${id}?${params}`));
131
+ }
118
132
  const parsed = messages.map(msg => this.parseMessage(msg, options));
119
133
  all.push(...parsed);
120
134
  if (onChunk)
@@ -188,8 +202,11 @@ export class GmailApiProvider {
188
202
  return null;
189
203
  const format = options.source ? "raw" : "metadata";
190
204
  const params = new URLSearchParams({ format });
191
- if (format === "metadata")
192
- params.set("metadataHeaders", "From,To,Cc,Subject,Message-ID,Date");
205
+ if (format === "metadata") {
206
+ for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
207
+ params.append("metadataHeaders", h);
208
+ }
209
+ }
193
210
  const msg = await this.fetch(`/messages/${id}?${params}`);
194
211
  return this.parseMessage(msg, options);
195
212
  }
@@ -58,6 +58,11 @@ export declare class MailxDB {
58
58
  getMessageBodyPath(accountId: string, uid: number): string;
59
59
  updateMessageFlags(accountId: string, uid: number, flags: string[]): void;
60
60
  updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
61
+ /** Get messages without cached bodies (for background prefetch) */
62
+ getMessagesWithoutBody(accountId: string, limit?: number): {
63
+ uid: number;
64
+ folderId: number;
65
+ }[];
61
66
  getHighestUid(accountId: string, folderId: number): number;
62
67
  getOldestDate(accountId: string, folderId: number): number;
63
68
  getMessageCount(accountId: string, folderId: number): number;
@@ -320,6 +320,10 @@ export class MailxDB {
320
320
  updateBodyPath(accountId, uid, bodyPath) {
321
321
  this.db.prepare("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?").run(bodyPath, accountId, uid);
322
322
  }
323
+ /** Get messages without cached bodies (for background prefetch) */
324
+ getMessagesWithoutBody(accountId, limit = 50) {
325
+ return this.db.prepare("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY date DESC LIMIT ?").all(accountId, limit);
326
+ }
323
327
  getHighestUid(accountId, folderId) {
324
328
  const r = this.db.prepare("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
325
329
  return r?.maxUid || 0;