@remit/imap-worker 0.0.85 → 0.0.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.test.ts +32 -4
- package/src/emit.ts +58 -23
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +35 -34
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +20 -18
- package/src/pending-sync.sqlite.test.ts +167 -0
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -567
- 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
|
@@ -3,7 +3,7 @@ import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-label
|
|
|
3
3
|
import type { IMailboxRepository } from "@remit/data-ports";
|
|
4
4
|
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
5
5
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
6
|
-
import type { Logger } from "@remit/logger
|
|
6
|
+
import type { Logger } from "@remit/logger";
|
|
7
7
|
import {
|
|
8
8
|
FolderGoneUpstreamError,
|
|
9
9
|
FolderRenameSettleError,
|
|
@@ -24,20 +24,6 @@ import type {
|
|
|
24
24
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
25
25
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
26
26
|
|
|
27
|
-
export interface MailboxManagementDeps {
|
|
28
|
-
getClient: typeof getClient;
|
|
29
|
-
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
30
|
-
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
31
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const defaultDeps: MailboxManagementDeps = {
|
|
35
|
-
getClient,
|
|
36
|
-
buildLifecycleDeps,
|
|
37
|
-
withOAuthLifecycle,
|
|
38
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
27
|
/**
|
|
42
28
|
* The outcome of a create, written against the state a create in flight is:
|
|
43
29
|
* `pending` with no recorded rename target (D3, D10). Without the absence
|
|
@@ -85,15 +71,8 @@ const recordCreateOutcome = async (
|
|
|
85
71
|
const handleCreate = async (
|
|
86
72
|
event: MailboxCreateEvent,
|
|
87
73
|
log: Logger,
|
|
88
|
-
|
|
74
|
+
createConnectionScope: typeof createConnectionScopeWithCredentials,
|
|
89
75
|
): Promise<void> => {
|
|
90
|
-
const {
|
|
91
|
-
getClient,
|
|
92
|
-
buildLifecycleDeps,
|
|
93
|
-
withOAuthLifecycle,
|
|
94
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
95
|
-
} = deps;
|
|
96
|
-
|
|
97
76
|
const {
|
|
98
77
|
account: accountService,
|
|
99
78
|
mailbox: mailboxService,
|
|
@@ -118,7 +97,7 @@ const handleCreate = async (
|
|
|
118
97
|
account,
|
|
119
98
|
log,
|
|
120
99
|
async (credentials) => {
|
|
121
|
-
const scope =
|
|
100
|
+
const scope = createConnectionScope(account, credentials);
|
|
122
101
|
const managementService = new MailboxManagementService(
|
|
123
102
|
mailboxService,
|
|
124
103
|
log,
|
|
@@ -209,15 +188,8 @@ const handleCreate = async (
|
|
|
209
188
|
const handleRename = async (
|
|
210
189
|
event: MailboxRenameEvent,
|
|
211
190
|
log: Logger,
|
|
212
|
-
|
|
191
|
+
createConnectionScope: typeof createConnectionScopeWithCredentials,
|
|
213
192
|
): Promise<void> => {
|
|
214
|
-
const {
|
|
215
|
-
getClient,
|
|
216
|
-
buildLifecycleDeps,
|
|
217
|
-
withOAuthLifecycle,
|
|
218
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
219
|
-
} = deps;
|
|
220
|
-
|
|
221
193
|
const {
|
|
222
194
|
account: accountService,
|
|
223
195
|
accountSetting: accountSettingService,
|
|
@@ -246,7 +218,7 @@ const handleRename = async (
|
|
|
246
218
|
account,
|
|
247
219
|
log,
|
|
248
220
|
async (credentials) => {
|
|
249
|
-
const scope =
|
|
221
|
+
const scope = createConnectionScope(account, credentials);
|
|
250
222
|
const managementService = new MailboxManagementService(
|
|
251
223
|
mailboxService,
|
|
252
224
|
log,
|
|
@@ -321,8 +293,7 @@ const handleRename = async (
|
|
|
321
293
|
// folder's mail. Removing the root row alone is the orphaning
|
|
322
294
|
// bug: every descendant recorded the same intent (D6) and would
|
|
323
295
|
// be left `pending` at a path that does not exist, with its mail
|
|
324
|
-
// stranded out of every reader
|
|
325
|
-
// (D8).
|
|
296
|
+
// stranded out of every reader (D8).
|
|
326
297
|
if (error instanceof FolderGoneUpstreamError) {
|
|
327
298
|
log.info(
|
|
328
299
|
{ accountId, mailboxId, oldPath, intent: "rename" },
|
|
@@ -387,15 +358,8 @@ const handleRename = async (
|
|
|
387
358
|
const handleDelete = async (
|
|
388
359
|
event: MailboxDeleteEvent,
|
|
389
360
|
log: Logger,
|
|
390
|
-
|
|
361
|
+
createConnectionScope: typeof createConnectionScopeWithCredentials,
|
|
391
362
|
): Promise<void> => {
|
|
392
|
-
const {
|
|
393
|
-
getClient,
|
|
394
|
-
buildLifecycleDeps,
|
|
395
|
-
withOAuthLifecycle,
|
|
396
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
397
|
-
} = deps;
|
|
398
|
-
|
|
399
363
|
const {
|
|
400
364
|
account: accountService,
|
|
401
365
|
mailbox: mailboxService,
|
|
@@ -420,7 +384,7 @@ const handleDelete = async (
|
|
|
420
384
|
account,
|
|
421
385
|
log,
|
|
422
386
|
async (credentials) => {
|
|
423
|
-
const scope =
|
|
387
|
+
const scope = createConnectionScope(account, credentials);
|
|
424
388
|
const managementService = new MailboxManagementService(
|
|
425
389
|
mailboxService,
|
|
426
390
|
log,
|
|
@@ -509,14 +473,14 @@ const handleDelete = async (
|
|
|
509
473
|
export const processMailboxManagement = async (
|
|
510
474
|
event: MailboxManagementEvent,
|
|
511
475
|
log: Logger,
|
|
512
|
-
|
|
476
|
+
createConnectionScope = createConnectionScopeWithCredentials,
|
|
513
477
|
): Promise<void> => {
|
|
514
478
|
switch (event.type) {
|
|
515
479
|
case "MAILBOX_CREATE":
|
|
516
|
-
return handleCreate(event, log,
|
|
480
|
+
return handleCreate(event, log, createConnectionScope);
|
|
517
481
|
case "MAILBOX_RENAME":
|
|
518
|
-
return handleRename(event, log,
|
|
482
|
+
return handleRename(event, log, createConnectionScope);
|
|
519
483
|
case "MAILBOX_DELETE":
|
|
520
|
-
return handleDelete(event, log,
|
|
484
|
+
return handleDelete(event, log, createConnectionScope);
|
|
521
485
|
}
|
|
522
486
|
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
MailboxCursorState,
|
|
5
|
+
MessageMutation,
|
|
6
|
+
MessageStatus,
|
|
7
|
+
MessageSyncStatus,
|
|
8
|
+
} from "@remit/domain-enums";
|
|
9
|
+
import type {
|
|
10
|
+
MessageCopyEvent,
|
|
11
|
+
MessageDeleteEvent,
|
|
12
|
+
MessageMoveEvent,
|
|
13
|
+
} from "../events.js";
|
|
14
|
+
import { mail, openStore, type World } from "../test-helpers/test-world.js";
|
|
15
|
+
import { handleMessageCopy } from "./message-copy.js";
|
|
16
|
+
import { handleMessageDelete } from "./message-delete.js";
|
|
17
|
+
import { handleMessageMove } from "./message-move.js";
|
|
18
|
+
|
|
19
|
+
let store: Awaited<ReturnType<typeof openStore>>;
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
store = await openStore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
after(() => store.close());
|
|
26
|
+
|
|
27
|
+
interface Arranged {
|
|
28
|
+
w: World;
|
|
29
|
+
copyId: string;
|
|
30
|
+
sourceMessageId: string;
|
|
31
|
+
uid: number;
|
|
32
|
+
messageIdHeader: string;
|
|
33
|
+
inboxId: string;
|
|
34
|
+
projectsId: string;
|
|
35
|
+
event: MessageCopyEvent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A message in INBOX, copied to Projects through the service the API calls. */
|
|
39
|
+
const arrange = async (
|
|
40
|
+
options: { olderCopyInProjects?: boolean } = {},
|
|
41
|
+
): Promise<Arranged> => {
|
|
42
|
+
const w = await store.world();
|
|
43
|
+
const inbox = await w.folder("INBOX");
|
|
44
|
+
const projects = await w.folder("Projects");
|
|
45
|
+
const seeded = await w.message(inbox);
|
|
46
|
+
if (options.olderCopyInProjects) {
|
|
47
|
+
w.imap.deliver("Projects", mail(seeded.messageIdHeader));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const copyId = await w.client.messageMove.copyMessage(
|
|
51
|
+
w.account.accountConfigId,
|
|
52
|
+
seeded.messageId,
|
|
53
|
+
projects.mailboxId,
|
|
54
|
+
w.account.accountId,
|
|
55
|
+
);
|
|
56
|
+
const event = w
|
|
57
|
+
.queued()
|
|
58
|
+
.find(
|
|
59
|
+
(body) => body.type === "MESSAGE_COPY",
|
|
60
|
+
) as unknown as MessageCopyEvent;
|
|
61
|
+
assert.ok(event, "the copy enqueued a MESSAGE_COPY");
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
w,
|
|
65
|
+
copyId,
|
|
66
|
+
sourceMessageId: seeded.messageId,
|
|
67
|
+
uid: seeded.uid,
|
|
68
|
+
messageIdHeader: seeded.messageIdHeader,
|
|
69
|
+
inboxId: inbox.mailboxId,
|
|
70
|
+
projectsId: projects.mailboxId,
|
|
71
|
+
event,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const run = (a: Arranged, receiveCount = 1) =>
|
|
76
|
+
handleMessageCopy(a.event, a.w.log, receiveCount, a.w.scope);
|
|
77
|
+
|
|
78
|
+
describe("a message copy", () => {
|
|
79
|
+
it("lands at the destination, leaves the source alone and settles the copy row", async () => {
|
|
80
|
+
const a = await arrange();
|
|
81
|
+
|
|
82
|
+
await run(a);
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(a.w.imap.uids("INBOX"), [a.uid]);
|
|
85
|
+
const [copyUid] = a.w.imap.uids("Projects", a.messageIdHeader);
|
|
86
|
+
const row = await a.w.row(a.copyId);
|
|
87
|
+
assert.equal(row?.mailboxId, a.projectsId);
|
|
88
|
+
assert.equal(row?.uid, copyUid);
|
|
89
|
+
assert.equal(row?.status, MessageStatus.active);
|
|
90
|
+
assert.equal(row?.syncStatus, MessageSyncStatus.synced);
|
|
91
|
+
assert.equal((await a.w.row(a.sourceMessageId))?.mailboxId, a.inboxId);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("settles on the copy this event made when the server sends no COPYUID", async () => {
|
|
95
|
+
const a = await arrange({ olderCopyInProjects: true });
|
|
96
|
+
a.w.imap.uidplus = false;
|
|
97
|
+
|
|
98
|
+
await run(a);
|
|
99
|
+
|
|
100
|
+
const copies = a.w.imap.uids("Projects", a.messageIdHeader);
|
|
101
|
+
assert.equal(copies.length, 2);
|
|
102
|
+
assert.equal((await a.w.row(a.copyId))?.uid, Math.max(...copies));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reconciles the copy row away when the destination does not hold the copy", async () => {
|
|
106
|
+
const a = await arrange();
|
|
107
|
+
a.w.imap.uidplus = false;
|
|
108
|
+
a.w.imap.ignoreNext.add("COPY");
|
|
109
|
+
|
|
110
|
+
await run(a);
|
|
111
|
+
|
|
112
|
+
assert.equal(await a.w.row(a.copyId), undefined);
|
|
113
|
+
assert.equal(
|
|
114
|
+
a.w.logs.filter(
|
|
115
|
+
(line) => line.fields.metric === "message_copy_not_landed",
|
|
116
|
+
).length,
|
|
117
|
+
1,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// COPY has no source-side effect, so a second one is a duplicate the user
|
|
122
|
+
// then has to find and delete.
|
|
123
|
+
it("never copies twice when a redelivery finds the copy already landed", async () => {
|
|
124
|
+
const a = await arrange();
|
|
125
|
+
a.w.imap.deliver("Projects", mail(a.messageIdHeader));
|
|
126
|
+
|
|
127
|
+
await run(a, 2);
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(a.w.imap.commands("COPY"), []);
|
|
130
|
+
assert.equal(a.w.imap.uids("Projects", a.messageIdHeader).length, 1);
|
|
131
|
+
assert.equal((await a.w.row(a.copyId))?.status, MessageStatus.active);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("issues nothing when a redelivery finds the copy already settled", async () => {
|
|
135
|
+
const a = await arrange();
|
|
136
|
+
await run(a);
|
|
137
|
+
const commands = a.w.imap.log.length;
|
|
138
|
+
|
|
139
|
+
await run(a);
|
|
140
|
+
|
|
141
|
+
assert.equal(a.w.imap.log.length, commands);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("creates a destination the server does not have and redelivers", async () => {
|
|
145
|
+
const a = await arrange();
|
|
146
|
+
a.w.imap.removeFolder("Projects");
|
|
147
|
+
|
|
148
|
+
await assert.rejects(run(a), /TRYCREATE/);
|
|
149
|
+
|
|
150
|
+
assert.deepEqual(a.w.imap.commands("CREATE"), ["CREATE Projects"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("gives the copy up when the source message is gone on the server", async () => {
|
|
154
|
+
const a = await arrange();
|
|
155
|
+
a.w.imap.failNext.COPY = new Error("Message not found (NONEXISTENT)");
|
|
156
|
+
|
|
157
|
+
await run(a);
|
|
158
|
+
|
|
159
|
+
const row = await a.w.row(a.copyId);
|
|
160
|
+
assert.equal(row?.status, MessageStatus.deleted);
|
|
161
|
+
assert.equal(row?.syncStatus, MessageSyncStatus.abandoned);
|
|
162
|
+
assert.equal(row?.abandonedMutation, MessageMutation.copy);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("marks the copy failed and redelivers when the connection drops", async () => {
|
|
166
|
+
const a = await arrange();
|
|
167
|
+
a.w.imap.failNext.COPY = new Error("Connection reset");
|
|
168
|
+
|
|
169
|
+
await assert.rejects(run(a), /Connection reset/);
|
|
170
|
+
|
|
171
|
+
assert.equal(
|
|
172
|
+
(await a.w.row(a.copyId))?.syncStatus,
|
|
173
|
+
MessageSyncStatus.failed,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("settles on the destination's copy at the ceiling instead of dead-lettering", async () => {
|
|
178
|
+
const a = await arrange();
|
|
179
|
+
a.w.imap.deliver("Projects", mail(a.messageIdHeader));
|
|
180
|
+
a.w.imap.failNext.COPY = new Error("Connection reset");
|
|
181
|
+
|
|
182
|
+
await run(a, 3);
|
|
183
|
+
|
|
184
|
+
const [copyUid] = a.w.imap.uids("Projects", a.messageIdHeader);
|
|
185
|
+
const row = await a.w.row(a.copyId);
|
|
186
|
+
assert.equal(row?.uid, copyUid);
|
|
187
|
+
assert.equal(row?.status, MessageStatus.active);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reconciles the copy row away at the ceiling when nothing reached the destination", async () => {
|
|
191
|
+
const a = await arrange();
|
|
192
|
+
a.w.imap.failNext.COPY = new Error("Connection reset");
|
|
193
|
+
|
|
194
|
+
await run(a, 3);
|
|
195
|
+
|
|
196
|
+
assert.equal(await a.w.row(a.copyId), undefined);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reconciles the copy row away without connecting when the destination mailbox was deleted", async () => {
|
|
200
|
+
const a = await arrange();
|
|
201
|
+
await a.w.client.mailbox.delete(a.w.account.accountId, a.projectsId);
|
|
202
|
+
|
|
203
|
+
await run(a);
|
|
204
|
+
|
|
205
|
+
assert.equal(a.w.imap.connections, 0);
|
|
206
|
+
assert.equal(await a.w.row(a.copyId), undefined);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("reconciles the copy row away without connecting while the source cursor is rebuilding", async () => {
|
|
210
|
+
const a = await arrange();
|
|
211
|
+
await a.w.client.mailbox.update(a.w.account.accountId, a.inboxId, {
|
|
212
|
+
cursorState: MailboxCursorState.rebuilding,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await run(a);
|
|
216
|
+
|
|
217
|
+
assert.equal(a.w.imap.connections, 0);
|
|
218
|
+
assert.equal(await a.w.row(a.copyId), undefined);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("copies nothing when the source's UIDVALIDITY changed under it", async () => {
|
|
222
|
+
const a = await arrange();
|
|
223
|
+
a.w.imap.setUidValidity("INBOX", 2);
|
|
224
|
+
|
|
225
|
+
await run(a);
|
|
226
|
+
|
|
227
|
+
assert.deepEqual(a.w.imap.commands("COPY"), []);
|
|
228
|
+
assert.equal(await a.w.row(a.copyId), undefined);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("acks without connecting when the source mailbox was deleted", async () => {
|
|
232
|
+
const a = await arrange();
|
|
233
|
+
await a.w.client.mailbox.delete(a.w.account.accountId, a.inboxId);
|
|
234
|
+
|
|
235
|
+
await run(a);
|
|
236
|
+
|
|
237
|
+
assert.equal(a.w.imap.connections, 0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("a fresh copy acted on before the server confirms it", () => {
|
|
242
|
+
const isMove = (
|
|
243
|
+
body: Record<string, unknown>,
|
|
244
|
+
): body is Record<string, unknown> & MessageMoveEvent =>
|
|
245
|
+
body.type === "MESSAGE_MOVE";
|
|
246
|
+
const isDelete = (
|
|
247
|
+
body: Record<string, unknown>,
|
|
248
|
+
): body is Record<string, unknown> & MessageDeleteEvent =>
|
|
249
|
+
body.type === "MESSAGE_DELETE";
|
|
250
|
+
|
|
251
|
+
it("waits for the copy to settle, then moves the copy and leaves the original", async () => {
|
|
252
|
+
const a = await arrange();
|
|
253
|
+
|
|
254
|
+
const moving = a.w.client.messageMove.moveMessage(
|
|
255
|
+
a.w.account.accountConfigId,
|
|
256
|
+
a.copyId,
|
|
257
|
+
a.inboxId,
|
|
258
|
+
a.w.account.accountId,
|
|
259
|
+
);
|
|
260
|
+
await run(a);
|
|
261
|
+
await moving;
|
|
262
|
+
const [copyUid] = a.w.imap.uids("Projects", a.messageIdHeader);
|
|
263
|
+
const event = a.w.queued().find(isMove);
|
|
264
|
+
assert.ok(event, "the move enqueued a MESSAGE_MOVE");
|
|
265
|
+
assert.equal(event.uid, copyUid);
|
|
266
|
+
|
|
267
|
+
await handleMessageMove(event, a.w.log, 1, a.w.scope);
|
|
268
|
+
|
|
269
|
+
assert.deepEqual(a.w.imap.uids("Projects", a.messageIdHeader), []);
|
|
270
|
+
assert.equal(a.w.imap.uids("INBOX", a.messageIdHeader).length, 2);
|
|
271
|
+
const row = await a.w.row(a.copyId);
|
|
272
|
+
assert.equal(row?.mailboxId, a.inboxId);
|
|
273
|
+
assert.equal(row?.status, MessageStatus.active);
|
|
274
|
+
assert.equal((await a.w.row(a.sourceMessageId))?.uid, a.uid);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("waits for the copy to settle, then expunges the copy and leaves the original", async () => {
|
|
278
|
+
const a = await arrange();
|
|
279
|
+
|
|
280
|
+
const deleting = a.w.client.messageMove.deleteMessages(
|
|
281
|
+
a.w.account.accountConfigId,
|
|
282
|
+
[a.copyId],
|
|
283
|
+
a.w.account.accountId,
|
|
284
|
+
{ permanent: true },
|
|
285
|
+
);
|
|
286
|
+
await run(a);
|
|
287
|
+
assert.deepEqual((await deleting).refusedMessageIds, []);
|
|
288
|
+
const event = a.w.queued().find(isDelete);
|
|
289
|
+
assert.ok(event, "the delete enqueued a MESSAGE_DELETE");
|
|
290
|
+
|
|
291
|
+
await handleMessageDelete(event, a.w.log, 1, a.w.scope);
|
|
292
|
+
|
|
293
|
+
assert.deepEqual(a.w.imap.uids("Projects", a.messageIdHeader), []);
|
|
294
|
+
assert.deepEqual(a.w.imap.uids("INBOX"), [a.uid]);
|
|
295
|
+
assert.equal(await a.w.row(a.copyId), undefined);
|
|
296
|
+
assert.equal((await a.w.row(a.sourceMessageId))?.mailboxId, a.inboxId);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -4,13 +4,12 @@ import {
|
|
|
4
4
|
MessageStatus,
|
|
5
5
|
MessageSyncStatus,
|
|
6
6
|
} from "@remit/domain-enums";
|
|
7
|
-
import type { Logger } from "@remit/logger
|
|
7
|
+
import type { Logger } from "@remit/logger";
|
|
8
8
|
import {
|
|
9
9
|
guardConnectionCursor,
|
|
10
10
|
type IImapConnection,
|
|
11
11
|
isCursorRebuildNeeded,
|
|
12
12
|
MailboxCursorPausedError,
|
|
13
|
-
reconcileStaleMessage,
|
|
14
13
|
} from "@remit/mailbox-service";
|
|
15
14
|
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
16
15
|
import { isAccountDeleted } from "../account-check.js";
|
|
@@ -24,22 +23,7 @@ import {
|
|
|
24
23
|
emitMoveResync,
|
|
25
24
|
searchMailboxForHighestMessageIdUid,
|
|
26
25
|
} from "./message-move.js";
|
|
27
|
-
|
|
28
|
-
export interface MessageCopyDeps {
|
|
29
|
-
getClient: typeof getClient;
|
|
30
|
-
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
31
|
-
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
32
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
33
|
-
emitEvent: typeof emitEvent;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const defaultDeps: MessageCopyDeps = {
|
|
37
|
-
getClient,
|
|
38
|
-
buildLifecycleDeps,
|
|
39
|
-
withOAuthLifecycle,
|
|
40
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
41
|
-
emitEvent,
|
|
42
|
-
};
|
|
26
|
+
import { probeableMessageIdHeader } from "./probe-message-id.js";
|
|
43
27
|
|
|
44
28
|
export const getMessageCopyMaxAttempts = (
|
|
45
29
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
@@ -49,8 +33,8 @@ export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
|
|
|
49
33
|
|
|
50
34
|
/**
|
|
51
35
|
* Where the destination says this copy is. `unprobeable` is the row that
|
|
52
|
-
* carries no Message-ID header (
|
|
53
|
-
*
|
|
36
|
+
* carries no usable Message-ID header (drafts, automated senders and bounces
|
|
37
|
+
* do arrive without one): the server was never
|
|
54
38
|
* asked, so its silence says nothing.
|
|
55
39
|
*/
|
|
56
40
|
export type CopyProbe =
|
|
@@ -85,16 +69,8 @@ export const handleMessageCopy = async (
|
|
|
85
69
|
event: MessageCopyEvent,
|
|
86
70
|
log: Logger,
|
|
87
71
|
receiveCount = 1,
|
|
88
|
-
|
|
72
|
+
createConnectionScope = createConnectionScopeWithCredentials,
|
|
89
73
|
): Promise<void> => {
|
|
90
|
-
const {
|
|
91
|
-
getClient,
|
|
92
|
-
buildLifecycleDeps,
|
|
93
|
-
withOAuthLifecycle,
|
|
94
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
95
|
-
emitEvent,
|
|
96
|
-
} = deps;
|
|
97
|
-
|
|
98
74
|
const {
|
|
99
75
|
account: accountService,
|
|
100
76
|
message: messageService,
|
|
@@ -153,6 +129,11 @@ export const handleMessageCopy = async (
|
|
|
153
129
|
// whether THIS copy is still outstanding, and a `deleting` row is somebody
|
|
154
130
|
// else's work — reading it as unsettled is what would let the second COPY
|
|
155
131
|
// through.
|
|
132
|
+
const copyPlacement = {
|
|
133
|
+
status: copyRow.status,
|
|
134
|
+
mailboxId: copyRow.mailboxId,
|
|
135
|
+
uid: copyRow.uid,
|
|
136
|
+
};
|
|
156
137
|
if (copyRow.status !== MessageStatus.moving) {
|
|
157
138
|
log.info(
|
|
158
139
|
{ accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
|
|
@@ -161,6 +142,11 @@ export const handleMessageCopy = async (
|
|
|
161
142
|
return;
|
|
162
143
|
}
|
|
163
144
|
|
|
145
|
+
// `failed` is an earlier attempt's mark, and the re-drive's: either may
|
|
146
|
+
// have issued a COPY whose answer was lost.
|
|
147
|
+
const earlierAttempt =
|
|
148
|
+
receiveCount > 1 || copyRow.syncStatus === MessageSyncStatus.failed;
|
|
149
|
+
|
|
164
150
|
await withOAuthLifecycle(
|
|
165
151
|
buildLifecycleDeps(secrets, accountService),
|
|
166
152
|
account,
|
|
@@ -192,10 +178,9 @@ export const handleMessageCopy = async (
|
|
|
192
178
|
destinationMailboxId,
|
|
193
179
|
);
|
|
194
180
|
if (destination.kind === "gone") {
|
|
195
|
-
const
|
|
196
|
-
{ messageService, threadMessageService },
|
|
197
|
-
account.accountConfigId,
|
|
181
|
+
const reconciled = await messageService.deleteIfPlacement(
|
|
198
182
|
newMessageId,
|
|
183
|
+
copyPlacement,
|
|
199
184
|
);
|
|
200
185
|
log.warn(
|
|
201
186
|
{
|
|
@@ -204,7 +189,7 @@ export const handleMessageCopy = async (
|
|
|
204
189
|
sourceMessageId,
|
|
205
190
|
newMessageId,
|
|
206
191
|
mailboxId: destinationMailboxId,
|
|
207
|
-
|
|
192
|
+
reconciled,
|
|
208
193
|
},
|
|
209
194
|
"Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away",
|
|
210
195
|
);
|
|
@@ -217,21 +202,19 @@ export const handleMessageCopy = async (
|
|
|
217
202
|
// handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
|
|
218
203
|
// snapshot alone, and opening the destination through it trips that
|
|
219
204
|
// mailbox into a rebuild.
|
|
220
|
-
//
|
|
221
|
-
// This is the call site with no source-side gate to fall back on
|
|
222
|
-
// (#1122): a COPY leaves the source untouched, so nothing here can rule
|
|
223
|
-
// an older destination copy out the way `confirmTrashMoveUid` does. The
|
|
224
|
-
// fresh copy is the highest matching uid, and `deriveCopyMessageId`
|
|
225
|
-
// makes a repeated copy of the same message a second server COPY, so
|
|
226
|
-
// this probe must never bind to the earlier one.
|
|
227
205
|
const probeDestination = async (
|
|
228
206
|
connection: IImapConnection,
|
|
229
207
|
): Promise<CopyProbe> => {
|
|
230
|
-
|
|
208
|
+
const messageIdHeader = await probeableMessageIdHeader(
|
|
209
|
+
threadMessageService,
|
|
210
|
+
account.accountConfigId,
|
|
211
|
+
newMessageId,
|
|
212
|
+
);
|
|
213
|
+
if (!messageIdHeader) return { kind: "unprobeable" };
|
|
231
214
|
const probedUid = await searchMailboxForHighestMessageIdUid(
|
|
232
215
|
connection,
|
|
233
216
|
destinationMailboxPath,
|
|
234
|
-
|
|
217
|
+
messageIdHeader,
|
|
235
218
|
);
|
|
236
219
|
return probedUid === null
|
|
237
220
|
? { kind: "absent" }
|
|
@@ -288,10 +271,9 @@ export const handleMessageCopy = async (
|
|
|
288
271
|
// reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
|
|
289
272
|
// calls `reconciled`. Routine: metric, no alarm.
|
|
290
273
|
const settleNeverLanded = async (reason: string): Promise<void> => {
|
|
291
|
-
const
|
|
292
|
-
{ messageService, threadMessageService },
|
|
293
|
-
account.accountConfigId,
|
|
274
|
+
const reconciled = await messageService.deleteIfPlacement(
|
|
294
275
|
newMessageId,
|
|
276
|
+
copyPlacement,
|
|
295
277
|
);
|
|
296
278
|
log.info(
|
|
297
279
|
{
|
|
@@ -300,7 +282,7 @@ export const handleMessageCopy = async (
|
|
|
300
282
|
sourceMessageId,
|
|
301
283
|
newMessageId,
|
|
302
284
|
destination: destinationMailboxPath,
|
|
303
|
-
|
|
285
|
+
reconciled,
|
|
304
286
|
reason,
|
|
305
287
|
},
|
|
306
288
|
"Copy absent from its destination; optimistic copy row reconciled away",
|
|
@@ -352,7 +334,7 @@ export const handleMessageCopy = async (
|
|
|
352
334
|
);
|
|
353
335
|
};
|
|
354
336
|
|
|
355
|
-
const scope =
|
|
337
|
+
const scope = createConnectionScope(account, credentials);
|
|
356
338
|
|
|
357
339
|
// A paused cursor settles rather than acks: acking leaves the optimistic
|
|
358
340
|
// row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
|
|
@@ -380,7 +362,7 @@ export const handleMessageCopy = async (
|
|
|
380
362
|
const settlePausedCopy = async (
|
|
381
363
|
commandIssued: boolean,
|
|
382
364
|
): Promise<void> => {
|
|
383
|
-
const mayHaveIssued = commandIssued ||
|
|
365
|
+
const mayHaveIssued = commandIssued || earlierAttempt;
|
|
384
366
|
|
|
385
367
|
if (!mayHaveIssued) {
|
|
386
368
|
await settleNeverLanded(
|
|
@@ -445,7 +427,7 @@ export const handleMessageCopy = async (
|
|
|
445
427
|
// duplicates the mail. `absent` is an answer the server gave, so
|
|
446
428
|
// copying again is safe; `unprobeable` is silence, and copying on
|
|
447
429
|
// silence is the duplicate this guard exists to prevent.
|
|
448
|
-
if (
|
|
430
|
+
if (earlierAttempt) {
|
|
449
431
|
const earlier = await probeDestination(rawConnection);
|
|
450
432
|
if (earlier.kind === "confirmed") {
|
|
451
433
|
await settleCopied(earlier.uid);
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IMessageRepository,
|
|
3
3
|
IThreadMessageRepository,
|
|
4
|
+
MessageItem,
|
|
4
5
|
} from "@remit/data-ports";
|
|
5
6
|
import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
|
|
6
7
|
import {
|
|
7
8
|
type IImapConnection,
|
|
8
9
|
isMessageGoneFromOpenMailbox,
|
|
9
|
-
reconcileStaleMessage,
|
|
10
10
|
restoreSourcePlacement,
|
|
11
|
-
type StaleMessageReconcileDeps,
|
|
12
11
|
} from "@remit/mailbox-service";
|
|
13
12
|
|
|
14
13
|
export interface MessageDeleteTerminalLogger {
|
|
@@ -16,9 +15,11 @@ export interface MessageDeleteTerminalLogger {
|
|
|
16
15
|
error(obj: Record<string, unknown>, msg: string): void;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
export interface ResolveExhaustedMessageDeleteDeps
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
export interface ResolveExhaustedMessageDeleteDeps {
|
|
19
|
+
messageService: Pick<
|
|
20
|
+
IMessageRepository,
|
|
21
|
+
"deleteIfPlacement" | "transitionPlacement"
|
|
22
|
+
>;
|
|
22
23
|
threadMessageService: Pick<
|
|
23
24
|
IThreadMessageRepository,
|
|
24
25
|
"findAllByMessageId" | "deleteMany" | "update"
|
|
@@ -27,6 +28,8 @@ export interface ResolveExhaustedMessageDeleteDeps
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ResolveExhaustedMessageDeleteInput {
|
|
31
|
+
/** Where the row stood when the caller read it; the delete holds only while it still does. */
|
|
32
|
+
placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
|
|
30
33
|
accountId: string;
|
|
31
34
|
accountConfigId: string;
|
|
32
35
|
messageId: string;
|
|
@@ -53,7 +56,7 @@ export interface ResolveExhaustedMessageDeleteResult {
|
|
|
53
56
|
* than by a FETCH coming back empty. The move to Trash landed server-side,
|
|
54
57
|
* or a foreign client moved or expunged the message; from here those are
|
|
55
58
|
* indistinguishable and have the same answer. The stale rows are deleted via
|
|
56
|
-
*
|
|
59
|
+
* `deleteIfPlacement` and the caller resyncs the affected folders,
|
|
57
60
|
* so whichever folder actually holds the message re-projects it with the
|
|
58
61
|
* server's own UID. Metric only, no alarm — routine.
|
|
59
62
|
* 2. BROKEN — the message is still at the source, so the delete never took
|
|
@@ -93,10 +96,9 @@ export const resolveExhaustedMessageDeleteFailure = async (
|
|
|
93
96
|
await connection.openBox(sourceMailboxPath, true);
|
|
94
97
|
|
|
95
98
|
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
96
|
-
const
|
|
97
|
-
deps,
|
|
98
|
-
accountConfigId,
|
|
99
|
+
const reconciled = await deps.messageService.deleteIfPlacement(
|
|
99
100
|
messageId,
|
|
101
|
+
input.placement,
|
|
100
102
|
);
|
|
101
103
|
deps.log.info(
|
|
102
104
|
{
|
|
@@ -106,7 +108,7 @@ export const resolveExhaustedMessageDeleteFailure = async (
|
|
|
106
108
|
messageId,
|
|
107
109
|
uid,
|
|
108
110
|
sourceMailboxPath,
|
|
109
|
-
|
|
111
|
+
reconciled,
|
|
110
112
|
},
|
|
111
113
|
"Message no longer at its delete source after retry exhaustion (the delete landed server-side, or an external delete or move); stale row reconciled",
|
|
112
114
|
);
|