@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,38 +1,16 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
9
|
-
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
eml,
|
|
6
|
+
FakeImapServer,
|
|
7
|
+
} from "@remit/mailbox-service/test-helpers/fake-imap";
|
|
10
8
|
import {
|
|
11
9
|
buildThreadMessageMoveUpdate,
|
|
12
|
-
emitMoveResync,
|
|
13
|
-
getMessageMoveMaxAttempts,
|
|
14
|
-
handleMessageMove,
|
|
15
|
-
MESSAGE_MOVE_MAX_ATTEMPTS,
|
|
16
|
-
type MessageMoveDeps,
|
|
17
|
-
moveThenResync,
|
|
18
10
|
probePausedPlacement,
|
|
19
11
|
searchMailboxForHighestMessageIdUid,
|
|
20
12
|
} from "./message-move.js";
|
|
21
13
|
|
|
22
|
-
const silentLogger = (() => {
|
|
23
|
-
const noop = () => {};
|
|
24
|
-
const log = {
|
|
25
|
-
info: noop,
|
|
26
|
-
warn: noop,
|
|
27
|
-
error: noop,
|
|
28
|
-
debug: noop,
|
|
29
|
-
fatal: noop,
|
|
30
|
-
trace: noop,
|
|
31
|
-
child: () => log,
|
|
32
|
-
} as unknown as Logger;
|
|
33
|
-
return log;
|
|
34
|
-
})();
|
|
35
|
-
|
|
36
14
|
const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
|
|
37
15
|
const destinationMailboxId = "destination-mailbox-aaaaa";
|
|
38
16
|
|
|
@@ -80,39 +58,6 @@ describe("buildThreadMessageMoveUpdate", () => {
|
|
|
80
58
|
);
|
|
81
59
|
});
|
|
82
60
|
|
|
83
|
-
it("composites.mailboxId is the CURRENT (source) mailboxId, not the destination", () => {
|
|
84
|
-
const args = buildThreadMessageMoveUpdate(
|
|
85
|
-
baseThreadMessage,
|
|
86
|
-
42,
|
|
87
|
-
destinationMailboxId,
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
assert.strictEqual(
|
|
91
|
-
args.composites.mailboxId,
|
|
92
|
-
sourceMailboxId,
|
|
93
|
-
"composites.mailboxId must be the CURRENT source mailbox; passing the destination breaks the conditional check",
|
|
94
|
-
);
|
|
95
|
-
assert.notStrictEqual(
|
|
96
|
-
args.composites.mailboxId,
|
|
97
|
-
destinationMailboxId,
|
|
98
|
-
"composites.mailboxId must NOT match the new destinationMailboxId",
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("composites.isDeleted is the CURRENT value, not the new false", () => {
|
|
103
|
-
const args = buildThreadMessageMoveUpdate(
|
|
104
|
-
{ ...baseThreadMessage, isDeleted: true },
|
|
105
|
-
42,
|
|
106
|
-
destinationMailboxId,
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
assert.strictEqual(
|
|
110
|
-
args.composites.isDeleted,
|
|
111
|
-
true,
|
|
112
|
-
"composites.isDeleted must be the CURRENT value (true here), not the new value",
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
61
|
it("composites mirrors every CURRENT indexed attribute on the threadMessage", () => {
|
|
117
62
|
const tm = {
|
|
118
63
|
sentDate: 1700000000123,
|
|
@@ -136,868 +81,136 @@ describe("buildThreadMessageMoveUpdate", () => {
|
|
|
136
81
|
});
|
|
137
82
|
});
|
|
138
83
|
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
describe("emitMoveResync (#1031)", () => {
|
|
142
|
-
// A move shifts a message between two folders; both folders' counts must
|
|
143
|
-
// refresh from IMAP via the existing per-folder SYNC_MESSAGES sync. Counts
|
|
144
|
-
// are never mutated locally — only re-read downstream.
|
|
145
|
-
|
|
146
|
-
it("emits SYNC_MESSAGES for both the source and destination folders", async () => {
|
|
147
|
-
const emit = mock.fn(async () => undefined);
|
|
148
|
-
|
|
149
|
-
await emitMoveResync(emit, {
|
|
150
|
-
accountId,
|
|
151
|
-
sourceMailboxId,
|
|
152
|
-
destinationMailboxId,
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
assert.equal(emit.mock.calls.length, 2);
|
|
156
|
-
assert.deepEqual(emit.mock.calls[0].arguments, [
|
|
157
|
-
{ type: "SYNC_MESSAGES", accountId, mailboxId: sourceMailboxId },
|
|
158
|
-
]);
|
|
159
|
-
assert.deepEqual(emit.mock.calls[1].arguments, [
|
|
160
|
-
{ type: "SYNC_MESSAGES", accountId, mailboxId: destinationMailboxId },
|
|
161
|
-
]);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe("moveThenResync (#1031)", () => {
|
|
166
|
-
it("runs the resync after the move resolves", async () => {
|
|
167
|
-
const order: string[] = [];
|
|
168
|
-
const performMove = mock.fn(async () => {
|
|
169
|
-
order.push("move");
|
|
170
|
-
});
|
|
171
|
-
const resync = mock.fn(async () => {
|
|
172
|
-
order.push("resync");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
await moveThenResync(performMove, resync);
|
|
176
|
-
|
|
177
|
-
assert.deepEqual(order, ["move", "resync"]);
|
|
178
|
-
assert.equal(resync.mock.calls.length, 1);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("does not resync when the move fails, and propagates the error", async () => {
|
|
182
|
-
const performMove = mock.fn(async () => {
|
|
183
|
-
throw new Error("IMAP move failed");
|
|
184
|
-
});
|
|
185
|
-
const resync = mock.fn(async () => undefined);
|
|
186
|
-
|
|
187
|
-
await assert.rejects(
|
|
188
|
-
() => moveThenResync(performMove, resync),
|
|
189
|
-
/IMAP move failed/,
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
assert.equal(resync.mock.calls.length, 0);
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe("getMessageMoveMaxAttempts — env-derived threshold (#655)", () => {
|
|
197
|
-
it("parses the injected env var", () => {
|
|
198
|
-
assert.equal(
|
|
199
|
-
getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "3" }),
|
|
200
|
-
3,
|
|
201
|
-
);
|
|
202
|
-
assert.equal(
|
|
203
|
-
getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "5" }),
|
|
204
|
-
5,
|
|
205
|
-
);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("defaults to the message queue's own maxReceiveCount when unset", () => {
|
|
209
|
-
assert.equal(getMessageMoveMaxAttempts({}), 3);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("defaults on a non-numeric or non-positive value", () => {
|
|
213
|
-
assert.equal(
|
|
214
|
-
getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "nope" }),
|
|
215
|
-
3,
|
|
216
|
-
);
|
|
217
|
-
assert.equal(
|
|
218
|
-
getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "0" }),
|
|
219
|
-
3,
|
|
220
|
-
);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("MESSAGE_MOVE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
|
|
224
|
-
assert.ok(MESSAGE_MOVE_MAX_ATTEMPTS > 0);
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
describe("handleMessageMove — the move's own pending state gates every attempt", () => {
|
|
229
|
-
const acctId = "mm-acc-zzz";
|
|
230
|
-
|
|
231
|
-
const cappedAccount = (): AccountItem =>
|
|
232
|
-
({
|
|
233
|
-
accountId: acctId,
|
|
234
|
-
accountConfigId: "mm-cfg-zzz",
|
|
235
|
-
connectionState: "authenticated",
|
|
236
|
-
username: "mm@imap.example.com",
|
|
237
|
-
imapHost: "imap.example.com",
|
|
238
|
-
imapPort: 993,
|
|
239
|
-
imapTls: true,
|
|
240
|
-
passwordHash: JSON.stringify({
|
|
241
|
-
encryptedDek: "",
|
|
242
|
-
encryptedData: "",
|
|
243
|
-
iv: "",
|
|
244
|
-
authTag: "",
|
|
245
|
-
}),
|
|
246
|
-
}) as unknown as AccountItem;
|
|
247
|
-
|
|
248
|
-
const event: MessageMoveEvent = {
|
|
249
|
-
type: "MESSAGE_MOVE",
|
|
250
|
-
accountId: acctId,
|
|
251
|
-
accountConfigId: "mm-cfg-zzz",
|
|
252
|
-
messageId: "mm-msg-zzz",
|
|
253
|
-
sourceMailboxId: "mm-src-zzz",
|
|
254
|
-
sourceMailboxPath: "INBOX",
|
|
255
|
-
destinationMailboxId: "mm-dst-zzz",
|
|
256
|
-
destinationMailboxPath: "Archive",
|
|
257
|
-
uid: 10,
|
|
258
|
-
eventId: "mm-evt-zzz",
|
|
259
|
-
timestamp: 1700000000000,
|
|
260
|
-
} as MessageMoveEvent;
|
|
261
|
-
|
|
262
|
-
const pendingRow = () => ({
|
|
263
|
-
messageId: "mm-msg-zzz",
|
|
264
|
-
mailboxId: "mm-dst-zzz",
|
|
265
|
-
uid: 10,
|
|
266
|
-
status: "moving",
|
|
267
|
-
syncStatus: "pending",
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
// The client is supplied by injection (`setClient`), so these tests register
|
|
271
|
-
// the repositories they then mock rather than reaching a composition.
|
|
272
|
-
before(() => {
|
|
273
|
-
setClient({
|
|
274
|
-
account: { get: async () => undefined },
|
|
275
|
-
mailbox: { get: async () => undefined },
|
|
276
|
-
message: {
|
|
277
|
-
get: async () => [],
|
|
278
|
-
update: async () => undefined,
|
|
279
|
-
updateUid: async () => undefined,
|
|
280
|
-
transitionPlacement: async () => undefined,
|
|
281
|
-
},
|
|
282
|
-
threadMessage: {
|
|
283
|
-
findAllByMessageId: async () => [],
|
|
284
|
-
findByMessageId: async () => undefined,
|
|
285
|
-
update: async () => undefined,
|
|
286
|
-
},
|
|
287
|
-
secrets: { decrypt: async () => undefined },
|
|
288
|
-
} as unknown as RemitClient);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
let emitted: unknown[] = [];
|
|
292
|
-
let connectCount = 0;
|
|
293
|
-
let logLines: { level: string; fields: Record<string, unknown> }[] = [];
|
|
294
|
-
|
|
295
|
-
// The level a give-up is logged at is behaviour: a routine pause must not
|
|
296
|
-
// page anyone, and a placement the server never settled must.
|
|
297
|
-
const capturingLogger = (): Logger => {
|
|
298
|
-
const at =
|
|
299
|
-
(level: string) =>
|
|
300
|
-
(fields: Record<string, unknown>): void => {
|
|
301
|
-
logLines.push({ level, fields });
|
|
302
|
-
};
|
|
303
|
-
const log = {
|
|
304
|
-
info: at("info"),
|
|
305
|
-
warn: at("warn"),
|
|
306
|
-
error: at("error"),
|
|
307
|
-
debug: at("debug"),
|
|
308
|
-
fatal: at("fatal"),
|
|
309
|
-
trace: at("trace"),
|
|
310
|
-
child: () => log,
|
|
311
|
-
} as unknown as Logger;
|
|
312
|
-
return log;
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
// One folder's Message-ID SEARCH answers, keyed by the box last opened.
|
|
316
|
-
// `openBox` on this handle is deliberately unguarded: the paused settle asks
|
|
317
|
-
// on the identity axis, which a UIDVALIDITY change leaves intact.
|
|
318
|
-
const holdingConnection = (
|
|
319
|
-
holdings: Record<string, number[]>,
|
|
320
|
-
): IImapConnection => {
|
|
321
|
-
let opened = "";
|
|
322
|
-
return {
|
|
323
|
-
openBox: async (path: string) => {
|
|
324
|
-
opened = path;
|
|
325
|
-
return {} as never;
|
|
326
|
-
},
|
|
327
|
-
search: async () => holdings[opened] ?? [],
|
|
328
|
-
} as unknown as IImapConnection;
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
const moveDeps = (connection?: IImapConnection): MessageMoveDeps => ({
|
|
332
|
-
getClient,
|
|
333
|
-
buildLifecycleDeps,
|
|
334
|
-
withOAuthLifecycle,
|
|
335
|
-
createConnectionScope: () => ({
|
|
336
|
-
getConnection: async () => {
|
|
337
|
-
connectCount += 1;
|
|
338
|
-
if (!connection) throw new Error("this case must not connect");
|
|
339
|
-
return connection;
|
|
340
|
-
},
|
|
341
|
-
disconnect: async () => undefined,
|
|
342
|
-
}),
|
|
343
|
-
emitEvent: (async (event: unknown) => {
|
|
344
|
-
emitted.push(event);
|
|
345
|
-
}) as MessageMoveDeps["emitEvent"],
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
const pausedSource = () => ({
|
|
349
|
-
mailboxId: "mm-src-zzz",
|
|
350
|
-
uidValidity: 1,
|
|
351
|
-
cursorState: "rebuilding",
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
beforeEach(() => {
|
|
355
|
-
emitted = [];
|
|
356
|
-
connectCount = 0;
|
|
357
|
-
logLines = [];
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
afterEach(() => mock.restoreAll());
|
|
361
|
-
|
|
362
|
-
it("acks without connecting when the source mailbox was deleted", async () => {
|
|
363
|
-
const client = await getClient();
|
|
364
|
-
mock.method(client.account, "get", async () => cappedAccount());
|
|
365
|
-
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
366
|
-
mock.method(client.message, "get", async () => [pendingRow()]);
|
|
367
|
-
mock.method(client.mailbox, "get", async () => {
|
|
368
|
-
throw Object.assign(new Error("Mailbox not found: mm-src-zzz"), {
|
|
369
|
-
name: "NotFoundError",
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
const updateUid = mock.method(client.message, "updateUid", async () => {});
|
|
373
|
-
|
|
374
|
-
// Must resolve, not reject — a deleted source folder makes the move moot.
|
|
375
|
-
await handleMessageMove(event, silentLogger);
|
|
376
|
-
|
|
377
|
-
assert.equal(
|
|
378
|
-
updateUid.mock.calls.length,
|
|
379
|
-
0,
|
|
380
|
-
"a deleted mailbox never reaches the IMAP move",
|
|
381
|
-
);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// Without this gate a redelivery of an already-confirmed move re-runs the
|
|
385
|
-
// MOVE against a UID the source no longer holds, and on exhaustion the
|
|
386
|
-
// terminal resolver reads the source's honest "gone" as grounds to delete a
|
|
387
|
-
// row that is correct and settled.
|
|
388
|
-
it("acks without connecting when the move already settled", async () => {
|
|
389
|
-
const client = await getClient();
|
|
390
|
-
mock.method(client.account, "get", async () => cappedAccount());
|
|
391
|
-
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
392
|
-
mock.method(client.message, "get", async () => [
|
|
393
|
-
{ ...pendingRow(), uid: 4711, status: "active", syncStatus: "synced" },
|
|
394
|
-
]);
|
|
395
|
-
const mailboxGet = mock.method(client.mailbox, "get", async () => {
|
|
396
|
-
throw new Error("a settled move must never resolve a mailbox");
|
|
397
|
-
});
|
|
398
|
-
const update = mock.method(client.message, "update", async () => {});
|
|
399
|
-
|
|
400
|
-
await handleMessageMove(event, silentLogger, MESSAGE_MOVE_MAX_ATTEMPTS);
|
|
401
|
-
|
|
402
|
-
assert.equal(mailboxGet.mock.calls.length, 0);
|
|
403
|
-
assert.equal(
|
|
404
|
-
update.mock.calls.length,
|
|
405
|
-
0,
|
|
406
|
-
"a settled row is never written again",
|
|
407
|
-
);
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
const arrangePausedMove = async (
|
|
411
|
-
options: { probeable: boolean } = { probeable: true },
|
|
412
|
-
): Promise<{
|
|
413
|
-
transitionCalls: unknown[][];
|
|
414
|
-
updateUidCalls: unknown[][];
|
|
415
|
-
threadUpdateCalls: unknown[][];
|
|
416
|
-
}> => {
|
|
417
|
-
const client = await getClient();
|
|
418
|
-
mock.method(client.account, "get", async () => cappedAccount());
|
|
419
|
-
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
420
|
-
mock.method(client.message, "get", async () => [
|
|
421
|
-
{
|
|
422
|
-
...pendingRow(),
|
|
423
|
-
messageIdHeader: options.probeable ? "<moved@example.com>" : undefined,
|
|
424
|
-
},
|
|
425
|
-
]);
|
|
426
|
-
mock.method(
|
|
427
|
-
client.mailbox,
|
|
428
|
-
"get",
|
|
429
|
-
async (_accountId: string, id: string) =>
|
|
430
|
-
id === "mm-src-zzz"
|
|
431
|
-
? { ...pausedSource(), fullPath: "INBOX" }
|
|
432
|
-
: {
|
|
433
|
-
mailboxId: id,
|
|
434
|
-
uidValidity: 1,
|
|
435
|
-
cursorState: "normal",
|
|
436
|
-
fullPath: "Archive",
|
|
437
|
-
},
|
|
438
|
-
);
|
|
439
|
-
const transitionCalls: unknown[][] = [];
|
|
440
|
-
mock.method(
|
|
441
|
-
client.message,
|
|
442
|
-
"transitionPlacement",
|
|
443
|
-
async (...args: unknown[]) => {
|
|
444
|
-
transitionCalls.push(args);
|
|
445
|
-
return { messageId: "mm-msg-zzz" };
|
|
446
|
-
},
|
|
447
|
-
);
|
|
448
|
-
const updateUidCalls: unknown[][] = [];
|
|
449
|
-
mock.method(client.message, "updateUid", async (...args: unknown[]) => {
|
|
450
|
-
updateUidCalls.push(args);
|
|
451
|
-
});
|
|
452
|
-
const threadRow = {
|
|
453
|
-
...baseThreadMessage,
|
|
454
|
-
accountConfigId: "mm-cfg-zzz",
|
|
455
|
-
threadMessageId: "mm-tm-zzz",
|
|
456
|
-
mailboxId: "mm-dst-zzz",
|
|
457
|
-
};
|
|
458
|
-
mock.method(client.threadMessage, "findAllByMessageId", async () => [
|
|
459
|
-
threadRow,
|
|
460
|
-
]);
|
|
461
|
-
mock.method(client.threadMessage, "findByMessageId", async () => threadRow);
|
|
462
|
-
const threadUpdateCalls: unknown[][] = [];
|
|
463
|
-
mock.method(client.threadMessage, "update", async (...args: unknown[]) => {
|
|
464
|
-
threadUpdateCalls.push(args);
|
|
465
|
-
});
|
|
466
|
-
return { transitionCalls, updateUidCalls, threadUpdateCalls };
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
// Issue #1203. Acking a paused cursor left the row `moving` with `mailboxId`
|
|
470
|
-
// on the destination and `uid` on the source. Nothing re-enqueues a
|
|
471
|
-
// MESSAGE_MOVE, and the cursor rebuild matches rows by
|
|
472
|
-
// `(accountConfigId, mailboxId)`, so a row naming the destination sits in
|
|
473
|
-
// neither folder's set: `placementBindingOf` answered `in_flight` for good
|
|
474
|
-
// and the message became unmovable and undeletable.
|
|
475
|
-
it("hands the row back to its source on a first delivery, without connecting", async () => {
|
|
476
|
-
const { transitionCalls, threadUpdateCalls } = await arrangePausedMove();
|
|
477
|
-
|
|
478
|
-
await handleMessageMove(event, silentLogger, 1, moveDeps());
|
|
479
|
-
|
|
480
|
-
assert.equal(
|
|
481
|
-
connectCount,
|
|
482
|
-
0,
|
|
483
|
-
"a first delivery has provably issued no MOVE, so it needs no answer from the server",
|
|
484
|
-
);
|
|
485
|
-
assert.deepEqual(
|
|
486
|
-
transitionCalls[0],
|
|
487
|
-
[
|
|
488
|
-
"mm-msg-zzz",
|
|
489
|
-
{ status: ["moving", "deleting"] },
|
|
490
|
-
{
|
|
491
|
-
mailboxId: "mm-src-zzz",
|
|
492
|
-
uid: 10,
|
|
493
|
-
status: "active",
|
|
494
|
-
syncStatus: "synced",
|
|
495
|
-
abandonedMutation: "none",
|
|
496
|
-
},
|
|
497
|
-
],
|
|
498
|
-
"the row goes back to the source pair, which is the set the rebuild adjudicates",
|
|
499
|
-
);
|
|
500
|
-
assert.equal(
|
|
501
|
-
(threadUpdateCalls[0]?.[2] as { mailboxId?: string })?.mailboxId,
|
|
502
|
-
"mm-src-zzz",
|
|
503
|
-
"the listing row follows the message back to its source folder",
|
|
504
|
-
);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
// A paused settle is a settle, so both folders re-read their counts from
|
|
508
|
-
// IMAP — the same resync every other terminal verdict in this handler runs.
|
|
509
|
-
// Without it the repair depended entirely on someone else arming the
|
|
510
|
-
// mailbox's rebuild.
|
|
511
|
-
it("resyncs both folders after a paused move settles", async () => {
|
|
512
|
-
await arrangePausedMove();
|
|
513
|
-
|
|
514
|
-
await handleMessageMove(event, silentLogger, 1, moveDeps());
|
|
515
|
-
|
|
516
|
-
assert.deepEqual(emitted, [
|
|
517
|
-
{ type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-src-zzz" },
|
|
518
|
-
{ type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-dst-zzz" },
|
|
519
|
-
]);
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
// Issue #1203, the redelivery half, and the mail-loss edge in it. INBOX was
|
|
523
|
-
// recreated (UIDVALIDITY bumped, which is why the cursor is paused), the
|
|
524
|
-
// MOVE never ran, and INBOX honestly answers "no" for the Message-ID because
|
|
525
|
-
// it no longer holds anything it used to. Archive holds an older copy of the
|
|
526
|
-
// same Message-ID at uid 40 — a sieve `fileinto` + `keep`, a resend — which
|
|
527
|
-
// `deriveMessageId` folds into this one local row. Settling `moved` on 40
|
|
528
|
-
// binds the row to that copy, and the next permanent delete expunges it:
|
|
529
|
-
// Archive is not paused, so no rebuild is ever coming to adjudicate there.
|
|
530
|
-
it("never settles a redelivered paused move as moved off a destination sighting", async () => {
|
|
531
|
-
const { transitionCalls, updateUidCalls } = await arrangePausedMove();
|
|
532
|
-
|
|
533
|
-
await handleMessageMove(
|
|
534
|
-
event,
|
|
535
|
-
capturingLogger(),
|
|
536
|
-
2,
|
|
537
|
-
moveDeps(holdingConnection({ INBOX: [], Archive: [40] })),
|
|
538
|
-
);
|
|
539
|
-
|
|
540
|
-
assert.equal(
|
|
541
|
-
updateUidCalls.length,
|
|
542
|
-
0,
|
|
543
|
-
"a sighting off a source whose uid axis has moved is not proof this move ran",
|
|
544
|
-
);
|
|
545
|
-
// The source pair is the set its own cursor rebuild walks and adjudicates
|
|
546
|
-
// by Message-ID, so handing the row back there is the reconcile path.
|
|
547
|
-
assert.deepEqual(transitionCalls[0]?.[2], {
|
|
548
|
-
mailboxId: "mm-src-zzz",
|
|
549
|
-
uid: 10,
|
|
550
|
-
status: "active",
|
|
551
|
-
syncStatus: "abandoned",
|
|
552
|
-
abandonedMutation: "move",
|
|
553
|
-
});
|
|
554
|
-
assert.equal(
|
|
555
|
-
logLines.filter(
|
|
556
|
-
(line) =>
|
|
557
|
-
line.fields.alert === "message_move_paused_placement_unproven",
|
|
558
|
-
).length,
|
|
559
|
-
1,
|
|
560
|
-
"a placement the server never settled is an operator's to adjudicate",
|
|
561
|
-
);
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
// The taxonomy `message-copy.ts` already applies: an evidence-free
|
|
565
|
-
// redelivery is broken, not settled. With no Message-ID header neither
|
|
566
|
-
// folder was asked, and silence is not an answer to write `synced` on.
|
|
567
|
-
it("settles a redelivered paused move broken when neither folder can be asked", async () => {
|
|
568
|
-
const { transitionCalls } = await arrangePausedMove({
|
|
569
|
-
probeable: false,
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
await handleMessageMove(
|
|
573
|
-
event,
|
|
574
|
-
capturingLogger(),
|
|
575
|
-
2,
|
|
576
|
-
moveDeps(holdingConnection({})),
|
|
577
|
-
);
|
|
578
|
-
|
|
579
|
-
assert.equal(
|
|
580
|
-
(transitionCalls[0]?.[2] as { syncStatus?: string })?.syncStatus,
|
|
581
|
-
"abandoned",
|
|
582
|
-
);
|
|
583
|
-
assert.equal(
|
|
584
|
-
logLines.filter(
|
|
585
|
-
(line) =>
|
|
586
|
-
line.fields.alert === "message_move_paused_placement_unproven",
|
|
587
|
-
).length,
|
|
588
|
-
1,
|
|
589
|
-
);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
// The user's move was silently dropped. Nothing throws, nothing alerts and
|
|
593
|
-
// the row reads settled, so the only trace it ever leaves is this metric —
|
|
594
|
-
// the counterpart of copy's `message_copy_not_landed`.
|
|
595
|
-
it("counts a paused move the server was never told about", async () => {
|
|
596
|
-
await arrangePausedMove();
|
|
84
|
+
const _accountId = "alice-account-aaaaaaaaaa";
|
|
597
85
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// answer ends it.
|
|
611
|
-
it("never binds a redelivered paused move to an older copy while the source still answers", async () => {
|
|
612
|
-
const { transitionCalls, updateUidCalls } = await arrangePausedMove();
|
|
613
|
-
|
|
614
|
-
await handleMessageMove(
|
|
615
|
-
event,
|
|
616
|
-
silentLogger,
|
|
617
|
-
2,
|
|
618
|
-
moveDeps(holdingConnection({ INBOX: [4], Archive: [12] })),
|
|
619
|
-
);
|
|
620
|
-
|
|
621
|
-
assert.equal(updateUidCalls.length, 0);
|
|
622
|
-
assert.deepEqual(transitionCalls[0]?.[2], {
|
|
623
|
-
mailboxId: "mm-src-zzz",
|
|
624
|
-
uid: 10,
|
|
625
|
-
status: "active",
|
|
626
|
-
syncStatus: "synced",
|
|
627
|
-
abandonedMutation: "none",
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
const arrangeLiveMove = async (
|
|
632
|
-
folders: Record<string, string>,
|
|
633
|
-
): Promise<{
|
|
634
|
-
opened: string[];
|
|
635
|
-
movedTo: string[];
|
|
636
|
-
updateUidCalls: unknown[][];
|
|
637
|
-
transitionCalls: unknown[][];
|
|
638
|
-
connection: IImapConnection;
|
|
639
|
-
}> => {
|
|
640
|
-
const client = await getClient();
|
|
641
|
-
mock.method(client.account, "get", async () => cappedAccount());
|
|
642
|
-
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
643
|
-
mock.method(client.message, "get", async () => [pendingRow()]);
|
|
644
|
-
mock.method(
|
|
645
|
-
client.mailbox,
|
|
646
|
-
"get",
|
|
647
|
-
async (_accountId: string, id: string) => {
|
|
648
|
-
const fullPath = folders[id];
|
|
649
|
-
if (fullPath === undefined) {
|
|
650
|
-
throw Object.assign(new Error(`Mailbox not found: ${id}`), {
|
|
651
|
-
name: "NotFoundError",
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
return {
|
|
655
|
-
mailboxId: id,
|
|
656
|
-
uidValidity: 1,
|
|
657
|
-
cursorState: "normal",
|
|
658
|
-
fullPath,
|
|
659
|
-
};
|
|
660
|
-
},
|
|
661
|
-
);
|
|
662
|
-
const updateUidCalls: unknown[][] = [];
|
|
663
|
-
mock.method(client.message, "updateUid", async (...args: unknown[]) => {
|
|
664
|
-
updateUidCalls.push(args);
|
|
665
|
-
});
|
|
666
|
-
const transitionCalls: unknown[][] = [];
|
|
667
|
-
mock.method(
|
|
668
|
-
client.message,
|
|
669
|
-
"transitionPlacement",
|
|
670
|
-
async (...args: unknown[]) => {
|
|
671
|
-
transitionCalls.push(args);
|
|
672
|
-
return { messageId: "mm-msg-zzz" };
|
|
673
|
-
},
|
|
674
|
-
);
|
|
675
|
-
const opened: string[] = [];
|
|
676
|
-
const movedTo: string[] = [];
|
|
677
|
-
const connection = {
|
|
678
|
-
openBox: async (path: string) => {
|
|
679
|
-
opened.push(path);
|
|
680
|
-
return { uidvalidity: 1 } as never;
|
|
681
|
-
},
|
|
682
|
-
moveMessages: async (_uids: number[], destination: string) => {
|
|
683
|
-
movedTo.push(destination);
|
|
684
|
-
return { uidMap: new Map([[10, 44]]) };
|
|
685
|
-
},
|
|
686
|
-
} as unknown as IImapConnection;
|
|
687
|
-
return { opened, movedTo, updateUidCalls, transitionCalls, connection };
|
|
86
|
+
describe("probePausedPlacement — which folder a paused mutation left the message in (#1203)", () => {
|
|
87
|
+
const HEADER = "<paused@example.com>";
|
|
88
|
+
|
|
89
|
+
const serverHolding = (holdings: Record<string, number[]>) => {
|
|
90
|
+
const server = new FakeImapServer();
|
|
91
|
+
server.addFolder("INBOX");
|
|
92
|
+
server.addFolder("Archive");
|
|
93
|
+
for (const [path, uids] of Object.entries(holdings)) {
|
|
94
|
+
for (const uid of uids)
|
|
95
|
+
server.deliver(path, eml({ "Message-ID": HEADER }), { uid: uid });
|
|
96
|
+
}
|
|
97
|
+
return server;
|
|
688
98
|
};
|
|
689
99
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
"
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
|
|
697
|
-
|
|
698
|
-
assert.deepEqual(movedTo, ["Archive"]);
|
|
699
|
-
assert.deepEqual(updateUidCalls[0], ["mm-msg-zzz", 44, "mm-dst-zzz"]);
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
it("runs a move enqueued with the pathless payload", async () => {
|
|
703
|
-
const { opened, movedTo, connection } = await arrangeLiveMove({
|
|
704
|
-
"mm-src-zzz": "INBOX",
|
|
705
|
-
"mm-dst-zzz": "Archive",
|
|
706
|
-
});
|
|
707
|
-
const pathless: MessageMoveEvent = {
|
|
708
|
-
type: "MESSAGE_MOVE",
|
|
709
|
-
schemaVersion: 3,
|
|
710
|
-
accountId: acctId,
|
|
711
|
-
messageId: "mm-msg-zzz",
|
|
712
|
-
sourceMailboxId: "mm-src-zzz",
|
|
713
|
-
destinationMailboxId: "mm-dst-zzz",
|
|
714
|
-
uid: 10,
|
|
715
|
-
eventId: "mm-evt-zzz",
|
|
716
|
-
timestamp: 1700000000000,
|
|
717
|
-
};
|
|
718
|
-
|
|
719
|
-
await handleMessageMove(pathless, silentLogger, 1, moveDeps(connection));
|
|
100
|
+
const opened = (server: FakeImapServer): string[] =>
|
|
101
|
+
server.log
|
|
102
|
+
.filter((line) => /^(SELECT|EXAMINE) /.test(line))
|
|
103
|
+
.map((line) => line.split(" ")[1] ?? "");
|
|
720
104
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
|
|
733
|
-
|
|
734
|
-
assert.deepEqual(opened, ["INBOX"]);
|
|
735
|
-
assert.deepEqual(
|
|
736
|
-
movedTo,
|
|
737
|
-
["Records"],
|
|
738
|
-
"the payload still says Archive; the row says where the folder is now",
|
|
739
|
-
);
|
|
740
|
-
assert.deepEqual(updateUidCalls[0], ["mm-msg-zzz", 44, "mm-dst-zzz"]);
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
it("opens the source under its current path when it was renamed after enqueue", async () => {
|
|
744
|
-
const { opened, movedTo, connection } = await arrangeLiveMove({
|
|
745
|
-
"mm-src-zzz": "Old/Inbox-renamed",
|
|
746
|
-
"mm-dst-zzz": "Archive",
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
|
|
750
|
-
|
|
751
|
-
assert.deepEqual(
|
|
752
|
-
opened,
|
|
753
|
-
["Old/Inbox-renamed"],
|
|
754
|
-
"the payload still says INBOX; the row says where the folder is now",
|
|
755
|
-
);
|
|
756
|
-
assert.deepEqual(movedTo, ["Archive"]);
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
it("hands the row back to its source without connecting when the destination mailbox was deleted", async () => {
|
|
760
|
-
const { transitionCalls, updateUidCalls } = await arrangeLiveMove({
|
|
761
|
-
"mm-src-zzz": "INBOX",
|
|
105
|
+
const probe = (
|
|
106
|
+
server: FakeImapServer,
|
|
107
|
+
input: Partial<Parameters<typeof probePausedPlacement>[1]> = {},
|
|
108
|
+
) =>
|
|
109
|
+
probePausedPlacement(server.connection(), {
|
|
110
|
+
messageIdHeader: HEADER,
|
|
111
|
+
sourceMailboxPath: "INBOX",
|
|
112
|
+
destinationMailboxPath: "Archive",
|
|
113
|
+
...input,
|
|
762
114
|
});
|
|
763
115
|
|
|
764
|
-
await handleMessageMove(event, capturingLogger(), 1, moveDeps());
|
|
765
|
-
|
|
766
|
-
assert.equal(connectCount, 0);
|
|
767
|
-
assert.equal(updateUidCalls.length, 0);
|
|
768
|
-
assert.deepEqual(transitionCalls[0]?.[2], {
|
|
769
|
-
mailboxId: "mm-src-zzz",
|
|
770
|
-
uid: 10,
|
|
771
|
-
status: "active",
|
|
772
|
-
syncStatus: "synced",
|
|
773
|
-
abandonedMutation: "none",
|
|
774
|
-
});
|
|
775
|
-
assert.equal(
|
|
776
|
-
logLines.filter(
|
|
777
|
-
(line) => line.fields.metric === "message_move_destination_gone",
|
|
778
|
-
).length,
|
|
779
|
-
1,
|
|
780
|
-
);
|
|
781
|
-
assert.deepEqual(emitted, [
|
|
782
|
-
{ type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-src-zzz" },
|
|
783
|
-
]);
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
it("acks without connecting when the message row is already gone", async () => {
|
|
787
|
-
const client = await getClient();
|
|
788
|
-
mock.method(client.account, "get", async () => cappedAccount());
|
|
789
|
-
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
790
|
-
mock.method(client.message, "get", async () => []);
|
|
791
|
-
const mailboxGet = mock.method(client.mailbox, "get", async () => {
|
|
792
|
-
throw new Error("a missing row must never resolve a mailbox");
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
await handleMessageMove(event, silentLogger, MESSAGE_MOVE_MAX_ATTEMPTS);
|
|
796
|
-
|
|
797
|
-
assert.equal(mailboxGet.mock.calls.length, 0);
|
|
798
|
-
});
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
describe("probePausedPlacement — which folder a paused mutation left the message in (#1203)", () => {
|
|
802
|
-
const buildConnection = (
|
|
803
|
-
holdings: Record<string, number[]>,
|
|
804
|
-
): { connection: IImapConnection; opened: string[] } => {
|
|
805
|
-
const opened: string[] = [];
|
|
806
|
-
let current = "";
|
|
807
|
-
return {
|
|
808
|
-
opened,
|
|
809
|
-
connection: {
|
|
810
|
-
openBox: async (path: string) => {
|
|
811
|
-
opened.push(path);
|
|
812
|
-
current = path;
|
|
813
|
-
return {} as never;
|
|
814
|
-
},
|
|
815
|
-
search: async () => holdings[current] ?? [],
|
|
816
|
-
} as unknown as IImapConnection,
|
|
817
|
-
};
|
|
818
|
-
};
|
|
819
|
-
|
|
820
|
-
const probe = (holdings: Record<string, number[]>) => {
|
|
821
|
-
const { connection, opened } = buildConnection(holdings);
|
|
822
|
-
return {
|
|
823
|
-
opened,
|
|
824
|
-
result: probePausedPlacement(connection, {
|
|
825
|
-
messageIdHeader: "<paused@example.com>",
|
|
826
|
-
sourceMailboxPath: "INBOX",
|
|
827
|
-
destinationMailboxPath: "Archive",
|
|
828
|
-
}),
|
|
829
|
-
};
|
|
830
|
-
};
|
|
831
|
-
|
|
832
116
|
it("stops at the source and never asks the destination when the source still holds it", async () => {
|
|
833
|
-
const
|
|
117
|
+
const server = serverHolding({ INBOX: [4], Archive: [12] });
|
|
834
118
|
|
|
835
|
-
assert.deepStrictEqual(await
|
|
119
|
+
assert.deepStrictEqual(await probe(server), { kind: "at-source" });
|
|
836
120
|
assert.deepStrictEqual(
|
|
837
|
-
opened,
|
|
121
|
+
opened(server),
|
|
838
122
|
["INBOX"],
|
|
839
123
|
"a source hit ends it: with the mutation unrun, every destination hit is an older copy (#1122)",
|
|
840
124
|
);
|
|
841
125
|
});
|
|
842
126
|
|
|
843
127
|
it("answers the destination's highest matching uid once the source has let go", async () => {
|
|
844
|
-
const
|
|
128
|
+
const server = serverHolding({ Archive: [12, 77] });
|
|
845
129
|
|
|
846
|
-
assert.deepStrictEqual(await
|
|
130
|
+
assert.deepStrictEqual(await probe(server), {
|
|
131
|
+
kind: "at-destination",
|
|
132
|
+
uid: 77,
|
|
133
|
+
});
|
|
847
134
|
});
|
|
848
135
|
|
|
849
136
|
it("answers gone when neither folder holds it", async () => {
|
|
850
|
-
assert.deepStrictEqual(await probe({})
|
|
137
|
+
assert.deepStrictEqual(await probe(serverHolding({})), { kind: "gone" });
|
|
851
138
|
});
|
|
852
139
|
|
|
853
140
|
it("answers gone, not at-source, when there is no destination to ask", async () => {
|
|
854
|
-
const
|
|
141
|
+
const server = serverHolding({});
|
|
855
142
|
|
|
856
143
|
assert.deepStrictEqual(
|
|
857
|
-
await
|
|
858
|
-
messageIdHeader: "<paused@example.com>",
|
|
859
|
-
sourceMailboxPath: "INBOX",
|
|
860
|
-
destinationMailboxPath: undefined,
|
|
861
|
-
}),
|
|
144
|
+
await probe(server, { destinationMailboxPath: undefined }),
|
|
862
145
|
{ kind: "gone" },
|
|
863
146
|
);
|
|
864
|
-
assert.deepStrictEqual(opened, ["INBOX"]);
|
|
147
|
+
assert.deepStrictEqual(opened(server), ["INBOX"]);
|
|
865
148
|
});
|
|
866
149
|
|
|
867
150
|
// A row with no Message-ID header cannot be asked about, and the server's
|
|
868
151
|
// silence is not an answer. `unprobeable` keeps that distinct from `gone`.
|
|
869
152
|
it("asks nothing at all when the row carries no Message-ID header", async () => {
|
|
870
|
-
const
|
|
153
|
+
const server = serverHolding({ INBOX: [4] });
|
|
871
154
|
|
|
872
155
|
assert.deepStrictEqual(
|
|
873
|
-
await
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}),
|
|
878
|
-
{ kind: "unprobeable" },
|
|
156
|
+
await probe(server, { messageIdHeader: undefined }),
|
|
157
|
+
{
|
|
158
|
+
kind: "unprobeable",
|
|
159
|
+
},
|
|
879
160
|
);
|
|
880
|
-
assert.deepStrictEqual(
|
|
161
|
+
assert.deepStrictEqual(server.log, []);
|
|
881
162
|
});
|
|
882
163
|
});
|
|
883
164
|
|
|
884
165
|
describe("searchMailboxForHighestMessageIdUid — the probe that binds a move to a UID (#912)", () => {
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
criterion[0].toUpperCase() === "HEADER" &&
|
|
892
|
-
typeof criterion[1] === "string" &&
|
|
893
|
-
criterion[1].toLowerCase() === "message-id" &&
|
|
894
|
-
typeof criterion[2] === "string";
|
|
895
|
-
|
|
896
|
-
const buildDestination = (
|
|
897
|
-
messages: Array<{ uid: number; messageIdHeader: string }>,
|
|
898
|
-
): IImapConnection => {
|
|
899
|
-
const searchAll = () => messages.map((row) => row.uid);
|
|
900
|
-
return {
|
|
901
|
-
openBox: async () => ({}) as never,
|
|
902
|
-
search: async (criteria: unknown[]) => {
|
|
903
|
-
const criterion = criteria.find(isMessageIdCriterion);
|
|
904
|
-
if (criterion === undefined) return searchAll();
|
|
905
|
-
return messages
|
|
906
|
-
.filter((row) => row.messageIdHeader === criterion[2])
|
|
907
|
-
.map((row) => row.uid);
|
|
908
|
-
},
|
|
909
|
-
} as unknown as IImapConnection;
|
|
166
|
+
const archiveHolding = (messages: Array<[uid: number, header: string]>) => {
|
|
167
|
+
const server = new FakeImapServer();
|
|
168
|
+
server.addFolder("Archive");
|
|
169
|
+
for (const [uid, header] of messages)
|
|
170
|
+
server.deliver("Archive", eml({ "Message-ID": header }), { uid: uid });
|
|
171
|
+
return server;
|
|
910
172
|
};
|
|
911
173
|
|
|
174
|
+
const search = (server: FakeImapServer, header: string) =>
|
|
175
|
+
searchMailboxForHighestMessageIdUid(server.connection(), "Archive", header);
|
|
176
|
+
|
|
912
177
|
it("asks the folder by Message-ID rather than by an interpolated string", async () => {
|
|
913
|
-
const
|
|
914
|
-
const
|
|
915
|
-
openBox: async () => ({}) as never,
|
|
916
|
-
search: async (criteria: unknown[]) => {
|
|
917
|
-
sent.push(criteria);
|
|
918
|
-
return [];
|
|
919
|
-
},
|
|
920
|
-
} as unknown as IImapConnection;
|
|
178
|
+
const server = archiveHolding([]);
|
|
179
|
+
const hostile = '<a"b@example.com>\r\nUID 1';
|
|
921
180
|
|
|
922
|
-
await
|
|
923
|
-
destination,
|
|
924
|
-
"Archive",
|
|
925
|
-
'<a"b@example.com>\r\nUID 1',
|
|
926
|
-
);
|
|
181
|
+
await search(server, hostile);
|
|
927
182
|
|
|
928
|
-
assert.deepStrictEqual(
|
|
929
|
-
[["HEADER", "Message-ID",
|
|
183
|
+
assert.deepStrictEqual(server.commands("SEARCH"), [
|
|
184
|
+
`SEARCH Archive ${JSON.stringify([["HEADER", "Message-ID", hostile]])}`,
|
|
930
185
|
]);
|
|
931
186
|
});
|
|
932
187
|
|
|
933
188
|
it("answers null, not another message's UID, when the folder holds no such message", async () => {
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
189
|
+
const server = archiveHolding([
|
|
190
|
+
[11, "<stranger-a@example.com>"],
|
|
191
|
+
[12, "<stranger-b@example.com>"],
|
|
937
192
|
]);
|
|
938
193
|
|
|
939
|
-
assert.strictEqual(
|
|
940
|
-
await searchMailboxForHighestMessageIdUid(
|
|
941
|
-
destination,
|
|
942
|
-
"Archive",
|
|
943
|
-
"<moved@example.com>",
|
|
944
|
-
),
|
|
945
|
-
null,
|
|
946
|
-
);
|
|
194
|
+
assert.strictEqual(await search(server, "<moved@example.com>"), null);
|
|
947
195
|
});
|
|
948
196
|
|
|
949
197
|
it("answers the UID of the message carrying that Message-ID", async () => {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
]);
|
|
954
|
-
|
|
955
|
-
assert.strictEqual(
|
|
956
|
-
await searchMailboxForHighestMessageIdUid(
|
|
957
|
-
destination,
|
|
958
|
-
"Archive",
|
|
959
|
-
"<moved@example.com>",
|
|
960
|
-
),
|
|
961
|
-
12,
|
|
962
|
-
);
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
// Issue #1122. The folder already held an older copy of this Message-ID — a
|
|
966
|
-
// sieve `fileinto` + `keep`, a resend, an earlier copy of the same message —
|
|
967
|
-
// and the uid this probe hands back settles the row the fresh copy lives in.
|
|
968
|
-
// Answering 12 binds that row to mail this operation never touched and
|
|
969
|
-
// leaves the copy that just arrived unreachable by any later delete.
|
|
970
|
-
it("answers the newest copy's UID when the folder already held an older copy of it", async () => {
|
|
971
|
-
const destination = buildDestination([
|
|
972
|
-
{ uid: 12, messageIdHeader: "<moved@example.com>" },
|
|
973
|
-
{ uid: 40, messageIdHeader: "<moved@example.com>" },
|
|
198
|
+
const server = archiveHolding([
|
|
199
|
+
[11, "<stranger-a@example.com>"],
|
|
200
|
+
[12, "<moved@example.com>"],
|
|
974
201
|
]);
|
|
975
202
|
|
|
976
|
-
assert.strictEqual(
|
|
977
|
-
await searchMailboxForHighestMessageIdUid(
|
|
978
|
-
destination,
|
|
979
|
-
"Archive",
|
|
980
|
-
"<moved@example.com>",
|
|
981
|
-
),
|
|
982
|
-
40,
|
|
983
|
-
);
|
|
203
|
+
assert.strictEqual(await search(server, "<moved@example.com>"), 12);
|
|
984
204
|
});
|
|
985
205
|
|
|
986
206
|
// RFC 3501 does not order a SEARCH response, so the fresh copy is the
|
|
987
207
|
// highest uid returned rather than the last one returned.
|
|
988
208
|
it("takes the highest match whatever order the server lists them in", async () => {
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
209
|
+
const server = archiveHolding([
|
|
210
|
+
[40, "<moved@example.com>"],
|
|
211
|
+
[12, "<moved@example.com>"],
|
|
992
212
|
]);
|
|
993
213
|
|
|
994
|
-
assert.strictEqual(
|
|
995
|
-
await searchMailboxForHighestMessageIdUid(
|
|
996
|
-
destination,
|
|
997
|
-
"Archive",
|
|
998
|
-
"<moved@example.com>",
|
|
999
|
-
),
|
|
1000
|
-
40,
|
|
1001
|
-
);
|
|
214
|
+
assert.strictEqual(await search(server, "<moved@example.com>"), 40);
|
|
1002
215
|
});
|
|
1003
216
|
});
|