@bobfrankston/mailx-imap 0.1.57 → 0.1.59
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/index.d.ts +50 -0
- package/index.js +222 -0
- package/package.json +3 -3
package/index.d.ts
CHANGED
|
@@ -473,9 +473,59 @@ export declare class ImapManager extends EventEmitter {
|
|
|
473
473
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
474
474
|
private outboxBackoff;
|
|
475
475
|
private outboxBackoffDelay;
|
|
476
|
+
/** Route a file dropped into the general `~/.rmfmail/outbox/` (no acct
|
|
477
|
+
* subdir) to one of the configured accounts. Match order:
|
|
478
|
+
* 1. `From:` address matches an `account.email` exactly (case-insensitive).
|
|
479
|
+
* 2. `From:` domain matches a known-provider domain → first account whose
|
|
480
|
+
* domain matches (gmail.com / googlemail.com → first Gmail account;
|
|
481
|
+
* outlook.com / hotmail.com / live.com → first Outlook account).
|
|
482
|
+
* 3. Default = first account with an explicit IMAP override host (i.e.,
|
|
483
|
+
* not one of the known providers). Bob's bobma is the prototype.
|
|
484
|
+
* Returns the routed accountId or null if no candidate exists. */
|
|
485
|
+
private routeGeneralOutboxFile;
|
|
486
|
+
/** Scan the general outbox (`~/.rmfmail/outbox/*.ltr|*.eml` — no acct
|
|
487
|
+
* subdir) and route each file into the appropriate per-account dir. Runs
|
|
488
|
+
* once per outboxLoop tick before the per-account sweep, so a file
|
|
489
|
+
* manually dropped at the root gets handed off the same tick. */
|
|
490
|
+
private routeGeneralOutbox;
|
|
476
491
|
startOutboxWorker(): void;
|
|
477
492
|
/** Stop Outbox worker */
|
|
478
493
|
stopOutboxWorker(): void;
|
|
494
|
+
/** Periodic Sent sweep — reconciles optimistic local-inserts against
|
|
495
|
+
* the server's actual Sent folder for every IMAP-path account.
|
|
496
|
+
*
|
|
497
|
+
* Why: when a message is sent on an IMAP account, the local DB row is
|
|
498
|
+
* inserted with a PREDICTED UID (the Sent folder's UIDNEXT at submit
|
|
499
|
+
* time). The submission server's drain-Outbox sieve appends the real
|
|
500
|
+
* Sent copy at whatever UID it chooses, which may differ if anything
|
|
501
|
+
* else raced — another client, a sieve append from a different rule,
|
|
502
|
+
* or just the sieve picking its own UID. Without reconciliation:
|
|
503
|
+
* - On the next set-diff sync the local row's UID doesn't match
|
|
504
|
+
* any server UID → set-diff deletes it → "Sent copy missing".
|
|
505
|
+
* - Or the server's actual Sent copy gets reconciled as a NEW row
|
|
506
|
+
* → duplicate in the user's Sent view.
|
|
507
|
+
*
|
|
508
|
+
* What this sweep does:
|
|
509
|
+
* For each IMAP-path account, walk local Sent rows newer than
|
|
510
|
+
* SENT_SWEEP_WINDOW_MS, query the server's Sent folder by
|
|
511
|
+
* Message-ID, and:
|
|
512
|
+
* - if found at the local-known UID → no-op
|
|
513
|
+
* - if found at a different UID → rebind the local row to the
|
|
514
|
+
* real UID
|
|
515
|
+
* - if not found and the .eml is on disk → IMAP APPEND it
|
|
516
|
+
* from disk (no SMTP)
|
|
517
|
+
* - if not found and no .eml → leave it (set-diff will GC; the
|
|
518
|
+
* user has bigger problems than the Sent reconcile)
|
|
519
|
+
*
|
|
520
|
+
* Gmail-path accounts: skipped. Gmail's Sent is API-driven, the
|
|
521
|
+
* optimistic-insert path doesn't apply (gmail-api writes the row
|
|
522
|
+
* directly post-send with the real Gmail message ID, no UID race). */
|
|
523
|
+
private sentSweepInterval;
|
|
524
|
+
private readonly SENT_SWEEP_WINDOW_MS;
|
|
525
|
+
private readonly SENT_SWEEP_INTERVAL_MS;
|
|
526
|
+
startSentSweep(): void;
|
|
527
|
+
stopSentSweep(): void;
|
|
528
|
+
private sweepSentOnce;
|
|
479
529
|
private configWatchers;
|
|
480
530
|
private cloudPollTimers;
|
|
481
531
|
/** Watch the local config files for external changes. On change, emit
|
package/index.js
CHANGED
|
@@ -4213,11 +4213,113 @@ export class ImapManager extends EventEmitter {
|
|
|
4213
4213
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
4214
4214
|
outboxBackoff = new Map(); // accountId → next retry timestamp
|
|
4215
4215
|
outboxBackoffDelay = new Map(); // accountId → current delay ms
|
|
4216
|
+
/** Route a file dropped into the general `~/.rmfmail/outbox/` (no acct
|
|
4217
|
+
* subdir) to one of the configured accounts. Match order:
|
|
4218
|
+
* 1. `From:` address matches an `account.email` exactly (case-insensitive).
|
|
4219
|
+
* 2. `From:` domain matches a known-provider domain → first account whose
|
|
4220
|
+
* domain matches (gmail.com / googlemail.com → first Gmail account;
|
|
4221
|
+
* outlook.com / hotmail.com / live.com → first Outlook account).
|
|
4222
|
+
* 3. Default = first account with an explicit IMAP override host (i.e.,
|
|
4223
|
+
* not one of the known providers). Bob's bobma is the prototype.
|
|
4224
|
+
* Returns the routed accountId or null if no candidate exists. */
|
|
4225
|
+
routeGeneralOutboxFile(filePath) {
|
|
4226
|
+
let raw = "";
|
|
4227
|
+
try {
|
|
4228
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
4229
|
+
}
|
|
4230
|
+
catch {
|
|
4231
|
+
return null;
|
|
4232
|
+
}
|
|
4233
|
+
const fromMatch = raw.match(/^From:\s*(?:[^<\n]*<\s*)?([^\s<>@]+@[^\s<>]+)/mi);
|
|
4234
|
+
const fromAddr = (fromMatch?.[1] || "").toLowerCase().replace(/[>\s].*$/, "");
|
|
4235
|
+
const fromDomain = fromAddr.split("@")[1] || "";
|
|
4236
|
+
const settings = loadSettings();
|
|
4237
|
+
const accounts = settings.accounts || [];
|
|
4238
|
+
// 1. exact email match
|
|
4239
|
+
if (fromAddr) {
|
|
4240
|
+
const m = accounts.find(a => (a.email || "").toLowerCase() === fromAddr);
|
|
4241
|
+
if (m)
|
|
4242
|
+
return m.id;
|
|
4243
|
+
}
|
|
4244
|
+
// 2. known-provider domain match
|
|
4245
|
+
const GMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
|
4246
|
+
const OUTLOOK_DOMAINS = new Set(["outlook.com", "hotmail.com", "live.com", "msn.com"]);
|
|
4247
|
+
if (fromDomain) {
|
|
4248
|
+
if (GMAIL_DOMAINS.has(fromDomain)) {
|
|
4249
|
+
const m = accounts.find(a => GMAIL_DOMAINS.has((a.email || "").split("@")[1]?.toLowerCase() || ""));
|
|
4250
|
+
if (m)
|
|
4251
|
+
return m.id;
|
|
4252
|
+
}
|
|
4253
|
+
if (OUTLOOK_DOMAINS.has(fromDomain)) {
|
|
4254
|
+
const m = accounts.find(a => OUTLOOK_DOMAINS.has((a.email || "").split("@")[1]?.toLowerCase() || ""));
|
|
4255
|
+
if (m)
|
|
4256
|
+
return m.id;
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
// 3. default: first account on a non-known-provider domain (i.e. one
|
|
4260
|
+
// whose IMAP host was explicitly configured rather than auto-detected).
|
|
4261
|
+
const KNOWN_PROVIDER_DOMAINS = new Set([
|
|
4262
|
+
...GMAIL_DOMAINS, ...OUTLOOK_DOMAINS,
|
|
4263
|
+
"yahoo.com", "aol.com", "icloud.com", "me.com", "mac.com",
|
|
4264
|
+
]);
|
|
4265
|
+
const override = accounts.find(a => {
|
|
4266
|
+
const d = (a.email || "").split("@")[1]?.toLowerCase() || "";
|
|
4267
|
+
return d && !KNOWN_PROVIDER_DOMAINS.has(d);
|
|
4268
|
+
});
|
|
4269
|
+
if (override)
|
|
4270
|
+
return override.id;
|
|
4271
|
+
// Last resort — any account at all
|
|
4272
|
+
return accounts[0]?.id || null;
|
|
4273
|
+
}
|
|
4274
|
+
/** Scan the general outbox (`~/.rmfmail/outbox/*.ltr|*.eml` — no acct
|
|
4275
|
+
* subdir) and route each file into the appropriate per-account dir. Runs
|
|
4276
|
+
* once per outboxLoop tick before the per-account sweep, so a file
|
|
4277
|
+
* manually dropped at the root gets handed off the same tick. */
|
|
4278
|
+
routeGeneralOutbox() {
|
|
4279
|
+
const root = path.join(getConfigDir(), "outbox");
|
|
4280
|
+
if (!fs.existsSync(root))
|
|
4281
|
+
return;
|
|
4282
|
+
let entries = [];
|
|
4283
|
+
try {
|
|
4284
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
4285
|
+
}
|
|
4286
|
+
catch {
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
for (const ent of entries) {
|
|
4290
|
+
if (!ent.isFile())
|
|
4291
|
+
continue;
|
|
4292
|
+
if (!/\.(ltr|eml)$/i.test(ent.name))
|
|
4293
|
+
continue;
|
|
4294
|
+
const filePath = path.join(root, ent.name);
|
|
4295
|
+
const accountId = this.routeGeneralOutboxFile(filePath);
|
|
4296
|
+
if (!accountId) {
|
|
4297
|
+
console.error(` [outbox] No account candidate for ${ent.name} — leaving in general outbox`);
|
|
4298
|
+
continue;
|
|
4299
|
+
}
|
|
4300
|
+
const acctDir = path.join(root, accountId);
|
|
4301
|
+
try {
|
|
4302
|
+
fs.mkdirSync(acctDir, { recursive: true });
|
|
4303
|
+
}
|
|
4304
|
+
catch { /* */ }
|
|
4305
|
+
try {
|
|
4306
|
+
fs.renameSync(filePath, path.join(acctDir, ent.name));
|
|
4307
|
+
console.log(` [outbox] Routed ${ent.name} → ${accountId}/`);
|
|
4308
|
+
}
|
|
4309
|
+
catch (e) {
|
|
4310
|
+
console.error(` [outbox] Failed to route ${ent.name}: ${e.message}`);
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4216
4314
|
startOutboxWorker() {
|
|
4217
4315
|
if (this.outboxInterval)
|
|
4218
4316
|
return;
|
|
4219
4317
|
const processAll = async () => {
|
|
4220
4318
|
const now = Date.now();
|
|
4319
|
+
// Auto-route any files dropped into the general (acct-agnostic)
|
|
4320
|
+
// outbox — they move into `outbox/<accountId>/` before the
|
|
4321
|
+
// per-account sweep picks them up below.
|
|
4322
|
+
this.routeGeneralOutbox();
|
|
4221
4323
|
for (const [accountId] of this.configs) {
|
|
4222
4324
|
// Skip accounts in backoff
|
|
4223
4325
|
const retryAfter = this.outboxBackoff.get(accountId) || 0;
|
|
@@ -4269,6 +4371,126 @@ export class ImapManager extends EventEmitter {
|
|
|
4269
4371
|
this.outboxInterval = null;
|
|
4270
4372
|
}
|
|
4271
4373
|
}
|
|
4374
|
+
/** Periodic Sent sweep — reconciles optimistic local-inserts against
|
|
4375
|
+
* the server's actual Sent folder for every IMAP-path account.
|
|
4376
|
+
*
|
|
4377
|
+
* Why: when a message is sent on an IMAP account, the local DB row is
|
|
4378
|
+
* inserted with a PREDICTED UID (the Sent folder's UIDNEXT at submit
|
|
4379
|
+
* time). The submission server's drain-Outbox sieve appends the real
|
|
4380
|
+
* Sent copy at whatever UID it chooses, which may differ if anything
|
|
4381
|
+
* else raced — another client, a sieve append from a different rule,
|
|
4382
|
+
* or just the sieve picking its own UID. Without reconciliation:
|
|
4383
|
+
* - On the next set-diff sync the local row's UID doesn't match
|
|
4384
|
+
* any server UID → set-diff deletes it → "Sent copy missing".
|
|
4385
|
+
* - Or the server's actual Sent copy gets reconciled as a NEW row
|
|
4386
|
+
* → duplicate in the user's Sent view.
|
|
4387
|
+
*
|
|
4388
|
+
* What this sweep does:
|
|
4389
|
+
* For each IMAP-path account, walk local Sent rows newer than
|
|
4390
|
+
* SENT_SWEEP_WINDOW_MS, query the server's Sent folder by
|
|
4391
|
+
* Message-ID, and:
|
|
4392
|
+
* - if found at the local-known UID → no-op
|
|
4393
|
+
* - if found at a different UID → rebind the local row to the
|
|
4394
|
+
* real UID
|
|
4395
|
+
* - if not found and the .eml is on disk → IMAP APPEND it
|
|
4396
|
+
* from disk (no SMTP)
|
|
4397
|
+
* - if not found and no .eml → leave it (set-diff will GC; the
|
|
4398
|
+
* user has bigger problems than the Sent reconcile)
|
|
4399
|
+
*
|
|
4400
|
+
* Gmail-path accounts: skipped. Gmail's Sent is API-driven, the
|
|
4401
|
+
* optimistic-insert path doesn't apply (gmail-api writes the row
|
|
4402
|
+
* directly post-send with the real Gmail message ID, no UID race). */
|
|
4403
|
+
sentSweepInterval = null;
|
|
4404
|
+
SENT_SWEEP_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
4405
|
+
SENT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
4406
|
+
startSentSweep() {
|
|
4407
|
+
if (this.sentSweepInterval)
|
|
4408
|
+
return;
|
|
4409
|
+
const tick = async () => {
|
|
4410
|
+
for (const [accountId] of this.configs) {
|
|
4411
|
+
if (this.isGmailAccount(accountId))
|
|
4412
|
+
continue;
|
|
4413
|
+
try {
|
|
4414
|
+
await this.sweepSentOnce(accountId);
|
|
4415
|
+
}
|
|
4416
|
+
catch (e) {
|
|
4417
|
+
console.error(` [sent-sweep] ${accountId}: ${e?.message || e}`);
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
};
|
|
4421
|
+
// First tick deferred 30 s so it doesn't race the daemon's
|
|
4422
|
+
// boot-time sync. Subsequent ticks every SENT_SWEEP_INTERVAL_MS.
|
|
4423
|
+
setTimeout(() => { tick().catch(() => { }); }, 30_000);
|
|
4424
|
+
this.sentSweepInterval = setInterval(() => { tick().catch(() => { }); }, this.SENT_SWEEP_INTERVAL_MS);
|
|
4425
|
+
}
|
|
4426
|
+
stopSentSweep() {
|
|
4427
|
+
if (this.sentSweepInterval) {
|
|
4428
|
+
clearInterval(this.sentSweepInterval);
|
|
4429
|
+
this.sentSweepInterval = null;
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
async sweepSentOnce(accountId) {
|
|
4433
|
+
const sent = this.findFolder(accountId, "sent");
|
|
4434
|
+
if (!sent)
|
|
4435
|
+
return;
|
|
4436
|
+
// Pull recent local rows. Restrict to (a) account+folder, (b) cached
|
|
4437
|
+
// in the last hour, (c) have a message_id (no MID = can't reconcile).
|
|
4438
|
+
const cutoff = Date.now() - this.SENT_SWEEP_WINDOW_MS;
|
|
4439
|
+
const rows = this.db.getRecentMessagesByCachedAt(accountId, sent.id, cutoff);
|
|
4440
|
+
if (rows.length === 0)
|
|
4441
|
+
return;
|
|
4442
|
+
let reconciled = 0;
|
|
4443
|
+
let appended = 0;
|
|
4444
|
+
await this.withConnection(accountId, async (client) => {
|
|
4445
|
+
for (const row of rows) {
|
|
4446
|
+
const msgId = row.message_id;
|
|
4447
|
+
if (!msgId)
|
|
4448
|
+
continue;
|
|
4449
|
+
let serverUids = [];
|
|
4450
|
+
try {
|
|
4451
|
+
serverUids = await client.searchByHeader(sent.path, "Message-ID", msgId);
|
|
4452
|
+
}
|
|
4453
|
+
catch (e) {
|
|
4454
|
+
console.error(` [sent-sweep] searchByHeader ${msgId}: ${e?.message || e}`);
|
|
4455
|
+
continue;
|
|
4456
|
+
}
|
|
4457
|
+
if (serverUids.length === 0) {
|
|
4458
|
+
// Not on server. Re-APPEND from .eml if we have one.
|
|
4459
|
+
if (!row.body_path)
|
|
4460
|
+
continue;
|
|
4461
|
+
try {
|
|
4462
|
+
const buf = await this.bodyStore.readByPath(row.body_path);
|
|
4463
|
+
if (!buf)
|
|
4464
|
+
continue;
|
|
4465
|
+
await client.appendMessage(sent.path, buf, ["\\Seen"]);
|
|
4466
|
+
appended++;
|
|
4467
|
+
console.log(` [sent-sweep] ${accountId}: re-APPENDed ${msgId} (was missing on server)`);
|
|
4468
|
+
}
|
|
4469
|
+
catch (e) {
|
|
4470
|
+
console.error(` [sent-sweep] APPEND ${msgId}: ${e?.message || e}`);
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
else if (!serverUids.includes(row.uid)) {
|
|
4474
|
+
// Server has it under a different UID — rebind local row by
|
|
4475
|
+
// patching the messages.uid in place. Cheap, no upsert
|
|
4476
|
+
// shuffle, no audit-trail noise.
|
|
4477
|
+
const realUid = serverUids[serverUids.length - 1];
|
|
4478
|
+
try {
|
|
4479
|
+
this.db.updateMessageUid?.(accountId, sent.id, row.uid, realUid);
|
|
4480
|
+
reconciled++;
|
|
4481
|
+
console.log(` [sent-sweep] ${accountId}: rebound ${msgId} ${row.uid} → ${realUid}`);
|
|
4482
|
+
}
|
|
4483
|
+
catch (e) {
|
|
4484
|
+
console.error(` [sent-sweep] rebind ${msgId}: ${e?.message || e}`);
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
4488
|
+
}, { slow: true, timeoutMs: 120_000 });
|
|
4489
|
+
if (reconciled + appended > 0) {
|
|
4490
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
4491
|
+
console.log(` [sent-sweep] ${accountId}: ${reconciled} rebound, ${appended} re-appended`);
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4272
4494
|
// ── Config file watcher ──
|
|
4273
4495
|
configWatchers = [];
|
|
4274
4496
|
cloudPollTimers = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-imap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.18",
|
|
13
13
|
"@bobfrankston/mailx-settings": "^0.1.22",
|
|
14
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
14
|
+
"@bobfrankston/mailx-store": "^0.1.35",
|
|
15
15
|
"@bobfrankston/iflow-direct": "^0.1.50",
|
|
16
16
|
"@bobfrankston/tcp-transport": "^0.1.6",
|
|
17
17
|
"@bobfrankston/smtp-direct": "^0.1.8",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bobfrankston/mailx-types": "^0.1.18",
|
|
41
41
|
"@bobfrankston/mailx-settings": "^0.1.22",
|
|
42
|
-
"@bobfrankston/mailx-store": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-store": "^0.1.35",
|
|
43
43
|
"@bobfrankston/iflow-direct": "^0.1.50",
|
|
44
44
|
"@bobfrankston/tcp-transport": "^0.1.6",
|
|
45
45
|
"@bobfrankston/smtp-direct": "^0.1.8",
|