@remit/imap-worker 0.0.86 → 0.0.88
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/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.ts +1 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +194 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +27 -33
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +21 -21
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +199 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +106 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +146 -15
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -569
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
|
@@ -1,569 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
AccountItem,
|
|
5
|
-
IMessageFlagPushRepository,
|
|
6
|
-
} from "@remit/data-ports";
|
|
7
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
8
|
-
import type { Logger } from "@remit/logger-lambda";
|
|
9
|
-
import type { SyncMessagesEvent } from "../events.js";
|
|
10
|
-
import {
|
|
11
|
-
drainPendingFlagPushes,
|
|
12
|
-
type SyncMessagesDeps,
|
|
13
|
-
syncMessages,
|
|
14
|
-
} from "./sync-messages.js";
|
|
15
|
-
|
|
16
|
-
const buildLogger = (): {
|
|
17
|
-
log: Logger;
|
|
18
|
-
infos: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
19
|
-
warns: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
20
|
-
errors: Array<{ fields: Record<string, unknown>; msg: string }>;
|
|
21
|
-
} => {
|
|
22
|
-
const infos: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
23
|
-
const warns: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
24
|
-
const errors: Array<{ fields: Record<string, unknown>; msg: string }> = [];
|
|
25
|
-
const log = {
|
|
26
|
-
info: (fields: Record<string, unknown>, msg: string) => {
|
|
27
|
-
infos.push({ fields, msg });
|
|
28
|
-
},
|
|
29
|
-
warn: (fields: Record<string, unknown>, msg: string) => {
|
|
30
|
-
warns.push({ fields, msg });
|
|
31
|
-
},
|
|
32
|
-
error: (fields: Record<string, unknown>, msg: string) => {
|
|
33
|
-
errors.push({ fields, msg });
|
|
34
|
-
},
|
|
35
|
-
debug: () => {},
|
|
36
|
-
fatal: () => {},
|
|
37
|
-
trace: () => {},
|
|
38
|
-
child: () => log,
|
|
39
|
-
} as unknown as Logger;
|
|
40
|
-
return { log, infos, warns, errors };
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const account = {
|
|
44
|
-
accountId: "acc-1",
|
|
45
|
-
accountConfigId: "acc-cfg-1",
|
|
46
|
-
} as unknown as AccountItem;
|
|
47
|
-
|
|
48
|
-
const marker = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
|
49
|
-
messageId: "msg-1",
|
|
50
|
-
flagName: "\\Seen",
|
|
51
|
-
accountId: "acc-1",
|
|
52
|
-
accountConfigId: "acc-cfg-1",
|
|
53
|
-
mailboxId: "mbx-1",
|
|
54
|
-
operation: "add",
|
|
55
|
-
state: "pending",
|
|
56
|
-
createdAt: 1,
|
|
57
|
-
updatedAt: 1,
|
|
58
|
-
...overrides,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe("drainPendingFlagPushes — periodic per-mailbox re-arm (issue #1273)", () => {
|
|
62
|
-
it("re-emits FLAG_PUSH for every marker stuck in `pending` (crash between local write and enqueue)", async () => {
|
|
63
|
-
const markerService = {
|
|
64
|
-
listByMailboxId: async () => [marker()],
|
|
65
|
-
} as unknown as IMessageFlagPushRepository;
|
|
66
|
-
|
|
67
|
-
const emitted: unknown[] = [];
|
|
68
|
-
const { log } = buildLogger();
|
|
69
|
-
|
|
70
|
-
await drainPendingFlagPushes(
|
|
71
|
-
markerService,
|
|
72
|
-
account,
|
|
73
|
-
"mbx-1",
|
|
74
|
-
log,
|
|
75
|
-
async (event) => {
|
|
76
|
-
emitted.push(event);
|
|
77
|
-
},
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
assert.equal(emitted.length, 1);
|
|
81
|
-
assert.deepEqual(emitted[0], {
|
|
82
|
-
type: "FLAG_PUSH",
|
|
83
|
-
accountId: "acc-1",
|
|
84
|
-
accountConfigId: "acc-cfg-1",
|
|
85
|
-
messageId: "msg-1",
|
|
86
|
-
flagName: "\\Seen",
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("does NOT re-arm markers already queued or processing — a live driver already owns them", async () => {
|
|
91
|
-
const markerService = {
|
|
92
|
-
listByMailboxId: async () => [
|
|
93
|
-
marker({ messageId: "queued-msg", state: "queued" }),
|
|
94
|
-
marker({ messageId: "processing-msg", state: "processing" }),
|
|
95
|
-
],
|
|
96
|
-
} as unknown as IMessageFlagPushRepository;
|
|
97
|
-
|
|
98
|
-
const emitted: unknown[] = [];
|
|
99
|
-
const { log } = buildLogger();
|
|
100
|
-
|
|
101
|
-
await drainPendingFlagPushes(
|
|
102
|
-
markerService,
|
|
103
|
-
account,
|
|
104
|
-
"mbx-1",
|
|
105
|
-
log,
|
|
106
|
-
async (event) => {
|
|
107
|
-
emitted.push(event);
|
|
108
|
-
},
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
assert.equal(emitted.length, 0);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("is a no-op when no markers exist for the mailbox", async () => {
|
|
115
|
-
const markerService = {
|
|
116
|
-
listByMailboxId: async () => [],
|
|
117
|
-
} as unknown as IMessageFlagPushRepository;
|
|
118
|
-
|
|
119
|
-
const emitted: unknown[] = [];
|
|
120
|
-
const { log, infos } = buildLogger();
|
|
121
|
-
|
|
122
|
-
await drainPendingFlagPushes(
|
|
123
|
-
markerService,
|
|
124
|
-
account,
|
|
125
|
-
"mbx-1",
|
|
126
|
-
log,
|
|
127
|
-
async (event) => {
|
|
128
|
-
emitted.push(event);
|
|
129
|
-
},
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
assert.equal(emitted.length, 0);
|
|
133
|
-
assert.equal(infos.length, 0);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("re-arms multiple stuck markers for the same mailbox (per-field, independent)", async () => {
|
|
137
|
-
const markerService = {
|
|
138
|
-
listByMailboxId: async () => [
|
|
139
|
-
marker({ flagName: "\\Seen" }),
|
|
140
|
-
marker({ flagName: "\\Flagged" }),
|
|
141
|
-
],
|
|
142
|
-
} as unknown as IMessageFlagPushRepository;
|
|
143
|
-
|
|
144
|
-
const emitted: unknown[] = [];
|
|
145
|
-
const { log } = buildLogger();
|
|
146
|
-
|
|
147
|
-
await drainPendingFlagPushes(
|
|
148
|
-
markerService,
|
|
149
|
-
account,
|
|
150
|
-
"mbx-1",
|
|
151
|
-
log,
|
|
152
|
-
async (event) => {
|
|
153
|
-
emitted.push(event);
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
assert.equal(emitted.length, 2);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Markers persisted before the system-flag wire-format fix carry the
|
|
162
|
-
* unprefixed spelling (`Seen`, not `\Seen`) the enum emitter used to
|
|
163
|
-
* produce. The drain re-arms from the stored `marker.flagName` and never
|
|
164
|
-
* from the enum, and `handleFlagPush` threads that same value through
|
|
165
|
-
* `find`/`updateState`/`delete` — so a marker written under the old
|
|
166
|
-
* spelling still matches itself and drains to completion. No migration,
|
|
167
|
-
* no orphans.
|
|
168
|
-
*/
|
|
169
|
-
it("re-arms a marker persisted under the pre-fix unprefixed spelling verbatim", async () => {
|
|
170
|
-
const markerService = {
|
|
171
|
-
listByMailboxId: async () => [
|
|
172
|
-
marker({ messageId: "legacy-msg", flagName: "Seen" }),
|
|
173
|
-
marker({ messageId: "legacy-star", flagName: "Flagged" }),
|
|
174
|
-
],
|
|
175
|
-
} as unknown as IMessageFlagPushRepository;
|
|
176
|
-
|
|
177
|
-
const emitted: Array<{ messageId: string; flagName: string }> = [];
|
|
178
|
-
const { log } = buildLogger();
|
|
179
|
-
|
|
180
|
-
await drainPendingFlagPushes(
|
|
181
|
-
markerService,
|
|
182
|
-
account,
|
|
183
|
-
"mbx-1",
|
|
184
|
-
log,
|
|
185
|
-
async (event) => {
|
|
186
|
-
emitted.push(event as unknown as (typeof emitted)[number]);
|
|
187
|
-
},
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
assert.deepEqual(
|
|
191
|
-
emitted.map((e) => [e.messageId, e.flagName]),
|
|
192
|
-
[
|
|
193
|
-
["legacy-msg", "Seen"],
|
|
194
|
-
["legacy-star", "Flagged"],
|
|
195
|
-
],
|
|
196
|
-
);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("a re-arm (SQS) failure is caught per-marker and logged loudly — never thrown", async () => {
|
|
200
|
-
const markerService = {
|
|
201
|
-
listByMailboxId: async () => [marker()],
|
|
202
|
-
} as unknown as IMessageFlagPushRepository;
|
|
203
|
-
|
|
204
|
-
const { log, errors } = buildLogger();
|
|
205
|
-
|
|
206
|
-
await assert.doesNotReject(
|
|
207
|
-
drainPendingFlagPushes(markerService, account, "mbx-1", log, async () => {
|
|
208
|
-
throw Object.assign(new Error("queue down"), { code: "ECONNREFUSED" });
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const alerted = errors.find(
|
|
213
|
-
(e) => e.fields.alert === "flag_push_drain_rearm_failed",
|
|
214
|
-
);
|
|
215
|
-
assert.ok(
|
|
216
|
-
alerted,
|
|
217
|
-
"expected an alertable flag_push_drain_rearm_failed log",
|
|
218
|
-
);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("a re-arm failure for one marker does not stop the others from being re-armed", async () => {
|
|
222
|
-
const markerService = {
|
|
223
|
-
listByMailboxId: async () => [
|
|
224
|
-
marker({ messageId: "will-fail", flagName: "\\Seen" }),
|
|
225
|
-
marker({ messageId: "will-succeed", flagName: "\\Flagged" }),
|
|
226
|
-
],
|
|
227
|
-
} as unknown as IMessageFlagPushRepository;
|
|
228
|
-
|
|
229
|
-
const emitted: unknown[] = [];
|
|
230
|
-
const { log } = buildLogger();
|
|
231
|
-
|
|
232
|
-
await drainPendingFlagPushes(
|
|
233
|
-
markerService,
|
|
234
|
-
account,
|
|
235
|
-
"mbx-1",
|
|
236
|
-
log,
|
|
237
|
-
async (event) => {
|
|
238
|
-
if (
|
|
239
|
-
(event as unknown as { messageId: string }).messageId === "will-fail"
|
|
240
|
-
) {
|
|
241
|
-
throw new Error("queue down");
|
|
242
|
-
}
|
|
243
|
-
emitted.push(event);
|
|
244
|
-
},
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
assert.equal(emitted.length, 1);
|
|
248
|
-
assert.equal(
|
|
249
|
-
(emitted[0] as { messageId: string }).messageId,
|
|
250
|
-
"will-succeed",
|
|
251
|
-
);
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
const liveAccount = {
|
|
256
|
-
accountId: "acc-1",
|
|
257
|
-
accountConfigId: "acc-cfg-1",
|
|
258
|
-
imapHost: "localhost",
|
|
259
|
-
syncedServices: ["Mail"],
|
|
260
|
-
username: "user@localhost",
|
|
261
|
-
} as unknown as AccountItem;
|
|
262
|
-
|
|
263
|
-
const syncEvent = (mailboxId: string): SyncMessagesEvent => ({
|
|
264
|
-
type: "SYNC_MESSAGES",
|
|
265
|
-
accountId: "acc-1",
|
|
266
|
-
mailboxId,
|
|
267
|
-
eventId: `evt-${mailboxId}`,
|
|
268
|
-
timestamp: 1,
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* A deps factory whose `withOAuthLifecycle` is a spy that records the call but
|
|
273
|
-
* never invokes the sync callback — the only thing under test here is the
|
|
274
|
-
* terminal gate BEFORE the lifecycle, so reaching (or not reaching) the spy is
|
|
275
|
-
* the observable outcome.
|
|
276
|
-
*/
|
|
277
|
-
const buildSyncDeps = (opts: {
|
|
278
|
-
mailboxGet: () => Promise<unknown>;
|
|
279
|
-
accountGet?: () => Promise<AccountItem>;
|
|
280
|
-
}): { deps: SyncMessagesDeps; lifecycleCalls: number } => {
|
|
281
|
-
const state = { lifecycleCalls: 0 };
|
|
282
|
-
const deps = {
|
|
283
|
-
getClient: async () => ({
|
|
284
|
-
account: {
|
|
285
|
-
get: opts.accountGet ?? (async () => liveAccount),
|
|
286
|
-
},
|
|
287
|
-
mailbox: {
|
|
288
|
-
get: opts.mailboxGet,
|
|
289
|
-
},
|
|
290
|
-
mailboxLock: { releaseLock: async () => {} },
|
|
291
|
-
secrets: {},
|
|
292
|
-
}),
|
|
293
|
-
buildLifecycleDeps: () => ({}),
|
|
294
|
-
withOAuthLifecycle: async () => {
|
|
295
|
-
state.lifecycleCalls += 1;
|
|
296
|
-
},
|
|
297
|
-
} as unknown as SyncMessagesDeps;
|
|
298
|
-
return {
|
|
299
|
-
deps,
|
|
300
|
-
get lifecycleCalls() {
|
|
301
|
-
return state.lifecycleCalls;
|
|
302
|
-
},
|
|
303
|
-
};
|
|
304
|
-
};
|
|
305
|
-
|
|
306
|
-
describe("syncMessages — terminal handling of events for a deleted mailbox (issue #287)", () => {
|
|
307
|
-
it("acks a SYNC_MESSAGES event whose mailbox row is gone — resolves without throwing, never connects", async () => {
|
|
308
|
-
const { log, warns } = buildLogger();
|
|
309
|
-
const harness = buildSyncDeps({
|
|
310
|
-
mailboxGet: async () => {
|
|
311
|
-
throw new NotFoundError("Mailbox not found: mbx-gone");
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
await assert.doesNotReject(
|
|
316
|
-
syncMessages(syncEvent("mbx-gone"), log, harness.deps),
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
assert.equal(
|
|
320
|
-
harness.lifecycleCalls,
|
|
321
|
-
0,
|
|
322
|
-
"a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
|
|
323
|
-
);
|
|
324
|
-
const skip = warns.find((w) =>
|
|
325
|
-
w.msg.includes("a folder mutation is in flight"),
|
|
326
|
-
);
|
|
327
|
-
assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
|
|
328
|
-
assert.equal(skip.fields.accountId, "acc-1");
|
|
329
|
-
assert.equal(skip.fields.mailboxId, "mbx-gone");
|
|
330
|
-
assert.equal(skip.fields.event, "SYNC_MESSAGES");
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("propagates a non-NotFound failure from the mailbox lookup — a transient read stays loud", async () => {
|
|
334
|
-
const { log } = buildLogger();
|
|
335
|
-
const harness = buildSyncDeps({
|
|
336
|
-
mailboxGet: async () => {
|
|
337
|
-
throw new Error("connection reset by peer");
|
|
338
|
-
},
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
await assert.rejects(
|
|
342
|
-
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
343
|
-
/connection reset by peer/,
|
|
344
|
-
);
|
|
345
|
-
assert.equal(harness.lifecycleCalls, 0);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it("proceeds to the sync lifecycle for a live mailbox", async () => {
|
|
349
|
-
const { log } = buildLogger();
|
|
350
|
-
const harness = buildSyncDeps({
|
|
351
|
-
mailboxGet: async () => ({ mailboxId: "mbx-1", fullPath: "INBOX" }),
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
await syncMessages(syncEvent("mbx-1"), log, harness.deps);
|
|
355
|
-
|
|
356
|
-
assert.equal(harness.lifecycleCalls, 1);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
for (const syncStatus of ["pending", "deleting"]) {
|
|
360
|
-
it(`acks a SYNC_MESSAGES event for a \`${syncStatus}\` mailbox — the row exists, the server folder does not`, async () => {
|
|
361
|
-
const { log, warns } = buildLogger();
|
|
362
|
-
const harness = buildSyncDeps({
|
|
363
|
-
mailboxGet: async () => ({
|
|
364
|
-
mailboxId: "mbx-unsettled",
|
|
365
|
-
fullPath: "Archive",
|
|
366
|
-
syncStatus,
|
|
367
|
-
}),
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
await assert.doesNotReject(
|
|
371
|
-
syncMessages(syncEvent("mbx-unsettled"), log, harness.deps),
|
|
372
|
-
);
|
|
373
|
-
|
|
374
|
-
assert.equal(harness.lifecycleCalls, 0);
|
|
375
|
-
assert.ok(
|
|
376
|
-
warns.find((w) => w.msg.includes("a folder mutation is in flight")),
|
|
377
|
-
);
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
it("a deleted-mailbox event does not stall the group — a following live-mailbox event still processes", async () => {
|
|
382
|
-
const { log } = buildLogger();
|
|
383
|
-
const gone = buildSyncDeps({
|
|
384
|
-
mailboxGet: async () => {
|
|
385
|
-
throw new NotFoundError("Mailbox not found: mbx-gone");
|
|
386
|
-
},
|
|
387
|
-
});
|
|
388
|
-
const live = buildSyncDeps({
|
|
389
|
-
mailboxGet: async () => ({ mailboxId: "mbx-live", fullPath: "INBOX" }),
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
await syncMessages(syncEvent("mbx-gone"), log, gone.deps);
|
|
393
|
-
await syncMessages(syncEvent("mbx-live"), log, live.deps);
|
|
394
|
-
|
|
395
|
-
assert.equal(gone.lifecycleCalls, 0);
|
|
396
|
-
assert.equal(live.lifecycleCalls, 1);
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* A deps factory that runs the lifecycle and the mailbox lock for real, so the
|
|
402
|
-
* sync body — and the failure handling around it — is what gets exercised.
|
|
403
|
-
* `mailboxGet` is called per lookup, which is how a delete landing mid-round is
|
|
404
|
-
* expressed: the guard's read succeeds, a later one does not.
|
|
405
|
-
*/
|
|
406
|
-
const buildRunningSyncDeps = (
|
|
407
|
-
mailboxGet: (call: number) => Promise<unknown>,
|
|
408
|
-
): {
|
|
409
|
-
deps: SyncMessagesDeps;
|
|
410
|
-
accountUpdates: Array<Record<string, unknown>>;
|
|
411
|
-
} => {
|
|
412
|
-
const accountUpdates: Array<Record<string, unknown>> = [];
|
|
413
|
-
let calls = 0;
|
|
414
|
-
const deps = {
|
|
415
|
-
getClient: async () => ({
|
|
416
|
-
account: {
|
|
417
|
-
get: async () => liveAccount,
|
|
418
|
-
update: async (_id: string, input: Record<string, unknown>) => {
|
|
419
|
-
accountUpdates.push(input);
|
|
420
|
-
},
|
|
421
|
-
},
|
|
422
|
-
mailbox: {
|
|
423
|
-
get: async () => {
|
|
424
|
-
calls += 1;
|
|
425
|
-
return mailboxGet(calls);
|
|
426
|
-
},
|
|
427
|
-
},
|
|
428
|
-
mailboxLock: {
|
|
429
|
-
releaseLock: async () => {},
|
|
430
|
-
withMailboxLock: async (
|
|
431
|
-
_mailboxId: string,
|
|
432
|
-
_operation: string,
|
|
433
|
-
_accountId: string,
|
|
434
|
-
run: () => Promise<void>,
|
|
435
|
-
) => {
|
|
436
|
-
await run();
|
|
437
|
-
return { executed: true };
|
|
438
|
-
},
|
|
439
|
-
},
|
|
440
|
-
flagPush: { listByMailboxId: async () => [] },
|
|
441
|
-
secrets: {},
|
|
442
|
-
}),
|
|
443
|
-
buildLifecycleDeps: () => ({}),
|
|
444
|
-
withOAuthLifecycle: async (
|
|
445
|
-
_lifecycleDeps: unknown,
|
|
446
|
-
_account: AccountItem,
|
|
447
|
-
_log: Logger,
|
|
448
|
-
run: (credentials: unknown) => Promise<void>,
|
|
449
|
-
) => {
|
|
450
|
-
await run({ type: "password", password: "pw" });
|
|
451
|
-
},
|
|
452
|
-
} as unknown as SyncMessagesDeps;
|
|
453
|
-
return { deps, accountUpdates };
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
describe("syncMessages — a delete that lands mid-round (issue #339)", () => {
|
|
457
|
-
it("resolves terminally when the mailbox went away while the sync was in flight, and records no error phase", async () => {
|
|
458
|
-
const { log, warns } = buildLogger();
|
|
459
|
-
const harness = buildRunningSyncDeps(async (call) => {
|
|
460
|
-
if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
461
|
-
throw new NotFoundError("Mailbox not found: mbx-1");
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
await assert.doesNotReject(
|
|
465
|
-
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
const resolved = warns.find((w) =>
|
|
469
|
-
w.msg.includes("left the server while the sync was in flight"),
|
|
470
|
-
);
|
|
471
|
-
assert.ok(
|
|
472
|
-
resolved,
|
|
473
|
-
"expected a WARN naming the folder that left mid-round",
|
|
474
|
-
);
|
|
475
|
-
assert.equal(resolved.fields.mailboxId, "mbx-1");
|
|
476
|
-
assert.deepEqual(
|
|
477
|
-
harness.accountUpdates,
|
|
478
|
-
[],
|
|
479
|
-
"one folder leaving must not put the account into an error phase",
|
|
480
|
-
);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
it("resolves terminally when the failure is on the server and the row is only marked deleting", async () => {
|
|
484
|
-
const { log, warns } = buildLogger();
|
|
485
|
-
const harness = buildRunningSyncDeps(async (call) => {
|
|
486
|
-
if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
487
|
-
if (call === 2) throw new Error("IMAP SEARCH failed in mailbox null");
|
|
488
|
-
return {
|
|
489
|
-
mailboxId: "mbx-1",
|
|
490
|
-
fullPath: "Archive",
|
|
491
|
-
syncStatus: "deleting",
|
|
492
|
-
};
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
await assert.doesNotReject(
|
|
496
|
-
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
497
|
-
);
|
|
498
|
-
|
|
499
|
-
assert.ok(
|
|
500
|
-
warns.find((w) =>
|
|
501
|
-
w.msg.includes("left the server while the sync was in flight"),
|
|
502
|
-
),
|
|
503
|
-
);
|
|
504
|
-
assert.deepEqual(harness.accountUpdates, []);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* The classifying read must not become the reported failure. A throttled or
|
|
509
|
-
* unreachable repository during the catch previously propagated in place of
|
|
510
|
-
* the real IMAP error, and the `syncPhase: error` write below it never ran —
|
|
511
|
-
* the let-it-crash contract lost both the diagnosis and the state.
|
|
512
|
-
*/
|
|
513
|
-
it("reports the real failure, and still records the error phase, when the classifying read itself fails", async () => {
|
|
514
|
-
const { log } = buildLogger();
|
|
515
|
-
const harness = buildRunningSyncDeps(async (call) => {
|
|
516
|
-
if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
517
|
-
if (call === 2) throw new Error("IMAP SEARCH failed in mailbox null");
|
|
518
|
-
throw new Error("dynamodb throttled");
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
await assert.rejects(
|
|
522
|
-
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
523
|
-
/IMAP SEARCH failed in mailbox null/,
|
|
524
|
-
);
|
|
525
|
-
assert.equal(harness.accountUpdates.length, 1);
|
|
526
|
-
assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
|
|
527
|
-
assert.match(
|
|
528
|
-
String(harness.accountUpdates[0]?.lastError),
|
|
529
|
-
/IMAP SEARCH failed in mailbox null/,
|
|
530
|
-
);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it("still fails loudly when the mailbox is live — the failure is the account's, not a deletion", async () => {
|
|
534
|
-
const { log } = buildLogger();
|
|
535
|
-
const harness = buildRunningSyncDeps(async (call) => {
|
|
536
|
-
if (call === 2) throw new Error("connection reset by peer");
|
|
537
|
-
return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
await assert.rejects(
|
|
541
|
-
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
542
|
-
/connection reset by peer/,
|
|
543
|
-
);
|
|
544
|
-
assert.equal(harness.accountUpdates.length, 1);
|
|
545
|
-
assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
|
|
546
|
-
});
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
describe("syncMessages — an account with mail sync off", () => {
|
|
550
|
-
it("acks the event without connecting and without touching the mailbox", async () => {
|
|
551
|
-
const { log } = buildLogger();
|
|
552
|
-
let mailboxReads = 0;
|
|
553
|
-
const harness = buildSyncDeps({
|
|
554
|
-
accountGet: async () =>
|
|
555
|
-
({ ...liveAccount, syncedServices: ["Calendar"] }) as AccountItem,
|
|
556
|
-
mailboxGet: async () => {
|
|
557
|
-
mailboxReads += 1;
|
|
558
|
-
return {};
|
|
559
|
-
},
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
await assert.doesNotReject(
|
|
563
|
-
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
assert.equal(harness.lifecycleCalls, 0);
|
|
567
|
-
assert.equal(mailboxReads, 0);
|
|
568
|
-
});
|
|
569
|
-
});
|