@remit/imap-worker 0.0.19 → 0.0.21
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/e2e-processor-shim.ts +1 -8
- package/src/handlers/empty-trash.test.ts +16 -1
- package/src/handlers/empty-trash.ts +19 -1
- package/src/handlers/flag-push.test.ts +75 -2
- package/src/handlers/flag-push.ts +20 -1
- package/src/handlers/mailbox-management.test.ts +42 -1
- package/src/handlers/mailbox-management.ts +191 -109
- package/src/handlers/message-copy.test.ts +20 -1
- package/src/handlers/message-copy.ts +19 -1
- package/src/handlers/message-delete.test.ts +17 -1
- package/src/handlers/message-delete.ts +19 -1
- package/src/handlers/message-move.test.ts +78 -2
- package/src/handlers/message-move.ts +19 -1
- package/src/handlers/placement-move-push.test.ts +70 -1
- package/src/handlers/placement-move-push.ts +27 -8
- package/src/handlers/sync-message-body.test.ts +48 -0
- package/src/handlers/sync-message-body.ts +20 -1
- package/src/handlers/sync-messages.ts +3 -2
- package/src/is-not-found.ts +17 -0
- package/src/e2e-processor-visibility.test.ts +0 -43
- package/src/e2e-processor-visibility.ts +0 -27
package/package.json
CHANGED
|
@@ -14,7 +14,6 @@ import type {
|
|
|
14
14
|
SQSHandler,
|
|
15
15
|
} from "aws-lambda";
|
|
16
16
|
import { env } from "expect-env";
|
|
17
|
-
import { receiveVisibilitySeconds } from "./e2e-processor-visibility.js";
|
|
18
17
|
import { handler } from "./index.js";
|
|
19
18
|
|
|
20
19
|
/**
|
|
@@ -151,13 +150,7 @@ if (cluster.isPrimary) {
|
|
|
151
150
|
QueueUrl: queueUrl,
|
|
152
151
|
MaxNumberOfMessages: maxMessages,
|
|
153
152
|
WaitTimeSeconds: waitTime,
|
|
154
|
-
|
|
155
|
-
// window lapses; on the per-account FIFO sync queues that window is
|
|
156
|
-
// also how long a single failure head-of-line blocks the account's
|
|
157
|
-
// whole pipeline (#290). Keep it short for FIFO, long for the
|
|
158
|
-
// standard queues whose slower work needs it. See
|
|
159
|
-
// `receiveVisibilitySeconds`.
|
|
160
|
-
VisibilityTimeout: receiveVisibilitySeconds(queueUrl),
|
|
153
|
+
VisibilityTimeout: 300,
|
|
161
154
|
MessageSystemAttributeNames: ["ApproximateReceiveCount"],
|
|
162
155
|
}),
|
|
163
156
|
);
|
|
@@ -36,6 +36,7 @@ interface Harness {
|
|
|
36
36
|
deletedAt?: number;
|
|
37
37
|
} | null;
|
|
38
38
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
39
|
+
mailboxError?: Error;
|
|
39
40
|
connection: Connection;
|
|
40
41
|
localMessages: { messageId: string }[];
|
|
41
42
|
threadMessage: { accountConfigId: string; threadMessageId: string } | null;
|
|
@@ -88,7 +89,10 @@ const deps = (): EmptyTrashDeps =>
|
|
|
88
89
|
delete: record("threadMessage.delete"),
|
|
89
90
|
},
|
|
90
91
|
mailbox: {
|
|
91
|
-
get: async () =>
|
|
92
|
+
get: async () => {
|
|
93
|
+
if (h.mailboxError) throw h.mailboxError;
|
|
94
|
+
return h.mailbox;
|
|
95
|
+
},
|
|
92
96
|
update: record("mailbox.update"),
|
|
93
97
|
},
|
|
94
98
|
secrets: {},
|
|
@@ -191,6 +195,17 @@ describe("handleEmptyTrash", () => {
|
|
|
191
195
|
assert.equal(called("message.delete").length, 0);
|
|
192
196
|
});
|
|
193
197
|
|
|
198
|
+
it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
|
|
199
|
+
h.mailboxError = Object.assign(new Error("Mailbox not found: trash-mbx"), {
|
|
200
|
+
name: "NotFoundError",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
204
|
+
|
|
205
|
+
assert.equal(h.getConnectionCount, 0);
|
|
206
|
+
assert.equal(called("message.delete").length, 0);
|
|
207
|
+
});
|
|
208
|
+
|
|
194
209
|
it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
195
210
|
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
196
211
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { isAccountDeleted } from "../account-check.js";
|
|
9
9
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
10
10
|
import type { EmptyTrashEvent } from "../events.js";
|
|
11
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
11
12
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
12
13
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
13
14
|
|
|
@@ -70,7 +71,24 @@ export const handleEmptyTrash = async (
|
|
|
70
71
|
account,
|
|
71
72
|
log,
|
|
72
73
|
async (credentials) => {
|
|
73
|
-
|
|
74
|
+
// The Trash folder can be deleted between enqueue and this sync, leaving a
|
|
75
|
+
// queued event pointing at a gone row. The lookup then throws
|
|
76
|
+
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
77
|
+
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
78
|
+
// Trash makes the empty moot: ack with a WARN.
|
|
79
|
+
const mailbox = await mailboxService
|
|
80
|
+
.get(accountId, trashMailboxId)
|
|
81
|
+
.catch((error: unknown) => {
|
|
82
|
+
if (isNotFoundError(error)) return null;
|
|
83
|
+
throw error;
|
|
84
|
+
});
|
|
85
|
+
if (!mailbox) {
|
|
86
|
+
log.warn(
|
|
87
|
+
{ accountId, mailboxId: trashMailboxId },
|
|
88
|
+
"Skipping EMPTY_TRASH: mailbox no longer exists (deleted)",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
74
92
|
|
|
75
93
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
76
94
|
// paused never even opens a connection. Optimization only — the
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
2
|
+
import { afterEach, describe, it, mock } from "node:test";
|
|
3
|
+
import { getClient } from "@remit/backend/client";
|
|
4
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
5
|
+
import type { FlagPushEvent } from "../events.js";
|
|
6
|
+
import {
|
|
7
|
+
FLAG_PUSH_MAX_ATTEMPTS,
|
|
8
|
+
getFlagPushMaxAttempts,
|
|
9
|
+
handleFlagPush,
|
|
10
|
+
} from "./flag-push.js";
|
|
11
|
+
|
|
12
|
+
const silentLogger = (() => {
|
|
13
|
+
const noop = () => {};
|
|
14
|
+
const log = {
|
|
15
|
+
info: noop,
|
|
16
|
+
warn: noop,
|
|
17
|
+
error: noop,
|
|
18
|
+
debug: noop,
|
|
19
|
+
fatal: noop,
|
|
20
|
+
trace: noop,
|
|
21
|
+
child: () => log,
|
|
22
|
+
} as unknown as Logger;
|
|
23
|
+
return log;
|
|
24
|
+
})();
|
|
4
25
|
|
|
5
26
|
describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts / #1289's getPlacementMoveMaxAttempts)", () => {
|
|
6
27
|
it("parses the CDK-injected env var", () => {
|
|
@@ -23,3 +44,55 @@ describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getB
|
|
|
23
44
|
assert.ok(FLAG_PUSH_MAX_ATTEMPTS > 0);
|
|
24
45
|
});
|
|
25
46
|
});
|
|
47
|
+
|
|
48
|
+
describe("handleFlagPush — deleted mailbox is terminal (#287/#289)", () => {
|
|
49
|
+
const accountId = "fp-acc-zzz";
|
|
50
|
+
const messageId = "fp-msg-zzz";
|
|
51
|
+
const flagName = "\\Seen";
|
|
52
|
+
|
|
53
|
+
const event: FlagPushEvent = {
|
|
54
|
+
type: "FLAG_PUSH",
|
|
55
|
+
accountId,
|
|
56
|
+
accountConfigId: "fp-cfg-zzz",
|
|
57
|
+
messageId,
|
|
58
|
+
flagName,
|
|
59
|
+
} as FlagPushEvent;
|
|
60
|
+
|
|
61
|
+
afterEach(() => mock.restoreAll());
|
|
62
|
+
|
|
63
|
+
it("drops the orphaned marker and acks without pushing when the mailbox is gone", async () => {
|
|
64
|
+
const client = await getClient();
|
|
65
|
+
mock.method(client.account, "get", async () => ({
|
|
66
|
+
accountId,
|
|
67
|
+
accountConfigId: "fp-cfg-zzz",
|
|
68
|
+
}));
|
|
69
|
+
mock.method(client.message, "get", async () => [
|
|
70
|
+
{ messageId, mailboxId: "gone-mbx", uid: 42 },
|
|
71
|
+
]);
|
|
72
|
+
mock.method(client.mailbox, "get", async () => {
|
|
73
|
+
throw Object.assign(new Error("Mailbox not found: gone-mbx"), {
|
|
74
|
+
name: "NotFoundError",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
mock.method(client.flagPush, "find", async () => ({
|
|
78
|
+
operation: "add",
|
|
79
|
+
state: "pending",
|
|
80
|
+
}));
|
|
81
|
+
mock.method(client.flagPush, "updateState", async () => {});
|
|
82
|
+
const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
|
|
83
|
+
|
|
84
|
+
// Must resolve, not reject — a deleted folder is an expected terminal
|
|
85
|
+
// outcome, not a fault to retry on the account's per-group FIFO.
|
|
86
|
+
await handleFlagPush(event, silentLogger, 1);
|
|
87
|
+
|
|
88
|
+
assert.equal(
|
|
89
|
+
deleteMarker.mock.calls.length,
|
|
90
|
+
1,
|
|
91
|
+
"the orphaned marker is dropped",
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(deleteMarker.mock.calls[0].arguments, [
|
|
94
|
+
messageId,
|
|
95
|
+
flagName,
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { isAccountDeleted } from "../account-check.js";
|
|
11
11
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
12
12
|
import type { FlagPushEvent } from "../events.js";
|
|
13
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
13
14
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
14
15
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
15
16
|
|
|
@@ -113,7 +114,25 @@ export const handleFlagPush = async (
|
|
|
113
114
|
// died mid-flight already left it here).
|
|
114
115
|
await markerService.updateState(messageId, flagName, "processing");
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
// The folder can be deleted between enqueue and this push, leaving a marker
|
|
118
|
+
// pointing at a gone row. The lookup then throws NotFoundError forever, and
|
|
119
|
+
// on the account's per-group FIFO that head message stalls the whole pipeline
|
|
120
|
+
// (issues #287, #289, #290). The push is moot — drop the orphaned marker (as
|
|
121
|
+
// the message-gone branch above already does) and ack with a WARN.
|
|
122
|
+
const mailbox = await mailboxService
|
|
123
|
+
.get(accountId, message.mailboxId)
|
|
124
|
+
.catch((error: unknown) => {
|
|
125
|
+
if (isNotFoundError(error)) return null;
|
|
126
|
+
throw error;
|
|
127
|
+
});
|
|
128
|
+
if (!mailbox) {
|
|
129
|
+
await markerService.delete(messageId, flagName);
|
|
130
|
+
log.warn(
|
|
131
|
+
{ messageId, flagName, accountId, mailboxId: message.mailboxId },
|
|
132
|
+
"Skipping FLAG_PUSH: mailbox no longer exists (deleted); marker dropped",
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
117
136
|
|
|
118
137
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
119
138
|
// paused never even borrows a connection. Optimization only — the
|
|
@@ -51,11 +51,17 @@ interface Harness {
|
|
|
51
51
|
deletedAt?: number;
|
|
52
52
|
} | null;
|
|
53
53
|
connection: Connection;
|
|
54
|
+
mailboxUpdateError?: Error;
|
|
54
55
|
disconnectCount: number;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
let h: Harness;
|
|
58
59
|
|
|
60
|
+
const notFoundError = (): Error =>
|
|
61
|
+
Object.assign(new Error("Mailbox not found: mbx-1"), {
|
|
62
|
+
name: "NotFoundError",
|
|
63
|
+
});
|
|
64
|
+
|
|
59
65
|
const record =
|
|
60
66
|
(method: string) =>
|
|
61
67
|
async (...args: unknown[]) => {
|
|
@@ -96,7 +102,10 @@ const deps = (): MailboxManagementDeps =>
|
|
|
96
102
|
},
|
|
97
103
|
},
|
|
98
104
|
mailbox: {
|
|
99
|
-
update:
|
|
105
|
+
update: async (...args: unknown[]) => {
|
|
106
|
+
h.calls.push({ method: "mailbox.update", args });
|
|
107
|
+
if (h.mailboxUpdateError) throw h.mailboxUpdateError;
|
|
108
|
+
},
|
|
100
109
|
delete: record("mailbox.delete"),
|
|
101
110
|
},
|
|
102
111
|
secrets: {},
|
|
@@ -207,6 +216,16 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
207
216
|
assert.equal(h.disconnectCount, 1);
|
|
208
217
|
});
|
|
209
218
|
|
|
219
|
+
it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
|
|
220
|
+
// The status write-back throws NotFoundError because the row is gone; the
|
|
221
|
+
// create is moot and must not poison the account's FIFO.
|
|
222
|
+
h.mailboxUpdateError = notFoundError();
|
|
223
|
+
|
|
224
|
+
await processMailboxManagement(createEvent, noopLog, deps());
|
|
225
|
+
|
|
226
|
+
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
227
|
+
});
|
|
228
|
+
|
|
210
229
|
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
211
230
|
h.account = {
|
|
212
231
|
accountId: "acc-1",
|
|
@@ -258,6 +277,17 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
258
277
|
assert.equal(called("mailbox.update").length, 0);
|
|
259
278
|
});
|
|
260
279
|
|
|
280
|
+
it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
|
|
281
|
+
h.connection.renameMailbox = async () => {
|
|
282
|
+
throw new Error("server exploded");
|
|
283
|
+
};
|
|
284
|
+
h.mailboxUpdateError = notFoundError();
|
|
285
|
+
|
|
286
|
+
await processMailboxManagement(renameEvent, noopLog, deps());
|
|
287
|
+
|
|
288
|
+
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
289
|
+
});
|
|
290
|
+
|
|
261
291
|
it("rolls the local path back and rethrows on any other rename error", async () => {
|
|
262
292
|
h.connection.renameMailbox = async () => {
|
|
263
293
|
throw new Error("server exploded");
|
|
@@ -321,4 +351,15 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
321
351
|
|
|
322
352
|
assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
|
|
323
353
|
});
|
|
354
|
+
|
|
355
|
+
it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
|
|
356
|
+
h.connection.deleteMailbox = async () => {
|
|
357
|
+
throw new Error("server exploded");
|
|
358
|
+
};
|
|
359
|
+
h.mailboxUpdateError = notFoundError();
|
|
360
|
+
|
|
361
|
+
await processMailboxManagement(deleteEvent, noopLog, deps());
|
|
362
|
+
|
|
363
|
+
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
364
|
+
});
|
|
324
365
|
});
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
MailboxManagementEvent,
|
|
11
11
|
MailboxRenameEvent,
|
|
12
12
|
} from "../events.js";
|
|
13
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
13
14
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
14
15
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
15
16
|
|
|
@@ -27,6 +28,21 @@ const defaultDeps: MailboxManagementDeps = {
|
|
|
27
28
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Pinned invariant for the whole-chain terminal guards below.
|
|
33
|
+
*
|
|
34
|
+
* Each handler wraps its `MailboxManagementService.sync*` chain in a try/catch
|
|
35
|
+
* that treats a NotFoundError as terminal (ack with a WARN, issue #289). That
|
|
36
|
+
* is only sound because `syncCreate` / `syncRename` / `syncDelete` — and their
|
|
37
|
+
* error-recovery branches here — touch nothing but the single target mailbox
|
|
38
|
+
* row via `mailboxService.get/update/delete(accountId, mailboxId)`. So the only
|
|
39
|
+
* row a NotFoundError can refer to is that target, and its absence can only mean
|
|
40
|
+
* the user deleted the folder mid-sync — never an unrelated missing entity that
|
|
41
|
+
* should have been retried. If a sync method ever reads or writes a second
|
|
42
|
+
* entity, narrow these catches (match the mailboxId / re-check existence) before
|
|
43
|
+
* a NotFoundError from elsewhere is silently acked.
|
|
44
|
+
*/
|
|
45
|
+
|
|
30
46
|
/**
|
|
31
47
|
* Handle MAILBOX_CREATE event
|
|
32
48
|
*/
|
|
@@ -72,39 +88,65 @@ const handleCreate = async (
|
|
|
72
88
|
log,
|
|
73
89
|
);
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
91
|
+
try {
|
|
92
|
+
await managementService
|
|
93
|
+
.syncCreate(
|
|
94
|
+
accountId,
|
|
95
|
+
mailboxId,
|
|
96
|
+
path,
|
|
97
|
+
scope.getConnection,
|
|
98
|
+
subscribe,
|
|
99
|
+
)
|
|
100
|
+
.then((result) => {
|
|
101
|
+
if (result.success) {
|
|
102
|
+
log.info(
|
|
103
|
+
{ accountId, mailboxId, path },
|
|
104
|
+
"Mailbox created on IMAP",
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
log.error(
|
|
108
|
+
{ accountId, mailboxId, path, error: result.error },
|
|
109
|
+
"Failed to create mailbox on IMAP",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(async (error) => {
|
|
114
|
+
// Check if mailbox already exists (idempotent)
|
|
115
|
+
if (
|
|
116
|
+
error instanceof Error &&
|
|
117
|
+
error.message.includes("already exists")
|
|
118
|
+
) {
|
|
119
|
+
log.info(
|
|
120
|
+
{ accountId, mailboxId, path },
|
|
121
|
+
"Mailbox already exists, marking as synced",
|
|
122
|
+
);
|
|
123
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
124
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
128
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
129
|
+
});
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
.finally(() => scope.disconnect());
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// The mailbox row was deleted between enqueue and this sync — the
|
|
136
|
+
// create-then-status-write throws NotFoundError, which can never
|
|
137
|
+
// succeed on retry and would poison the account's per-group FIFO
|
|
138
|
+
// forever (issue #289, same class as #287/#290). A folder the user
|
|
139
|
+
// deleted is an expected terminal outcome: ack with a WARN. Real
|
|
140
|
+
// IMAP/infra failures carry other errors and still propagate.
|
|
141
|
+
if (isNotFoundError(error)) {
|
|
142
|
+
log.warn(
|
|
143
|
+
{ accountId, mailboxId, path, eventId: event.eventId },
|
|
144
|
+
"Skipping MAILBOX_CREATE: mailbox no longer exists (deleted)",
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
108
150
|
},
|
|
109
151
|
);
|
|
110
152
|
};
|
|
@@ -157,40 +199,61 @@ const handleRename = async (
|
|
|
157
199
|
log,
|
|
158
200
|
);
|
|
159
201
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
202
|
+
try {
|
|
203
|
+
await managementService
|
|
204
|
+
.syncRename(
|
|
205
|
+
accountId,
|
|
206
|
+
mailboxId,
|
|
207
|
+
oldPath,
|
|
208
|
+
newPath,
|
|
209
|
+
scope.getConnection,
|
|
210
|
+
)
|
|
211
|
+
.then((result) => {
|
|
212
|
+
if (result.success) {
|
|
213
|
+
log.info(
|
|
214
|
+
{ accountId, mailboxId, oldPath, newPath },
|
|
215
|
+
"Mailbox renamed on IMAP",
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
log.error(
|
|
219
|
+
{ accountId, mailboxId, oldPath, newPath, error: result.error },
|
|
220
|
+
"Failed to rename mailbox on IMAP",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
.catch(async (error) => {
|
|
225
|
+
// If source not found, delete local mailbox
|
|
226
|
+
if (error instanceof Error && error.message.includes("not found")) {
|
|
227
|
+
log.info(
|
|
228
|
+
{ accountId, mailboxId, oldPath },
|
|
229
|
+
"Source mailbox not found, deleting local",
|
|
230
|
+
);
|
|
231
|
+
await mailboxService.delete(accountId, mailboxId);
|
|
232
|
+
} else {
|
|
233
|
+
// Rollback local rename by restoring old path
|
|
234
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
235
|
+
fullPath: oldPath,
|
|
236
|
+
oldPath: undefined,
|
|
237
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
238
|
+
});
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
.finally(() => scope.disconnect());
|
|
243
|
+
} catch (error) {
|
|
244
|
+
// The mailbox row was deleted between enqueue and this sync — the
|
|
245
|
+
// status write-back throws NotFoundError, unretryable and would poison
|
|
246
|
+
// the account's per-group FIFO forever (issue #289 class). Ack with a
|
|
247
|
+
// WARN; real IMAP/infra failures carry other errors and still propagate.
|
|
248
|
+
if (isNotFoundError(error)) {
|
|
249
|
+
log.warn(
|
|
250
|
+
{ accountId, mailboxId, oldPath, newPath, eventId: event.eventId },
|
|
251
|
+
"Skipping MAILBOX_RENAME: mailbox no longer exists (deleted)",
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
194
257
|
},
|
|
195
258
|
);
|
|
196
259
|
};
|
|
@@ -240,48 +303,67 @@ const handleDelete = async (
|
|
|
240
303
|
log,
|
|
241
304
|
);
|
|
242
305
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
306
|
+
try {
|
|
307
|
+
await managementService
|
|
308
|
+
.syncDelete(accountId, mailboxId, path, scope.getConnection)
|
|
309
|
+
.then((result) => {
|
|
310
|
+
if (result.success) {
|
|
311
|
+
log.info(
|
|
312
|
+
{ accountId, mailboxId, path },
|
|
313
|
+
"Mailbox deleted on IMAP",
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
log.error(
|
|
317
|
+
{ accountId, mailboxId, path, error: result.error },
|
|
318
|
+
"Failed to delete mailbox on IMAP",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
.catch(async (error) => {
|
|
323
|
+
// If mailbox not found, it's already deleted (idempotent)
|
|
324
|
+
if (error instanceof Error && error.message.includes("not found")) {
|
|
325
|
+
log.info(
|
|
326
|
+
{ accountId, mailboxId, path },
|
|
327
|
+
"Mailbox not found on IMAP, deleting local",
|
|
328
|
+
);
|
|
329
|
+
await mailboxService.delete(accountId, mailboxId);
|
|
330
|
+
} else if (
|
|
331
|
+
error instanceof Error &&
|
|
332
|
+
error.message.includes("Cannot delete INBOX")
|
|
333
|
+
) {
|
|
334
|
+
// Restore the mailbox
|
|
335
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
336
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
337
|
+
});
|
|
338
|
+
log.error(
|
|
339
|
+
{ accountId, mailboxId, path },
|
|
340
|
+
"Cannot delete INBOX, restoring mailbox",
|
|
341
|
+
);
|
|
342
|
+
// Don't rethrow - this is an expected error
|
|
343
|
+
} else {
|
|
344
|
+
// Restore the mailbox on other errors
|
|
345
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
346
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
347
|
+
});
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
.finally(() => scope.disconnect());
|
|
352
|
+
} catch (error) {
|
|
353
|
+
// The mailbox row was already deleted (a duplicate/racing delete) — the
|
|
354
|
+
// error-recovery status write throws NotFoundError, unretryable and
|
|
355
|
+
// would poison the account's per-group FIFO forever (issue #289 class).
|
|
356
|
+
// The delete has effectively happened: ack with a WARN. Real IMAP/infra
|
|
357
|
+
// failures carry other errors and still propagate.
|
|
358
|
+
if (isNotFoundError(error)) {
|
|
359
|
+
log.warn(
|
|
360
|
+
{ accountId, mailboxId, path, eventId: event.eventId },
|
|
361
|
+
"Skipping MAILBOX_DELETE: mailbox no longer exists (deleted)",
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
285
367
|
},
|
|
286
368
|
);
|
|
287
369
|
};
|
|
@@ -39,6 +39,7 @@ interface Harness {
|
|
|
39
39
|
deletedAt?: number;
|
|
40
40
|
} | null;
|
|
41
41
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
42
|
+
mailboxError?: Error;
|
|
42
43
|
connection: Connection;
|
|
43
44
|
getConnectionCount: number;
|
|
44
45
|
disconnectCount: number;
|
|
@@ -46,6 +47,11 @@ interface Harness {
|
|
|
46
47
|
|
|
47
48
|
let h: Harness;
|
|
48
49
|
|
|
50
|
+
const notFoundError = (): Error =>
|
|
51
|
+
Object.assign(new Error("Mailbox not found: src-mbx"), {
|
|
52
|
+
name: "NotFoundError",
|
|
53
|
+
});
|
|
54
|
+
|
|
49
55
|
const record =
|
|
50
56
|
(method: string) =>
|
|
51
57
|
async (...args: unknown[]) => {
|
|
@@ -94,7 +100,10 @@ const deps = (): MessageCopyDeps =>
|
|
|
94
100
|
update: record("threadMessage.update"),
|
|
95
101
|
},
|
|
96
102
|
mailbox: {
|
|
97
|
-
get: async () =>
|
|
103
|
+
get: async () => {
|
|
104
|
+
if (h.mailboxError) throw h.mailboxError;
|
|
105
|
+
return h.mailbox;
|
|
106
|
+
},
|
|
98
107
|
update: record("mailbox.update"),
|
|
99
108
|
},
|
|
100
109
|
secrets: {},
|
|
@@ -189,6 +198,16 @@ describe("handleMessageCopy", () => {
|
|
|
189
198
|
);
|
|
190
199
|
});
|
|
191
200
|
|
|
201
|
+
it("acks terminally without connecting when the source mailbox was deleted", async () => {
|
|
202
|
+
h.mailboxError = notFoundError();
|
|
203
|
+
|
|
204
|
+
await handleMessageCopy(event, noopLog, deps());
|
|
205
|
+
|
|
206
|
+
assert.equal(h.getConnectionCount, 0);
|
|
207
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
208
|
+
assert.equal(called("message.update").length, 0);
|
|
209
|
+
});
|
|
210
|
+
|
|
192
211
|
it("skips the copy without opening a connection when the cursor is rebuilding", async () => {
|
|
193
212
|
h.mailbox = {
|
|
194
213
|
mailboxId: "src-mbx",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { isAccountDeleted } from "../account-check.js";
|
|
10
10
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
11
11
|
import type { MessageCopyEvent } from "../events.js";
|
|
12
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
12
13
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
13
14
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
14
15
|
|
|
@@ -87,7 +88,24 @@ export const handleMessageCopy = async (
|
|
|
87
88
|
account,
|
|
88
89
|
log,
|
|
89
90
|
async (credentials) => {
|
|
90
|
-
|
|
91
|
+
// The source folder can be deleted between enqueue and this sync, leaving
|
|
92
|
+
// a queued event pointing at a gone row. The lookup then throws
|
|
93
|
+
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
94
|
+
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
95
|
+
// source mailbox makes the copy moot: ack with a WARN.
|
|
96
|
+
const mailbox = await mailboxService
|
|
97
|
+
.get(accountId, sourceMailboxId)
|
|
98
|
+
.catch((error: unknown) => {
|
|
99
|
+
if (isNotFoundError(error)) return null;
|
|
100
|
+
throw error;
|
|
101
|
+
});
|
|
102
|
+
if (!mailbox) {
|
|
103
|
+
log.warn(
|
|
104
|
+
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
105
|
+
"Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)",
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
91
109
|
|
|
92
110
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
93
111
|
// paused never even opens a connection. Optimization only — the
|
|
@@ -215,6 +215,7 @@ interface Harness {
|
|
|
215
215
|
deletedAt?: number;
|
|
216
216
|
} | null;
|
|
217
217
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
218
|
+
mailboxError?: Error;
|
|
218
219
|
connection: Connection;
|
|
219
220
|
threadMessage: Record<string, unknown> | null;
|
|
220
221
|
allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
|
|
@@ -280,7 +281,10 @@ const deps = (): MessageDeleteDeps =>
|
|
|
280
281
|
delete: record("threadMessage.delete"),
|
|
281
282
|
},
|
|
282
283
|
mailbox: {
|
|
283
|
-
get: async () =>
|
|
284
|
+
get: async () => {
|
|
285
|
+
if (h.mailboxError) throw h.mailboxError;
|
|
286
|
+
return h.mailbox;
|
|
287
|
+
},
|
|
284
288
|
update: record("mailbox.update"),
|
|
285
289
|
},
|
|
286
290
|
secrets: {},
|
|
@@ -442,6 +446,18 @@ describe("handleMessageDelete", () => {
|
|
|
442
446
|
assert.equal(h.getConnectionCount, 0);
|
|
443
447
|
});
|
|
444
448
|
|
|
449
|
+
it("acks terminally without connecting when the mailbox was deleted", async () => {
|
|
450
|
+
h.mailboxError = Object.assign(new Error("Mailbox not found: src-mbx"), {
|
|
451
|
+
name: "NotFoundError",
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
455
|
+
|
|
456
|
+
assert.equal(h.getConnectionCount, 0);
|
|
457
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
458
|
+
assert.equal(called("message.delete").length, 0);
|
|
459
|
+
});
|
|
460
|
+
|
|
445
461
|
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
446
462
|
h.account = {
|
|
447
463
|
accountId: "acc-1",
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { isAccountDeleted } from "../account-check.js";
|
|
14
14
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
15
|
import type { MessageDeleteEvent } from "../events.js";
|
|
16
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
16
17
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
17
18
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
19
|
|
|
@@ -151,7 +152,24 @@ export const handleMessageDelete = async (
|
|
|
151
152
|
account,
|
|
152
153
|
log,
|
|
153
154
|
async (credentials) => {
|
|
154
|
-
|
|
155
|
+
// The folder can be deleted between enqueue and this sync, leaving a
|
|
156
|
+
// queued event pointing at a gone row. The lookup then throws
|
|
157
|
+
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
158
|
+
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
159
|
+
// mailbox makes the delete moot: ack with a WARN.
|
|
160
|
+
const mailbox = await mailboxService
|
|
161
|
+
.get(accountId, mailboxId)
|
|
162
|
+
.catch((error: unknown) => {
|
|
163
|
+
if (isNotFoundError(error)) return null;
|
|
164
|
+
throw error;
|
|
165
|
+
});
|
|
166
|
+
if (!mailbox) {
|
|
167
|
+
log.warn(
|
|
168
|
+
{ accountId, messageId, mailboxId },
|
|
169
|
+
"Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)",
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
155
173
|
|
|
156
174
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
157
175
|
// paused never even opens a connection. Optimization only — the
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
|
-
import { describe, it, mock } from "node:test";
|
|
3
|
-
import
|
|
2
|
+
import { afterEach, describe, it, mock } from "node:test";
|
|
3
|
+
import { getClient } from "@remit/backend/client";
|
|
4
|
+
import type { AccountItem, ThreadMessageItem } from "@remit/data-ports";
|
|
5
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
6
|
+
import type { MessageMoveEvent } from "../events.js";
|
|
4
7
|
import {
|
|
5
8
|
buildThreadMessageMoveUpdate,
|
|
6
9
|
emitMoveResync,
|
|
10
|
+
handleMessageMove,
|
|
7
11
|
moveThenResync,
|
|
8
12
|
} from "./message-move.js";
|
|
9
13
|
|
|
14
|
+
const silentLogger = (() => {
|
|
15
|
+
const noop = () => {};
|
|
16
|
+
const log = {
|
|
17
|
+
info: noop,
|
|
18
|
+
warn: noop,
|
|
19
|
+
error: noop,
|
|
20
|
+
debug: noop,
|
|
21
|
+
fatal: noop,
|
|
22
|
+
trace: noop,
|
|
23
|
+
child: () => log,
|
|
24
|
+
} as unknown as Logger;
|
|
25
|
+
return log;
|
|
26
|
+
})();
|
|
27
|
+
|
|
10
28
|
const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
|
|
11
29
|
const destinationMailboxId = "destination-mailbox-aaaaa";
|
|
12
30
|
|
|
@@ -166,3 +184,61 @@ describe("moveThenResync (#1031)", () => {
|
|
|
166
184
|
assert.equal(resync.mock.calls.length, 0);
|
|
167
185
|
});
|
|
168
186
|
});
|
|
187
|
+
|
|
188
|
+
describe("handleMessageMove — deleted mailbox is terminal (#287/#289)", () => {
|
|
189
|
+
const acctId = "mm-acc-zzz";
|
|
190
|
+
|
|
191
|
+
const cappedAccount = (): AccountItem =>
|
|
192
|
+
({
|
|
193
|
+
accountId: acctId,
|
|
194
|
+
accountConfigId: "mm-cfg-zzz",
|
|
195
|
+
connectionState: "authenticated",
|
|
196
|
+
username: "mm@imap.example.com",
|
|
197
|
+
imapHost: "imap.example.com",
|
|
198
|
+
imapPort: 993,
|
|
199
|
+
imapTls: true,
|
|
200
|
+
passwordHash: JSON.stringify({
|
|
201
|
+
encryptedDek: "",
|
|
202
|
+
encryptedData: "",
|
|
203
|
+
iv: "",
|
|
204
|
+
authTag: "",
|
|
205
|
+
}),
|
|
206
|
+
}) as unknown as AccountItem;
|
|
207
|
+
|
|
208
|
+
const event: MessageMoveEvent = {
|
|
209
|
+
type: "MESSAGE_MOVE",
|
|
210
|
+
accountId: acctId,
|
|
211
|
+
accountConfigId: "mm-cfg-zzz",
|
|
212
|
+
messageId: "mm-msg-zzz",
|
|
213
|
+
sourceMailboxId: "mm-src-zzz",
|
|
214
|
+
sourceMailboxPath: "INBOX",
|
|
215
|
+
destinationMailboxId: "mm-dst-zzz",
|
|
216
|
+
destinationMailboxPath: "Archive",
|
|
217
|
+
uid: 10,
|
|
218
|
+
eventId: "mm-evt-zzz",
|
|
219
|
+
timestamp: 1700000000000,
|
|
220
|
+
} as MessageMoveEvent;
|
|
221
|
+
|
|
222
|
+
afterEach(() => mock.restoreAll());
|
|
223
|
+
|
|
224
|
+
it("acks without connecting when the source mailbox was deleted", async () => {
|
|
225
|
+
const client = await getClient();
|
|
226
|
+
mock.method(client.account, "get", async () => cappedAccount());
|
|
227
|
+
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
228
|
+
mock.method(client.mailbox, "get", async () => {
|
|
229
|
+
throw Object.assign(new Error("Mailbox not found: mm-src-zzz"), {
|
|
230
|
+
name: "NotFoundError",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
const updateUid = mock.method(client.message, "updateUid", async () => {});
|
|
234
|
+
|
|
235
|
+
// Must resolve, not reject — a deleted source folder makes the move moot.
|
|
236
|
+
await handleMessageMove(event, silentLogger);
|
|
237
|
+
|
|
238
|
+
assert.equal(
|
|
239
|
+
updateUid.mock.calls.length,
|
|
240
|
+
0,
|
|
241
|
+
"a deleted mailbox never reaches the IMAP move",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -11,6 +11,7 @@ import { isAccountDeleted } from "../account-check.js";
|
|
|
11
11
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
12
12
|
import { emitEvent } from "../emit.js";
|
|
13
13
|
import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
|
|
14
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
14
15
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
15
16
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
16
17
|
|
|
@@ -144,7 +145,24 @@ export const handleMessageMove = async (
|
|
|
144
145
|
account,
|
|
145
146
|
log,
|
|
146
147
|
async (credentials) => {
|
|
147
|
-
|
|
148
|
+
// The source folder can be deleted between enqueue and this sync, leaving
|
|
149
|
+
// a queued event pointing at a gone row. The lookup then throws
|
|
150
|
+
// NotFoundError forever, and on the account's per-group FIFO that head
|
|
151
|
+
// message stalls the whole pipeline (issues #287, #289, #290). A deleted
|
|
152
|
+
// source mailbox makes the move moot: ack with a WARN.
|
|
153
|
+
const mailbox = await mailboxService
|
|
154
|
+
.get(accountId, sourceMailboxId)
|
|
155
|
+
.catch((error: unknown) => {
|
|
156
|
+
if (isNotFoundError(error)) return null;
|
|
157
|
+
throw error;
|
|
158
|
+
});
|
|
159
|
+
if (!mailbox) {
|
|
160
|
+
log.warn(
|
|
161
|
+
{ accountId, messageId, mailboxId: sourceMailboxId },
|
|
162
|
+
"Skipping MESSAGE_MOVE: source mailbox no longer exists (deleted)",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
148
166
|
|
|
149
167
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
150
168
|
// paused never even opens a connection. Optimization only — the
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
2
|
+
import { afterEach, describe, it, mock } from "node:test";
|
|
3
|
+
import { getClient } from "@remit/backend/client";
|
|
4
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
3
5
|
import type { IImapConnection } from "@remit/mailbox-service";
|
|
6
|
+
import type { PlacementMovePushEvent } from "../events.js";
|
|
4
7
|
import {
|
|
5
8
|
attemptMove,
|
|
6
9
|
getPlacementMoveMaxAttempts,
|
|
10
|
+
handlePlacementMovePush,
|
|
7
11
|
PLACEMENT_MOVE_MAX_ATTEMPTS,
|
|
8
12
|
} from "./placement-move-push.js";
|
|
9
13
|
|
|
14
|
+
const silentLogger = (() => {
|
|
15
|
+
const noop = () => {};
|
|
16
|
+
const log = {
|
|
17
|
+
info: noop,
|
|
18
|
+
warn: noop,
|
|
19
|
+
error: noop,
|
|
20
|
+
debug: noop,
|
|
21
|
+
fatal: noop,
|
|
22
|
+
trace: noop,
|
|
23
|
+
child: () => log,
|
|
24
|
+
} as unknown as Logger;
|
|
25
|
+
return log;
|
|
26
|
+
})();
|
|
27
|
+
|
|
10
28
|
const buildConnection = (opts: {
|
|
11
29
|
uidMap?: Map<number, number>;
|
|
12
30
|
moveError?: Error;
|
|
@@ -262,3 +280,54 @@ describe("getPlacementMoveMaxAttempts — env-derived threshold (mirrors #1270's
|
|
|
262
280
|
assert.ok(PLACEMENT_MOVE_MAX_ATTEMPTS > 0);
|
|
263
281
|
});
|
|
264
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("handlePlacementMovePush — deleted mailbox is terminal (#287/#289)", () => {
|
|
285
|
+
const accountId = "pm-acc-zzz";
|
|
286
|
+
const messageId = "pm-msg-zzz";
|
|
287
|
+
const destinationMailboxId = "pm-dest-zzz";
|
|
288
|
+
|
|
289
|
+
const event: PlacementMovePushEvent = {
|
|
290
|
+
type: "PLACEMENT_MOVE_PUSH",
|
|
291
|
+
accountId,
|
|
292
|
+
accountConfigId: "pm-cfg-zzz",
|
|
293
|
+
messageId,
|
|
294
|
+
eventId: "pm-evt-zzz",
|
|
295
|
+
timestamp: 1700000000000,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
afterEach(() => mock.restoreAll());
|
|
299
|
+
|
|
300
|
+
it("drops the marker and acks without pushing when a mailbox is gone", async () => {
|
|
301
|
+
const client = await getClient();
|
|
302
|
+
mock.method(client.account, "get", async () => ({
|
|
303
|
+
accountId,
|
|
304
|
+
accountConfigId: "pm-cfg-zzz",
|
|
305
|
+
}));
|
|
306
|
+
mock.method(client.message, "get", async () => ({
|
|
307
|
+
messageId,
|
|
308
|
+
mailboxId: destinationMailboxId,
|
|
309
|
+
}));
|
|
310
|
+
mock.method(client.mailbox, "get", async () => {
|
|
311
|
+
throw Object.assign(new Error("Mailbox not found: pm-src-zzz"), {
|
|
312
|
+
name: "NotFoundError",
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
mock.method(client.placementMove, "find", async () => ({
|
|
316
|
+
sourceMailboxId: "pm-src-zzz",
|
|
317
|
+
destinationMailboxId,
|
|
318
|
+
}));
|
|
319
|
+
mock.method(client.placementMove, "updateState", async () => {});
|
|
320
|
+
const deleteMarker = mock.method(
|
|
321
|
+
client.placementMove,
|
|
322
|
+
"delete",
|
|
323
|
+
async () => {},
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// Must resolve, not reject — a deleted folder is an expected terminal
|
|
327
|
+
// outcome, not a fault to retry on the account's per-group FIFO.
|
|
328
|
+
await handlePlacementMovePush(event, silentLogger, 1);
|
|
329
|
+
|
|
330
|
+
assert.equal(deleteMarker.mock.calls.length, 1, "the marker is dropped");
|
|
331
|
+
assert.deepEqual(deleteMarker.mock.calls[0].arguments, [messageId]);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -14,6 +14,7 @@ import { isAccountDeleted } from "../account-check.js";
|
|
|
14
14
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
15
|
import { emitEvent } from "../emit.js";
|
|
16
16
|
import type { PlacementMovePushEvent } from "../events.js";
|
|
17
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
17
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
18
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
19
20
|
import {
|
|
@@ -227,14 +228,32 @@ export const handlePlacementMovePush = async (
|
|
|
227
228
|
// died mid-flight already left it here).
|
|
228
229
|
await markerService.updateState(messageId, "processing");
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
marker.
|
|
237
|
-
|
|
231
|
+
// Either folder can be deleted between enqueue and this push, leaving a
|
|
232
|
+
// marker pointing at a gone row. The lookup then throws NotFoundError forever,
|
|
233
|
+
// and on the account's per-group FIFO that head message stalls the whole
|
|
234
|
+
// pipeline (issues #287, #289, #290). The move is moot — drop the marker (as
|
|
235
|
+
// the superseded branch above does) and ack with a WARN.
|
|
236
|
+
const mailboxes = await Promise.all([
|
|
237
|
+
mailboxService.get(accountId, marker.sourceMailboxId),
|
|
238
|
+
mailboxService.get(accountId, marker.destinationMailboxId),
|
|
239
|
+
]).catch((error: unknown) => {
|
|
240
|
+
if (isNotFoundError(error)) return null;
|
|
241
|
+
throw error;
|
|
242
|
+
});
|
|
243
|
+
if (!mailboxes) {
|
|
244
|
+
await markerService.delete(messageId);
|
|
245
|
+
log.warn(
|
|
246
|
+
{
|
|
247
|
+
messageId,
|
|
248
|
+
accountId,
|
|
249
|
+
sourceMailboxId: marker.sourceMailboxId,
|
|
250
|
+
destinationMailboxId: marker.destinationMailboxId,
|
|
251
|
+
},
|
|
252
|
+
"Skipping PLACEMENT_MOVE_PUSH: a mailbox no longer exists (deleted); marker dropped",
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const [sourceMailbox, destinationMailbox] = mailboxes;
|
|
238
257
|
|
|
239
258
|
// Cheap frugal skip (epic #1281 invariant 6): either mailbox already known
|
|
240
259
|
// paused never even borrows a connection. Optimization only — the
|
|
@@ -302,6 +302,54 @@ describe("syncMessageBody — DLQ propagation (integrated, #1270)", () => {
|
|
|
302
302
|
);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
test("deleted mailbox: acks-and-skips without ever calling BodySyncService.syncBodies (#287/#289)", async () => {
|
|
306
|
+
// A SYNC_MESSAGE_BODY trigger outlived the folder it targeted. The mailbox
|
|
307
|
+
// lookup throws NotFoundError, which would poison the account's per-group
|
|
308
|
+
// body FIFO forever; the handler must resolve it terminally instead.
|
|
309
|
+
mockClient(SSMClient)
|
|
310
|
+
.on(GetParameterCommand)
|
|
311
|
+
.resolves({ Parameter: { Value: "true" } });
|
|
312
|
+
|
|
313
|
+
mock.method((await getClient()).account, "get", async () =>
|
|
314
|
+
cappedAccount(),
|
|
315
|
+
);
|
|
316
|
+
mock.method((await getClient()).mailbox, "get", async () => {
|
|
317
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
318
|
+
name: "NotFoundError",
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
mock.method(
|
|
322
|
+
(await getClient()).secrets,
|
|
323
|
+
"decrypt",
|
|
324
|
+
async () => "fake-password",
|
|
325
|
+
);
|
|
326
|
+
const syncBodies = mock.method(
|
|
327
|
+
BodySyncService.prototype,
|
|
328
|
+
"syncBodies",
|
|
329
|
+
async () => {
|
|
330
|
+
throw new Error("must not be called for a deleted mailbox");
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const event: SyncMessageBodyEvent = {
|
|
335
|
+
...baseEvent,
|
|
336
|
+
accountId,
|
|
337
|
+
mailboxId,
|
|
338
|
+
messageIds: ["msg-1"],
|
|
339
|
+
messages: [{ messageId: "msg-1", uid: 101 }],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Must resolve, not reject — a deleted folder is an expected terminal
|
|
343
|
+
// outcome, not a fault to retry/DLQ.
|
|
344
|
+
await syncMessageBody(event, silentLogger, 1);
|
|
345
|
+
|
|
346
|
+
assert.equal(
|
|
347
|
+
syncBodies.mock.calls.length,
|
|
348
|
+
0,
|
|
349
|
+
"a deleted mailbox must never reach the body fetch",
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
305
353
|
test("cursor_invalid: acks-and-skips without ever calling BodySyncService.syncBodies (#1272)", async () => {
|
|
306
354
|
// The cheap pre-check (isCursorRebuildNeeded, run before borrowing any
|
|
307
355
|
// connection — frugal, epic #1281 invariant 6) catches an already-paused
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createConnectionScopeWithCredentials,
|
|
20
20
|
} from "../connection-scope.js";
|
|
21
21
|
import type { SyncMessageBodyEvent } from "../events.js";
|
|
22
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
22
23
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
23
24
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
24
25
|
import { workerVersion } from "../worker-version.js";
|
|
@@ -184,7 +185,25 @@ export const syncMessageBody = async (
|
|
|
184
185
|
account,
|
|
185
186
|
log,
|
|
186
187
|
async (credentials) => {
|
|
187
|
-
|
|
188
|
+
// A SYNC_MESSAGE_BODY trigger can outlive the mailbox it targets: deleting
|
|
189
|
+
// a folder that held mail leaves already-queued body events pointing at a
|
|
190
|
+
// row that is now gone, and the lookup then throws NotFoundError forever.
|
|
191
|
+
// Since the body queue carries MessageGroupId=accountId, that head message
|
|
192
|
+
// stalls the account's whole body pipeline (issues #287, #289, #290). A
|
|
193
|
+
// deleted folder is an expected terminal outcome: ack with a WARN.
|
|
194
|
+
const mailbox = await mailboxService
|
|
195
|
+
.get(accountId, mailboxId)
|
|
196
|
+
.catch((error: unknown) => {
|
|
197
|
+
if (isNotFoundError(error)) return null;
|
|
198
|
+
throw error;
|
|
199
|
+
});
|
|
200
|
+
if (!mailbox) {
|
|
201
|
+
log.warn(
|
|
202
|
+
{ accountId, mailboxId, eventId: event.eventId },
|
|
203
|
+
"Skipping SYNC_MESSAGE_BODY: mailbox no longer exists (deleted)",
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
188
207
|
|
|
189
208
|
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
190
209
|
// paused never even borrows a connection. This is an optimization only
|
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
SyncMessageBodyEvent,
|
|
33
33
|
SyncMessagesEvent,
|
|
34
34
|
} from "../events.js";
|
|
35
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
35
36
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
36
37
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
37
38
|
import { workerVersion } from "../worker-version.js";
|
|
@@ -104,7 +105,7 @@ export const syncMessages = async (
|
|
|
104
105
|
// other errors and still propagate to be retried.
|
|
105
106
|
let account: AccountItem;
|
|
106
107
|
const rawAccount = await accountService.get(event.accountId).catch((err) => {
|
|
107
|
-
if ((err
|
|
108
|
+
if (isNotFoundError(err)) return null;
|
|
108
109
|
throw err;
|
|
109
110
|
});
|
|
110
111
|
if (!rawAccount) {
|
|
@@ -144,7 +145,7 @@ export const syncMessages = async (
|
|
|
144
145
|
.get(event.accountId, event.mailboxId)
|
|
145
146
|
.then(() => true)
|
|
146
147
|
.catch((err) => {
|
|
147
|
-
if ((err
|
|
148
|
+
if (isNotFoundError(err)) return false;
|
|
148
149
|
throw err;
|
|
149
150
|
});
|
|
150
151
|
if (!mailboxExists) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A repository lookup or write for a row that no longer exists rejects with a
|
|
3
|
+
* `NotFoundError` (name-matched, since the class crosses the data-ports adapter
|
|
4
|
+
* boundary — see `@remit/data-ports/errors`).
|
|
5
|
+
*
|
|
6
|
+
* A worker event that references a deliberately-deleted mailbox is completed or
|
|
7
|
+
* moot work, never a transient fault: every redelivery re-throws the same
|
|
8
|
+
* `NotFoundError`, and because the sync queues carry `MessageGroupId=accountId`
|
|
9
|
+
* that permanently-failing head message stalls the whole account's per-group
|
|
10
|
+
* FIFO. Handlers use this predicate to resolve such an event terminally (ack
|
|
11
|
+
* with a WARN) instead of retrying forever (issues #287, #289, #290).
|
|
12
|
+
*
|
|
13
|
+
* The guard is narrow on purpose: only a genuine not-found terminates. Real
|
|
14
|
+
* IMAP/infra failures carry other errors and must still propagate to be retried.
|
|
15
|
+
*/
|
|
16
|
+
export const isNotFoundError = (error: unknown): boolean =>
|
|
17
|
+
(error as { name?: string })?.name === "NotFoundError";
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
FIFO_RECEIVE_VISIBILITY_SECONDS,
|
|
5
|
-
receiveVisibilitySeconds,
|
|
6
|
-
STANDARD_RECEIVE_VISIBILITY_SECONDS,
|
|
7
|
-
} from "./e2e-processor-visibility.js";
|
|
8
|
-
|
|
9
|
-
describe("receiveVisibilitySeconds", () => {
|
|
10
|
-
it("gives the per-account FIFO sync queues a short window so a failure unblocks fast", () => {
|
|
11
|
-
for (const url of [
|
|
12
|
-
"http://localhost:9324/000/remit-mailboxes.fifo",
|
|
13
|
-
"http://localhost:9324/000/remit-messages.fifo",
|
|
14
|
-
"http://localhost:9324/000/remit-flags.fifo",
|
|
15
|
-
]) {
|
|
16
|
-
assert.equal(
|
|
17
|
-
receiveVisibilitySeconds(url),
|
|
18
|
-
FIFO_RECEIVE_VISIBILITY_SECONDS,
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("keeps the long window for the standard body and management queues", () => {
|
|
24
|
-
for (const url of [
|
|
25
|
-
"http://localhost:9324/000/remit-body",
|
|
26
|
-
"http://localhost:9324/000/remit-mailbox-mgmt",
|
|
27
|
-
"http://localhost:9324/000/remit-message-mgmt",
|
|
28
|
-
"http://localhost:9324/000/remit-search-index",
|
|
29
|
-
]) {
|
|
30
|
-
assert.equal(
|
|
31
|
-
receiveVisibilitySeconds(url),
|
|
32
|
-
STANDARD_RECEIVE_VISIBILITY_SECONDS,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("keeps the FIFO window short enough to unblock within a spec's seed budget, above a sync round's own duration", () => {
|
|
38
|
-
assert.ok(FIFO_RECEIVE_VISIBILITY_SECONDS > 0);
|
|
39
|
-
assert.ok(
|
|
40
|
-
FIFO_RECEIVE_VISIBILITY_SECONDS < STANDARD_RECEIVE_VISIBILITY_SECONDS,
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
});
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Receive-time visibility (seconds) the e2e drainer requests for a queue.
|
|
3
|
-
*
|
|
4
|
-
* A message the handler reports as failed is left un-deleted and only
|
|
5
|
-
* redelivers once its visibility lapses. The sync queues are per-account FIFO
|
|
6
|
-
* (`MessageGroupId = accountId`): while one message sits invisible, every later
|
|
7
|
-
* message in its group is held back too. So the visibility window a failed sync
|
|
8
|
-
* event sits out is also the window the whole account's sync pipeline stalls —
|
|
9
|
-
* and at a multi-minute window that stall swallows a spec's entire seed-and-wait
|
|
10
|
-
* budget when a transient IMAP hiccup fails one event mid-run, which is the
|
|
11
|
-
* recurring e2e-dev flake (#290).
|
|
12
|
-
*
|
|
13
|
-
* FIFO sync queues therefore get a short window: a transient failure costs one
|
|
14
|
-
* brief retry instead of a minutes-long group stall, and the handlers on these
|
|
15
|
-
* queues (a mailbox LIST, a header SEARCH/FETCH) finish well inside it — a rare
|
|
16
|
-
* slow round redelivers harmlessly, since the mailbox lock makes a concurrent
|
|
17
|
-
* re-run a no-op. The standard queues (body sync's ranged FETCH of up to 200
|
|
18
|
-
* message bodies, the management queues) keep the longer window their slower,
|
|
19
|
-
* non-FIFO work needs and where a redelivery blocks nothing but itself.
|
|
20
|
-
*/
|
|
21
|
-
export const FIFO_RECEIVE_VISIBILITY_SECONDS = 30;
|
|
22
|
-
export const STANDARD_RECEIVE_VISIBILITY_SECONDS = 300;
|
|
23
|
-
|
|
24
|
-
export const receiveVisibilitySeconds = (queueUrl: string): number =>
|
|
25
|
-
queueUrl.endsWith(".fifo")
|
|
26
|
-
? FIFO_RECEIVE_VISIBILITY_SECONDS
|
|
27
|
-
: STANDARD_RECEIVE_VISIBILITY_SECONDS;
|