@remit/mailbox-service 0.0.110 → 0.0.111

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/mailbox-service",
3
- "version": "0.0.110",
3
+ "version": "0.0.111",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -223,7 +223,7 @@ describe("Adversarial MIME corpus (Dovecot) — #402", {
223
223
  // (multipart walking, base64/qp decoding, charset folding).
224
224
  const rfc822 = await connection.fetchMessageBody(appendResult.uid);
225
225
  assert.ok(
226
- rfc822.length > 0,
226
+ rfc822 && rfc822.length > 0,
227
227
  `fetchMessageBody should return bytes for ${name}`,
228
228
  );
229
229
 
@@ -0,0 +1,139 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IAddressRepository,
5
+ IEnvelopeRepository,
6
+ IMessageRepository,
7
+ IThreadMessageRepository,
8
+ MessageItem,
9
+ PlacementPredicate,
10
+ } from "@remit/data-ports";
11
+ import {
12
+ MessageGoneFromServerError,
13
+ MessagePlacementUnsettledError,
14
+ } from "@remit/data-ports/errors";
15
+ import type { StorageService } from "@remit/storage-service";
16
+ import { BodySyncService } from "./body-sync.js";
17
+ import type { IImapConnection } from "./types.js";
18
+
19
+ const MESSAGE_ID = "m-gone";
20
+
21
+ interface Scenario {
22
+ status: MessageItem["status"];
23
+ rowStillMatches?: boolean;
24
+ serverStillLists?: boolean;
25
+ }
26
+
27
+ const readUnstoredMessage = async ({
28
+ status,
29
+ rowStillMatches = true,
30
+ serverStillLists = false,
31
+ }: Scenario) => {
32
+ const predicates: PlacementPredicate[] = [];
33
+ const deletedMessages: string[] = [];
34
+ const deletedThreadMessages: string[] = [];
35
+
36
+ const messageService = {
37
+ get: async () =>
38
+ ({
39
+ messageId: MESSAGE_ID,
40
+ mailboxId: "mb-inbox",
41
+ uid: 42,
42
+ status,
43
+ }) as unknown as MessageItem,
44
+ deleteIfPlacement: async (
45
+ messageId: string,
46
+ expected: PlacementPredicate,
47
+ ) => {
48
+ predicates.push(expected);
49
+ if (!rowStillMatches) return false;
50
+ deletedMessages.push(messageId);
51
+ return true;
52
+ },
53
+ } as unknown as IMessageRepository;
54
+
55
+ const threadMessageService = {
56
+ findAllByMessageId: async () => [
57
+ { accountConfigId: "cfg-1", threadMessageId: "tm-1" },
58
+ ],
59
+ deleteMany: async (keys: { threadMessageId: string }[]) => {
60
+ deletedThreadMessages.push(...keys.map((key) => key.threadMessageId));
61
+ },
62
+ } as unknown as IThreadMessageRepository;
63
+
64
+ const service = new BodySyncService(
65
+ messageService,
66
+ {} as unknown as StorageService,
67
+ threadMessageService,
68
+ {} as unknown as IAddressRepository,
69
+ {} as unknown as IEnvelopeRepository,
70
+ { info: () => {}, error: () => {} },
71
+ );
72
+
73
+ const connection = {
74
+ openBox: async () => {},
75
+ fetchMessageBody: async () => null,
76
+ fetchMessages: async () => [],
77
+ search: async () => (serverStillLists ? [42] : []),
78
+ } as unknown as IImapConnection;
79
+
80
+ const outcome = await service
81
+ .fetchAndGetBody(
82
+ MESSAGE_ID,
83
+ "acc-1",
84
+ "cfg-1",
85
+ { fullPath: "INBOX", placement: "inbox" },
86
+ async () => connection,
87
+ )
88
+ .then(
89
+ () => assert.fail("expected the read to be refused"),
90
+ (error: unknown) => error,
91
+ );
92
+
93
+ return { outcome, predicates, deletedMessages, deletedThreadMessages };
94
+ };
95
+
96
+ describe("reading a message the mail server no longer holds", () => {
97
+ it("answers 404 and removes the settled row with its thread entries", async () => {
98
+ const { outcome, predicates, deletedMessages, deletedThreadMessages } =
99
+ await readUnstoredMessage({ status: "active" });
100
+
101
+ assert.ok(outcome instanceof MessageGoneFromServerError);
102
+ assert.equal(outcome.statusCode, 404);
103
+ assert.deepEqual(predicates, [
104
+ { status: "active", mailboxId: "mb-inbox", uid: 42 },
105
+ ]);
106
+ assert.deepEqual(deletedMessages, [MESSAGE_ID]);
107
+ assert.deepEqual(deletedThreadMessages, ["tm-1"]);
108
+ });
109
+
110
+ it("answers 409 and deletes nothing while our own move is in flight", async () => {
111
+ const { outcome, predicates, deletedThreadMessages } =
112
+ await readUnstoredMessage({ status: "moving" });
113
+
114
+ assert.ok(outcome instanceof MessagePlacementUnsettledError);
115
+ assert.equal(outcome.statusCode, 409);
116
+ assert.deepEqual(predicates, []);
117
+ assert.deepEqual(deletedThreadMessages, []);
118
+ });
119
+
120
+ it("answers 409 and keeps the thread entries when the row changed after it was read", async () => {
121
+ const { outcome, deletedMessages, deletedThreadMessages } =
122
+ await readUnstoredMessage({ status: "active", rowStillMatches: false });
123
+
124
+ assert.ok(outcome instanceof MessagePlacementUnsettledError);
125
+ assert.deepEqual(deletedMessages, []);
126
+ assert.deepEqual(deletedThreadMessages, []);
127
+ });
128
+
129
+ it("fails loudly and deletes nothing when the server still lists the UID", async () => {
130
+ const { outcome, predicates } = await readUnstoredMessage({
131
+ status: "active",
132
+ serverStillLists: true,
133
+ });
134
+
135
+ assert.ok(outcome instanceof Error);
136
+ assert.ok(!(outcome instanceof MessageGoneFromServerError));
137
+ assert.deepEqual(predicates, []);
138
+ });
139
+ });
package/src/body-sync.ts CHANGED
@@ -13,12 +13,17 @@ import {
13
13
  type IMailboxSpecialUseRepository,
14
14
  type IMessageRepository,
15
15
  type IThreadMessageRepository,
16
+ type IUnitOfWork,
16
17
  isSenderMuted,
17
18
  type MessageItem,
18
19
  type ThreadMessageItem,
19
20
  type UpdateMessageInput,
20
21
  } from "@remit/data-ports";
21
- import { NotFoundError } from "@remit/data-ports/errors";
22
+ import {
23
+ MessageGoneFromServerError,
24
+ MessagePlacementUnsettledError,
25
+ NotFoundError,
26
+ } from "@remit/data-ports/errors";
22
27
  import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
23
28
  import { isBulkSender } from "@remit/data-ports/wellknown";
24
29
  import {
@@ -62,7 +67,10 @@ import {
62
67
  denormalizeMessageCategory,
63
68
  hasDecidedCategory,
64
69
  } from "./message-category.js";
70
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
71
+ import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
65
72
  import type { PlacementMoveService } from "./placement-move.js";
73
+ import { isPlacementUnsettled } from "./placement-settled.js";
66
74
  import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
67
75
  import { extractSnippetFromEmail } from "./snippet.js";
68
76
  import { type IImapConnection, MailConnectionError } from "./types.js";
@@ -308,6 +316,7 @@ export interface CalendarSuggestionConfig {
308
316
  export class BodySyncService {
309
317
  private log: BodySyncLogger;
310
318
  private readonly filterPipeline?: FilterPipeline;
319
+ private readonly unitOfWork: IUnitOfWork;
311
320
 
312
321
  constructor(
313
322
  private messageService: IMessageRepository,
@@ -321,8 +330,17 @@ export class BodySyncService {
321
330
  private readonly quarantineConfig?: QuarantineConfig,
322
331
  private readonly unsubscribeConfig?: UnsubscribeConfig,
323
332
  private readonly calendarConfig?: CalendarSuggestionConfig,
333
+ unitOfWork?: IUnitOfWork,
324
334
  ) {
325
335
  this.log = logger ?? noopLogger;
336
+ this.unitOfWork =
337
+ unitOfWork ??
338
+ new PassThroughUnitOfWork({
339
+ message: messageService,
340
+ envelope: envelopeService,
341
+ address: addressService,
342
+ threadMessage: threadMessageService,
343
+ });
326
344
  this.filterPipeline = filterConfig
327
345
  ? new FilterPipeline(filterConfig, this.log)
328
346
  : undefined;
@@ -1047,7 +1065,9 @@ export class BodySyncService {
1047
1065
  );
1048
1066
  needsStore = true;
1049
1067
  body = await this.fetchFromImap(
1050
- message.uid,
1068
+ message,
1069
+ accountId,
1070
+ accountConfigId,
1051
1071
  folder.fullPath,
1052
1072
  getConnection,
1053
1073
  );
@@ -1056,7 +1076,9 @@ export class BodySyncService {
1056
1076
  // Fetch from IMAP and store
1057
1077
  needsStore = true;
1058
1078
  body = await this.fetchFromImap(
1059
- message.uid,
1079
+ message,
1080
+ accountId,
1081
+ accountConfigId,
1060
1082
  folder.fullPath,
1061
1083
  getConnection,
1062
1084
  );
@@ -1099,14 +1121,97 @@ export class BodySyncService {
1099
1121
  }
1100
1122
 
1101
1123
  private async fetchFromImap(
1102
- uid: number,
1124
+ message: MessageItem,
1125
+ accountId: string,
1126
+ accountConfigId: string,
1103
1127
  mailboxPath: string,
1104
1128
  getConnection: ConnectionGetter,
1105
1129
  ): Promise<Buffer> {
1130
+ const { uid } = message;
1106
1131
  this.log.debug?.({ uid }, "Fetching body from IMAP");
1107
1132
  const connection = await getConnection();
1108
1133
  await connection.openBox(mailboxPath);
1109
- return connection.fetchMessageBody(uid);
1134
+ const body = await connection.fetchMessageBody(uid);
1135
+ if (body) return body;
1136
+
1137
+ if (!(await isMessageGoneFromOpenMailbox(connection, uid))) {
1138
+ throw new Error(
1139
+ `IMAP FETCH returned no source for UID ${uid} in ${mailboxPath}, which the server still lists`,
1140
+ );
1141
+ }
1142
+
1143
+ return this.reconcileGoneMessage(
1144
+ message,
1145
+ accountId,
1146
+ accountConfigId,
1147
+ mailboxPath,
1148
+ );
1149
+ }
1150
+
1151
+ /**
1152
+ * The server no longer holds the message at the row's UID. A row with our
1153
+ * own move or delete in flight belongs to that mutation (409). A settled row
1154
+ * is removed with its thread entries, but only while its placement still
1155
+ * matches what was read (imap-mutations R3); losing that predicate is a 409
1156
+ * like any other lost placement transition.
1157
+ */
1158
+ private async reconcileGoneMessage(
1159
+ message: MessageItem,
1160
+ accountId: string,
1161
+ accountConfigId: string,
1162
+ mailboxPath: string,
1163
+ ): Promise<never> {
1164
+ const { messageId, uid } = message;
1165
+ if (isPlacementUnsettled(message)) {
1166
+ throw new MessagePlacementUnsettledError(
1167
+ `Message ${messageId} is not at its UID while a move or delete is in flight`,
1168
+ accountId,
1169
+ messageId,
1170
+ "in_flight",
1171
+ );
1172
+ }
1173
+
1174
+ const reconciled = await this.unitOfWork.transaction(async (repos) => {
1175
+ const deleted = await repos.message.deleteIfPlacement(messageId, {
1176
+ status: message.status,
1177
+ mailboxId: message.mailboxId,
1178
+ uid,
1179
+ });
1180
+ if (!deleted) return false;
1181
+ const threadMessages = await repos.threadMessage.findAllByMessageId(
1182
+ accountConfigId,
1183
+ messageId,
1184
+ );
1185
+ if (threadMessages.length > 0) {
1186
+ await repos.threadMessage.deleteMany(
1187
+ threadMessages.map((row) => ({
1188
+ accountConfigId: row.accountConfigId,
1189
+ threadMessageId: row.threadMessageId,
1190
+ })),
1191
+ );
1192
+ }
1193
+ return true;
1194
+ });
1195
+
1196
+ if (!reconciled) {
1197
+ throw new MessagePlacementUnsettledError(
1198
+ `Message ${messageId} changed placement while its absence from ${mailboxPath} was being reconciled`,
1199
+ accountId,
1200
+ messageId,
1201
+ "in_flight",
1202
+ );
1203
+ }
1204
+
1205
+ this.log.info(
1206
+ {
1207
+ metric: "message_gone_stale_row_reconciled",
1208
+ messageId,
1209
+ uid,
1210
+ mailboxPath,
1211
+ },
1212
+ "Message no longer on the mail server (expunged or moved by another client); stale row reconciled",
1213
+ );
1214
+ throw new MessageGoneFromServerError(messageId);
1110
1215
  }
1111
1216
 
1112
1217
  /**
@@ -113,7 +113,7 @@ describe("Message bodies (Dovecot)", {
113
113
  assert.ok(uids.length > 0, "Should have messages to fetch body for");
114
114
 
115
115
  const body = await connection.fetchMessageBody(uids[0]);
116
- assert.ok(body.length > 0, "Body should not be empty");
116
+ assert.ok(body && body.length > 0, "Body should not be empty");
117
117
 
118
118
  const bodyText = body.toString("utf-8");
119
119
  assert.ok(bodyText.length > 0, "Body text should not be empty");
@@ -721,9 +721,9 @@ export class ImapFlowConnection {
721
721
  * Fetch the full message body (RFC822 source) for a single message by UID.
722
722
  *
723
723
  * @param uid - The UID of the message to fetch
724
- * @returns The raw message body as a Buffer
724
+ * @returns The raw message body, or null when the UID FETCH matched no message
725
725
  */
726
- fetchMessageBody = async (uid: number): Promise<Buffer> => {
726
+ fetchMessageBody = async (uid: number): Promise<Buffer | null> => {
727
727
  this.ensureConnected();
728
728
  const { client } = this;
729
729
 
@@ -739,19 +739,22 @@ export class ImapFlowConnection {
739
739
  uid: true,
740
740
  });
741
741
 
742
- // Connection may have been lost during download
743
- if (!result || !result.content) {
744
- throw new Error(
745
- `IMAP connection lost while downloading message UID ${uid}`,
746
- );
742
+ if (result?.content) {
743
+ const chunks: Buffer[] = [];
744
+ for await (const chunk of result.content) {
745
+ chunks.push(chunk);
746
+ }
747
+ return Buffer.concat(chunks);
747
748
  }
748
749
 
749
- const chunks: Buffer[] = [];
750
- for await (const chunk of result.content) {
751
- chunks.push(chunk);
750
+ if (!client.usable || !client.mailbox) {
751
+ throw new MailConnectionError(
752
+ "network",
753
+ `IMAP connection lost while downloading message UID ${uid}`,
754
+ );
752
755
  }
753
756
 
754
- return Buffer.concat(chunks);
757
+ return null;
755
758
  };
756
759
 
757
760
  /**
package/src/types.ts CHANGED
@@ -253,7 +253,7 @@ export interface IImapConnection {
253
253
  * no body. Requires a mailbox to be open.
254
254
  */
255
255
  fetchEnvelopeSnapshots(uids: number[]): Promise<ImapEnvelopeSnapshot[]>;
256
- fetchMessageBody(uid: number): Promise<Buffer>;
256
+ fetchMessageBody(uid: number): Promise<Buffer | null>;
257
257
  /**
258
258
  * Fetch full message bodies for many UIDs in ONE pipelined ranged UID FETCH.
259
259
  * Yields `{ uid, source }` per message; `source` is a readable stream the