@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
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 { getDeduplicationId } from "./emit.js";
4
- import type { SyncMessagesEvent } from "./events.js";
5
-
6
- type SyncMessagesInput = Omit<SyncMessagesEvent, "eventId" | "timestamp">;
7
-
8
- const syncMessages = (over: Partial<SyncMessagesInput>): SyncMessagesInput => ({
9
- type: "SYNC_MESSAGES",
10
- accountId: "acc-1",
11
- mailboxId: "mbx-1",
12
- ...over,
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
- describe("getDeduplicationId for SYNC_MESSAGES continuation", () => {
16
- it("gives an initial (cursor-less) sync a stable id so concurrent fresh syncs dedup", () => {
17
- assert.strictEqual(
18
- getDeduplicationId(syncMessages({})),
19
- "SYNC_MESSAGES:mbx-1",
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
- it("gives each continuation batch a distinct id so FIFO does not drop batches 2..N", () => {
24
- // Regression guard: a constant dedup id per mailbox let SQS FIFO reject
25
- // every continuation within the 5-minute window, capping a sync at one
26
- // batch (~200 messages). Folding the batch's remaining count into the id
27
- // keeps sequential batches distinct.
28
- const batch2 = getDeduplicationId(syncMessages({ resumeCursor: 800 }));
29
- const batch3 = getDeduplicationId(syncMessages({ resumeCursor: 600 }));
30
-
31
- assert.strictEqual(batch2, "SYNC_MESSAGES:mbx-1:800");
32
- assert.strictEqual(batch3, "SYNC_MESSAGES:mbx-1:600");
33
- assert.notStrictEqual(batch2, batch3);
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("separates a continuation from the initial event of the same mailbox", () => {
37
- const initial = getDeduplicationId(syncMessages({}));
38
- const continuation = getDeduplicationId(
39
- syncMessages({ resumeCursor: 800 }),
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
- assert.notStrictEqual(initial, continuation);
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) && fifoEventTypes.has(event.type);
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 - only set if queue is FIFO
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: getDeduplicationId(event),
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
- const SYNC_COOLDOWN_MS = 30_000; // 30 seconds
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
- // Filter out mailboxes that were synced recently (cooldown)
145
- // Always include mailboxes that were never synced (lastMessageSyncAt is 0, undefined, or null)
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
- (m) => !m.lastMessageSyncAt || now - m.lastMessageSyncAt > SYNC_COOLDOWN_MS,
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({ accountId, skipped }, "Skipped mailboxes due to sync cooldown");
196
+ log.info(
197
+ { accountId, skipped },
198
+ "Skipped recently-synced mailboxes for a side-effect trigger",
199
+ );
154
200
  }
155
201
 
156
- if (mailboxes.length === 0) {
202
+ if (allMailboxes.length === 0) {
157
203
  log.info({ accountId }, "No mailboxes to sync messages for");
158
- // If there are no mailboxes to sync, mark as complete
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. Completion events only fire for the enqueued
173
- // (cooldown-filtered) set, so pre-credit the skipped mailboxes into
174
- // mailboxCountSynced — otherwise synced can never reach total.
175
- // If INBOX itself was skipped, go straight to syncing_others.
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()],
@@ -369,7 +369,6 @@ const syncMailboxMessages = async (
369
369
  type: "SYNC_MESSAGES",
370
370
  accountId: event.accountId,
371
371
  mailboxId,
372
- resumeCursor: result.remainingCount,
373
372
  };
374
373
  await emitEvent(nextSyncEvent);
375
374
  return;