@bobfrankston/iflow-direct 0.1.65 → 0.1.67
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/imap-compat.d.ts +3 -0
- package/imap-compat.js +1 -1
- package/imap-native.d.ts +5 -1
- package/imap-native.js +48 -12
- package/package.json +1 -1
package/imap-compat.d.ts
CHANGED
|
@@ -166,6 +166,9 @@ export declare class CompatImapClient {
|
|
|
166
166
|
notifySpec?: string;
|
|
167
167
|
onMailboxStatus?: (mailbox: string, data: proto.StatusData) => void;
|
|
168
168
|
onExpunge?: () => void;
|
|
169
|
+
/** A flag changed on a message in the watched mailbox — another
|
|
170
|
+
* client starred, read or unread something. */
|
|
171
|
+
onFlagChange?: (seq: number, flags: string[]) => void;
|
|
169
172
|
}): Promise<() => Promise<void>>;
|
|
170
173
|
/** Copy a message to another server (cross-account) */
|
|
171
174
|
moveMessageToServer(msg: any, fromMailbox: string, targetClient: CompatImapClient, toMailbox: string): Promise<void>;
|
package/imap-compat.js
CHANGED
|
@@ -340,7 +340,7 @@ export class CompatImapClient {
|
|
|
340
340
|
this.native.onMailboxStatus = opts.onMailboxStatus;
|
|
341
341
|
if (opts?.notifySpec)
|
|
342
342
|
await this.native.notify(opts.notifySpec);
|
|
343
|
-
return this.native.startIdle(onNew, opts?.onExpunge);
|
|
343
|
+
return this.native.startIdle(onNew, opts?.onExpunge, opts?.onFlagChange);
|
|
344
344
|
}
|
|
345
345
|
/** Copy a message to another server (cross-account) */
|
|
346
346
|
async moveMessageToServer(msg, fromMailbox, targetClient, toMailbox) {
|
package/imap-native.d.ts
CHANGED
|
@@ -120,6 +120,10 @@ export declare class NativeImapClient {
|
|
|
120
120
|
* a server-side deletion in real time instead of waiting for the next
|
|
121
121
|
* periodic poll. Distinct from `idleCallback` (new mail only). */
|
|
122
122
|
private idleExpungeCallback;
|
|
123
|
+
/** Fired for an unsolicited FETCH carrying FLAGS while parked in IDLE —
|
|
124
|
+
* another client (a phone, Thunderbird, webmail) changed a flag on a
|
|
125
|
+
* message in the selected mailbox. */
|
|
126
|
+
private idleFlagCallback;
|
|
123
127
|
private idleRefreshTimer;
|
|
124
128
|
/** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
|
|
125
129
|
* mailboxes (the server pushes these when the client has issued NOTIFY
|
|
@@ -264,7 +268,7 @@ export declare class NativeImapClient {
|
|
|
264
268
|
* SELECT and BEFORE startIdle — the server holds the spec for the
|
|
265
269
|
* lifetime of the connection. */
|
|
266
270
|
notify(spec: string): Promise<void>;
|
|
267
|
-
startIdle(onNewMail: (count: number) => void, onExpunge?: () => void): Promise<() => Promise<void>>;
|
|
271
|
+
startIdle(onNewMail: (count: number) => void, onExpunge?: () => void, onFlagChange?: (seq: number, flags: string[]) => void): Promise<() => Promise<void>>;
|
|
268
272
|
/**
|
|
269
273
|
* If IDLE is currently active, send DONE and wait for its tagged OK so the
|
|
270
274
|
* connection is free to accept a new command. Saves the active callback so
|
package/imap-native.js
CHANGED
|
@@ -52,6 +52,10 @@ export class NativeImapClient {
|
|
|
52
52
|
* a server-side deletion in real time instead of waiting for the next
|
|
53
53
|
* periodic poll. Distinct from `idleCallback` (new mail only). */
|
|
54
54
|
idleExpungeCallback = null;
|
|
55
|
+
/** Fired for an unsolicited FETCH carrying FLAGS while parked in IDLE —
|
|
56
|
+
* another client (a phone, Thunderbird, webmail) changed a flag on a
|
|
57
|
+
* message in the selected mailbox. */
|
|
58
|
+
idleFlagCallback = null;
|
|
55
59
|
idleRefreshTimer = null;
|
|
56
60
|
/** RFC 5465 NOTIFY: fires on unsolicited STATUS responses for non-selected
|
|
57
61
|
* mailboxes (the server pushes these when the client has issued NOTIFY
|
|
@@ -579,7 +583,8 @@ export class NativeImapClient {
|
|
|
579
583
|
if (uids.length === 0)
|
|
580
584
|
return [];
|
|
581
585
|
uids.reverse(); // Newest first
|
|
582
|
-
|
|
586
|
+
if (this.verbose)
|
|
587
|
+
console.log(` [fetch] ${uids.length} UIDs since ${sinceUid} (newest first)`);
|
|
583
588
|
if (uids.length <= this.fetchChunkSize) {
|
|
584
589
|
const msgs = await this.fetchMessages(uids.join(","), options);
|
|
585
590
|
if (onChunk)
|
|
@@ -613,14 +618,19 @@ export class NativeImapClient {
|
|
|
613
618
|
before: before || undefined,
|
|
614
619
|
});
|
|
615
620
|
// SEARCH SINCE on Dovecot can take minutes on a cold mailbox while it
|
|
616
|
-
// (re)builds its date index
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
621
|
+
// (re)builds its date index, and unlabelled silence reads as a hang.
|
|
622
|
+
// That is what the sendCommand heartbeat is for — "still waiting for
|
|
623
|
+
// tag N after 30.0s — SEARCH …", unconditional and generic — so this
|
|
624
|
+
// bracket does not have to fire on every call. It used to: one sync
|
|
625
|
+
// pass over ~90 folders wrote two lines per folder per cycle, and on
|
|
626
|
+
// Android every line is an HTTP request to the log server (Bob
|
|
627
|
+
// 2026-08-29: "are we overlogging"). Verbose keeps it for debugging.
|
|
620
628
|
const t0 = Date.now();
|
|
621
|
-
|
|
629
|
+
if (this.verbose)
|
|
630
|
+
console.log(` [search] SINCE ${since.toISOString().slice(0, 10)}${before ? ` BEFORE ${before.toISOString().slice(0, 10)}` : ""} — running...`);
|
|
622
631
|
const uids = await this.search(criteria);
|
|
623
|
-
|
|
632
|
+
if (this.verbose)
|
|
633
|
+
console.log(` [search] returned ${uids.length} UIDs in ${Date.now() - t0}ms`);
|
|
624
634
|
if (uids.length === 0)
|
|
625
635
|
return [];
|
|
626
636
|
// Reverse so newest messages (highest UIDs) come first
|
|
@@ -639,7 +649,8 @@ export class NativeImapClient {
|
|
|
639
649
|
// without spamming. The first-chunk log proves the fetch is alive
|
|
640
650
|
// even when subsequent chunks are slow.
|
|
641
651
|
if (chunkIndex === 1 || chunkIndex % 5 === 0) {
|
|
642
|
-
|
|
652
|
+
if (this.verbose)
|
|
653
|
+
console.log(` [fetch] ${allMessages.length}/${uids.length} messages (chunk ${chunkIndex})`);
|
|
643
654
|
}
|
|
644
655
|
if (onChunk)
|
|
645
656
|
onChunk(msgs);
|
|
@@ -647,7 +658,8 @@ export class NativeImapClient {
|
|
|
647
658
|
if (chunkSize < this.fetchChunkSizeMax)
|
|
648
659
|
chunkSize = Math.min(chunkSize * 4, this.fetchChunkSizeMax);
|
|
649
660
|
}
|
|
650
|
-
|
|
661
|
+
if (this.verbose)
|
|
662
|
+
console.log(` [fetch] done — ${allMessages.length} messages in ${Date.now() - t0}ms`);
|
|
651
663
|
return allMessages;
|
|
652
664
|
}
|
|
653
665
|
/** Fetch the most recent N messages by sequence number — avoids
|
|
@@ -663,7 +675,8 @@ export class NativeImapClient {
|
|
|
663
675
|
const start = Math.max(1, exists - n + 1);
|
|
664
676
|
const range = `${start}:${exists}`;
|
|
665
677
|
const t0 = Date.now();
|
|
666
|
-
|
|
678
|
+
if (this.verbose)
|
|
679
|
+
console.log(` [fetch-latest] ${this.selectedMailbox || "?"}: sequence ${range} (${Math.min(n, exists)} most recent of ${exists})`);
|
|
667
680
|
const items = ["UID", "FLAGS", "ENVELOPE", "RFC822.SIZE", "INTERNALDATE", "BODY.PEEK[HEADER]"];
|
|
668
681
|
if (options.source)
|
|
669
682
|
items.push("BODY.PEEK[]");
|
|
@@ -694,7 +707,8 @@ export class NativeImapClient {
|
|
|
694
707
|
// Reverse so newest-first matches fetchByDate ordering — caller code
|
|
695
708
|
// (mailx) expects that ordering when computing highestUid windows.
|
|
696
709
|
streamed.reverse();
|
|
697
|
-
|
|
710
|
+
if (this.verbose)
|
|
711
|
+
console.log(` [fetch-latest] done — ${streamed.length} messages in ${Date.now() - t0}ms`);
|
|
698
712
|
return streamed;
|
|
699
713
|
}
|
|
700
714
|
/** Fetch a single message by UID */
|
|
@@ -817,9 +831,10 @@ export class NativeImapClient {
|
|
|
817
831
|
}
|
|
818
832
|
}
|
|
819
833
|
// ── IDLE ──
|
|
820
|
-
async startIdle(onNewMail, onExpunge) {
|
|
834
|
+
async startIdle(onNewMail, onExpunge, onFlagChange) {
|
|
821
835
|
this.idleCallback = onNewMail;
|
|
822
836
|
this.idleExpungeCallback = onExpunge ?? null;
|
|
837
|
+
this.idleFlagCallback = onFlagChange ?? null;
|
|
823
838
|
this.idleStopped = false;
|
|
824
839
|
const beginIdleCycle = async () => {
|
|
825
840
|
const tag = proto.nextTag();
|
|
@@ -1416,6 +1431,27 @@ export class NativeImapClient {
|
|
|
1416
1431
|
this.idleExpungeCallback();
|
|
1417
1432
|
continue;
|
|
1418
1433
|
}
|
|
1434
|
+
// A flag changed in the SELECTED mailbox: the server sends an
|
|
1435
|
+
// unsolicited "* 1234 FETCH (FLAGS (\Seen \Flagged))" — this is
|
|
1436
|
+
// how starring a message on a phone reaches another client. It fell
|
|
1437
|
+
// through here and was dropped, so the desktop only learned about
|
|
1438
|
+
// it on its next periodic sync of that folder: measured on Bob's
|
|
1439
|
+
// own log, a median of 5 minutes and up to 20 (2026-08-29).
|
|
1440
|
+
if (this.idleTag && resp.tag === "*" && resp.type === "FETCH" && /FLAGS\s*\(/i.test(resp.text)) {
|
|
1441
|
+
if (this.idleFlagCallback) {
|
|
1442
|
+
const seq = parseInt(resp.text, 10);
|
|
1443
|
+
const inner = resp.text.match(/FLAGS\s*\(([^)]*)\)/i)?.[1] || "";
|
|
1444
|
+
const flags = inner.split(/\s+/).filter(Boolean);
|
|
1445
|
+
try {
|
|
1446
|
+
this.idleFlagCallback(Number.isFinite(seq) ? seq : 0, flags);
|
|
1447
|
+
}
|
|
1448
|
+
catch (err) {
|
|
1449
|
+
if (this.verbose)
|
|
1450
|
+
console.error(` [imap] onFlagChange threw: ${err.message}`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1419
1455
|
// RFC 5465 NOTIFY: unsolicited STATUS responses for non-selected
|
|
1420
1456
|
// mailboxes arrive when the server has accepted a NOTIFY SET that
|
|
1421
1457
|
// included a PERSONAL (or other) event group. Only route to the
|