@remit/imap-worker 0.0.52 → 0.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -260,6 +260,8 @@ interface Harness {
|
|
|
260
260
|
mailboxError?: Error;
|
|
261
261
|
connection: Connection;
|
|
262
262
|
threadMessageUpdateError?: Error;
|
|
263
|
+
messageRow: { messageIdHeader?: string } | undefined;
|
|
264
|
+
destinationSearchUids: number[];
|
|
263
265
|
threadMessage: Record<string, unknown> | null;
|
|
264
266
|
allThreadMessages: Record<string, unknown>[];
|
|
265
267
|
getConnectionCount: number;
|
|
@@ -274,6 +276,16 @@ const record =
|
|
|
274
276
|
h.calls.push({ method, args });
|
|
275
277
|
};
|
|
276
278
|
|
|
279
|
+
const MESSAGE_ID_HEADER = "<trashed-message@example.com>";
|
|
280
|
+
|
|
281
|
+
// The source-presence probe (`isMessageGoneFromOpenMailbox`) and the
|
|
282
|
+
// destination probe both go through `connection.search`; only the criterion
|
|
283
|
+
// form tells them apart.
|
|
284
|
+
const isMessageIdSearch = (criteria: unknown): boolean =>
|
|
285
|
+
Array.isArray(criteria) &&
|
|
286
|
+
Array.isArray(criteria[0]) &&
|
|
287
|
+
criteria[0][0] === "HEADER";
|
|
288
|
+
|
|
277
289
|
const buildConnection = (): Connection => ({
|
|
278
290
|
openBox: async () => ({ uidvalidity: 1 }),
|
|
279
291
|
moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
|
|
@@ -288,7 +300,7 @@ const buildConnection = (): Connection => ({
|
|
|
288
300
|
fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
|
|
289
301
|
search: async (...args: unknown[]) => {
|
|
290
302
|
h.calls.push({ method: "connection.search", args });
|
|
291
|
-
return [10];
|
|
303
|
+
return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [10];
|
|
292
304
|
},
|
|
293
305
|
});
|
|
294
306
|
|
|
@@ -296,7 +308,7 @@ const sourceNoLongerHoldsTheUid = (): void => {
|
|
|
296
308
|
h.connection.fetchMessages = async () => [];
|
|
297
309
|
h.connection.search = async (...args: unknown[]) => {
|
|
298
310
|
h.calls.push({ method: "connection.search", args });
|
|
299
|
-
return [];
|
|
311
|
+
return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [];
|
|
300
312
|
};
|
|
301
313
|
};
|
|
302
314
|
|
|
@@ -304,6 +316,8 @@ const fresh = (): Harness => ({
|
|
|
304
316
|
calls: [],
|
|
305
317
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
306
318
|
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
319
|
+
messageRow: { messageIdHeader: MESSAGE_ID_HEADER },
|
|
320
|
+
destinationSearchUids: [],
|
|
307
321
|
connection: buildConnection(),
|
|
308
322
|
threadMessage: {
|
|
309
323
|
...baseThreadMessage,
|
|
@@ -328,6 +342,10 @@ const deps = (): MessageDeleteDeps =>
|
|
|
328
342
|
},
|
|
329
343
|
},
|
|
330
344
|
message: {
|
|
345
|
+
get: async (messageIds: string[]) => {
|
|
346
|
+
h.calls.push({ method: "message.get", args: [messageIds] });
|
|
347
|
+
return h.messageRow ? [h.messageRow] : [];
|
|
348
|
+
},
|
|
331
349
|
updateUid: record("message.updateUid"),
|
|
332
350
|
update: record("message.update"),
|
|
333
351
|
delete: record("message.delete"),
|
|
@@ -417,17 +435,161 @@ describe("handleMessageDelete", () => {
|
|
|
417
435
|
assert.equal(h.disconnectCount, 1);
|
|
418
436
|
});
|
|
419
437
|
|
|
420
|
-
|
|
421
|
-
|
|
438
|
+
// UIDPLUS is an extension: a server without it answers a perfectly
|
|
439
|
+
// successful MOVE with no COPYUID entry. An empty uidMap is therefore
|
|
440
|
+
// UNCONFIRMED, and the destination is asked by Message-ID before any
|
|
441
|
+
// verdict — the rule every sibling handler already carries. Issue #979.
|
|
442
|
+
describe("no COPYUID entry on the move to trash", () => {
|
|
443
|
+
it("settles the row on the probed uid when the message left the source and is at the destination (non-UIDPLUS server, genuine success)", async () => {
|
|
444
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
445
|
+
sourceNoLongerHoldsTheUid();
|
|
446
|
+
h.destinationSearchUids = [77];
|
|
422
447
|
|
|
423
|
-
|
|
448
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
424
449
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
450
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
451
|
+
"msg-1",
|
|
452
|
+
77,
|
|
453
|
+
"trash-mbx",
|
|
454
|
+
]);
|
|
455
|
+
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
456
|
+
uid: 77,
|
|
457
|
+
mailboxId: "trash-mbx",
|
|
458
|
+
isDeleted: true,
|
|
459
|
+
});
|
|
460
|
+
assert.equal(
|
|
461
|
+
called("message.update").length,
|
|
462
|
+
0,
|
|
463
|
+
"a settled move never marks the row failed",
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("probes the DESTINATION mailbox, read-only, by Message-ID", async () => {
|
|
468
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
469
|
+
sourceNoLongerHoldsTheUid();
|
|
470
|
+
h.destinationSearchUids = [77];
|
|
471
|
+
const opened: unknown[][] = [];
|
|
472
|
+
h.connection.openBox = (async (...args: unknown[]) => {
|
|
473
|
+
opened.push(args);
|
|
474
|
+
return { uidvalidity: 1 };
|
|
475
|
+
}) as Connection["openBox"];
|
|
476
|
+
|
|
477
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
478
|
+
|
|
479
|
+
assert.deepEqual(
|
|
480
|
+
opened,
|
|
481
|
+
[
|
|
482
|
+
["INBOX", false],
|
|
483
|
+
["INBOX", true],
|
|
484
|
+
["Trash", true],
|
|
485
|
+
],
|
|
486
|
+
"the source is re-asked read-only, then the destination is EXAMINEd — neither probe may SELECT a box writable",
|
|
487
|
+
);
|
|
488
|
+
assert.deepEqual(called("connection.search").at(-1)?.args[0], [
|
|
489
|
+
["HEADER", "Message-ID", MESSAGE_ID_HEADER],
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("leaves local state alone when the probe finds nothing — never reverts, never deletes (#655)", async () => {
|
|
494
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
495
|
+
sourceNoLongerHoldsTheUid();
|
|
496
|
+
h.destinationSearchUids = [];
|
|
497
|
+
|
|
498
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
499
|
+
|
|
500
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
501
|
+
assert.equal(
|
|
502
|
+
called("message.delete").length,
|
|
503
|
+
0,
|
|
504
|
+
"an unconfirmed move must never delete the local row",
|
|
505
|
+
);
|
|
506
|
+
assert.equal(
|
|
507
|
+
called("threadMessage.delete").length,
|
|
508
|
+
0,
|
|
509
|
+
"an unconfirmed move must never delete the listing rows",
|
|
510
|
+
);
|
|
511
|
+
assert.equal(
|
|
512
|
+
called("threadMessage.update").length,
|
|
513
|
+
0,
|
|
514
|
+
"an unconfirmed move must never revert the listing row to the source",
|
|
515
|
+
);
|
|
516
|
+
assert.equal(
|
|
517
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
518
|
+
?.syncStatus,
|
|
519
|
+
"failed",
|
|
520
|
+
);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("does not probe when the row carries no Message-ID header", async () => {
|
|
524
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
525
|
+
sourceNoLongerHoldsTheUid();
|
|
526
|
+
h.messageRow = {};
|
|
527
|
+
|
|
528
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
529
|
+
|
|
530
|
+
assert.equal(
|
|
531
|
+
called("connection.search").filter((c) =>
|
|
532
|
+
JSON.stringify(c.args).includes("HEADER"),
|
|
533
|
+
).length,
|
|
534
|
+
0,
|
|
535
|
+
);
|
|
536
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
537
|
+
assert.equal(
|
|
538
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
539
|
+
?.syncStatus,
|
|
540
|
+
"failed",
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// `searchMailboxByMessageId` returns the LOWEST matching uid, and one
|
|
545
|
+
// Message-ID can have several server copies in one account while
|
|
546
|
+
// `deriveMessageId` gives them one local row. A source that still holds
|
|
547
|
+
// the uid proves the MOVE did not happen, so any hit at the destination
|
|
548
|
+
// is a different copy.
|
|
549
|
+
it("never takes a destination hit while the source still holds the uid (duplicate Message-ID)", async () => {
|
|
550
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
551
|
+
h.destinationSearchUids = [100];
|
|
552
|
+
|
|
553
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
554
|
+
|
|
555
|
+
assert.equal(
|
|
556
|
+
called("message.updateUid").length,
|
|
557
|
+
0,
|
|
558
|
+
"an earlier copy's uid must never settle this row",
|
|
559
|
+
);
|
|
560
|
+
assert.equal(called("threadMessage.update").length, 0);
|
|
561
|
+
assert.equal(called("message.delete").length, 0);
|
|
562
|
+
assert.equal(
|
|
563
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
564
|
+
?.syncStatus,
|
|
565
|
+
"failed",
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// The MOVE has already run by the time the probe goes out, so a probe
|
|
570
|
+
// that cannot answer counts as not-confirmed. Throwing would redeliver
|
|
571
|
+
// on the account's per-group FIFO and re-MOVE a uid the source no longer
|
|
572
|
+
// holds, head-of-line blocking every other delete on the account.
|
|
573
|
+
it("treats an unanswerable probe as unconfirmed rather than throwing", async () => {
|
|
574
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
575
|
+
h.connection.openBox = (async (_path: string, readOnly?: boolean) => {
|
|
576
|
+
if (readOnly) throw new Error("NO [SERVERBUG] EXAMINE failed");
|
|
577
|
+
return { uidvalidity: 1 };
|
|
578
|
+
}) as Connection["openBox"];
|
|
579
|
+
|
|
580
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
581
|
+
|
|
582
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
583
|
+
assert.equal(called("message.delete").length, 0);
|
|
584
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
585
|
+
assert.equal(called("threadMessage.update").length, 0);
|
|
586
|
+
assert.equal(
|
|
587
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
588
|
+
?.syncStatus,
|
|
589
|
+
"failed",
|
|
590
|
+
);
|
|
591
|
+
assert.equal(h.disconnectCount, 1);
|
|
592
|
+
});
|
|
431
593
|
});
|
|
432
594
|
|
|
433
595
|
// The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type {
|
|
3
|
+
IMessageRepository,
|
|
3
4
|
IThreadMessageRepository,
|
|
4
5
|
ThreadMessageItem,
|
|
5
6
|
} from "@remit/data-ports";
|
|
@@ -8,6 +9,7 @@ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
|
8
9
|
import type { Logger } from "@remit/logger-lambda";
|
|
9
10
|
import {
|
|
10
11
|
guardConnectionCursor,
|
|
12
|
+
type IImapConnection,
|
|
11
13
|
isCursorRebuildNeeded,
|
|
12
14
|
isMessageGoneFromOpenMailbox,
|
|
13
15
|
MailboxCursorPausedError,
|
|
@@ -18,6 +20,7 @@ import type { MessageDeleteEvent } from "../events.js";
|
|
|
18
20
|
import { isNotFoundError } from "../is-not-found.js";
|
|
19
21
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
20
22
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
23
|
+
import { searchMailboxByMessageId } from "./message-move.js";
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Delete every ThreadMessage row that points at this messageId.
|
|
@@ -122,6 +125,49 @@ export const buildThreadMessageUndelete = (
|
|
|
122
125
|
composites: currentComposites(threadMessage),
|
|
123
126
|
});
|
|
124
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Settle a move to Trash the server left unconfirmed, by asking it two
|
|
130
|
+
* read-only questions instead of one. Both handles wrap the SAME connection but
|
|
131
|
+
* are scoped to their own mailbox: a `guardConnectionCursor` wrap binds its
|
|
132
|
+
* checks to the ONE mailbox snapshot it was built with, so the destination must
|
|
133
|
+
* never be opened through the source's guard.
|
|
134
|
+
*
|
|
135
|
+
* The source is asked first, and a source that still holds the uid ends it: the
|
|
136
|
+
* MOVE did not happen, so nothing at the destination can be this message.
|
|
137
|
+
* Skipping that question is not safe, because `searchMailboxByMessageId`
|
|
138
|
+
* returns the LOWEST matching uid rather than the one that just arrived, and
|
|
139
|
+
* one Message-ID can have several server copies in one account (a sieve
|
|
140
|
+
* `fileinto` + `keep`, a multi-label store, a resend) while
|
|
141
|
+
* `deriveMessageId` is folder-independent and gives them one local row. An
|
|
142
|
+
* ungated probe can hand back an earlier copy's uid, and Empty Trash then
|
|
143
|
+
* expunges by that uid. It also closes the second half of #912: an empty
|
|
144
|
+
* `uidMap` can mean the MOVE matched nothing at all.
|
|
145
|
+
*
|
|
146
|
+
* A row with no `messageIdHeader` has nothing to probe with, so it stays
|
|
147
|
+
* unconfirmed rather than guessing.
|
|
148
|
+
*/
|
|
149
|
+
const confirmTrashMoveUid = async (
|
|
150
|
+
sourceConnection: IImapConnection,
|
|
151
|
+
destinationConnection: IImapConnection,
|
|
152
|
+
messageService: Pick<IMessageRepository, "get">,
|
|
153
|
+
messageId: string,
|
|
154
|
+
sourceMailboxPath: string,
|
|
155
|
+
destinationMailboxPath: string,
|
|
156
|
+
uid: number,
|
|
157
|
+
): Promise<number | null> => {
|
|
158
|
+
await sourceConnection.openBox(sourceMailboxPath, true);
|
|
159
|
+
if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) return null;
|
|
160
|
+
|
|
161
|
+
const [message] = await messageService.get([messageId]);
|
|
162
|
+
if (!message?.messageIdHeader) return null;
|
|
163
|
+
|
|
164
|
+
return searchMailboxByMessageId(
|
|
165
|
+
destinationConnection,
|
|
166
|
+
destinationMailboxPath,
|
|
167
|
+
message.messageIdHeader,
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
125
171
|
export interface MessageDeleteDeps {
|
|
126
172
|
getClient: typeof getClient;
|
|
127
173
|
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
@@ -324,15 +370,57 @@ export const handleMessageDelete = async (
|
|
|
324
370
|
"Refused to delete: move to trash carries no destination mailbox",
|
|
325
371
|
"message_delete_missing_destination",
|
|
326
372
|
);
|
|
327
|
-
} else if (
|
|
373
|
+
} else if (
|
|
374
|
+
operation === "move_to_trash" &&
|
|
375
|
+
destinationMailboxPath &&
|
|
376
|
+
destinationMailboxId
|
|
377
|
+
) {
|
|
328
378
|
// Move to Trash
|
|
329
379
|
const result = await connection.moveMessages(
|
|
330
380
|
[uid],
|
|
331
381
|
destinationMailboxPath,
|
|
332
382
|
);
|
|
333
|
-
const newUid = result.uidMap.get(uid);
|
|
334
383
|
|
|
335
|
-
|
|
384
|
+
// UIDPLUS is an extension. A server without it answers a
|
|
385
|
+
// perfectly successful MOVE with no COPYUID entry, so an empty
|
|
386
|
+
// map is UNCONFIRMED, never evidence the move failed: the server
|
|
387
|
+
// is asked before any verdict, exactly as `handleMessageMove` and
|
|
388
|
+
// `attemptMove` do. Reading the empty map as a failure left the
|
|
389
|
+
// message in Trash under a uid nothing local knew, while the row
|
|
390
|
+
// kept the SOURCE folder's uid — which Empty Trash then decides
|
|
391
|
+
// by (issues #979, #665).
|
|
392
|
+
//
|
|
393
|
+
// A probe that cannot answer counts as not-confirmed, never as a
|
|
394
|
+
// throw. The MOVE has already run by this point, so throwing here
|
|
395
|
+
// would redeliver on the account's per-group FIFO and re-MOVE a
|
|
396
|
+
// uid the source no longer holds — head-of-line blocking the
|
|
397
|
+
// whole account's deletes over a transient NO, a renamed folder
|
|
398
|
+
// or a SEARCH the server refused.
|
|
399
|
+
const newUid =
|
|
400
|
+
result.uidMap.get(uid) ??
|
|
401
|
+
(await confirmTrashMoveUid(
|
|
402
|
+
connection,
|
|
403
|
+
rawConnection,
|
|
404
|
+
messageService,
|
|
405
|
+
messageId,
|
|
406
|
+
mailboxPath,
|
|
407
|
+
destinationMailboxPath,
|
|
408
|
+
uid,
|
|
409
|
+
).catch((probeError: unknown) => {
|
|
410
|
+
log.warn(
|
|
411
|
+
{
|
|
412
|
+
messageId,
|
|
413
|
+
uid,
|
|
414
|
+
mailboxPath,
|
|
415
|
+
destinationMailboxPath,
|
|
416
|
+
probeError,
|
|
417
|
+
},
|
|
418
|
+
"Could not confirm the move to trash; keeping local rows",
|
|
419
|
+
);
|
|
420
|
+
return null;
|
|
421
|
+
}));
|
|
422
|
+
|
|
423
|
+
if (newUid) {
|
|
336
424
|
// Update message with new UID in Trash
|
|
337
425
|
await messageService.updateUid(
|
|
338
426
|
messageId,
|
|
@@ -361,9 +449,33 @@ export const handleMessageDelete = async (
|
|
|
361
449
|
|
|
362
450
|
log.info({ messageId, newUid }, "Message moved to trash");
|
|
363
451
|
} else {
|
|
452
|
+
// Unconfirmed, not failed. A MOVE that ran server-side but
|
|
453
|
+
// dropped before the tagged OK is indistinguishable from one
|
|
454
|
+
// that never ran, so local state is left exactly as it stands:
|
|
455
|
+
// nothing is reverted and nothing is deleted. Reverting on that
|
|
456
|
+
// ambiguity is the blind revert #655 recorded when it was
|
|
457
|
+
// pulled from PR #652.
|
|
458
|
+
//
|
|
459
|
+
// This marks and returns rather than throwing. Throwing is the
|
|
460
|
+
// shape `handleMessageMove` uses, but it can only carry a
|
|
461
|
+
// budget: this handler has no `receiveCount`, no MAX_ATTEMPTS
|
|
462
|
+
// and no exhaustion path, so every redelivery on the account's
|
|
463
|
+
// per-group FIFO would re-MOVE a uid the source no longer
|
|
464
|
+
// holds, fail identically, and stall that account's whole
|
|
465
|
+
// delete pipeline (#287, #289, #290) until the queue's
|
|
466
|
+
// maxReceiveCount dead-letters it — leaving the row in this
|
|
467
|
+
// same state, minus the pipeline. Issue #980 wires the budget;
|
|
468
|
+
// the throw belongs with it, not ahead of it.
|
|
364
469
|
log.error(
|
|
365
|
-
{
|
|
366
|
-
|
|
470
|
+
{
|
|
471
|
+
alert: "message_delete_trash_move_unconfirmed",
|
|
472
|
+
accountId,
|
|
473
|
+
messageId,
|
|
474
|
+
uid,
|
|
475
|
+
mailboxPath,
|
|
476
|
+
destinationMailboxPath,
|
|
477
|
+
},
|
|
478
|
+
"Move to trash unconfirmed: no COPYUID entry, and the server did not confirm the message at the destination; local rows left as they stand",
|
|
367
479
|
);
|
|
368
480
|
await messageService.update(messageId, {
|
|
369
481
|
syncStatus: MessageSyncStatus.failed,
|
|
@@ -3,6 +3,7 @@ import { afterEach, before, describe, it, mock } from "node:test";
|
|
|
3
3
|
import { getClient, type RemitClient, setClient } from "@remit/backend/client";
|
|
4
4
|
import type { AccountItem, ThreadMessageItem } from "@remit/data-ports";
|
|
5
5
|
import type { Logger } from "@remit/logger-lambda";
|
|
6
|
+
import type { IImapConnection } from "@remit/mailbox-service";
|
|
6
7
|
import type { MessageMoveEvent } from "../events.js";
|
|
7
8
|
import {
|
|
8
9
|
buildThreadMessageMoveUpdate,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
handleMessageMove,
|
|
12
13
|
MESSAGE_MOVE_MAX_ATTEMPTS,
|
|
13
14
|
moveThenResync,
|
|
15
|
+
searchMailboxByMessageId,
|
|
14
16
|
} from "./message-move.js";
|
|
15
17
|
|
|
16
18
|
const silentLogger = (() => {
|
|
@@ -340,3 +342,85 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
340
342
|
assert.equal(mailboxGet.mock.calls.length, 0);
|
|
341
343
|
});
|
|
342
344
|
});
|
|
345
|
+
|
|
346
|
+
describe("searchMailboxByMessageId — the probe that binds a move to a UID (#912)", () => {
|
|
347
|
+
const isMessageIdCriterion = (
|
|
348
|
+
criterion: unknown,
|
|
349
|
+
): criterion is [string, string, string] =>
|
|
350
|
+
Array.isArray(criterion) &&
|
|
351
|
+
criterion.length === 3 &&
|
|
352
|
+
typeof criterion[0] === "string" &&
|
|
353
|
+
criterion[0].toUpperCase() === "HEADER" &&
|
|
354
|
+
typeof criterion[1] === "string" &&
|
|
355
|
+
criterion[1].toLowerCase() === "message-id" &&
|
|
356
|
+
typeof criterion[2] === "string";
|
|
357
|
+
|
|
358
|
+
const buildDestination = (
|
|
359
|
+
messages: Array<{ uid: number; messageIdHeader: string }>,
|
|
360
|
+
): IImapConnection => {
|
|
361
|
+
const searchAll = () => messages.map((row) => row.uid);
|
|
362
|
+
return {
|
|
363
|
+
openBox: async () => ({}) as never,
|
|
364
|
+
search: async (criteria: unknown[]) => {
|
|
365
|
+
const criterion = criteria.find(isMessageIdCriterion);
|
|
366
|
+
if (criterion === undefined) return searchAll();
|
|
367
|
+
return messages
|
|
368
|
+
.filter((row) => row.messageIdHeader === criterion[2])
|
|
369
|
+
.map((row) => row.uid);
|
|
370
|
+
},
|
|
371
|
+
} as unknown as IImapConnection;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
it("asks the folder by Message-ID rather than by an interpolated string", async () => {
|
|
375
|
+
const sent: unknown[][] = [];
|
|
376
|
+
const destination = {
|
|
377
|
+
openBox: async () => ({}) as never,
|
|
378
|
+
search: async (criteria: unknown[]) => {
|
|
379
|
+
sent.push(criteria);
|
|
380
|
+
return [];
|
|
381
|
+
},
|
|
382
|
+
} as unknown as IImapConnection;
|
|
383
|
+
|
|
384
|
+
await searchMailboxByMessageId(
|
|
385
|
+
destination,
|
|
386
|
+
"Archive",
|
|
387
|
+
'<a"b@example.com>\r\nUID 1',
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
assert.deepStrictEqual(sent, [
|
|
391
|
+
[["HEADER", "Message-ID", '<a"b@example.com>\r\nUID 1']],
|
|
392
|
+
]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("answers null, not the lowest UID, when the folder holds no such message", async () => {
|
|
396
|
+
const destination = buildDestination([
|
|
397
|
+
{ uid: 11, messageIdHeader: "<stranger-a@example.com>" },
|
|
398
|
+
{ uid: 12, messageIdHeader: "<stranger-b@example.com>" },
|
|
399
|
+
]);
|
|
400
|
+
|
|
401
|
+
assert.strictEqual(
|
|
402
|
+
await searchMailboxByMessageId(
|
|
403
|
+
destination,
|
|
404
|
+
"Archive",
|
|
405
|
+
"<moved@example.com>",
|
|
406
|
+
),
|
|
407
|
+
null,
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("answers the UID of the message carrying that Message-ID", async () => {
|
|
412
|
+
const destination = buildDestination([
|
|
413
|
+
{ uid: 11, messageIdHeader: "<stranger-a@example.com>" },
|
|
414
|
+
{ uid: 12, messageIdHeader: "<moved@example.com>" },
|
|
415
|
+
]);
|
|
416
|
+
|
|
417
|
+
assert.strictEqual(
|
|
418
|
+
await searchMailboxByMessageId(
|
|
419
|
+
destination,
|
|
420
|
+
"Archive",
|
|
421
|
+
"<moved@example.com>",
|
|
422
|
+
),
|
|
423
|
+
12,
|
|
424
|
+
);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
@@ -78,7 +78,7 @@ export const searchMailboxByMessageId = async (
|
|
|
78
78
|
): Promise<number | null> => {
|
|
79
79
|
await connection.openBox(mailboxPath, true);
|
|
80
80
|
const uids = await connection.search([
|
|
81
|
-
|
|
81
|
+
["HEADER", "Message-ID", messageIdHeader],
|
|
82
82
|
]);
|
|
83
83
|
return uids[0] ?? null;
|
|
84
84
|
};
|