@bobfrankston/mailx 1.0.50 → 1.0.57
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/client/app.js +103 -41
- package/client/compose/compose.css +66 -0
- package/client/compose/compose.html +1 -2
- package/client/compose/compose.js +59 -21
- package/client/compose/editor.js +160 -0
- package/client/index.html +8 -0
- package/client/styles/components.css +2 -0
- package/package.json +1 -1
- package/packages/mailx-api/index.d.ts +2 -1
- package/packages/mailx-api/index.js +56 -505
- package/packages/mailx-api/package.json +2 -3
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +164 -83
- package/packages/mailx-service/index.d.ts +58 -0
- package/packages/mailx-service/index.js +456 -0
- package/packages/mailx-service/package.json +22 -0
- package/packages/mailx-settings/index.d.ts +1 -0
- package/packages/mailx-settings/index.js +1 -0
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -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
|
-
"
|
|
17
|
-
"
|
|
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",
|
|
@@ -49,6 +49,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
49
49
|
private _syncAll;
|
|
50
50
|
/** Sync just INBOX for each account (fast check for new mail) */
|
|
51
51
|
syncInbox(): Promise<void>;
|
|
52
|
+
/** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
|
|
53
|
+
* If message count changed, triggers a full inbox sync. */
|
|
54
|
+
private lastInboxCounts;
|
|
55
|
+
quickInboxCheck(): Promise<void>;
|
|
52
56
|
/** Start periodic sync */
|
|
53
57
|
startPeriodicSync(intervalMinutes: number): void;
|
|
54
58
|
/** Stop periodic sync */
|
|
@@ -170,6 +170,7 @@ export class ImapManager extends EventEmitter {
|
|
|
170
170
|
// Get the highest UID we already have for this folder
|
|
171
171
|
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
172
172
|
let messages;
|
|
173
|
+
const firstSync = highestUid === 0;
|
|
173
174
|
if (highestUid > 0) {
|
|
174
175
|
// Incremental: only fetch messages newer than what we have
|
|
175
176
|
const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: true });
|
|
@@ -184,18 +185,47 @@ export class ImapManager extends EventEmitter {
|
|
|
184
185
|
? new Date(Date.now() - historyDays * 86400000)
|
|
185
186
|
: new Date(0);
|
|
186
187
|
messages = await client.fetchMessageByDate(folder.path, startDate, new Date(), { source: true });
|
|
188
|
+
// Sort newest first so most recent messages appear in the UI immediately
|
|
189
|
+
messages.sort((a, b) => {
|
|
190
|
+
const da = a.date instanceof Date ? a.date.getTime() : (typeof a.date === "number" ? a.date : 0);
|
|
191
|
+
const db = b.date instanceof Date ? b.date.getTime() : (typeof b.date === "number" ? b.date : 0);
|
|
192
|
+
return db - da;
|
|
193
|
+
});
|
|
187
194
|
}
|
|
188
195
|
if (messages.length > 0)
|
|
189
196
|
console.log(` ${folder.path}: ${messages.length} new messages`);
|
|
190
197
|
let newCount = 0;
|
|
191
198
|
const batchSize = 50;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
+
for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
|
|
200
|
+
const batchEnd = Math.min(batchStart + batchSize, messages.length);
|
|
201
|
+
this.db.beginTransaction();
|
|
202
|
+
try {
|
|
203
|
+
for (let i = batchStart; i < batchEnd; i++) {
|
|
204
|
+
const msg = messages[i];
|
|
205
|
+
// Skip if we already have this UID
|
|
206
|
+
if (msg.uid <= highestUid) {
|
|
207
|
+
// But update flags in case they changed
|
|
208
|
+
const flags = [];
|
|
209
|
+
if (msg.seen)
|
|
210
|
+
flags.push("\\Seen");
|
|
211
|
+
if (msg.flagged)
|
|
212
|
+
flags.push("\\Flagged");
|
|
213
|
+
if (msg.answered)
|
|
214
|
+
flags.push("\\Answered");
|
|
215
|
+
if (msg.draft)
|
|
216
|
+
flags.push("\\Draft");
|
|
217
|
+
this.db.updateMessageFlags(accountId, msg.uid, flags);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Store body
|
|
221
|
+
const source = msg.source || "";
|
|
222
|
+
let bodyPath = "";
|
|
223
|
+
if (source) {
|
|
224
|
+
bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
|
|
225
|
+
}
|
|
226
|
+
// Parse for preview and attachment info
|
|
227
|
+
const parsed = await extractPreview(source);
|
|
228
|
+
// Build flags array
|
|
199
229
|
const flags = [];
|
|
200
230
|
if (msg.seen)
|
|
201
231
|
flags.push("\\Seen");
|
|
@@ -205,61 +235,49 @@ export class ImapManager extends EventEmitter {
|
|
|
205
235
|
flags.push("\\Answered");
|
|
206
236
|
if (msg.draft)
|
|
207
237
|
flags.push("\\Draft");
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
238
|
+
// Store metadata
|
|
239
|
+
this.db.upsertMessage({
|
|
240
|
+
accountId,
|
|
241
|
+
folderId,
|
|
242
|
+
uid: msg.uid,
|
|
243
|
+
messageId: msg.messageId || "",
|
|
244
|
+
inReplyTo: "",
|
|
245
|
+
references: [],
|
|
246
|
+
date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
|
|
247
|
+
subject: msg.subject || "",
|
|
248
|
+
from: toEmailAddress(msg.from?.[0] || {}),
|
|
249
|
+
to: toEmailAddresses(msg.to || []),
|
|
250
|
+
cc: toEmailAddresses(msg.cc || []),
|
|
251
|
+
flags,
|
|
252
|
+
size: msg.size || 0,
|
|
253
|
+
hasAttachments: parsed.hasAttachments,
|
|
254
|
+
preview: parsed.preview,
|
|
255
|
+
bodyPath
|
|
256
|
+
});
|
|
257
|
+
newCount++;
|
|
216
258
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
messageId: msg.messageId || "",
|
|
235
|
-
inReplyTo: "",
|
|
236
|
-
references: [],
|
|
237
|
-
date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
|
|
238
|
-
subject: msg.subject || "",
|
|
239
|
-
from: toEmailAddress(msg.from?.[0] || {}),
|
|
240
|
-
to: toEmailAddresses(msg.to || []),
|
|
241
|
-
cc: toEmailAddresses(msg.cc || []),
|
|
242
|
-
flags,
|
|
243
|
-
size: msg.size || 0,
|
|
244
|
-
hasAttachments: parsed.hasAttachments,
|
|
245
|
-
preview: parsed.preview,
|
|
246
|
-
bodyPath
|
|
259
|
+
this.db.commitTransaction();
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
console.error(` transaction error: ${e.message}`);
|
|
263
|
+
this.db.rollbackTransaction();
|
|
264
|
+
throw e;
|
|
265
|
+
}
|
|
266
|
+
// Emit progress and notify client after each batch
|
|
267
|
+
this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((batchEnd / messages.length) * 100));
|
|
268
|
+
// On first sync, emit folderCountsChanged per batch so newest messages appear immediately
|
|
269
|
+
if (firstSync && newCount > 0) {
|
|
270
|
+
const total = newCount;
|
|
271
|
+
const unread = this.db.getMessages({ accountId, folderId, page: 1, pageSize: total })
|
|
272
|
+
.items.filter((m) => !m.flags.includes("\\Seen")).length;
|
|
273
|
+
this.db.updateFolderCounts(folderId, total, unread);
|
|
274
|
+
this.emit("folderCountsChanged", accountId, {
|
|
275
|
+
[folderId]: { total, unread }
|
|
247
276
|
});
|
|
248
|
-
newCount++;
|
|
249
|
-
// Emit progress periodically
|
|
250
|
-
if (i % batchSize === 0) {
|
|
251
|
-
this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((i / messages.length) * 100));
|
|
252
|
-
}
|
|
253
277
|
}
|
|
254
|
-
this.db.commitTransaction();
|
|
255
|
-
if (newCount > 0)
|
|
256
|
-
console.log(` stored ${newCount} new messages`);
|
|
257
|
-
}
|
|
258
|
-
catch (e) {
|
|
259
|
-
console.error(` transaction error: ${e.message}`);
|
|
260
|
-
this.db.rollbackTransaction();
|
|
261
|
-
throw e;
|
|
262
278
|
}
|
|
279
|
+
if (newCount > 0)
|
|
280
|
+
console.log(` stored ${newCount} new messages`);
|
|
263
281
|
// Remove messages deleted on the server
|
|
264
282
|
let deletedCount = 0;
|
|
265
283
|
try {
|
|
@@ -308,10 +326,12 @@ export class ImapManager extends EventEmitter {
|
|
|
308
326
|
}
|
|
309
327
|
}
|
|
310
328
|
async _syncAll() {
|
|
329
|
+
// Phase 1: Sync folder lists and inboxes for ALL accounts first
|
|
330
|
+
// so every account has content visible quickly
|
|
331
|
+
const accountFolders = new Map();
|
|
311
332
|
for (const [accountId] of this.configs) {
|
|
312
333
|
let client = null;
|
|
313
334
|
try {
|
|
314
|
-
// Fresh client for folder list (30s timeout)
|
|
315
335
|
client = this.createClient(accountId);
|
|
316
336
|
const folders = await Promise.race([
|
|
317
337
|
this.syncFolders(accountId, client),
|
|
@@ -319,20 +339,14 @@ export class ImapManager extends EventEmitter {
|
|
|
319
339
|
]);
|
|
320
340
|
await client.logout();
|
|
321
341
|
client = null;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (b.specialUse === "inbox")
|
|
327
|
-
return 1;
|
|
328
|
-
return 0;
|
|
329
|
-
});
|
|
330
|
-
// Fresh client per folder with 60s timeout — IMAP connections can hang
|
|
331
|
-
for (const folder of folders) {
|
|
342
|
+
accountFolders.set(accountId, folders);
|
|
343
|
+
// Sync inbox immediately
|
|
344
|
+
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
345
|
+
if (inbox) {
|
|
332
346
|
try {
|
|
333
347
|
client = this.createClient(accountId);
|
|
334
348
|
await Promise.race([
|
|
335
|
-
this.syncFolder(accountId,
|
|
349
|
+
this.syncFolder(accountId, inbox.id, client),
|
|
336
350
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
|
|
337
351
|
]);
|
|
338
352
|
await client.logout();
|
|
@@ -346,16 +360,9 @@ export class ImapManager extends EventEmitter {
|
|
|
346
360
|
catch { /* ignore */ }
|
|
347
361
|
client = null;
|
|
348
362
|
}
|
|
349
|
-
|
|
350
|
-
console.log(` Removing non-existent folder: ${folder.path}`);
|
|
351
|
-
this.db.deleteFolder(folder.id);
|
|
352
|
-
}
|
|
353
|
-
else {
|
|
354
|
-
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
355
|
-
}
|
|
363
|
+
console.error(` Inbox sync error for ${accountId}: ${e.message}`);
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
|
-
this.emit("syncComplete", accountId);
|
|
359
366
|
}
|
|
360
367
|
catch (e) {
|
|
361
368
|
this.emit("syncError", accountId, e.message);
|
|
@@ -369,6 +376,40 @@ export class ImapManager extends EventEmitter {
|
|
|
369
376
|
catch { /* ignore */ }
|
|
370
377
|
}
|
|
371
378
|
}
|
|
379
|
+
// Phase 2: Sync remaining folders for all accounts
|
|
380
|
+
for (const [accountId, folders] of accountFolders) {
|
|
381
|
+
let client = null;
|
|
382
|
+
for (const folder of folders) {
|
|
383
|
+
if (folder.specialUse === "inbox")
|
|
384
|
+
continue; // already synced
|
|
385
|
+
try {
|
|
386
|
+
client = this.createClient(accountId);
|
|
387
|
+
await Promise.race([
|
|
388
|
+
this.syncFolder(accountId, folder.id, client),
|
|
389
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
|
|
390
|
+
]);
|
|
391
|
+
await client.logout();
|
|
392
|
+
client = null;
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
if (client) {
|
|
396
|
+
try {
|
|
397
|
+
await client.logout();
|
|
398
|
+
}
|
|
399
|
+
catch { /* ignore */ }
|
|
400
|
+
client = null;
|
|
401
|
+
}
|
|
402
|
+
if (e.responseText?.includes("doesn't exist")) {
|
|
403
|
+
console.log(` Removing non-existent folder: ${folder.path}`);
|
|
404
|
+
this.db.deleteFolder(folder.id);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
this.emit("syncComplete", accountId);
|
|
412
|
+
}
|
|
372
413
|
}
|
|
373
414
|
/** Sync just INBOX for each account (fast check for new mail) */
|
|
374
415
|
async syncInbox() {
|
|
@@ -419,19 +460,59 @@ export class ImapManager extends EventEmitter {
|
|
|
419
460
|
this.inboxSyncing = false;
|
|
420
461
|
}
|
|
421
462
|
}
|
|
463
|
+
/** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
|
|
464
|
+
* If message count changed, triggers a full inbox sync. */
|
|
465
|
+
lastInboxCounts = new Map();
|
|
466
|
+
async quickInboxCheck() {
|
|
467
|
+
for (const [accountId] of this.configs) {
|
|
468
|
+
let client = null;
|
|
469
|
+
try {
|
|
470
|
+
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
471
|
+
if (!inbox)
|
|
472
|
+
continue;
|
|
473
|
+
client = this.createClient(accountId);
|
|
474
|
+
const count = await client.getMessagesCount("INBOX");
|
|
475
|
+
await client.logout();
|
|
476
|
+
client = null;
|
|
477
|
+
const prev = this.lastInboxCounts.get(accountId) ?? count;
|
|
478
|
+
this.lastInboxCounts.set(accountId, count);
|
|
479
|
+
if (count !== prev) {
|
|
480
|
+
console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
|
|
481
|
+
// New mail detected — do a full inbox sync
|
|
482
|
+
client = this.createClient(accountId);
|
|
483
|
+
await this.syncFolder(accountId, inbox.id, client);
|
|
484
|
+
await client.logout();
|
|
485
|
+
client = null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// Lightweight check — silently ignore errors (full sync will catch up)
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
if (client)
|
|
493
|
+
try {
|
|
494
|
+
await client.logout();
|
|
495
|
+
}
|
|
496
|
+
catch { /* ignore */ }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
422
500
|
/** Start periodic sync */
|
|
423
501
|
startPeriodicSync(intervalMinutes) {
|
|
424
502
|
this.stopPeriodicSync();
|
|
425
|
-
//
|
|
426
|
-
const
|
|
427
|
-
|
|
503
|
+
// Quick inbox check every 3 seconds — lightweight STATUS command
|
|
504
|
+
const quickCheck = setInterval(() => {
|
|
505
|
+
this.quickInboxCheck().catch(() => { });
|
|
506
|
+
}, 3000);
|
|
507
|
+
this.syncIntervals.set("quick", quickCheck);
|
|
508
|
+
// Sync actions (sends + flags/deletes/moves) every 30 seconds
|
|
509
|
+
const actionsInterval = setInterval(async () => {
|
|
428
510
|
for (const [accountId] of this.configs) {
|
|
429
511
|
this.processSendActions(accountId).catch(() => { });
|
|
430
512
|
this.processSyncActions(accountId).catch(() => { });
|
|
431
513
|
}
|
|
432
|
-
this.syncInbox().catch(e => console.error(` [inbox] error: ${e.message}`));
|
|
433
514
|
}, 30000);
|
|
434
|
-
this.syncIntervals.set("
|
|
515
|
+
this.syncIntervals.set("actions", actionsInterval);
|
|
435
516
|
// Full sync (all folders + IDLE restart) at configured interval
|
|
436
517
|
const fullInterval = setInterval(async () => {
|
|
437
518
|
console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
send(msg: any): Promise<void>;
|
|
32
|
+
deleteMessage(accountId: string, uid: number): Promise<void>;
|
|
33
|
+
moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
|
|
34
|
+
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
35
|
+
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
36
|
+
createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
|
|
37
|
+
renameFolder(accountId: string, folderId: number, newName: string): Promise<void>;
|
|
38
|
+
deleteFolder(accountId: string, folderId: number): Promise<void>;
|
|
39
|
+
markFolderRead(folderId: number): void;
|
|
40
|
+
emptyFolder(accountId: string, folderId: number): Promise<void>;
|
|
41
|
+
getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{
|
|
42
|
+
content: Buffer;
|
|
43
|
+
contentType: string;
|
|
44
|
+
filename: string;
|
|
45
|
+
}>;
|
|
46
|
+
saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number): Promise<number | null>;
|
|
47
|
+
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
48
|
+
searchContacts(query: string): any[];
|
|
49
|
+
syncGoogleContacts(): Promise<void>;
|
|
50
|
+
seedContacts(): number;
|
|
51
|
+
getSettings(): any;
|
|
52
|
+
saveSettings(settings: any): void;
|
|
53
|
+
getStorageInfo(): {
|
|
54
|
+
provider: string;
|
|
55
|
+
mode: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=index.d.ts.map
|