@bobfrankston/mailx 1.0.172 → 1.0.174
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/bin/mailx.js +3 -0
- package/client/app.js +8 -2
- package/client/components/message-viewer.js +8 -1
- package/client/compose/compose.js +10 -1
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +2 -0
- package/packages/mailx-imap/index.js +35 -1
- package/packages/mailx-imap/providers/gmail-api.js +37 -20
- package/packages/mailx-store/db.d.ts +5 -0
- package/packages/mailx-store/db.js +4 -0
package/bin/mailx.js
CHANGED
|
@@ -740,10 +740,13 @@ async function main() {
|
|
|
740
740
|
console.log(`${reason} — shutting down`);
|
|
741
741
|
imapManager.stopPeriodicSync();
|
|
742
742
|
imapManager.stopOutboxWorker();
|
|
743
|
+
// 3s hard timeout — don't hang on broken IMAP connections
|
|
744
|
+
const forceExit = setTimeout(() => { console.log("Forced exit"); process.exit(0); }, 3000);
|
|
743
745
|
try {
|
|
744
746
|
await imapManager.shutdown();
|
|
745
747
|
}
|
|
746
748
|
catch { /* proceed */ }
|
|
749
|
+
clearTimeout(forceExit);
|
|
747
750
|
db.close();
|
|
748
751
|
process.exit(0);
|
|
749
752
|
}
|
package/client/app.js
CHANGED
|
@@ -286,8 +286,14 @@ async function openCompose(mode) {
|
|
|
286
286
|
}
|
|
287
287
|
// Store init data for compose window to pick up
|
|
288
288
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
289
|
-
//
|
|
290
|
-
|
|
289
|
+
// IPC mode: navigate in same window (popups don't have custom protocol)
|
|
290
|
+
// HTTP mode: open as popup window
|
|
291
|
+
if (typeof mailxapi !== "undefined") {
|
|
292
|
+
window.location.href = "compose/compose.html";
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
296
|
+
}
|
|
291
297
|
}
|
|
292
298
|
function quoteBody(msg) {
|
|
293
299
|
const date = new Date(msg.date).toLocaleString();
|
|
@@ -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
|
}
|
|
@@ -133,7 +135,12 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
133
135
|
draftFolderId: msg.folderId,
|
|
134
136
|
};
|
|
135
137
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
136
|
-
window.
|
|
138
|
+
if (typeof window.mailxapi !== "undefined") {
|
|
139
|
+
window.location.href = "compose/compose.html";
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
|
|
143
|
+
}
|
|
137
144
|
};
|
|
138
145
|
}
|
|
139
146
|
else {
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createEditor } from "./editor.js";
|
|
7
7
|
import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
|
|
8
|
+
/** Close compose — navigate back in IPC mode, window.close() in HTTP mode */
|
|
9
|
+
function closeCompose() {
|
|
10
|
+
if (typeof window.mailxapi !== "undefined") {
|
|
11
|
+
window.location.href = "../index.html";
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
closeCompose();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
8
17
|
// ── Load editor scripts dynamically ──
|
|
9
18
|
function loadScript(src) {
|
|
10
19
|
return new Promise((resolve, reject) => {
|
|
@@ -349,7 +358,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
349
358
|
if (draftUid) {
|
|
350
359
|
deleteDraft(getFromAccountId(), draftUid).catch(() => { });
|
|
351
360
|
}
|
|
352
|
-
|
|
361
|
+
closeCompose();
|
|
353
362
|
}
|
|
354
363
|
catch (e) {
|
|
355
364
|
btn.disabled = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.174",
|
|
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.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.5",
|
|
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.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.223",
|
|
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 */
|
|
@@ -291,8 +291,15 @@ export class ImapManager extends EventEmitter {
|
|
|
291
291
|
const client = this.opsClients.get(accountId);
|
|
292
292
|
this.opsClients.delete(accountId);
|
|
293
293
|
if (client) {
|
|
294
|
+
// Force-close: don't wait for LOGOUT on a possibly dead socket
|
|
294
295
|
try {
|
|
295
|
-
|
|
296
|
+
const timeout = new Promise(r => setTimeout(r, 2000));
|
|
297
|
+
await Promise.race([(client._realLogout || client.logout)(), timeout]);
|
|
298
|
+
}
|
|
299
|
+
catch { /* */ }
|
|
300
|
+
// Destroy underlying socket if still open
|
|
301
|
+
try {
|
|
302
|
+
client.destroy?.();
|
|
296
303
|
}
|
|
297
304
|
catch { /* */ }
|
|
298
305
|
console.log(` [conn] ${accountId}: disconnected`);
|
|
@@ -731,6 +738,12 @@ export class ImapManager extends EventEmitter {
|
|
|
731
738
|
// Sync all accounts in parallel — each manages its own connection
|
|
732
739
|
const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
|
|
733
740
|
await Promise.allSettled(syncPromises);
|
|
741
|
+
// Background body prefetch — after sync, fetch bodies for messages that don't have them
|
|
742
|
+
if (getPrefetch()) {
|
|
743
|
+
for (const accountId of this.configs.keys()) {
|
|
744
|
+
this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
734
747
|
}
|
|
735
748
|
/** Sync a single account — manages its own connection lifecycle */
|
|
736
749
|
async syncAccount(accountId, priorityOrder) {
|
|
@@ -1230,6 +1243,27 @@ export class ImapManager extends EventEmitter {
|
|
|
1230
1243
|
return null;
|
|
1231
1244
|
}
|
|
1232
1245
|
}
|
|
1246
|
+
/** Background body prefetch — download bodies for messages that don't have them */
|
|
1247
|
+
async prefetchBodies(accountId) {
|
|
1248
|
+
const missing = this.db.getMessagesWithoutBody(accountId, 25);
|
|
1249
|
+
if (missing.length === 0)
|
|
1250
|
+
return;
|
|
1251
|
+
console.log(` [prefetch] ${accountId}: ${missing.length} bodies to fetch`);
|
|
1252
|
+
let fetched = 0;
|
|
1253
|
+
for (const msg of missing) {
|
|
1254
|
+
try {
|
|
1255
|
+
const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
|
|
1256
|
+
if (result)
|
|
1257
|
+
fetched++;
|
|
1258
|
+
}
|
|
1259
|
+
catch (e) {
|
|
1260
|
+
console.error(` [prefetch] ${accountId}/${msg.uid}: ${e.message}`);
|
|
1261
|
+
break; // Stop on error — don't hammer a broken connection
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (fetched > 0)
|
|
1265
|
+
console.log(` [prefetch] ${accountId}: ${fetched} bodies cached`);
|
|
1266
|
+
}
|
|
1233
1267
|
/** Get the body store for direct access */
|
|
1234
1268
|
getBodyStore() {
|
|
1235
1269
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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;
|