@remit/imap-worker 0.0.76 → 0.0.78
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 +1 -1
- package/src/events.ts +54 -13
- package/src/handlers/empty-trash.test.ts +67 -4
- package/src/handlers/empty-trash.ts +14 -20
- package/src/handlers/message-copy.test.ts +64 -2
- package/src/handlers/message-copy.ts +37 -12
- package/src/handlers/message-delete.test.ts +130 -2
- package/src/handlers/message-delete.ts +53 -19
- package/src/handlers/message-move.test.ts +168 -1
- package/src/handlers/message-move.ts +35 -22
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/mailbox-row.ts +20 -0
package/package.json
CHANGED
package/src/events.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
MUTATION_EVENT_SCHEMA_VERSION,
|
|
3
|
+
PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
|
|
4
|
+
} from "@remit/data-ports/mutation-events";
|
|
2
5
|
|
|
3
6
|
export interface BaseEvent {
|
|
4
7
|
accountId: string;
|
|
@@ -88,39 +91,56 @@ export type MailboxManagementEvent =
|
|
|
88
91
|
/**
|
|
89
92
|
* Event for deleting a message (move to trash or permanent delete).
|
|
90
93
|
*/
|
|
91
|
-
|
|
94
|
+
interface MessageDeleteEventFields extends BaseEvent {
|
|
92
95
|
type: "MESSAGE_DELETE";
|
|
93
|
-
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
94
96
|
messageId: string;
|
|
95
97
|
mailboxId: string;
|
|
96
|
-
mailboxPath: string;
|
|
97
98
|
uid: number;
|
|
98
99
|
operation: "move_to_trash" | "permanent_delete";
|
|
99
100
|
destinationMailboxId?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MessageDeleteEventV2 extends MessageDeleteEventFields {
|
|
104
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
105
|
+
mailboxPath: string;
|
|
100
106
|
destinationMailboxPath?: string;
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
export interface MessageDeleteEventV3 extends MessageDeleteEventFields {
|
|
110
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type MessageDeleteEvent = MessageDeleteEventV2 | MessageDeleteEventV3;
|
|
114
|
+
|
|
103
115
|
/**
|
|
104
116
|
* Event for moving a message to another mailbox.
|
|
105
117
|
*/
|
|
106
|
-
|
|
118
|
+
interface MessageMoveEventFields extends BaseEvent {
|
|
107
119
|
type: "MESSAGE_MOVE";
|
|
108
120
|
messageId: string;
|
|
109
121
|
sourceMailboxId: string;
|
|
110
|
-
sourceMailboxPath: string;
|
|
111
122
|
destinationMailboxId: string;
|
|
112
|
-
destinationMailboxPath: string;
|
|
113
123
|
uid: number;
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
export interface MessageMoveEventV1 extends MessageMoveEventFields {
|
|
127
|
+
schemaVersion?: undefined;
|
|
128
|
+
sourceMailboxPath: string;
|
|
129
|
+
destinationMailboxPath: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface MessageMoveEventV3 extends MessageMoveEventFields {
|
|
133
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type MessageMoveEvent = MessageMoveEventV1 | MessageMoveEventV3;
|
|
137
|
+
|
|
116
138
|
/**
|
|
117
139
|
* Event for emptying the Trash mailbox.
|
|
118
140
|
*/
|
|
119
|
-
|
|
141
|
+
interface EmptyTrashEventFields extends BaseEvent {
|
|
120
142
|
type: "EMPTY_TRASH";
|
|
121
|
-
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
122
143
|
trashMailboxId: string;
|
|
123
|
-
trashMailboxPath: string;
|
|
124
144
|
/**
|
|
125
145
|
* The Trash folder's UIDVALIDITY at the moment the user consented. The
|
|
126
146
|
* handler compares it against what the SELECT serves: a path reused by a
|
|
@@ -130,20 +150,41 @@ export interface EmptyTrashEvent extends BaseEvent {
|
|
|
130
150
|
trashUidValidity: number;
|
|
131
151
|
}
|
|
132
152
|
|
|
153
|
+
export interface EmptyTrashEventV2 extends EmptyTrashEventFields {
|
|
154
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
155
|
+
trashMailboxPath: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface EmptyTrashEventV3 extends EmptyTrashEventFields {
|
|
159
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type EmptyTrashEvent = EmptyTrashEventV2 | EmptyTrashEventV3;
|
|
163
|
+
|
|
133
164
|
/**
|
|
134
165
|
* Event for copying a message to another mailbox.
|
|
135
166
|
*/
|
|
136
|
-
|
|
167
|
+
interface MessageCopyEventFields extends BaseEvent {
|
|
137
168
|
type: "MESSAGE_COPY";
|
|
138
169
|
sourceMessageId: string;
|
|
139
170
|
newMessageId: string;
|
|
140
171
|
sourceMailboxId: string;
|
|
141
|
-
sourceMailboxPath: string;
|
|
142
172
|
destinationMailboxId: string;
|
|
143
|
-
destinationMailboxPath: string;
|
|
144
173
|
uid: number;
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
export interface MessageCopyEventV1 extends MessageCopyEventFields {
|
|
177
|
+
schemaVersion?: undefined;
|
|
178
|
+
sourceMailboxPath: string;
|
|
179
|
+
destinationMailboxPath: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface MessageCopyEventV3 extends MessageCopyEventFields {
|
|
183
|
+
schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type MessageCopyEvent = MessageCopyEventV1 | MessageCopyEventV3;
|
|
187
|
+
|
|
147
188
|
/**
|
|
148
189
|
* Event for appending a sent message to the Sent mailbox via IMAP APPEND.
|
|
149
190
|
*/
|
|
@@ -38,7 +38,12 @@ interface Harness {
|
|
|
38
38
|
accountConfigId: string;
|
|
39
39
|
deletedAt?: number;
|
|
40
40
|
} | null;
|
|
41
|
-
mailbox: {
|
|
41
|
+
mailbox: {
|
|
42
|
+
mailboxId: string;
|
|
43
|
+
fullPath: string;
|
|
44
|
+
uidValidity: number;
|
|
45
|
+
cursorState?: string;
|
|
46
|
+
};
|
|
42
47
|
mailboxError?: Error;
|
|
43
48
|
trashResolution: RoleResolution<TrashMailbox>;
|
|
44
49
|
connection: Connection;
|
|
@@ -118,7 +123,12 @@ const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
|
|
|
118
123
|
const fresh = (): Harness => ({
|
|
119
124
|
calls: [],
|
|
120
125
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
121
|
-
mailbox: {
|
|
126
|
+
mailbox: {
|
|
127
|
+
mailboxId: "trash-mbx",
|
|
128
|
+
fullPath: "Trash",
|
|
129
|
+
uidValidity: 1,
|
|
130
|
+
cursorState: undefined,
|
|
131
|
+
},
|
|
122
132
|
trashResolution: {
|
|
123
133
|
kind: "flagged",
|
|
124
134
|
mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
|
|
@@ -406,7 +416,7 @@ describe("handleEmptyTrash", () => {
|
|
|
406
416
|
// The path was reused: a third-party client renamed Trash away and made a
|
|
407
417
|
// fresh one. Same path, different folder, and nobody consented to empty it.
|
|
408
418
|
h.connection.openBox = async () => ({ uidvalidity: 77 });
|
|
409
|
-
h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
|
|
419
|
+
h.mailbox = { mailboxId: "trash-mbx", fullPath: "Trash", uidValidity: 77 };
|
|
410
420
|
|
|
411
421
|
await handleEmptyTrash(event, noopLogger, deps());
|
|
412
422
|
|
|
@@ -422,7 +432,7 @@ describe("handleEmptyTrash", () => {
|
|
|
422
432
|
{ messageId: "msg-arrived", uid: 12, status: "active" },
|
|
423
433
|
];
|
|
424
434
|
h.connection.openBox = async () => ({ uidvalidity: 77 });
|
|
425
|
-
h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
|
|
435
|
+
h.mailbox = { mailboxId: "trash-mbx", fullPath: "Trash", uidValidity: 77 };
|
|
426
436
|
|
|
427
437
|
await handleEmptyTrash(event, noopLogger, deps());
|
|
428
438
|
|
|
@@ -450,6 +460,7 @@ describe("handleEmptyTrash", () => {
|
|
|
450
460
|
it("reverts the marks without connecting when the cursor is rebuilding", async () => {
|
|
451
461
|
h.mailbox = {
|
|
452
462
|
mailboxId: "trash-mbx",
|
|
463
|
+
fullPath: "Trash",
|
|
453
464
|
uidValidity: 1,
|
|
454
465
|
cursorState: "rebuilding",
|
|
455
466
|
};
|
|
@@ -663,3 +674,55 @@ describe("handleEmptyTrash and an unsettled placement", () => {
|
|
|
663
674
|
);
|
|
664
675
|
});
|
|
665
676
|
});
|
|
677
|
+
|
|
678
|
+
describe("handleEmptyTrash — the Trash path comes from its mailbox row", () => {
|
|
679
|
+
beforeEach(() => {
|
|
680
|
+
h = fresh();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const openedPaths = (): string[] => {
|
|
684
|
+
const opened: string[] = [];
|
|
685
|
+
h.connection.openBox = async (path: string) => {
|
|
686
|
+
opened.push(path);
|
|
687
|
+
return { uidvalidity: 1 };
|
|
688
|
+
};
|
|
689
|
+
return opened;
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
it("runs an empty enqueued with the version 2 payload that carries the folder path", async () => {
|
|
693
|
+
const opened = openedPaths();
|
|
694
|
+
|
|
695
|
+
await handleEmptyTrash(event, noopLogger, deps());
|
|
696
|
+
|
|
697
|
+
assert.deepEqual(opened, ["Trash"]);
|
|
698
|
+
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("runs an empty enqueued with the pathless payload", async () => {
|
|
702
|
+
const opened = openedPaths();
|
|
703
|
+
const pathless: EmptyTrashEvent = {
|
|
704
|
+
type: "EMPTY_TRASH",
|
|
705
|
+
schemaVersion: 3,
|
|
706
|
+
eventId: "evt-1",
|
|
707
|
+
timestamp: 1,
|
|
708
|
+
accountId: "acc-1",
|
|
709
|
+
trashMailboxId: "trash-mbx",
|
|
710
|
+
trashUidValidity: 1,
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
await handleEmptyTrash(pathless, noopLogger, deps());
|
|
714
|
+
|
|
715
|
+
assert.deepEqual(opened, ["Trash"]);
|
|
716
|
+
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("empties Trash under its current path when the folder was renamed after enqueue", async () => {
|
|
720
|
+
const opened = openedPaths();
|
|
721
|
+
h.mailbox = { ...h.mailbox, fullPath: "Deleted Items" };
|
|
722
|
+
|
|
723
|
+
await handleEmptyTrash(event, noopLogger, deps());
|
|
724
|
+
|
|
725
|
+
assert.deepEqual(opened, ["Deleted Items"]);
|
|
726
|
+
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { MessageItem } from "@remit/data-ports";
|
|
3
|
-
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
4
3
|
import { trashMailboxAt } from "@remit/data-ports/folder-role";
|
|
5
|
-
import {
|
|
4
|
+
import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
6
5
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
7
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
8
7
|
import {
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
import { isAccountDeleted } from "../account-check.js";
|
|
16
15
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
17
16
|
import type { EmptyTrashEvent } from "../events.js";
|
|
17
|
+
import { findMailboxRow } from "../mailbox-row.js";
|
|
18
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
19
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
20
20
|
|
|
@@ -64,12 +64,9 @@ export const handleEmptyTrash = async (
|
|
|
64
64
|
secrets,
|
|
65
65
|
} = await getClient();
|
|
66
66
|
|
|
67
|
-
const { accountId, trashMailboxId
|
|
67
|
+
const { accountId, trashMailboxId } = event;
|
|
68
68
|
|
|
69
|
-
log.info(
|
|
70
|
-
{ event: event.type, accountId, trashMailboxPath },
|
|
71
|
-
"Handling event",
|
|
72
|
-
);
|
|
69
|
+
log.info({ event: event.type, accountId, trashMailboxId }, "Handling event");
|
|
73
70
|
|
|
74
71
|
const account = await accountService.get(accountId);
|
|
75
72
|
if (!account) {
|
|
@@ -151,16 +148,13 @@ export const handleEmptyTrash = async (
|
|
|
151
148
|
alert: string,
|
|
152
149
|
context: Record<string, unknown> = {},
|
|
153
150
|
): Promise<void> => {
|
|
154
|
-
log.error(
|
|
155
|
-
{ alert, accountId, trashMailboxId, trashMailboxPath, ...context },
|
|
156
|
-
reason,
|
|
157
|
-
);
|
|
151
|
+
log.error({ alert, accountId, trashMailboxId, ...context }, reason);
|
|
158
152
|
await handBackMarkedRows(
|
|
159
153
|
await messageService.listAllByMailbox(trashMailboxId),
|
|
160
154
|
);
|
|
161
155
|
};
|
|
162
156
|
|
|
163
|
-
if (!
|
|
157
|
+
if (!isAcceptedSchemaVersion(event.schemaVersion)) {
|
|
164
158
|
await abandonEmptyTrash(
|
|
165
159
|
"Refused to empty trash: event was minted under an unknown contract",
|
|
166
160
|
"empty_trash_unknown_schema_version",
|
|
@@ -199,19 +193,19 @@ export const handleEmptyTrash = async (
|
|
|
199
193
|
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
200
194
|
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
201
195
|
// Trash makes the empty moot: ack with a WARN.
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (!mailbox) {
|
|
196
|
+
const trash = await findMailboxRow(
|
|
197
|
+
mailboxService,
|
|
198
|
+
accountId,
|
|
199
|
+
trashMailboxId,
|
|
200
|
+
);
|
|
201
|
+
if (trash.kind === "gone") {
|
|
209
202
|
log.warn(
|
|
210
203
|
{ accountId, mailboxId: trashMailboxId },
|
|
211
204
|
"Skipping EMPTY_TRASH: mailbox no longer exists (deleted)",
|
|
212
205
|
);
|
|
213
206
|
return;
|
|
214
207
|
}
|
|
208
|
+
const mailbox = trash.mailbox;
|
|
215
209
|
|
|
216
210
|
// A paused cursor is not a wait here: this return acks the event and
|
|
217
211
|
// nothing re-issues it, so the marks have to come back or the folder
|
|
@@ -239,7 +233,7 @@ export const handleEmptyTrash = async (
|
|
|
239
233
|
accountId,
|
|
240
234
|
mailbox,
|
|
241
235
|
);
|
|
242
|
-
const boxStatus = await connection.openBox(
|
|
236
|
+
const boxStatus = await connection.openBox(mailbox.fullPath, false);
|
|
243
237
|
|
|
244
238
|
// The path is not the folder. A third-party client that renames
|
|
245
239
|
// Trash and creates a fresh one leaves this event pointing at a
|
|
@@ -40,6 +40,8 @@ interface Harness {
|
|
|
40
40
|
} | null;
|
|
41
41
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
42
42
|
mailboxError?: Error;
|
|
43
|
+
folderPaths: Record<string, string>;
|
|
44
|
+
goneMailboxIds: string[];
|
|
43
45
|
copyRow: CopyRow | null;
|
|
44
46
|
destinationHolds: number[];
|
|
45
47
|
connection: Connection;
|
|
@@ -89,6 +91,8 @@ const fresh = (): Harness => ({
|
|
|
89
91
|
calls: [],
|
|
90
92
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
91
93
|
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
94
|
+
folderPaths: { "src-mbx": "INBOX", "dst-mbx": "Archive" },
|
|
95
|
+
goneMailboxIds: [],
|
|
92
96
|
copyRow: unsettledCopyRow(),
|
|
93
97
|
destinationHolds: [],
|
|
94
98
|
connection: buildConnection(),
|
|
@@ -136,9 +140,14 @@ const deps = (): MessageCopyDeps =>
|
|
|
136
140
|
update: record("threadMessage.update"),
|
|
137
141
|
},
|
|
138
142
|
mailbox: {
|
|
139
|
-
get: async () => {
|
|
143
|
+
get: async (_accountId: string, mailboxId: string) => {
|
|
140
144
|
if (h.mailboxError) throw h.mailboxError;
|
|
141
|
-
|
|
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] };
|
|
142
151
|
},
|
|
143
152
|
update: record("mailbox.update"),
|
|
144
153
|
},
|
|
@@ -589,4 +598,57 @@ describe("handleMessageCopy", () => {
|
|
|
589
598
|
assert.equal(called("message.delete")[0]?.args[0], "new-msg");
|
|
590
599
|
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
591
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
|
+
});
|
|
592
654
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
3
2
|
import {
|
|
4
3
|
MessageMutation,
|
|
5
4
|
MessageStatus,
|
|
@@ -18,6 +17,7 @@ import { isAccountDeleted } from "../account-check.js";
|
|
|
18
17
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
19
18
|
import { emitEvent } from "../emit.js";
|
|
20
19
|
import type { MessageCopyEvent } from "../events.js";
|
|
20
|
+
import { findMailboxRow } from "../mailbox-row.js";
|
|
21
21
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
22
22
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
23
23
|
import {
|
|
@@ -108,8 +108,6 @@ export const handleMessageCopy = async (
|
|
|
108
108
|
sourceMessageId,
|
|
109
109
|
newMessageId,
|
|
110
110
|
sourceMailboxId,
|
|
111
|
-
sourceMailboxPath,
|
|
112
|
-
destinationMailboxPath,
|
|
113
111
|
destinationMailboxId,
|
|
114
112
|
uid,
|
|
115
113
|
} = event;
|
|
@@ -120,8 +118,8 @@ export const handleMessageCopy = async (
|
|
|
120
118
|
accountId,
|
|
121
119
|
sourceMessageId,
|
|
122
120
|
newMessageId,
|
|
123
|
-
from:
|
|
124
|
-
to:
|
|
121
|
+
from: sourceMailboxId,
|
|
122
|
+
to: destinationMailboxId,
|
|
125
123
|
},
|
|
126
124
|
"Handling event",
|
|
127
125
|
);
|
|
@@ -173,19 +171,46 @@ export const handleMessageCopy = async (
|
|
|
173
171
|
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
174
172
|
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
175
173
|
// source mailbox makes the copy moot: ack with a WARN.
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (!mailbox) {
|
|
174
|
+
const source = await findMailboxRow(
|
|
175
|
+
mailboxService,
|
|
176
|
+
accountId,
|
|
177
|
+
sourceMailboxId,
|
|
178
|
+
);
|
|
179
|
+
if (source.kind === "gone") {
|
|
183
180
|
log.warn(
|
|
184
181
|
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
185
182
|
"Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)",
|
|
186
183
|
);
|
|
187
184
|
return;
|
|
188
185
|
}
|
|
186
|
+
const mailbox = source.mailbox;
|
|
187
|
+
const sourceMailboxPath = mailbox.fullPath;
|
|
188
|
+
|
|
189
|
+
const destination = await findMailboxRow(
|
|
190
|
+
mailboxService,
|
|
191
|
+
accountId,
|
|
192
|
+
destinationMailboxId,
|
|
193
|
+
);
|
|
194
|
+
if (destination.kind === "gone") {
|
|
195
|
+
const { threadMessagesDeleted } = await reconcileStaleMessage(
|
|
196
|
+
{ messageService, threadMessageService },
|
|
197
|
+
account.accountConfigId,
|
|
198
|
+
newMessageId,
|
|
199
|
+
);
|
|
200
|
+
log.warn(
|
|
201
|
+
{
|
|
202
|
+
metric: "message_copy_destination_gone",
|
|
203
|
+
accountId,
|
|
204
|
+
sourceMessageId,
|
|
205
|
+
newMessageId,
|
|
206
|
+
mailboxId: destinationMailboxId,
|
|
207
|
+
threadMessagesDeleted,
|
|
208
|
+
},
|
|
209
|
+
"Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away",
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const destinationMailboxPath = destination.mailbox.fullPath;
|
|
189
214
|
|
|
190
215
|
// The copy row carries the source's Message-ID header, so the
|
|
191
216
|
// destination can be asked where this copy is. Always on the UNGUARDED
|
|
@@ -291,6 +291,8 @@ interface Harness {
|
|
|
291
291
|
} | null;
|
|
292
292
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
293
293
|
mailboxError?: Error;
|
|
294
|
+
folderPaths: Record<string, string>;
|
|
295
|
+
goneMailboxIds: string[];
|
|
294
296
|
connection: Connection;
|
|
295
297
|
threadMessageUpdateError?: Error;
|
|
296
298
|
messageRow: { messageIdHeader?: string; status?: string } | undefined;
|
|
@@ -398,6 +400,8 @@ const fresh = (): Harness => ({
|
|
|
398
400
|
calls: [],
|
|
399
401
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
400
402
|
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
403
|
+
folderPaths: { "src-mbx": "INBOX", "trash-mbx": "Trash" },
|
|
404
|
+
goneMailboxIds: [],
|
|
401
405
|
// What the API writes before it enqueues: a move to Trash records `moving`,
|
|
402
406
|
// a permanent delete records `deleting`, and the handler's guard asks one
|
|
403
407
|
// question of both — is a mutation still outstanding on this row.
|
|
@@ -457,9 +461,18 @@ const deps = (): MessageDeleteDeps =>
|
|
|
457
461
|
deleteMany: record("threadMessage.deleteMany"),
|
|
458
462
|
},
|
|
459
463
|
mailbox: {
|
|
460
|
-
get: async () => {
|
|
464
|
+
get: async (_accountId: string, mailboxId: string) => {
|
|
461
465
|
if (h.mailboxError) throw h.mailboxError;
|
|
462
|
-
|
|
466
|
+
if (h.goneMailboxIds.includes(mailboxId)) {
|
|
467
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
468
|
+
name: "NotFoundError",
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const row =
|
|
472
|
+
mailboxId === h.mailbox.mailboxId
|
|
473
|
+
? h.mailbox
|
|
474
|
+
: { mailboxId, uidValidity: 1, cursorState: "normal" };
|
|
475
|
+
return { ...row, fullPath: h.folderPaths[mailboxId] };
|
|
463
476
|
},
|
|
464
477
|
update: record("mailbox.update"),
|
|
465
478
|
},
|
|
@@ -1739,3 +1752,118 @@ describe("handleMessageDelete", () => {
|
|
|
1739
1752
|
);
|
|
1740
1753
|
});
|
|
1741
1754
|
});
|
|
1755
|
+
|
|
1756
|
+
describe("handleMessageDelete — folder paths come from the mailbox rows", () => {
|
|
1757
|
+
beforeEach(() => {
|
|
1758
|
+
h = fresh();
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
const movedTo = (): string[] => {
|
|
1762
|
+
const destinations: string[] = [];
|
|
1763
|
+
h.connection.moveMessages = async (
|
|
1764
|
+
_uids: number[],
|
|
1765
|
+
destination: string,
|
|
1766
|
+
) => {
|
|
1767
|
+
destinations.push(destination);
|
|
1768
|
+
return { uidMap: new Map([[10, 20]]) };
|
|
1769
|
+
};
|
|
1770
|
+
return destinations;
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
it("runs a delete enqueued with the version 2 payload that carries folder paths", async () => {
|
|
1774
|
+
const destinations = movedTo();
|
|
1775
|
+
|
|
1776
|
+
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1777
|
+
|
|
1778
|
+
assert.deepEqual(destinations, ["Trash"]);
|
|
1779
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
1780
|
+
"msg-1",
|
|
1781
|
+
20,
|
|
1782
|
+
"trash-mbx",
|
|
1783
|
+
]);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
it("runs a delete enqueued with the pathless payload", async () => {
|
|
1787
|
+
const destinations = movedTo();
|
|
1788
|
+
const pathless: MessageDeleteEvent = {
|
|
1789
|
+
type: "MESSAGE_DELETE",
|
|
1790
|
+
schemaVersion: 3,
|
|
1791
|
+
eventId: "evt-1",
|
|
1792
|
+
timestamp: 1,
|
|
1793
|
+
accountId: "acc-1",
|
|
1794
|
+
messageId: "msg-1",
|
|
1795
|
+
mailboxId: "src-mbx",
|
|
1796
|
+
uid: 10,
|
|
1797
|
+
operation: "move_to_trash",
|
|
1798
|
+
destinationMailboxId: "trash-mbx",
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
await handleMessageDelete(pathless, noopLogger, 1, deps());
|
|
1802
|
+
|
|
1803
|
+
assert.deepEqual(destinations, ["Trash"]);
|
|
1804
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
1805
|
+
"msg-1",
|
|
1806
|
+
20,
|
|
1807
|
+
"trash-mbx",
|
|
1808
|
+
]);
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
it("moves to Trash under its current path when the folder was renamed after enqueue", async () => {
|
|
1812
|
+
const destinations = movedTo();
|
|
1813
|
+
const opened: string[] = [];
|
|
1814
|
+
const openBox = h.connection.openBox;
|
|
1815
|
+
h.connection.openBox = async (path: string) => {
|
|
1816
|
+
opened.push(path);
|
|
1817
|
+
return openBox(path);
|
|
1818
|
+
};
|
|
1819
|
+
h.folderPaths = { "src-mbx": "Mail/INBOX", "trash-mbx": "Deleted Items" };
|
|
1820
|
+
|
|
1821
|
+
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1822
|
+
|
|
1823
|
+
assert.equal(opened[0], "Mail/INBOX");
|
|
1824
|
+
assert.deepEqual(destinations, ["Deleted Items"]);
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
it("opens the source under its current path when it was renamed after enqueue", async () => {
|
|
1828
|
+
const destinations = movedTo();
|
|
1829
|
+
const opened: string[] = [];
|
|
1830
|
+
const openBox = h.connection.openBox;
|
|
1831
|
+
h.connection.openBox = async (path: string) => {
|
|
1832
|
+
opened.push(path);
|
|
1833
|
+
return openBox(path);
|
|
1834
|
+
};
|
|
1835
|
+
h.folderPaths = { "src-mbx": "Old/Inbox-renamed", "trash-mbx": "Trash" };
|
|
1836
|
+
|
|
1837
|
+
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1838
|
+
|
|
1839
|
+
assert.equal(opened[0], "Old/Inbox-renamed");
|
|
1840
|
+
assert.deepEqual(destinations, ["Trash"]);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it("resyncs the source when a redelivered delete finds the Trash mailbox deleted", async () => {
|
|
1844
|
+
h.goneMailboxIds = ["trash-mbx"];
|
|
1845
|
+
|
|
1846
|
+
await handleMessageDelete(moveEvent, noopLogger, 2, deps());
|
|
1847
|
+
|
|
1848
|
+
assert.deepEqual(called("emitEvent")[0]?.args, [
|
|
1849
|
+
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
1850
|
+
]);
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it("hands the row back without connecting when the Trash mailbox was deleted", async () => {
|
|
1854
|
+
h.goneMailboxIds = ["trash-mbx"];
|
|
1855
|
+
const destinations = movedTo();
|
|
1856
|
+
|
|
1857
|
+
await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
|
|
1858
|
+
|
|
1859
|
+
assert.equal(h.getConnectionCount, 0);
|
|
1860
|
+
assert.deepEqual(destinations, []);
|
|
1861
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
1862
|
+
assert.equal(called("message.delete").length, 0);
|
|
1863
|
+
assert.deepEqual(restoreCalls()[0]?.args, restoredToSource("abandoned"));
|
|
1864
|
+
assert.equal(
|
|
1865
|
+
loggedWith("alert", "message_delete_destination_gone").length,
|
|
1866
|
+
1,
|
|
1867
|
+
);
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
@@ -4,8 +4,7 @@ import type {
|
|
|
4
4
|
IThreadMessageRepository,
|
|
5
5
|
MessageItem,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
-
import {
|
|
8
|
-
import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
7
|
+
import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
9
8
|
import {
|
|
10
9
|
MessageMutation,
|
|
11
10
|
MessageStatus,
|
|
@@ -27,6 +26,7 @@ import { isAccountDeleted } from "../account-check.js";
|
|
|
27
26
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
28
27
|
import { emitEvent } from "../emit.js";
|
|
29
28
|
import type { MessageDeleteEvent } from "../events.js";
|
|
29
|
+
import { findMailboxRow } from "../mailbox-row.js";
|
|
30
30
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
31
31
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
32
32
|
import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
|
|
@@ -38,6 +38,11 @@ import {
|
|
|
38
38
|
searchMailboxForHighestMessageIdUid,
|
|
39
39
|
} from "./message-move.js";
|
|
40
40
|
|
|
41
|
+
interface FolderPaths {
|
|
42
|
+
source: string;
|
|
43
|
+
destination: string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
export const getMessageDeleteMaxAttempts = (
|
|
42
47
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
43
48
|
): number => attemptBudget("MESSAGE_DELETE_MAX_ATTEMPTS", 3, processEnv);
|
|
@@ -179,15 +184,13 @@ export const handleMessageDelete = async (
|
|
|
179
184
|
accountId,
|
|
180
185
|
messageId,
|
|
181
186
|
mailboxId,
|
|
182
|
-
mailboxPath,
|
|
183
187
|
uid,
|
|
184
188
|
operation,
|
|
185
189
|
destinationMailboxId,
|
|
186
|
-
destinationMailboxPath,
|
|
187
190
|
} = event;
|
|
188
191
|
|
|
189
192
|
log.info(
|
|
190
|
-
{ event: event.type, accountId, messageId,
|
|
193
|
+
{ event: event.type, accountId, messageId, mailboxId, operation },
|
|
191
194
|
"Handling event",
|
|
192
195
|
);
|
|
193
196
|
|
|
@@ -308,7 +311,7 @@ export const handleMessageDelete = async (
|
|
|
308
311
|
accountId,
|
|
309
312
|
messageId,
|
|
310
313
|
uid,
|
|
311
|
-
|
|
314
|
+
mailboxId,
|
|
312
315
|
operation,
|
|
313
316
|
receiveCount,
|
|
314
317
|
reason,
|
|
@@ -431,8 +434,10 @@ export const handleMessageDelete = async (
|
|
|
431
434
|
const settlePausedDelete = async (
|
|
432
435
|
getRawConnection: () => Promise<IImapConnection>,
|
|
433
436
|
commandIssued: boolean,
|
|
437
|
+
paths: FolderPaths,
|
|
434
438
|
): Promise<void> => {
|
|
435
439
|
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
440
|
+
const destinationMailboxPath = paths.destination;
|
|
436
441
|
const isProbeableTrashMove =
|
|
437
442
|
mayHaveIssued &&
|
|
438
443
|
operation === "move_to_trash" &&
|
|
@@ -443,7 +448,7 @@ export const handleMessageDelete = async (
|
|
|
443
448
|
? await probePausedPlacement(await getRawConnection(), {
|
|
444
449
|
messageIdHeader: (await messageService.get([messageId]))[0]
|
|
445
450
|
?.messageIdHeader,
|
|
446
|
-
sourceMailboxPath:
|
|
451
|
+
sourceMailboxPath: paths.source,
|
|
447
452
|
destinationMailboxPath,
|
|
448
453
|
})
|
|
449
454
|
: { kind: "at-source" };
|
|
@@ -486,6 +491,7 @@ export const handleMessageDelete = async (
|
|
|
486
491
|
|
|
487
492
|
const settleExhaustedDelete = async (
|
|
488
493
|
accountConfigId: string,
|
|
494
|
+
sourceMailboxPath: string,
|
|
489
495
|
getConnection: () => Promise<IImapConnection>,
|
|
490
496
|
settlePaused: () => Promise<void>,
|
|
491
497
|
): Promise<void> => {
|
|
@@ -497,7 +503,7 @@ export const handleMessageDelete = async (
|
|
|
497
503
|
messageId,
|
|
498
504
|
sourceMailboxId: mailboxId,
|
|
499
505
|
uid,
|
|
500
|
-
sourceMailboxPath
|
|
506
|
+
sourceMailboxPath,
|
|
501
507
|
getConnection,
|
|
502
508
|
},
|
|
503
509
|
).catch(async (settleError: unknown) => {
|
|
@@ -542,6 +548,7 @@ export const handleMessageDelete = async (
|
|
|
542
548
|
const settleUnconfirmedTrashMove = async (
|
|
543
549
|
confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
|
|
544
550
|
accountConfigId: string,
|
|
551
|
+
sourceMailboxPath: string,
|
|
545
552
|
getConnection: () => Promise<IImapConnection>,
|
|
546
553
|
settlePaused: () => Promise<void>,
|
|
547
554
|
): Promise<void> => {
|
|
@@ -550,7 +557,7 @@ export const handleMessageDelete = async (
|
|
|
550
557
|
accountConfigId,
|
|
551
558
|
messageId,
|
|
552
559
|
uid,
|
|
553
|
-
|
|
560
|
+
mailboxId,
|
|
554
561
|
receiveCount,
|
|
555
562
|
confirmation: confirmation.outcome,
|
|
556
563
|
};
|
|
@@ -569,7 +576,12 @@ export const handleMessageDelete = async (
|
|
|
569
576
|
context,
|
|
570
577
|
"Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
|
|
571
578
|
);
|
|
572
|
-
await settleExhaustedDelete(
|
|
579
|
+
await settleExhaustedDelete(
|
|
580
|
+
accountConfigId,
|
|
581
|
+
sourceMailboxPath,
|
|
582
|
+
getConnection,
|
|
583
|
+
settlePaused,
|
|
584
|
+
);
|
|
573
585
|
return;
|
|
574
586
|
}
|
|
575
587
|
|
|
@@ -578,7 +590,7 @@ export const handleMessageDelete = async (
|
|
|
578
590
|
);
|
|
579
591
|
};
|
|
580
592
|
|
|
581
|
-
if (!
|
|
593
|
+
if (!isAcceptedSchemaVersion(event.schemaVersion)) {
|
|
582
594
|
await abandonDelete(
|
|
583
595
|
"Refused to delete: event was minted under an unknown contract",
|
|
584
596
|
"message_delete_unknown_schema_version",
|
|
@@ -596,19 +608,39 @@ export const handleMessageDelete = async (
|
|
|
596
608
|
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
597
609
|
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
598
610
|
// mailbox makes the delete moot: ack with a WARN.
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
.catch((error: unknown) => {
|
|
602
|
-
if (isNotFoundError(error)) return null;
|
|
603
|
-
throw error;
|
|
604
|
-
});
|
|
605
|
-
if (!mailbox) {
|
|
611
|
+
const source = await findMailboxRow(mailboxService, accountId, mailboxId);
|
|
612
|
+
if (source.kind === "gone") {
|
|
606
613
|
log.warn(
|
|
607
614
|
{ accountId, messageId, mailboxId },
|
|
608
615
|
"Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)",
|
|
609
616
|
);
|
|
610
617
|
return;
|
|
611
618
|
}
|
|
619
|
+
const mailbox = source.mailbox;
|
|
620
|
+
|
|
621
|
+
const destination =
|
|
622
|
+
operation === "move_to_trash" && destinationMailboxId !== undefined
|
|
623
|
+
? await findMailboxRow(
|
|
624
|
+
mailboxService,
|
|
625
|
+
accountId,
|
|
626
|
+
destinationMailboxId,
|
|
627
|
+
)
|
|
628
|
+
: undefined;
|
|
629
|
+
if (destination?.kind === "gone") {
|
|
630
|
+
await abandonDelete(
|
|
631
|
+
"Refused to delete: the destination mailbox no longer exists (deleted)",
|
|
632
|
+
"message_delete_destination_gone",
|
|
633
|
+
);
|
|
634
|
+
await emitMailboxResync(emitEvent, { accountId, mailboxId });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const mailboxPath = mailbox.fullPath;
|
|
639
|
+
const destinationMailboxPath = destination?.mailbox.fullPath;
|
|
640
|
+
const paths: FolderPaths = {
|
|
641
|
+
source: mailboxPath,
|
|
642
|
+
destination: destinationMailboxPath,
|
|
643
|
+
};
|
|
612
644
|
|
|
613
645
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
614
646
|
|
|
@@ -628,7 +660,7 @@ export const handleMessageDelete = async (
|
|
|
628
660
|
);
|
|
629
661
|
|
|
630
662
|
const settlePaused = (commandIssued: boolean): Promise<void> =>
|
|
631
|
-
settlePausedDelete(scope.getConnection, commandIssued);
|
|
663
|
+
settlePausedDelete(scope.getConnection, commandIssued, paths);
|
|
632
664
|
|
|
633
665
|
// Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
|
|
634
666
|
// downstream of it knows the server was asked.
|
|
@@ -734,6 +766,7 @@ export const handleMessageDelete = async (
|
|
|
734
766
|
await settleUnconfirmedTrashMove(
|
|
735
767
|
confirmation,
|
|
736
768
|
account.accountConfigId,
|
|
769
|
+
mailboxPath,
|
|
737
770
|
getGuardedConnection,
|
|
738
771
|
() => settlePaused(true),
|
|
739
772
|
);
|
|
@@ -843,6 +876,7 @@ export const handleMessageDelete = async (
|
|
|
843
876
|
// failure this budget exists for.
|
|
844
877
|
await settleExhaustedDelete(
|
|
845
878
|
account.accountConfigId,
|
|
879
|
+
mailboxPath,
|
|
846
880
|
getGuardedConnection,
|
|
847
881
|
() => settlePaused(commandIssued),
|
|
848
882
|
);
|
|
@@ -423,7 +423,19 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
423
423
|
messageIdHeader: options.probeable ? "<moved@example.com>" : undefined,
|
|
424
424
|
},
|
|
425
425
|
]);
|
|
426
|
-
mock.method(
|
|
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
|
+
);
|
|
427
439
|
const transitionCalls: unknown[][] = [];
|
|
428
440
|
mock.method(
|
|
429
441
|
client.message,
|
|
@@ -616,6 +628,161 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
616
628
|
});
|
|
617
629
|
});
|
|
618
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 };
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
it("runs a move enqueued with the unversioned payload that carries folder paths", async () => {
|
|
691
|
+
const { movedTo, updateUidCalls, connection } = await arrangeLiveMove({
|
|
692
|
+
"mm-src-zzz": "INBOX",
|
|
693
|
+
"mm-dst-zzz": "Archive",
|
|
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));
|
|
720
|
+
|
|
721
|
+
assert.deepEqual(opened, ["INBOX"]);
|
|
722
|
+
assert.deepEqual(movedTo, ["Archive"]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("moves under the destination's current path when the folder was renamed after enqueue", async () => {
|
|
726
|
+
const { opened, movedTo, updateUidCalls, connection } =
|
|
727
|
+
await arrangeLiveMove({
|
|
728
|
+
"mm-src-zzz": "INBOX",
|
|
729
|
+
"mm-dst-zzz": "Records",
|
|
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",
|
|
762
|
+
});
|
|
763
|
+
|
|
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
|
+
|
|
619
786
|
it("acks without connecting when the message row is already gone", async () => {
|
|
620
787
|
const client = await getClient();
|
|
621
788
|
mock.method(client.account, "get", async () => cappedAccount());
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
|
|
3
|
-
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
4
3
|
import {
|
|
5
4
|
MessageMutation,
|
|
6
5
|
MessageStatus,
|
|
@@ -20,6 +19,7 @@ import { isAccountDeleted } from "../account-check.js";
|
|
|
20
19
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
21
20
|
import { emitEvent } from "../emit.js";
|
|
22
21
|
import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
|
|
22
|
+
import { findMailboxRow } from "../mailbox-row.js";
|
|
23
23
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
24
24
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
25
25
|
import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
|
|
@@ -278,23 +278,16 @@ export const handleMessageMove = async (
|
|
|
278
278
|
secrets,
|
|
279
279
|
} = await getClient();
|
|
280
280
|
|
|
281
|
-
const {
|
|
282
|
-
|
|
283
|
-
messageId,
|
|
284
|
-
sourceMailboxId,
|
|
285
|
-
sourceMailboxPath,
|
|
286
|
-
destinationMailboxPath,
|
|
287
|
-
destinationMailboxId,
|
|
288
|
-
uid,
|
|
289
|
-
} = event;
|
|
281
|
+
const { accountId, messageId, sourceMailboxId, destinationMailboxId, uid } =
|
|
282
|
+
event;
|
|
290
283
|
|
|
291
284
|
log.info(
|
|
292
285
|
{
|
|
293
286
|
event: event.type,
|
|
294
287
|
accountId,
|
|
295
288
|
messageId,
|
|
296
|
-
from:
|
|
297
|
-
to:
|
|
289
|
+
from: sourceMailboxId,
|
|
290
|
+
to: destinationMailboxId,
|
|
298
291
|
},
|
|
299
292
|
"Handling event",
|
|
300
293
|
);
|
|
@@ -349,19 +342,25 @@ export const handleMessageMove = async (
|
|
|
349
342
|
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
350
343
|
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
351
344
|
// source mailbox makes the move moot: ack with a WARN.
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (!mailbox) {
|
|
345
|
+
const source = await findMailboxRow(
|
|
346
|
+
mailboxService,
|
|
347
|
+
accountId,
|
|
348
|
+
sourceMailboxId,
|
|
349
|
+
);
|
|
350
|
+
if (source.kind === "gone") {
|
|
359
351
|
log.warn(
|
|
360
352
|
{ accountId, messageId, mailboxId: sourceMailboxId },
|
|
361
353
|
"Skipping MESSAGE_MOVE: source mailbox no longer exists (deleted)",
|
|
362
354
|
);
|
|
363
355
|
return;
|
|
364
356
|
}
|
|
357
|
+
const mailbox = source.mailbox;
|
|
358
|
+
const sourceMailboxPath = mailbox.fullPath;
|
|
359
|
+
const destination = await findMailboxRow(
|
|
360
|
+
mailboxService,
|
|
361
|
+
accountId,
|
|
362
|
+
destinationMailboxId,
|
|
363
|
+
);
|
|
365
364
|
|
|
366
365
|
const settleMoved = async (newUid: number): Promise<void> => {
|
|
367
366
|
await messageService.updateUid(messageId, newUid, destinationMailboxId);
|
|
@@ -389,7 +388,7 @@ export const handleMessageMove = async (
|
|
|
389
388
|
messageId,
|
|
390
389
|
oldUid: uid,
|
|
391
390
|
newUid,
|
|
392
|
-
destination:
|
|
391
|
+
destination: destinationMailboxId,
|
|
393
392
|
},
|
|
394
393
|
"Message moved successfully",
|
|
395
394
|
);
|
|
@@ -413,8 +412,8 @@ export const handleMessageMove = async (
|
|
|
413
412
|
accountId,
|
|
414
413
|
messageId,
|
|
415
414
|
uid,
|
|
416
|
-
|
|
417
|
-
|
|
415
|
+
sourceMailboxId,
|
|
416
|
+
destinationMailboxId,
|
|
418
417
|
receiveCount,
|
|
419
418
|
reason,
|
|
420
419
|
};
|
|
@@ -449,6 +448,20 @@ export const handleMessageMove = async (
|
|
|
449
448
|
);
|
|
450
449
|
};
|
|
451
450
|
|
|
451
|
+
if (destination.kind === "gone") {
|
|
452
|
+
await handBackToSource(
|
|
453
|
+
MessageSyncStatus.synced,
|
|
454
|
+
{ metric: "message_move_destination_gone" },
|
|
455
|
+
"destination mailbox no longer exists (deleted)",
|
|
456
|
+
);
|
|
457
|
+
await emitMailboxResync(emitEvent, {
|
|
458
|
+
accountId,
|
|
459
|
+
mailboxId: sourceMailboxId,
|
|
460
|
+
});
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const destinationMailboxPath = destination.mailbox.fullPath;
|
|
464
|
+
|
|
452
465
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
453
466
|
|
|
454
467
|
// The terminal resolver opens the source itself, and its answer now
|
|
@@ -225,7 +225,7 @@ const syncMailboxesForAccount = async (
|
|
|
225
225
|
account.accountConfigId,
|
|
226
226
|
accountId,
|
|
227
227
|
);
|
|
228
|
-
if (bound.bound > 0 || bound.stillPending > 0) {
|
|
228
|
+
if (bound.bound > 0 || bound.dropped > 0 || bound.stillPending > 0) {
|
|
229
229
|
log.info({ accountId, ...bound }, "Bound imported folder references");
|
|
230
230
|
}
|
|
231
231
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
|
|
4
|
+
export type MailboxRow =
|
|
5
|
+
| { kind: "present"; mailbox: MailboxItem }
|
|
6
|
+
| { kind: "gone"; mailboxId: string };
|
|
7
|
+
|
|
8
|
+
export const findMailboxRow = (
|
|
9
|
+
mailboxService: Pick<IMailboxRepository, "get">,
|
|
10
|
+
accountId: string,
|
|
11
|
+
mailboxId: string,
|
|
12
|
+
): Promise<MailboxRow> =>
|
|
13
|
+
mailboxService.get(accountId, mailboxId).then(
|
|
14
|
+
(mailbox): MailboxRow =>
|
|
15
|
+
mailbox ? { kind: "present", mailbox } : { kind: "gone", mailboxId },
|
|
16
|
+
(error: unknown): MailboxRow => {
|
|
17
|
+
if (isNotFoundError(error)) return { kind: "gone", mailboxId };
|
|
18
|
+
throw error;
|
|
19
|
+
},
|
|
20
|
+
);
|