@remit/imap-worker 0.0.36 → 0.0.38

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.36",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -32,8 +32,8 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@aws-sdk/client-s3": "^3.1055.0",
35
- "nodemailer": "^9.0.3",
36
35
  "@remit/data-ports": "*",
36
+ "@remit/smtp-service": "*",
37
37
  "@remit/logger-lambda": "*",
38
38
  "@remit/mailbox-service": "*",
39
39
  "@remit/search-index-worker": "*",
@@ -47,8 +47,7 @@
47
47
  "@aws-sdk/client-ssm": "*",
48
48
  "expect-env": "*",
49
49
  "p-map": "*",
50
- "@types/aws-lambda": "*",
51
- "@types/nodemailer": "*"
50
+ "@types/aws-lambda": "*"
52
51
  },
53
52
  "license": "MIT",
54
53
  "publishConfig": {
@@ -2,11 +2,13 @@ import { getClient } from "@remit/backend/client";
2
2
  import type {
3
3
  IMailboxRepository,
4
4
  IMailboxSpecialUseRepository,
5
- OutboxMessageItem,
6
5
  } from "@remit/data-ports";
7
6
  import { MailboxSpecialUse } from "@remit/domain-enums";
8
7
  import type { Logger } from "@remit/logger-lambda";
9
- import nodemailer from "nodemailer";
8
+ import {
9
+ buildMailMessage,
10
+ renderRawMessage,
11
+ } from "@remit/smtp-service/message-builder";
10
12
  import { isAccountDeleted } from "../account-check.js";
11
13
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
14
  import type { AppendSentMessageEvent } from "../events.js";
@@ -46,35 +48,6 @@ const findSentMailbox = async (
46
48
  return null;
47
49
  };
48
50
 
49
- const buildRawMessage = async (outbox: OutboxMessageItem): Promise<Buffer> => {
50
- const from = outbox.fromName
51
- ? `${outbox.fromName} <${outbox.fromAddress}>`
52
- : outbox.fromAddress;
53
-
54
- const transport = nodemailer.createTransport({ streamTransport: true });
55
-
56
- const info = await transport.sendMail({
57
- from,
58
- to: outbox.toAddresses.join(", "),
59
- cc: outbox.ccAddresses?.join(", "),
60
- bcc: outbox.bccAddresses?.join(", "),
61
- replyTo: outbox.replyToAddress,
62
- subject: outbox.subject,
63
- text: outbox.textBody,
64
- html: outbox.htmlBody,
65
- messageId: `<${outbox.messageIdValue}>`,
66
- inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
67
- references: outbox.references?.map((r) => `<${r}>`).join(" "),
68
- date: outbox.sentAt ? new Date(outbox.sentAt) : new Date(),
69
- });
70
-
71
- const chunks: Buffer[] = [];
72
- for await (const chunk of info.message as AsyncIterable<Buffer>) {
73
- chunks.push(chunk);
74
- }
75
- return Buffer.concat(chunks);
76
- };
77
-
78
51
  export interface AppendSentMessageDeps {
79
52
  getClient: typeof getClient;
80
53
  buildLifecycleDeps: typeof buildLifecycleDeps;
@@ -150,7 +123,7 @@ export const handleAppendSentMessage = async (
150
123
  await scope
151
124
  .getConnection()
152
125
  .then(async (connection) => {
153
- const rawMessage = await buildRawMessage(outbox);
126
+ const rawMessage = await renderRawMessage(buildMailMessage(outbox));
154
127
 
155
128
  const result = await connection.append(
156
129
  sentMailbox.fullPath,
@@ -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 — its `mailboxId`/`uid` are
136
- // the local optimistic write `MessageMoveService` made before enqueueing
137
- // the IMAP MOVE, not yet confirmed by the server, so a STORE resolved
138
- // against this row right now would land on the wrong UID or the wrong
139
- // folder (or both). `syncStatus` is NOT the right signal here: an ordinary
140
- // freshly-synced inbound row is `pending` too (nothing on the inbound path
141
- // ever promotes it to `synced`), so keying off it would defer every
142
- // outbound flag push in the product forever. `status === moving` is set
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) {