@remit/imap-worker 0.0.35 → 0.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@aws-sdk/core": "*",
|
|
29
29
|
"@remit/storage-service": "*",
|
|
30
30
|
"aws-sdk-client-mock": "*",
|
|
31
|
-
"esbuild": "^0.
|
|
31
|
+
"esbuild": "^0.28.1"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@aws-sdk/client-s3": "^3.1055.0",
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #496: auto-mark-read and the placement move are enqueued from the same
|
|
3
|
+
* body-sync pass onto two different queues, so the flag push can reach the
|
|
4
|
+
* server first. It must not, because the row it would resolve against names the
|
|
5
|
+
* destination mailbox while still carrying the source folder's uid — a STORE
|
|
6
|
+
* there lands on the destination's OWN message at that uid.
|
|
7
|
+
*
|
|
8
|
+
* These tests drive the REAL `PlacementMoveService` and `FlagQueueService` to
|
|
9
|
+
* produce the row and the marker, rather than hand-writing the shape the
|
|
10
|
+
* handler guards on. A change that stops `PlacementMoveService` marking the row
|
|
11
|
+
* `moving` reopens the defect while a hand-written fixture stays green.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
|
16
|
+
import { SQSClient } from "@aws-sdk/client-sqs";
|
|
17
|
+
import { getClient, type RemitClient, setClient } from "@remit/backend/client";
|
|
18
|
+
import type {
|
|
19
|
+
IMessageFlagPushRepository,
|
|
20
|
+
IMessageFlagRepository,
|
|
21
|
+
IMessagePlacementMoveRepository,
|
|
22
|
+
IMessageRepository,
|
|
23
|
+
IThreadMessageRepository,
|
|
24
|
+
MessageFlagPushItem,
|
|
25
|
+
MessageItem,
|
|
26
|
+
MessagePlacementMoveItem,
|
|
27
|
+
UpdateMessageMoveInput,
|
|
28
|
+
} from "@remit/data-ports";
|
|
29
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
30
|
+
import {
|
|
31
|
+
FlagPushService,
|
|
32
|
+
FlagQueueService,
|
|
33
|
+
PlacementMoveService,
|
|
34
|
+
} from "@remit/mailbox-service";
|
|
35
|
+
import type { FlagPushEvent } from "../events.js";
|
|
36
|
+
import { handleFlagPush } from "./flag-push.js";
|
|
37
|
+
|
|
38
|
+
const ACCOUNT_ID = "acc-496";
|
|
39
|
+
const ACCOUNT_CONFIG_ID = "cfg-496";
|
|
40
|
+
const MESSAGE_ID = "msg-496";
|
|
41
|
+
const INBOX_ID = "mbx-inbox";
|
|
42
|
+
const ARCHIVE_ID = "mbx-archive";
|
|
43
|
+
/** The newsletter's uid in INBOX — and, on a real server, some unrelated message's uid in Archive. */
|
|
44
|
+
const INBOX_UID = 42;
|
|
45
|
+
const SEEN = "\\Seen";
|
|
46
|
+
|
|
47
|
+
const silentLogger = (() => {
|
|
48
|
+
const noop = () => {};
|
|
49
|
+
const log = {
|
|
50
|
+
info: noop,
|
|
51
|
+
warn: noop,
|
|
52
|
+
error: noop,
|
|
53
|
+
debug: noop,
|
|
54
|
+
fatal: noop,
|
|
55
|
+
trace: noop,
|
|
56
|
+
child: () => log,
|
|
57
|
+
} as unknown as Logger;
|
|
58
|
+
return log;
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
interface Harness {
|
|
62
|
+
row: MessageItem;
|
|
63
|
+
flagMarkers: Map<string, MessageFlagPushItem>;
|
|
64
|
+
placementMarkers: Map<string, MessagePlacementMoveItem>;
|
|
65
|
+
placementMoveService: PlacementMoveService;
|
|
66
|
+
flagQueueService: FlagQueueService;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const buildHarness = (): Harness => {
|
|
70
|
+
const row = {
|
|
71
|
+
messageId: MESSAGE_ID,
|
|
72
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
73
|
+
mailboxId: INBOX_ID,
|
|
74
|
+
uid: INBOX_UID,
|
|
75
|
+
status: "active",
|
|
76
|
+
syncStatus: "synced",
|
|
77
|
+
} as unknown as MessageItem;
|
|
78
|
+
|
|
79
|
+
const flagMarkers = new Map<string, MessageFlagPushItem>();
|
|
80
|
+
const placementMarkers = new Map<string, MessagePlacementMoveItem>();
|
|
81
|
+
const localFlags = new Set<string>();
|
|
82
|
+
|
|
83
|
+
const messageService = {
|
|
84
|
+
get: async (messageId: string) => {
|
|
85
|
+
assert.equal(messageId, MESSAGE_ID);
|
|
86
|
+
return row;
|
|
87
|
+
},
|
|
88
|
+
updateForMove: async (
|
|
89
|
+
_messageId: string,
|
|
90
|
+
input: UpdateMessageMoveInput,
|
|
91
|
+
) => {
|
|
92
|
+
Object.assign(row, input);
|
|
93
|
+
return row;
|
|
94
|
+
},
|
|
95
|
+
} as unknown as IMessageRepository;
|
|
96
|
+
|
|
97
|
+
const threadMessageService = {
|
|
98
|
+
getByMessageId: async () => ({
|
|
99
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
100
|
+
threadMessageId: "tm-496",
|
|
101
|
+
sentDate: 1,
|
|
102
|
+
mailboxId: row.mailboxId,
|
|
103
|
+
isRead: false,
|
|
104
|
+
isDeleted: false,
|
|
105
|
+
hasStars: false,
|
|
106
|
+
hasAttachment: false,
|
|
107
|
+
}),
|
|
108
|
+
findAllByMessageId: async () => [],
|
|
109
|
+
update: async () => {},
|
|
110
|
+
} as unknown as IThreadMessageRepository;
|
|
111
|
+
|
|
112
|
+
const placementMarkerService = {
|
|
113
|
+
put: async (input: { messageId: string }) => {
|
|
114
|
+
const marker = {
|
|
115
|
+
...input,
|
|
116
|
+
state: "pending",
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
} as unknown as MessagePlacementMoveItem;
|
|
119
|
+
placementMarkers.set(input.messageId, marker);
|
|
120
|
+
return marker;
|
|
121
|
+
},
|
|
122
|
+
find: async (messageId: string) => placementMarkers.get(messageId) ?? null,
|
|
123
|
+
updateState: async (
|
|
124
|
+
messageId: string,
|
|
125
|
+
state: MessagePlacementMoveItem["state"],
|
|
126
|
+
) => {
|
|
127
|
+
const marker = placementMarkers.get(messageId);
|
|
128
|
+
if (!marker) throw new Error(`no placement marker for ${messageId}`);
|
|
129
|
+
marker.state = state;
|
|
130
|
+
return marker;
|
|
131
|
+
},
|
|
132
|
+
delete: async (messageId: string) => {
|
|
133
|
+
placementMarkers.delete(messageId);
|
|
134
|
+
},
|
|
135
|
+
} as unknown as IMessagePlacementMoveRepository;
|
|
136
|
+
|
|
137
|
+
const flagMarkerService = {
|
|
138
|
+
put: async (input: { messageId: string; flagName: string }) => {
|
|
139
|
+
const marker = {
|
|
140
|
+
...input,
|
|
141
|
+
state: "pending",
|
|
142
|
+
createdAt: Date.now(),
|
|
143
|
+
} as unknown as MessageFlagPushItem;
|
|
144
|
+
flagMarkers.set(`${input.messageId}:${input.flagName}`, marker);
|
|
145
|
+
return marker;
|
|
146
|
+
},
|
|
147
|
+
find: async (messageId: string, flagName: string) =>
|
|
148
|
+
flagMarkers.get(`${messageId}:${flagName}`) ?? null,
|
|
149
|
+
updateState: async (
|
|
150
|
+
messageId: string,
|
|
151
|
+
flagName: string,
|
|
152
|
+
state: MessageFlagPushItem["state"],
|
|
153
|
+
) => {
|
|
154
|
+
const marker = flagMarkers.get(`${messageId}:${flagName}`);
|
|
155
|
+
if (!marker) throw new Error(`no flag marker for ${messageId}`);
|
|
156
|
+
marker.state = state;
|
|
157
|
+
return marker;
|
|
158
|
+
},
|
|
159
|
+
delete: async (messageId: string, flagName: string) => {
|
|
160
|
+
flagMarkers.delete(`${messageId}:${flagName}`);
|
|
161
|
+
},
|
|
162
|
+
} as unknown as IMessageFlagPushRepository;
|
|
163
|
+
|
|
164
|
+
const messageFlagService = {
|
|
165
|
+
hasFlag: async (_messageId: string, flagName: string) =>
|
|
166
|
+
localFlags.has(flagName),
|
|
167
|
+
addFlag: async (_messageId: string, flagName: string) => {
|
|
168
|
+
localFlags.add(flagName);
|
|
169
|
+
return {} as never;
|
|
170
|
+
},
|
|
171
|
+
removeFlag: async (_messageId: string, flagName: string) => {
|
|
172
|
+
localFlags.delete(flagName);
|
|
173
|
+
},
|
|
174
|
+
} as unknown as IMessageFlagRepository;
|
|
175
|
+
|
|
176
|
+
const placementMoveService = new PlacementMoveService({
|
|
177
|
+
messageService,
|
|
178
|
+
threadMessageService,
|
|
179
|
+
markerService: placementMarkerService,
|
|
180
|
+
sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/message-mgmt",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const flagQueueService = new FlagQueueService({
|
|
184
|
+
messageFlagService,
|
|
185
|
+
messageService,
|
|
186
|
+
threadMessageService,
|
|
187
|
+
flagPushService: new FlagPushService({
|
|
188
|
+
markerService: flagMarkerService,
|
|
189
|
+
sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/messages",
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
row,
|
|
195
|
+
flagMarkers,
|
|
196
|
+
placementMarkers,
|
|
197
|
+
placementMoveService,
|
|
198
|
+
flagQueueService,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const event: FlagPushEvent = {
|
|
203
|
+
type: "FLAG_PUSH",
|
|
204
|
+
accountId: ACCOUNT_ID,
|
|
205
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
206
|
+
messageId: MESSAGE_ID,
|
|
207
|
+
flagName: SEEN,
|
|
208
|
+
} as FlagPushEvent;
|
|
209
|
+
|
|
210
|
+
describe("auto-mark-read racing the placement move (#496)", () => {
|
|
211
|
+
let harness: Harness;
|
|
212
|
+
|
|
213
|
+
beforeEach(async () => {
|
|
214
|
+
mock.method(SQSClient.prototype, "send", async () => ({}));
|
|
215
|
+
harness = buildHarness();
|
|
216
|
+
|
|
217
|
+
setClient({
|
|
218
|
+
account: { get: async () => undefined },
|
|
219
|
+
message: { get: async () => undefined },
|
|
220
|
+
mailbox: { get: async () => undefined },
|
|
221
|
+
flagPush: {
|
|
222
|
+
find: async () => undefined,
|
|
223
|
+
updateState: async () => undefined,
|
|
224
|
+
delete: async () => undefined,
|
|
225
|
+
},
|
|
226
|
+
} as unknown as RemitClient);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
afterEach(() => mock.restoreAll());
|
|
230
|
+
|
|
231
|
+
it("leaves the row naming the destination while it still carries the source uid", async () => {
|
|
232
|
+
await harness.placementMoveService.moveMessage(
|
|
233
|
+
ACCOUNT_CONFIG_ID,
|
|
234
|
+
MESSAGE_ID,
|
|
235
|
+
ARCHIVE_ID,
|
|
236
|
+
ACCOUNT_ID,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
assert.equal(harness.row.mailboxId, ARCHIVE_ID);
|
|
240
|
+
assert.equal(
|
|
241
|
+
harness.row.uid,
|
|
242
|
+
INBOX_UID,
|
|
243
|
+
"the uid still belongs to INBOX — this pair is what any dependent push would resolve",
|
|
244
|
+
);
|
|
245
|
+
assert.equal(harness.row.status, "moving");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("never pushes the flag while the move that row is waiting on has not settled", async () => {
|
|
249
|
+
await harness.placementMoveService.moveMessage(
|
|
250
|
+
ACCOUNT_CONFIG_ID,
|
|
251
|
+
MESSAGE_ID,
|
|
252
|
+
ARCHIVE_ID,
|
|
253
|
+
ACCOUNT_ID,
|
|
254
|
+
);
|
|
255
|
+
await harness.flagQueueService.markAsRead(
|
|
256
|
+
ACCOUNT_CONFIG_ID,
|
|
257
|
+
MESSAGE_ID,
|
|
258
|
+
ACCOUNT_ID,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const marker = harness.flagMarkers.get(`${MESSAGE_ID}:${SEEN}`);
|
|
262
|
+
assert.ok(marker, "the auto-read wrote a pending flag-push marker");
|
|
263
|
+
assert.equal(
|
|
264
|
+
(marker as unknown as { mailboxId: string }).mailboxId,
|
|
265
|
+
ARCHIVE_ID,
|
|
266
|
+
"the marker names the destination the unsettled move wrote",
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const client = await getClient();
|
|
270
|
+
mock.method(client.account, "get", async () => ({
|
|
271
|
+
accountId: ACCOUNT_ID,
|
|
272
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
273
|
+
}));
|
|
274
|
+
mock.method(client.message, "get", async () => [harness.row]);
|
|
275
|
+
const mailboxGet = mock.method(client.mailbox, "get", async () => ({
|
|
276
|
+
mailboxId: ARCHIVE_ID,
|
|
277
|
+
fullPath: "Archive",
|
|
278
|
+
}));
|
|
279
|
+
mock.method(client.flagPush, "find", async () => marker);
|
|
280
|
+
const updateState = mock.method(
|
|
281
|
+
client.flagPush,
|
|
282
|
+
"updateState",
|
|
283
|
+
async () => {},
|
|
284
|
+
);
|
|
285
|
+
const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
|
|
286
|
+
|
|
287
|
+
// Captured rather than awaited bare: unguarded, the handler walks on to
|
|
288
|
+
// borrow a connection and fails there, which would mask the assertion
|
|
289
|
+
// below that says why it should never have got that far.
|
|
290
|
+
const fault = await handleFlagPush(event, silentLogger, 1).then(
|
|
291
|
+
() => undefined,
|
|
292
|
+
(error: unknown) => error,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
assert.equal(
|
|
296
|
+
mailboxGet.mock.calls.length,
|
|
297
|
+
0,
|
|
298
|
+
"returns before resolving Archive — nothing reaches the server against uid 42",
|
|
299
|
+
);
|
|
300
|
+
assert.equal(fault, undefined, "the deferral acks, it does not fault");
|
|
301
|
+
assert.equal(
|
|
302
|
+
deleteMarker.mock.calls.length,
|
|
303
|
+
0,
|
|
304
|
+
"the read intent survives the deferral",
|
|
305
|
+
);
|
|
306
|
+
assert.deepEqual(updateState.mock.calls[0]?.arguments, [
|
|
307
|
+
MESSAGE_ID,
|
|
308
|
+
SEEN,
|
|
309
|
+
"pending",
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("pushes once the move has settled and the row's uid matches its mailbox", async () => {
|
|
314
|
+
await harness.placementMoveService.moveMessage(
|
|
315
|
+
ACCOUNT_CONFIG_ID,
|
|
316
|
+
MESSAGE_ID,
|
|
317
|
+
ARCHIVE_ID,
|
|
318
|
+
ACCOUNT_ID,
|
|
319
|
+
);
|
|
320
|
+
await harness.flagQueueService.markAsRead(
|
|
321
|
+
ACCOUNT_CONFIG_ID,
|
|
322
|
+
MESSAGE_ID,
|
|
323
|
+
ACCOUNT_ID,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// What `updateUid` writes when the IMAP move confirms: the destination's
|
|
327
|
+
// own COPYUID, and the status back to active.
|
|
328
|
+
Object.assign(harness.row, {
|
|
329
|
+
uid: 907,
|
|
330
|
+
mailboxId: ARCHIVE_ID,
|
|
331
|
+
status: "active",
|
|
332
|
+
syncStatus: "synced",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const client = await getClient();
|
|
336
|
+
mock.method(client.account, "get", async () => ({
|
|
337
|
+
accountId: ACCOUNT_ID,
|
|
338
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
339
|
+
}));
|
|
340
|
+
mock.method(client.message, "get", async () => [harness.row]);
|
|
341
|
+
const mailboxGet = mock.method(client.mailbox, "get", async () => {
|
|
342
|
+
throw Object.assign(new Error("stop before connecting"), {
|
|
343
|
+
name: "NotFoundError",
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
mock.method(client.flagPush, "find", async () =>
|
|
347
|
+
harness.flagMarkers.get(`${MESSAGE_ID}:${SEEN}`),
|
|
348
|
+
);
|
|
349
|
+
mock.method(client.flagPush, "updateState", async () => {});
|
|
350
|
+
mock.method(client.flagPush, "delete", async () => {});
|
|
351
|
+
|
|
352
|
+
await handleFlagPush(event, silentLogger, 1);
|
|
353
|
+
|
|
354
|
+
assert.equal(
|
|
355
|
+
mailboxGet.mock.calls.length,
|
|
356
|
+
1,
|
|
357
|
+
"the deferral is not permanent — a settled row resolves its mailbox and proceeds",
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import { MessageStatus } from "@remit/domain-enums";
|
|
3
2
|
import type { Logger } from "@remit/logger-lambda";
|
|
4
3
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
5
4
|
import {
|
|
6
5
|
guardConnectionCursor,
|
|
7
6
|
isCursorRebuildNeeded,
|
|
7
|
+
isPlacementUnsettled,
|
|
8
8
|
MailboxCursorPausedError,
|
|
9
9
|
resolveExhaustedFlagPushFailure,
|
|
10
10
|
} from "@remit/mailbox-service";
|
|
@@ -132,16 +132,14 @@ export const handleFlagPush = async (
|
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// The message carries a move still in flight —
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
// only by an actual move/delete-to-trash and cleared only once it settles.
|
|
144
|
-
if (message.status === MessageStatus.moving) {
|
|
135
|
+
// The message carries a move still in flight — `MessageMoveService`'s or
|
|
136
|
+
// `PlacementMoveService`'s local optimistic write, not yet confirmed by the
|
|
137
|
+
// server. A STORE resolved against this row addresses the destination
|
|
138
|
+
// folder at the SOURCE folder's uid: a different message (issue #496). This
|
|
139
|
+
// is the wait half of the wait-or-reconcile decision
|
|
140
|
+
// (docs/architecture/imap-mutations.md R2) — the push blocks until the move
|
|
141
|
+
// it depends on settles.
|
|
142
|
+
if (isPlacementUnsettled(message)) {
|
|
145
143
|
const deferredForMs = Date.now() - marker.createdAt;
|
|
146
144
|
|
|
147
145
|
if (deferredForMs > FLAG_PUSH_DEFER_MAX_MS) {
|