@remit/imap-worker 0.0.7 → 0.0.9
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/package.json +1 -1
- package/src/emit.test.ts +124 -34
- package/src/emit.ts +16 -51
- package/src/events.ts +7 -10
- package/src/handlers/sync-mailboxes.test.ts +47 -0
- package/src/handlers/sync-mailboxes.ts +74 -13
- package/src/handlers/sync-messages.test.ts +39 -0
- package/src/handlers/sync-messages.ts +0 -1
package/package.json
CHANGED
package/src/emit.test.ts
CHANGED
|
@@ -1,44 +1,134 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { before, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
4
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
5
|
+
import type { emitEvent as EmitEvent } from "./emit.js";
|
|
6
|
+
import type {
|
|
7
|
+
FlagPushEvent,
|
|
8
|
+
SyncMailboxesEvent,
|
|
9
|
+
SyncMessageBodyEvent,
|
|
10
|
+
SyncMessagesEvent,
|
|
11
|
+
} from "./events.js";
|
|
12
|
+
|
|
13
|
+
type Emitted<T> = Omit<T, "eventId" | "timestamp">;
|
|
14
|
+
|
|
15
|
+
// The queue urls are read once, at import. Point them at FIFO queues first —
|
|
16
|
+
// this suite is about what emitEvent puts on a FIFO queue, which is what both
|
|
17
|
+
// the self-host stack and the deployed stack run (deploy/vps/queues.json).
|
|
18
|
+
const fifo = (name: string) =>
|
|
19
|
+
`https://sqs.eu-west-1.amazonaws.com/0/test-${name}.fifo`;
|
|
20
|
+
|
|
21
|
+
const sqsMock = mockClient(SQSClient);
|
|
22
|
+
|
|
23
|
+
let emitEvent: typeof EmitEvent;
|
|
24
|
+
|
|
25
|
+
const sentCommands = (): SendMessageCommand["input"][] =>
|
|
26
|
+
sqsMock.commandCalls(SendMessageCommand).map((call) => call.args[0].input);
|
|
27
|
+
|
|
28
|
+
const bodyOf = (input: SendMessageCommand["input"]): Record<string, unknown> =>
|
|
29
|
+
JSON.parse(input.MessageBody ?? "{}") as Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
before(async () => {
|
|
32
|
+
process.env.SQS_QUEUE_URL_MAILBOXES = fifo("mailboxes");
|
|
33
|
+
process.env.SQS_QUEUE_URL_MESSAGES = fifo("messages");
|
|
34
|
+
process.env.SQS_QUEUE_URL_FLAGS = fifo("flags");
|
|
35
|
+
({ emitEvent } = await import("./emit.js"));
|
|
13
36
|
});
|
|
14
37
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
sqsMock.reset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("emitEvent on a FIFO queue", () => {
|
|
43
|
+
it("groups by account and deduplicates on the event's own id", async () => {
|
|
44
|
+
const event: Emitted<SyncMessagesEvent> = {
|
|
45
|
+
type: "SYNC_MESSAGES",
|
|
46
|
+
accountId: "acc-1",
|
|
47
|
+
mailboxId: "mbx-1",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await emitEvent(event);
|
|
51
|
+
|
|
52
|
+
const [sent] = sentCommands();
|
|
53
|
+
if (!sent) throw new Error("expected a send");
|
|
54
|
+
assert.equal(sent.MessageGroupId, "acc-1");
|
|
55
|
+
assert.equal(sent.MessageDeduplicationId, bodyOf(sent).eventId);
|
|
21
56
|
});
|
|
22
57
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
58
|
+
// Issue #37: every SYNC_MESSAGES for a mailbox carried one dedup id, so
|
|
59
|
+
// SQS FIFO's 5-minute window discarded the second sync of that mailbox
|
|
60
|
+
// before any worker saw it — mail appended after a sync could not be
|
|
61
|
+
// fetched until the window elapsed. Two syncs of one mailbox must both
|
|
62
|
+
// reach the queue.
|
|
63
|
+
it("lets a second sync of the same mailbox through", async () => {
|
|
64
|
+
const event: Emitted<SyncMessagesEvent> = {
|
|
65
|
+
type: "SYNC_MESSAGES",
|
|
66
|
+
accountId: "acc-1",
|
|
67
|
+
mailboxId: "mbx-1",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await emitEvent(event);
|
|
71
|
+
await emitEvent(event);
|
|
72
|
+
|
|
73
|
+
const sent = sentCommands();
|
|
74
|
+
assert.equal(sent.length, 2);
|
|
75
|
+
assert.notEqual(
|
|
76
|
+
sent[0]?.MessageDeduplicationId,
|
|
77
|
+
sent[1]?.MessageDeduplicationId,
|
|
78
|
+
);
|
|
34
79
|
});
|
|
35
80
|
|
|
36
|
-
it("
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
81
|
+
it("lets a second sync of the same account's mailbox list through", async () => {
|
|
82
|
+
const event: Emitted<SyncMailboxesEvent> = {
|
|
83
|
+
type: "SYNC_MAILBOXES",
|
|
84
|
+
accountId: "acc-1",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
await emitEvent(event);
|
|
88
|
+
await emitEvent(event);
|
|
89
|
+
|
|
90
|
+
const sent = sentCommands();
|
|
91
|
+
assert.equal(sent.length, 2);
|
|
92
|
+
assert.notEqual(
|
|
93
|
+
sent[0]?.MessageDeduplicationId,
|
|
94
|
+
sent[1]?.MessageDeduplicationId,
|
|
40
95
|
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A FIFO queue without content-based deduplication rejects a send carrying
|
|
99
|
+
// no MessageDeduplicationId, so every event routed onto one needs its own —
|
|
100
|
+
// not only the sync events.
|
|
101
|
+
it("gives a flag-push re-arm a deduplication id", async () => {
|
|
102
|
+
const event: Emitted<FlagPushEvent> = {
|
|
103
|
+
type: "FLAG_PUSH",
|
|
104
|
+
accountId: "acc-1",
|
|
105
|
+
accountConfigId: "cfg-1",
|
|
106
|
+
messageId: "msg-1",
|
|
107
|
+
flagName: "\\Seen",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await emitEvent(event);
|
|
111
|
+
|
|
112
|
+
const [sent] = sentCommands();
|
|
113
|
+
if (!sent) throw new Error("expected a send");
|
|
114
|
+
assert.equal(sent.MessageDeduplicationId, bodyOf(sent).eventId);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("emitEvent on a standard queue", () => {
|
|
119
|
+
it("carries no FIFO parameters", async () => {
|
|
120
|
+
const event: Emitted<SyncMessageBodyEvent> = {
|
|
121
|
+
type: "SYNC_MESSAGE_BODY",
|
|
122
|
+
accountId: "acc-1",
|
|
123
|
+
mailboxId: "mbx-1",
|
|
124
|
+
messageIds: ["msg-1"],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await emitEvent(event);
|
|
41
128
|
|
|
42
|
-
|
|
129
|
+
const [sent] = sentCommands();
|
|
130
|
+
if (!sent) throw new Error("expected a send");
|
|
131
|
+
assert.equal(sent.MessageGroupId, undefined);
|
|
132
|
+
assert.equal(sent.MessageDeduplicationId, undefined);
|
|
43
133
|
});
|
|
44
134
|
});
|
package/src/emit.ts
CHANGED
|
@@ -5,11 +5,7 @@ import {
|
|
|
5
5
|
isLocalEndpoint,
|
|
6
6
|
} from "@remit/sqs-client/producer";
|
|
7
7
|
import { env } from "expect-env";
|
|
8
|
-
import type {
|
|
9
|
-
ImapEvent,
|
|
10
|
-
SyncMailboxesEvent,
|
|
11
|
-
SyncMessagesEvent,
|
|
12
|
-
} from "./events.js";
|
|
8
|
+
import type { ImapEvent } from "./events.js";
|
|
13
9
|
|
|
14
10
|
type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
|
|
15
11
|
|
|
@@ -52,49 +48,6 @@ const queueUrlMap: Record<ImapEvent["type"], string> = {
|
|
|
52
48
|
FLAG_PUSH: flagsQueueUrl,
|
|
53
49
|
};
|
|
54
50
|
|
|
55
|
-
/**
|
|
56
|
-
* FIFO queue event types that support deduplication.
|
|
57
|
-
* Management events (MAILBOX_*, MESSAGE_*, EMPTY_TRASH) are not deduplicated
|
|
58
|
-
* because each operation is unique.
|
|
59
|
-
*/
|
|
60
|
-
const fifoEventTypes = new Set([
|
|
61
|
-
"SYNC_MAILBOXES",
|
|
62
|
-
"SYNC_MESSAGES",
|
|
63
|
-
// flagsQueue is FIFO and requires a MessageGroupId on every message.
|
|
64
|
-
// Content-based deduplication (queue-level setting) covers the dedup id —
|
|
65
|
-
// no per-event case is needed in getDeduplicationId below, same as the
|
|
66
|
-
// (standard-queue) management events that fall through its default.
|
|
67
|
-
"FLAG_PUSH",
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Generate a deduplication ID for FIFO queue events.
|
|
72
|
-
* SQS deduplication window is 5 minutes - duplicate messages within
|
|
73
|
-
* this window are rejected.
|
|
74
|
-
*/
|
|
75
|
-
export const getDeduplicationId = (event: EventInput): string | undefined => {
|
|
76
|
-
switch (event.type) {
|
|
77
|
-
case "SYNC_MAILBOXES": {
|
|
78
|
-
const e = event as Omit<SyncMailboxesEvent, "eventId" | "timestamp">;
|
|
79
|
-
return `SYNC_MAILBOXES:${e.accountId}`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
case "SYNC_MESSAGES": {
|
|
83
|
-
const e = event as Omit<SyncMessagesEvent, "eventId" | "timestamp">;
|
|
84
|
-
// A continuation carries a per-batch cursor so batches 2..N are not
|
|
85
|
-
// deduped against the initial event (which has none) or each other; the
|
|
86
|
-
// cursor-less initial id still dedups concurrent fresh syncs of a mailbox.
|
|
87
|
-
return e.resumeCursor === undefined
|
|
88
|
-
? `SYNC_MESSAGES:${e.mailboxId}`
|
|
89
|
-
: `SYNC_MESSAGES:${e.mailboxId}:${e.resumeCursor}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
default:
|
|
93
|
-
// Management events are not deduplicated
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
|
|
98
51
|
/**
|
|
99
52
|
* Check if queue URL is a FIFO queue (ends with .fifo)
|
|
100
53
|
*/
|
|
@@ -119,7 +72,7 @@ export const emitEvent = async (
|
|
|
119
72
|
} as ImapEvent;
|
|
120
73
|
|
|
121
74
|
const queueUrl = queueUrlMap[event.type];
|
|
122
|
-
const useFifo = isFifoQueue(queueUrl)
|
|
75
|
+
const useFifo = isFifoQueue(queueUrl);
|
|
123
76
|
|
|
124
77
|
// ElasticMQ FIFO queues don't support per-message DelaySeconds
|
|
125
78
|
const useDelay = options?.delaySeconds && !isLocal;
|
|
@@ -130,10 +83,22 @@ export const emitEvent = async (
|
|
|
130
83
|
MessageBody: JSON.stringify(fullEvent),
|
|
131
84
|
// Delay delivery for retry backoff (skip for local ElasticMQ)
|
|
132
85
|
...(useDelay && { DelaySeconds: options.delaySeconds }),
|
|
133
|
-
// FIFO queue parameters
|
|
86
|
+
// FIFO queue parameters — only set if the queue is FIFO. The
|
|
87
|
+
// deduplication id is the event's own id, so the queue suppresses a
|
|
88
|
+
// re-send of one event (the retry SQS's own idempotency guard is for)
|
|
89
|
+
// and nothing else. A shared id per account or per mailbox instead made
|
|
90
|
+
// the 5-minute window a rate limiter: the second sync of a mailbox
|
|
91
|
+
// within five minutes was discarded before any worker saw it, so mail
|
|
92
|
+
// that arrived after a sync could not be fetched until the window
|
|
93
|
+
// elapsed (issue #37).
|
|
94
|
+
//
|
|
95
|
+
// Dropping events is not how repeated work is bounded — that is the
|
|
96
|
+
// freshness gate in the sync-mailboxes fan-out, which decides whether
|
|
97
|
+
// a mailbox is worth enumerating at all. MessageGroupId only orders an
|
|
98
|
+
// account's events; it makes duplicates serial, not cheap.
|
|
134
99
|
...(useFifo && {
|
|
135
100
|
MessageGroupId: event.accountId,
|
|
136
|
-
MessageDeduplicationId:
|
|
101
|
+
MessageDeduplicationId: fullEvent.eventId,
|
|
137
102
|
}),
|
|
138
103
|
}),
|
|
139
104
|
);
|
package/src/events.ts
CHANGED
|
@@ -6,22 +6,19 @@ export interface BaseEvent {
|
|
|
6
6
|
|
|
7
7
|
export interface SyncMailboxesEvent extends BaseEvent {
|
|
8
8
|
type: "SYNC_MAILBOXES";
|
|
9
|
+
/**
|
|
10
|
+
* Set by POST /sync — the only trigger a person asks for by name. It makes
|
|
11
|
+
* the fan-out sync every mailbox, skipping the freshness gate that keeps
|
|
12
|
+
* incidental triggers (config load, OAuth connect, the scheduled tick) from
|
|
13
|
+
* re-enumerating folders that were just enumerated.
|
|
14
|
+
*/
|
|
15
|
+
explicitRequest?: boolean;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
export interface SyncMessagesEvent extends BaseEvent {
|
|
12
19
|
type: "SYNC_MESSAGES";
|
|
13
20
|
mailboxId: string;
|
|
14
21
|
fullSync?: boolean; // If true, ignore lastSyncUid
|
|
15
|
-
/**
|
|
16
|
-
* Set on a continuation event (the "next batch" a batch emits when it drains
|
|
17
|
-
* only part of a mailbox). Carries the batch's remaining-message count so the
|
|
18
|
-
* FIFO deduplication id is distinct per batch — otherwise every continuation
|
|
19
|
-
* for a mailbox shares one dedup id and the 5-minute window silently drops
|
|
20
|
-
* batches 2..N, capping any mailbox over one batch. The value is not used by
|
|
21
|
-
* the handler (the resume point comes from the persisted watermark); it only
|
|
22
|
-
* makes the dedup id unique.
|
|
23
|
-
*/
|
|
24
|
-
resumeCursor?: number;
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { MAILBOX_FRESHNESS_MS, mailboxNeedsSync } from "./sync-mailboxes.js";
|
|
4
|
+
|
|
5
|
+
const NOW = 1_700_000_000_000;
|
|
6
|
+
|
|
7
|
+
const askedForByName = { explicitRequest: true } as const;
|
|
8
|
+
const sideEffect = {} as const;
|
|
9
|
+
|
|
10
|
+
describe("mailboxNeedsSync", () => {
|
|
11
|
+
// Issue #37: the gate this replaces applied to every trigger, so a refresh
|
|
12
|
+
// that landed just after a side-effect sync did nothing at all. A sync
|
|
13
|
+
// asked for by name (POST /sync) is never gated, however recently one ran.
|
|
14
|
+
it("syncs a mailbox synced a moment ago when the sync was asked for by name", () => {
|
|
15
|
+
const mailbox = { lastMessageSyncAt: NOW - 1_000 };
|
|
16
|
+
|
|
17
|
+
assert.equal(mailboxNeedsSync(mailbox, askedForByName, NOW), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Without this, `GET /config` — which fires a trigger per account on every
|
|
21
|
+
// call — re-enumerates every folder an account owns on every page load.
|
|
22
|
+
it("skips a freshly-synced mailbox for a side-effect trigger", () => {
|
|
23
|
+
const mailbox = { lastMessageSyncAt: NOW - 1_000 };
|
|
24
|
+
|
|
25
|
+
assert.equal(mailboxNeedsSync(mailbox, sideEffect, NOW), false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("syncs a stale mailbox for a side-effect trigger", () => {
|
|
29
|
+
const mailbox = { lastMessageSyncAt: NOW - MAILBOX_FRESHNESS_MS - 1 };
|
|
30
|
+
|
|
31
|
+
assert.equal(mailboxNeedsSync(mailbox, sideEffect, NOW), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("syncs exactly at the freshness threshold", () => {
|
|
35
|
+
const mailbox = { lastMessageSyncAt: NOW - MAILBOX_FRESHNESS_MS };
|
|
36
|
+
|
|
37
|
+
assert.equal(mailboxNeedsSync(mailbox, sideEffect, NOW), true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("always syncs a mailbox that has never synced", () => {
|
|
41
|
+
assert.equal(mailboxNeedsSync({}, sideEffect, NOW), true);
|
|
42
|
+
assert.equal(
|
|
43
|
+
mailboxNeedsSync({ lastMessageSyncAt: 0 }, sideEffect, NOW),
|
|
44
|
+
true,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -23,7 +23,42 @@ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
|
23
23
|
import { orderMailboxesForSync } from "./mailbox-sync-order.js";
|
|
24
24
|
|
|
25
25
|
const EVENT_EMIT_CONCURRENCY = 20;
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How recently a mailbox must have been synced for a side-effect trigger to
|
|
29
|
+
* leave it alone. Short enough that a folder is never more than a minute
|
|
30
|
+
* staler than the trigger that arrived, long enough to collapse the burst a
|
|
31
|
+
* client produces when it loads (`GET /config` triggers a sync per account)
|
|
32
|
+
* into one round of IMAP work.
|
|
33
|
+
*
|
|
34
|
+
* The web client floors its automatic poll at this same window
|
|
35
|
+
* (`MIN_POLL_INTERVAL_MS` in useStaleAccountSync), so the one caller that
|
|
36
|
+
* skips this gate on a timer still cannot drive a fan-out faster than it.
|
|
37
|
+
*/
|
|
38
|
+
export const MAILBOX_FRESHNESS_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether this fan-out should enqueue a mailbox.
|
|
42
|
+
*
|
|
43
|
+
* A sync asked for by name — POST /sync, which is the refresh control,
|
|
44
|
+
* pull-to-refresh, and the client's automatic poll — always syncs every
|
|
45
|
+
* mailbox, whatever ran a moment ago. A sync that happens as a side effect of
|
|
46
|
+
* something else (config load, OAuth connect, account create, the scheduled
|
|
47
|
+
* tick) skips mailboxes synced inside {@link MAILBOX_FRESHNESS_MS}, which is
|
|
48
|
+
* what stops those triggers from re-enumerating an account's folders on every
|
|
49
|
+
* page load.
|
|
50
|
+
*
|
|
51
|
+
* A mailbox that has never synced is always due.
|
|
52
|
+
*/
|
|
53
|
+
export const mailboxNeedsSync = (
|
|
54
|
+
mailbox: { lastMessageSyncAt?: number },
|
|
55
|
+
event: Pick<SyncMailboxesEvent, "explicitRequest">,
|
|
56
|
+
now: number,
|
|
57
|
+
): boolean => {
|
|
58
|
+
if (event.explicitRequest) return true;
|
|
59
|
+
if (!mailbox.lastMessageSyncAt) return true;
|
|
60
|
+
return now - mailbox.lastMessageSyncAt >= MAILBOX_FRESHNESS_MS;
|
|
61
|
+
};
|
|
27
62
|
|
|
28
63
|
export const syncMailboxes = async (
|
|
29
64
|
event: SyncMailboxesEvent,
|
|
@@ -66,6 +101,7 @@ export const syncMailboxes = async (
|
|
|
66
101
|
async (credentials) => {
|
|
67
102
|
try {
|
|
68
103
|
await syncMailboxesForAccount(
|
|
104
|
+
event,
|
|
69
105
|
account,
|
|
70
106
|
credentials,
|
|
71
107
|
mailboxService,
|
|
@@ -97,6 +133,7 @@ export const syncMailboxes = async (
|
|
|
97
133
|
};
|
|
98
134
|
|
|
99
135
|
const syncMailboxesForAccount = async (
|
|
136
|
+
event: SyncMailboxesEvent,
|
|
100
137
|
account: AccountItem,
|
|
101
138
|
credentials: MailCredentials,
|
|
102
139
|
mailboxService: IMailboxRepository,
|
|
@@ -138,24 +175,42 @@ const syncMailboxesForAccount = async (
|
|
|
138
175
|
|
|
139
176
|
log.info({ result }, "Mailbox sync complete");
|
|
140
177
|
|
|
141
|
-
// Get all mailboxes and emit SYNC_MESSAGES for each
|
|
142
178
|
const allMailboxes = await collectAllMailboxes(accountId, mailboxService);
|
|
143
179
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
180
|
+
// This fan-out is where an account's IMAP work is decided: one SEARCH per
|
|
181
|
+
// mailbox per event, on a queue every account shares. `GET /config` fires a
|
|
182
|
+
// trigger per account on every call, so without a gate here an idle client
|
|
183
|
+
// re-enumerates every folder it owns on every page load.
|
|
184
|
+
//
|
|
185
|
+
// The gate is per mailbox and a sync asked for by name skips it outright.
|
|
186
|
+
// That distinction is the whole point: the previous cooldown gated
|
|
187
|
+
// everything, which is what made a refresh a no-op whenever a side-effect
|
|
188
|
+
// trigger had just run (issue #37).
|
|
146
189
|
const now = Date.now();
|
|
147
|
-
const mailboxes = allMailboxes.filter(
|
|
148
|
-
(
|
|
190
|
+
const mailboxes = allMailboxes.filter((mailbox) =>
|
|
191
|
+
mailboxNeedsSync(mailbox, event, now),
|
|
149
192
|
);
|
|
150
193
|
|
|
151
194
|
const skipped = allMailboxes.length - mailboxes.length;
|
|
152
195
|
if (skipped > 0) {
|
|
153
|
-
log.info(
|
|
196
|
+
log.info(
|
|
197
|
+
{ accountId, skipped },
|
|
198
|
+
"Skipped recently-synced mailboxes for a side-effect trigger",
|
|
199
|
+
);
|
|
154
200
|
}
|
|
155
201
|
|
|
156
|
-
if (
|
|
202
|
+
if (allMailboxes.length === 0) {
|
|
157
203
|
log.info({ accountId }, "No mailboxes to sync messages for");
|
|
158
|
-
|
|
204
|
+
await accountService.update(accountId, {
|
|
205
|
+
syncPhase: SyncPhase.complete,
|
|
206
|
+
mailboxCountTotal: 0,
|
|
207
|
+
mailboxCountSynced: 0,
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (mailboxes.length === 0) {
|
|
213
|
+
log.info({ accountId }, "Every mailbox is fresh; nothing to sync");
|
|
159
214
|
await accountService.update(accountId, {
|
|
160
215
|
syncPhase: SyncPhase.complete,
|
|
161
216
|
mailboxCountTotal: allMailboxes.length,
|
|
@@ -169,10 +224,16 @@ const syncMailboxesForAccount = async (
|
|
|
169
224
|
"Emitting SYNC_MESSAGES events",
|
|
170
225
|
);
|
|
171
226
|
|
|
172
|
-
// Phase transition.
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
227
|
+
// Phase transition. Only the enqueued mailboxes emit a completion, so the
|
|
228
|
+
// gated ones are pre-credited — otherwise synced could never reach total.
|
|
229
|
+
//
|
|
230
|
+
// The counter is progress through THIS round, not a lifetime total: a new
|
|
231
|
+
// round restarts it, which is why it can read lower than a moment ago while
|
|
232
|
+
// a previous round is still draining. That is the same instant
|
|
233
|
+
// `account.lastSyncAt` is stamped, and the per-mailbox completion guard in
|
|
234
|
+
// sync-messages.ts keys off exactly that stamp — so a completion still in
|
|
235
|
+
// flight from the previous round counts once towards the new one, and each
|
|
236
|
+
// mailbox counts at most once per round.
|
|
176
237
|
const inboxEnqueued = mailboxes.some(
|
|
177
238
|
(m) => m.fullPath.toUpperCase() === "INBOX",
|
|
178
239
|
);
|
|
@@ -147,6 +147,45 @@ describe("drainPendingFlagPushes — periodic per-mailbox re-arm (issue #1273)",
|
|
|
147
147
|
assert.equal(emitted.length, 2);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Markers persisted before the system-flag wire-format fix carry the
|
|
152
|
+
* unprefixed spelling (`Seen`, not `\Seen`) the enum emitter used to
|
|
153
|
+
* produce. The drain re-arms from the stored `marker.flagName` and never
|
|
154
|
+
* from the enum, and `handleFlagPush` threads that same value through
|
|
155
|
+
* `find`/`updateState`/`delete` — so a marker written under the old
|
|
156
|
+
* spelling still matches itself and drains to completion. No migration,
|
|
157
|
+
* no orphans.
|
|
158
|
+
*/
|
|
159
|
+
it("re-arms a marker persisted under the pre-fix unprefixed spelling verbatim", async () => {
|
|
160
|
+
const markerService = {
|
|
161
|
+
listByMailboxId: async () => [
|
|
162
|
+
marker({ messageId: "legacy-msg", flagName: "Seen" }),
|
|
163
|
+
marker({ messageId: "legacy-star", flagName: "Flagged" }),
|
|
164
|
+
],
|
|
165
|
+
} as unknown as IMessageFlagPushRepository;
|
|
166
|
+
|
|
167
|
+
const emitted: Array<{ messageId: string; flagName: string }> = [];
|
|
168
|
+
const { log } = buildLogger();
|
|
169
|
+
|
|
170
|
+
await drainPendingFlagPushes(
|
|
171
|
+
markerService,
|
|
172
|
+
account,
|
|
173
|
+
"mbx-1",
|
|
174
|
+
log,
|
|
175
|
+
async (event) => {
|
|
176
|
+
emitted.push(event as unknown as (typeof emitted)[number]);
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
assert.deepEqual(
|
|
181
|
+
emitted.map((e) => [e.messageId, e.flagName]),
|
|
182
|
+
[
|
|
183
|
+
["legacy-msg", "Seen"],
|
|
184
|
+
["legacy-star", "Flagged"],
|
|
185
|
+
],
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
150
189
|
it("a re-arm (SQS) failure is caught per-marker and logged loudly — never thrown", async () => {
|
|
151
190
|
const markerService = {
|
|
152
191
|
listByMailboxId: async () => [marker()],
|