@remit/imap-worker 0.0.86 → 0.0.87
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 +197 -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 +16 -18
- 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 +192 -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 +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- 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,654 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { beforeEach, describe, it } from "node:test";
|
|
3
|
-
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
4
|
-
import type { MessageCopyEvent } from "../events.js";
|
|
5
|
-
import { handleMessageCopy, type MessageCopyDeps } from "./message-copy.js";
|
|
6
|
-
|
|
7
|
-
interface Call {
|
|
8
|
-
method: string;
|
|
9
|
-
args: unknown[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface Connection {
|
|
13
|
-
openBox: (
|
|
14
|
-
path: string,
|
|
15
|
-
readOnly?: boolean,
|
|
16
|
-
) => Promise<{ uidvalidity: number }>;
|
|
17
|
-
copyMessages: (
|
|
18
|
-
uids: number[],
|
|
19
|
-
dest: string,
|
|
20
|
-
) => Promise<{ uidMap: Map<number, number> }>;
|
|
21
|
-
search: (criteria: unknown[]) => Promise<number[]>;
|
|
22
|
-
createMailbox: (path: string) => Promise<void>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface CopyRow {
|
|
26
|
-
messageId: string;
|
|
27
|
-
mailboxId: string;
|
|
28
|
-
uid: number;
|
|
29
|
-
status: string;
|
|
30
|
-
syncStatus: string;
|
|
31
|
-
messageIdHeader?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Harness {
|
|
35
|
-
calls: Call[];
|
|
36
|
-
account: {
|
|
37
|
-
accountId: string;
|
|
38
|
-
accountConfigId: string;
|
|
39
|
-
deletedAt?: number;
|
|
40
|
-
} | null;
|
|
41
|
-
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
42
|
-
mailboxError?: Error;
|
|
43
|
-
folderPaths: Record<string, string>;
|
|
44
|
-
goneMailboxIds: string[];
|
|
45
|
-
copyRow: CopyRow | null;
|
|
46
|
-
destinationHolds: number[];
|
|
47
|
-
connection: Connection;
|
|
48
|
-
getConnectionCount: number;
|
|
49
|
-
disconnectCount: number;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
let h: Harness;
|
|
53
|
-
|
|
54
|
-
const notFoundError = (): Error =>
|
|
55
|
-
Object.assign(new Error("Mailbox not found: src-mbx"), {
|
|
56
|
-
name: "NotFoundError",
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const record =
|
|
60
|
-
(method: string) =>
|
|
61
|
-
async (...args: unknown[]) => {
|
|
62
|
-
h.calls.push({ method, args });
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const buildConnection = (): Connection => ({
|
|
66
|
-
openBox: async (path: string, readOnly?: boolean) => {
|
|
67
|
-
h.calls.push({ method: "openBox", args: [path, readOnly] });
|
|
68
|
-
return { uidvalidity: 1 };
|
|
69
|
-
},
|
|
70
|
-
copyMessages: async (uids: number[], dest: string) => {
|
|
71
|
-
h.calls.push({ method: "copyMessages", args: [uids, dest] });
|
|
72
|
-
return { uidMap: new Map([[10, 20]]) };
|
|
73
|
-
},
|
|
74
|
-
search: async (criteria: unknown[]) => {
|
|
75
|
-
h.calls.push({ method: "search", args: criteria });
|
|
76
|
-
return h.destinationHolds;
|
|
77
|
-
},
|
|
78
|
-
createMailbox: record("createMailbox") as Connection["createMailbox"],
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const unsettledCopyRow = (): CopyRow => ({
|
|
82
|
-
messageId: "new-msg",
|
|
83
|
-
mailboxId: "dst-mbx",
|
|
84
|
-
uid: 0,
|
|
85
|
-
status: "moving",
|
|
86
|
-
syncStatus: "pending",
|
|
87
|
-
messageIdHeader: "<abc@example.com>",
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const fresh = (): Harness => ({
|
|
91
|
-
calls: [],
|
|
92
|
-
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
93
|
-
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
94
|
-
folderPaths: { "src-mbx": "INBOX", "dst-mbx": "Archive" },
|
|
95
|
-
goneMailboxIds: [],
|
|
96
|
-
copyRow: unsettledCopyRow(),
|
|
97
|
-
destinationHolds: [],
|
|
98
|
-
connection: buildConnection(),
|
|
99
|
-
getConnectionCount: 0,
|
|
100
|
-
disconnectCount: 0,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const deps = (): MessageCopyDeps =>
|
|
104
|
-
({
|
|
105
|
-
getClient: async () => ({
|
|
106
|
-
account: {
|
|
107
|
-
get: async (accountId: string) => {
|
|
108
|
-
h.calls.push({ method: "account.get", args: [accountId] });
|
|
109
|
-
return h.account;
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
message: {
|
|
113
|
-
get: async (messageIds: string[]) => {
|
|
114
|
-
h.calls.push({ method: "message.get", args: [messageIds] });
|
|
115
|
-
return h.copyRow ? [h.copyRow] : [];
|
|
116
|
-
},
|
|
117
|
-
updateUid: record("message.updateUid"),
|
|
118
|
-
update: record("message.update"),
|
|
119
|
-
transitionPlacement: async (...args: unknown[]) => {
|
|
120
|
-
h.calls.push({ method: "message.transitionPlacement", args });
|
|
121
|
-
return { messageId: "new-msg" };
|
|
122
|
-
},
|
|
123
|
-
delete: record("message.delete"),
|
|
124
|
-
},
|
|
125
|
-
threadMessage: {
|
|
126
|
-
findByMessageId: async (cfg: string) => ({
|
|
127
|
-
accountConfigId: cfg,
|
|
128
|
-
threadMessageId: "tm-1",
|
|
129
|
-
sentDate: 1,
|
|
130
|
-
mailboxId: "src-mbx",
|
|
131
|
-
isRead: false,
|
|
132
|
-
isDeleted: false,
|
|
133
|
-
hasStars: false,
|
|
134
|
-
hasAttachment: false,
|
|
135
|
-
}),
|
|
136
|
-
findAllByMessageId: async (cfg: string) => [
|
|
137
|
-
{ accountConfigId: cfg, threadMessageId: "tm-1" },
|
|
138
|
-
],
|
|
139
|
-
deleteMany: record("threadMessage.deleteMany"),
|
|
140
|
-
update: record("threadMessage.update"),
|
|
141
|
-
},
|
|
142
|
-
mailbox: {
|
|
143
|
-
get: async (_accountId: string, mailboxId: string) => {
|
|
144
|
-
if (h.mailboxError) throw h.mailboxError;
|
|
145
|
-
if (h.goneMailboxIds.includes(mailboxId)) throw notFoundError();
|
|
146
|
-
const row =
|
|
147
|
-
mailboxId === h.mailbox.mailboxId
|
|
148
|
-
? h.mailbox
|
|
149
|
-
: { mailboxId, uidValidity: 1, cursorState: "normal" };
|
|
150
|
-
return { ...row, fullPath: h.folderPaths[mailboxId] };
|
|
151
|
-
},
|
|
152
|
-
update: record("mailbox.update"),
|
|
153
|
-
},
|
|
154
|
-
secrets: {},
|
|
155
|
-
}),
|
|
156
|
-
buildLifecycleDeps: () => ({}),
|
|
157
|
-
withOAuthLifecycle: async (
|
|
158
|
-
_deps: unknown,
|
|
159
|
-
_account: unknown,
|
|
160
|
-
_log: unknown,
|
|
161
|
-
cb: (credentials: unknown) => Promise<void>,
|
|
162
|
-
) => cb({}),
|
|
163
|
-
emitEvent: record("emitEvent"),
|
|
164
|
-
createConnectionScope: () => ({
|
|
165
|
-
getConnection: async () => {
|
|
166
|
-
h.getConnectionCount += 1;
|
|
167
|
-
return h.connection;
|
|
168
|
-
},
|
|
169
|
-
disconnect: async () => {
|
|
170
|
-
h.disconnectCount += 1;
|
|
171
|
-
},
|
|
172
|
-
}),
|
|
173
|
-
}) as unknown as MessageCopyDeps;
|
|
174
|
-
|
|
175
|
-
const event: MessageCopyEvent = {
|
|
176
|
-
type: "MESSAGE_COPY",
|
|
177
|
-
accountId: "acc-1",
|
|
178
|
-
sourceMessageId: "src-msg",
|
|
179
|
-
newMessageId: "new-msg",
|
|
180
|
-
sourceMailboxId: "src-mbx",
|
|
181
|
-
sourceMailboxPath: "INBOX",
|
|
182
|
-
destinationMailboxPath: "Archive",
|
|
183
|
-
destinationMailboxId: "dst-mbx",
|
|
184
|
-
uid: 10,
|
|
185
|
-
} as MessageCopyEvent;
|
|
186
|
-
|
|
187
|
-
const called = (method: string): Call[] =>
|
|
188
|
-
h.calls.filter((c) => c.method === method);
|
|
189
|
-
|
|
190
|
-
const askedTheDestination = (): boolean =>
|
|
191
|
-
called("search").length > 0 &&
|
|
192
|
-
called("openBox").some((c) => c.args[0] === "Archive");
|
|
193
|
-
|
|
194
|
-
describe("handleMessageCopy", () => {
|
|
195
|
-
beforeEach(() => {
|
|
196
|
-
h = fresh();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("writes the new UID, marks the copy synced, and updates the thread row", async () => {
|
|
200
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
201
|
-
|
|
202
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
203
|
-
"new-msg",
|
|
204
|
-
20,
|
|
205
|
-
"dst-mbx",
|
|
206
|
-
]);
|
|
207
|
-
// `updateUid` writes the confirmed uid, `active` and `synced` in one
|
|
208
|
-
// statement, so there is no second, unpredicated write behind it.
|
|
209
|
-
assert.equal(called("message.update").length, 0);
|
|
210
|
-
assert.equal(called("threadMessage.update").length, 1);
|
|
211
|
-
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("settles on the destination UID when a server without UIDPLUS omits COPYUID", async () => {
|
|
215
|
-
h.connection.copyMessages = async () => ({ uidMap: new Map() });
|
|
216
|
-
h.destinationHolds = [42];
|
|
217
|
-
|
|
218
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
219
|
-
|
|
220
|
-
assert.ok(askedTheDestination(), "the destination was asked for the copy");
|
|
221
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
222
|
-
"new-msg",
|
|
223
|
-
42,
|
|
224
|
-
"dst-mbx",
|
|
225
|
-
]);
|
|
226
|
-
assert.equal(called("message.update").length, 0);
|
|
227
|
-
assert.equal(called("threadMessage.update").length, 1);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
// Issue #1122. `deriveCopyMessageId` reuses the row, so copying the same
|
|
231
|
-
// message into the same folder twice issues a second server COPY and leaves
|
|
232
|
-
// the destination holding both. Nothing here can rule the older one out the
|
|
233
|
-
// way the trash probe's source check does — a COPY leaves the source
|
|
234
|
-
// untouched — so the row must settle on the highest match. Settling on 12
|
|
235
|
-
// points it at the earlier copy and orphans the one just made.
|
|
236
|
-
it("settles on the copy this event made, not on an earlier copy of the same message", async () => {
|
|
237
|
-
h.connection.copyMessages = async () => ({ uidMap: new Map() });
|
|
238
|
-
h.destinationHolds = [12, 42];
|
|
239
|
-
|
|
240
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
241
|
-
|
|
242
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
243
|
-
"new-msg",
|
|
244
|
-
42,
|
|
245
|
-
"dst-mbx",
|
|
246
|
-
]);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("reconciles the optimistic row away when the destination does not hold the copy", async () => {
|
|
250
|
-
h.connection.copyMessages = async () => ({ uidMap: new Map() });
|
|
251
|
-
h.destinationHolds = [];
|
|
252
|
-
|
|
253
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
254
|
-
|
|
255
|
-
assert.ok(askedTheDestination());
|
|
256
|
-
assert.equal(called("message.delete")[0]?.args[0], "new-msg");
|
|
257
|
-
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
258
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("settles a copy it cannot probe instead of retrying it into duplicates", async () => {
|
|
262
|
-
let copies = 0;
|
|
263
|
-
h.connection.copyMessages = async () => {
|
|
264
|
-
copies += 1;
|
|
265
|
-
return { uidMap: new Map() };
|
|
266
|
-
};
|
|
267
|
-
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
268
|
-
|
|
269
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
270
|
-
|
|
271
|
-
assert.equal(copies, 1, "the COPY is issued once");
|
|
272
|
-
assert.equal(called("search").length, 0, "there is nothing to ask with");
|
|
273
|
-
const [givenUp] = called("message.transitionPlacement");
|
|
274
|
-
assert.deepEqual(
|
|
275
|
-
givenUp?.args[2],
|
|
276
|
-
{
|
|
277
|
-
status: "deleted",
|
|
278
|
-
syncStatus: "abandoned",
|
|
279
|
-
abandonedMutation: "copy",
|
|
280
|
-
},
|
|
281
|
-
"a copy that gave up carries the give-up value and names itself",
|
|
282
|
-
);
|
|
283
|
-
assert.equal(called("message.delete").length, 0, "no row is thrown away");
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("never copies twice when a redelivered copy already landed", async () => {
|
|
287
|
-
h.destinationHolds = [42];
|
|
288
|
-
|
|
289
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
290
|
-
|
|
291
|
-
assert.equal(called("copyMessages").length, 0);
|
|
292
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
293
|
-
"new-msg",
|
|
294
|
-
42,
|
|
295
|
-
"dst-mbx",
|
|
296
|
-
]);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("never copies twice when a redelivered copy cannot be probed", async () => {
|
|
300
|
-
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
301
|
-
let copies = 0;
|
|
302
|
-
h.connection.copyMessages = async () => {
|
|
303
|
-
copies += 1;
|
|
304
|
-
// The COPY lands, then the tagged OK is lost with the connection.
|
|
305
|
-
throw new Error("connection reset by peer");
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
await assert.rejects(
|
|
309
|
-
handleMessageCopy(event, noopLogger, 1, deps()),
|
|
310
|
-
/connection reset/,
|
|
311
|
-
);
|
|
312
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
313
|
-
|
|
314
|
-
assert.equal(copies, 1, "the redelivery issues no second COPY");
|
|
315
|
-
assert.equal(called("search").length, 0);
|
|
316
|
-
const settled = called("message.transitionPlacement").at(-1);
|
|
317
|
-
assert.equal((settled?.args[2] as { status?: string })?.status, "deleted");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("acks a copy that already settled without touching IMAP", async () => {
|
|
321
|
-
h.copyRow = {
|
|
322
|
-
...unsettledCopyRow(),
|
|
323
|
-
uid: 20,
|
|
324
|
-
status: "active",
|
|
325
|
-
syncStatus: "synced",
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
329
|
-
|
|
330
|
-
assert.equal(h.getConnectionCount, 0);
|
|
331
|
-
assert.equal(called("copyMessages").length, 0);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it("acks when the copy row no longer exists", async () => {
|
|
335
|
-
h.copyRow = null;
|
|
336
|
-
|
|
337
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
338
|
-
|
|
339
|
-
assert.equal(h.getConnectionCount, 0);
|
|
340
|
-
assert.equal(called("message.update").length, 0);
|
|
341
|
-
assert.equal(called("message.transitionPlacement").length, 0);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
345
|
-
h.account = {
|
|
346
|
-
accountId: "acc-1",
|
|
347
|
-
accountConfigId: "cfg-1",
|
|
348
|
-
deletedAt: Date.now(),
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
352
|
-
|
|
353
|
-
assert.equal(h.getConnectionCount, 0);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it("throws when the account no longer exists", async () => {
|
|
357
|
-
h.account = null;
|
|
358
|
-
|
|
359
|
-
await assert.rejects(
|
|
360
|
-
handleMessageCopy(event, noopLogger, 1, deps()),
|
|
361
|
-
/not found/,
|
|
362
|
-
);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("acks terminally without connecting when the source mailbox was deleted", async () => {
|
|
366
|
-
h.mailboxError = notFoundError();
|
|
367
|
-
|
|
368
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
369
|
-
|
|
370
|
-
assert.equal(h.getConnectionCount, 0);
|
|
371
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
372
|
-
assert.equal(called("message.update").length, 0);
|
|
373
|
-
assert.equal(called("message.transitionPlacement").length, 0);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
// Issue #1203. Both pauses are reached before `copyMessages`, so the COPY
|
|
377
|
-
// was never issued. Acking left the optimistic row at `uid: 0`/`moving`
|
|
378
|
-
// with nothing to re-enqueue it and no folder set for the cursor rebuild to
|
|
379
|
-
// find it in, which is `holdsCopyOf`'s row-existence answer turning into a
|
|
380
|
-
// copy that can never settle.
|
|
381
|
-
it("reconciles the optimistic copy row away when the cursor is rebuilding", async () => {
|
|
382
|
-
h.mailbox = {
|
|
383
|
-
mailboxId: "src-mbx",
|
|
384
|
-
uidValidity: 1,
|
|
385
|
-
cursorState: "rebuilding",
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
389
|
-
|
|
390
|
-
assert.equal(h.getConnectionCount, 0);
|
|
391
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
392
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
393
|
-
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
// A redelivery's earlier attempt may have copied: the tagged OK can be lost
|
|
397
|
-
// with the connection. Deleting this row on the pause alone would leave the
|
|
398
|
-
// landed copy with no row of its own — `holdsCopyOf` answers on row
|
|
399
|
-
// existence, so no sync makes a second one — and the next destination sync
|
|
400
|
-
// would repoint the SOURCE row out of the folder the server still holds it
|
|
401
|
-
// in. The pause is on the source; the destination is asked before anything
|
|
402
|
-
// is deleted.
|
|
403
|
-
it("asks the destination before reconciling a redelivered copy onto a paused cursor", async () => {
|
|
404
|
-
h.mailbox = {
|
|
405
|
-
mailboxId: "src-mbx",
|
|
406
|
-
uidValidity: 1,
|
|
407
|
-
cursorState: "rebuilding",
|
|
408
|
-
};
|
|
409
|
-
h.destinationHolds = [42];
|
|
410
|
-
|
|
411
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
412
|
-
|
|
413
|
-
assert.ok(askedTheDestination(), "the destination was asked for the copy");
|
|
414
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
415
|
-
"new-msg",
|
|
416
|
-
42,
|
|
417
|
-
"dst-mbx",
|
|
418
|
-
]);
|
|
419
|
-
assert.equal(
|
|
420
|
-
called("message.delete").length,
|
|
421
|
-
0,
|
|
422
|
-
"a copy the destination confirms is never reconciled away",
|
|
423
|
-
);
|
|
424
|
-
assert.equal(h.disconnectCount, 1);
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
it("settles a redelivered copy broken when the destination cannot be asked", async () => {
|
|
428
|
-
h.mailbox = {
|
|
429
|
-
mailboxId: "src-mbx",
|
|
430
|
-
uidValidity: 1,
|
|
431
|
-
cursorState: "rebuilding",
|
|
432
|
-
};
|
|
433
|
-
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
434
|
-
|
|
435
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
436
|
-
|
|
437
|
-
assert.equal(
|
|
438
|
-
called("message.delete").length,
|
|
439
|
-
0,
|
|
440
|
-
"silence is never grounds to delete a row that may describe real mail",
|
|
441
|
-
);
|
|
442
|
-
assert.equal(
|
|
443
|
-
(
|
|
444
|
-
called("message.transitionPlacement")[0]?.args[2] as {
|
|
445
|
-
status?: string;
|
|
446
|
-
}
|
|
447
|
-
)?.status,
|
|
448
|
-
"deleted",
|
|
449
|
-
);
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
// A paused settle is a settle, and this handler had no way to say so: it
|
|
453
|
-
// carried no `emitEvent` at all, so the destination — which is not paused
|
|
454
|
-
// and has no rebuild coming — was never told to re-project. The move and
|
|
455
|
-
// delete handlers have run this resync since #1203.
|
|
456
|
-
it("resyncs both folders after a paused copy settles", async () => {
|
|
457
|
-
h.mailbox = {
|
|
458
|
-
mailboxId: "src-mbx",
|
|
459
|
-
uidValidity: 1,
|
|
460
|
-
cursorState: "rebuilding",
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
464
|
-
|
|
465
|
-
assert.deepEqual(
|
|
466
|
-
called("emitEvent").map((c) => c.args[0]),
|
|
467
|
-
[
|
|
468
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
469
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "dst-mbx" },
|
|
470
|
-
],
|
|
471
|
-
);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it("reconciles a redelivered copy away when the destination denies holding it", async () => {
|
|
475
|
-
h.mailbox = {
|
|
476
|
-
mailboxId: "src-mbx",
|
|
477
|
-
uidValidity: 1,
|
|
478
|
-
cursorState: "rebuilding",
|
|
479
|
-
};
|
|
480
|
-
h.destinationHolds = [];
|
|
481
|
-
|
|
482
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
483
|
-
|
|
484
|
-
assert.ok(askedTheDestination());
|
|
485
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("reconciles the optimistic copy row away when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
489
|
-
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
490
|
-
|
|
491
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
492
|
-
|
|
493
|
-
assert.equal(
|
|
494
|
-
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
495
|
-
?.cursorState,
|
|
496
|
-
"cursor_invalid",
|
|
497
|
-
"the mismatch trips the mailbox cursor",
|
|
498
|
-
);
|
|
499
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
500
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
501
|
-
assert.equal(h.disconnectCount, 1);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
it("creates the destination and rethrows on a TRYCREATE error", async () => {
|
|
505
|
-
h.connection.copyMessages = async () => {
|
|
506
|
-
throw new Error("TRYCREATE: mailbox does not exist");
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
await assert.rejects(
|
|
510
|
-
handleMessageCopy(event, noopLogger, 1, deps()),
|
|
511
|
-
/TRYCREATE/,
|
|
512
|
-
);
|
|
513
|
-
|
|
514
|
-
assert.equal(called("createMailbox")[0]?.args[0], "Archive");
|
|
515
|
-
assert.equal(h.getConnectionCount, 2, "reconnects to create the mailbox");
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
it("marks the copy deleted-and-failed when the source is gone on the server", async () => {
|
|
519
|
-
h.connection.copyMessages = async () => {
|
|
520
|
-
throw new Error("NONEXISTENT source message");
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
524
|
-
|
|
525
|
-
const [givenUp] = called("message.transitionPlacement");
|
|
526
|
-
assert.equal((givenUp?.args[2] as { status?: string })?.status, "deleted");
|
|
527
|
-
assert.equal(
|
|
528
|
-
(givenUp?.args[2] as { abandonedMutation?: string })?.abandonedMutation,
|
|
529
|
-
"copy",
|
|
530
|
-
);
|
|
531
|
-
assert.equal(called("createMailbox").length, 0);
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
// The guard asks whether THIS copy is still outstanding, not whether anything
|
|
535
|
-
// is. A row a delete has claimed is `deleting`, and reading that as unsettled
|
|
536
|
-
// let a redelivery COPY the message a second time — COPY has no source-side
|
|
537
|
-
// effect to make that a no-op, so the duplicate is permanent.
|
|
538
|
-
it("skips a redelivered copy whose row a delete has claimed", async () => {
|
|
539
|
-
let copies = 0;
|
|
540
|
-
h.connection.copyMessages = async () => {
|
|
541
|
-
copies += 1;
|
|
542
|
-
return { uidMap: new Map([[10, 20]]) };
|
|
543
|
-
};
|
|
544
|
-
h.copyRow = { ...unsettledCopyRow(), status: "deleting" };
|
|
545
|
-
|
|
546
|
-
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
547
|
-
|
|
548
|
-
assert.equal(copies, 0, "no second copy is issued");
|
|
549
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("marks failed and rethrows on an unclassified IMAP error within the budget", async () => {
|
|
553
|
-
h.connection.copyMessages = async () => {
|
|
554
|
-
throw new Error("server exploded");
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
await assert.rejects(
|
|
558
|
-
handleMessageCopy(event, noopLogger, 1, deps()),
|
|
559
|
-
/server exploded/,
|
|
560
|
-
);
|
|
561
|
-
|
|
562
|
-
// The attempt marker is a transition too, predicated on this copy still
|
|
563
|
-
// being outstanding: a row another mutation has claimed keeps its own
|
|
564
|
-
// placement rather than picking up this attempt's failure.
|
|
565
|
-
const [marker] = called("message.transitionPlacement");
|
|
566
|
-
assert.deepEqual(marker?.args[1], { status: "moving" });
|
|
567
|
-
assert.deepEqual(marker?.args[2], { syncStatus: "failed" });
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it("settles on the destination UID instead of dead-lettering the last attempt", async () => {
|
|
571
|
-
h.connection.copyMessages = async () => {
|
|
572
|
-
throw new Error("server exploded");
|
|
573
|
-
};
|
|
574
|
-
let asked = 0;
|
|
575
|
-
h.connection.search = async (criteria: unknown[]) => {
|
|
576
|
-
h.calls.push({ method: "search", args: criteria });
|
|
577
|
-
asked += 1;
|
|
578
|
-
return asked === 1 ? [] : [42];
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
await handleMessageCopy(event, noopLogger, 3, deps());
|
|
582
|
-
|
|
583
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
584
|
-
"new-msg",
|
|
585
|
-
42,
|
|
586
|
-
"dst-mbx",
|
|
587
|
-
]);
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
it("reconciles the row away when the last attempt finds nothing at the destination", async () => {
|
|
591
|
-
h.connection.copyMessages = async () => {
|
|
592
|
-
throw new Error("server exploded");
|
|
593
|
-
};
|
|
594
|
-
h.destinationHolds = [];
|
|
595
|
-
|
|
596
|
-
await handleMessageCopy(event, noopLogger, 3, deps());
|
|
597
|
-
|
|
598
|
-
assert.equal(called("message.delete")[0]?.args[0], "new-msg");
|
|
599
|
-
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
it("runs a copy enqueued with the unversioned payload that carries folder paths", async () => {
|
|
603
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
604
|
-
|
|
605
|
-
assert.deepEqual(called("copyMessages")[0]?.args, [[10], "Archive"]);
|
|
606
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
607
|
-
"new-msg",
|
|
608
|
-
20,
|
|
609
|
-
"dst-mbx",
|
|
610
|
-
]);
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
it("runs a copy enqueued with the pathless payload", async () => {
|
|
614
|
-
const pathless: MessageCopyEvent = {
|
|
615
|
-
type: "MESSAGE_COPY",
|
|
616
|
-
schemaVersion: 3,
|
|
617
|
-
eventId: "evt-1",
|
|
618
|
-
timestamp: 1,
|
|
619
|
-
accountId: "acc-1",
|
|
620
|
-
sourceMessageId: "src-msg",
|
|
621
|
-
newMessageId: "new-msg",
|
|
622
|
-
sourceMailboxId: "src-mbx",
|
|
623
|
-
destinationMailboxId: "dst-mbx",
|
|
624
|
-
uid: 10,
|
|
625
|
-
};
|
|
626
|
-
|
|
627
|
-
await handleMessageCopy(pathless, noopLogger, 1, deps());
|
|
628
|
-
|
|
629
|
-
assert.deepEqual(called("openBox")[0]?.args, ["INBOX", true]);
|
|
630
|
-
assert.deepEqual(called("copyMessages")[0]?.args, [[10], "Archive"]);
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
it("copies between the paths the rows hold when both folders were renamed after enqueue", async () => {
|
|
634
|
-
h.folderPaths = { "src-mbx": "Old/INBOX", "dst-mbx": "Records/Archive" };
|
|
635
|
-
|
|
636
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
637
|
-
|
|
638
|
-
assert.deepEqual(called("openBox")[0]?.args, ["Old/INBOX", true]);
|
|
639
|
-
assert.deepEqual(called("copyMessages")[0]?.args, [
|
|
640
|
-
[10],
|
|
641
|
-
"Records/Archive",
|
|
642
|
-
]);
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("reconciles the copy row away without connecting when the destination mailbox was deleted", async () => {
|
|
646
|
-
h.goneMailboxIds = ["dst-mbx"];
|
|
647
|
-
|
|
648
|
-
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
649
|
-
|
|
650
|
-
assert.equal(h.getConnectionCount, 0);
|
|
651
|
-
assert.equal(called("copyMessages").length, 0);
|
|
652
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
653
|
-
});
|
|
654
|
-
});
|