@remit/mailbox-service 0.0.15 → 0.0.17

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.
@@ -33,13 +33,21 @@ const buildLogger = (): {
33
33
  };
34
34
  };
35
35
 
36
- const buildConnection = (present: Set<number>): IImapConnection =>
36
+ const buildConnection = (
37
+ present: Set<number>,
38
+ fetchDrops: Set<number> = new Set(),
39
+ ): IImapConnection =>
37
40
  ({
38
41
  openBox: async () => ({}) as never,
39
42
  fetchMessages: async (uids: number[]) =>
40
43
  uids
41
- .filter((uid) => present.has(uid))
44
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
42
45
  .map((uid) => ({ uid }) as unknown as never),
46
+ search: async (criteria: unknown[]) => {
47
+ const [, value] = (criteria as Array<[string, string]>)[0];
48
+ const uid = Number(value);
49
+ return present.has(uid) ? [uid] : [];
50
+ },
43
51
  }) as unknown as IImapConnection;
44
52
 
45
53
  describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mirrors #1270 for placement moves)", () => {
@@ -148,6 +156,54 @@ describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mi
148
156
  );
149
157
  });
150
158
 
159
+ it("a dropped FETCH row is not absence: the message is still at the source — marker and local rows survive", async () => {
160
+ const deletedMessages: string[] = [];
161
+ const deletedThreadMessages: unknown[] = [];
162
+ const markerDeletes: string[] = [];
163
+ const { log, errors } = buildLogger();
164
+
165
+ const deps: ResolveExhaustedPlacementMoveDeps = {
166
+ markerService: {
167
+ delete: async (id: string) => {
168
+ markerDeletes.push(id);
169
+ },
170
+ },
171
+ messageService: {
172
+ delete: async (id: string) => {
173
+ deletedMessages.push(id);
174
+ },
175
+ } as unknown as Pick<IMessageRepository, "delete">,
176
+ threadMessageService: {
177
+ findAllByMessageId: async () => [
178
+ { accountConfigId: "cfg-1", threadMessageId: "tm-msg-live" },
179
+ ],
180
+ deleteMany: async (keys: unknown[]) => {
181
+ deletedThreadMessages.push(...keys);
182
+ },
183
+ } as unknown as Pick<
184
+ IThreadMessageRepository,
185
+ "findAllByMessageId" | "deleteMany"
186
+ >,
187
+ log,
188
+ };
189
+
190
+ const result = await resolveExhaustedPlacementMoveFailure(deps, {
191
+ accountId: "acc-1",
192
+ accountConfigId: "cfg-1",
193
+ messageId: "msg-live",
194
+ uid: 303,
195
+ sourceMailboxPath: "INBOX",
196
+ getConnection: async () =>
197
+ buildConnection(new Set([303]), new Set([303])),
198
+ });
199
+
200
+ assert.equal(result.outcome, "broken");
201
+ assert.deepEqual(markerDeletes, []);
202
+ assert.deepEqual(deletedMessages, []);
203
+ assert.deepEqual(deletedThreadMessages, []);
204
+ assert.ok(errors.some((e) => e.obj.alert === "placement_move_failed"));
205
+ });
206
+
151
207
  it("never throws — both outcomes are terminal, the caller always acks", async () => {
152
208
  const { log } = buildLogger();
153
209
  const deps: ResolveExhaustedPlacementMoveDeps = {
@@ -1,3 +1,4 @@
1
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
1
2
  import type { PlacementMoveLogger } from "./placement-move.js";
2
3
  import {
3
4
  reconcileStaleMessage,
@@ -33,9 +34,11 @@ export interface ResolveExhaustedPlacementMoveResult {
33
34
  * taxonomy (epic #1281 invariant 3) — no third, softer outcome.
34
35
  *
35
36
  * 1. RECONCILED (expected) — the message no longer exists at its pending-move
36
- * source on IMAP. Per invariant 2, an external delete supersedes the
37
- * marker entirely: the marker is dropped and the stale Message/ThreadMessage
38
- * rows are deleted via {@link reconcileStaleMessage}. This is also the
37
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
38
+ * than by a FETCH coming back empty. Per invariant 2, an external delete
39
+ * supersedes the marker entirely: the marker is dropped and the stale
40
+ * Message/ThreadMessage rows are deleted via
41
+ * {@link reconcileStaleMessage}. This is also the
39
42
  * outcome for the (rarer, functionally indistinguishable from here) case
40
43
  * where a foreign client moved the message elsewhere — either way, our
41
44
  * prediction no longer holds, and the marker cannot be honoured. Metric
@@ -49,6 +52,15 @@ export interface ResolveExhaustedPlacementMoveResult {
49
52
  * `alert`-shaped entry for an operator alarm; never re-thrown (terminal —
50
53
  * the caller acks either way, since retrying a stale or permanently-broken
51
54
  * move can never succeed).
55
+ *
56
+ * An operator reading `placement_move_failed` should know one case where the
57
+ * message is not actually at the source: a message another client expunged
58
+ * mid-session can answer an empty FETCH while the server still lists its UID
59
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
60
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
61
+ * alert stands until someone clears it. The reverse mistake deletes live
62
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
63
+ * deleted message is not.
52
64
  */
53
65
  export const resolveExhaustedPlacementMoveFailure = async (
54
66
  deps: ResolveExhaustedPlacementMoveDeps,
@@ -65,9 +77,8 @@ export const resolveExhaustedPlacementMoveFailure = async (
65
77
 
66
78
  const connection = await getConnection();
67
79
  await connection.openBox(sourceMailboxPath);
68
- const found = await connection.fetchMessages([uid]);
69
80
 
70
- if (found.length === 0) {
81
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
71
82
  await deps.markerService.delete(messageId);
72
83
  const { threadMessagesDeleted } = await reconcileStaleMessage(
73
84
  deps,
@@ -0,0 +1,191 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IMailboxSpecialUseRepository,
5
+ IQuarantineRepository,
6
+ MessageData,
7
+ MessageItem,
8
+ QuarantineItem,
9
+ QuarantineUpsertInput,
10
+ } from "@remit/data-ports";
11
+ import {
12
+ QuarantinedUids,
13
+ QuarantineService,
14
+ resolveMailboxRole,
15
+ shapeFromMessageData,
16
+ } from "./quarantine.js";
17
+
18
+ const noopLog = { info: () => {}, warn: () => {} };
19
+
20
+ const buildService = (
21
+ specialUse: string[] = [],
22
+ ): {
23
+ service: QuarantineService;
24
+ writes: QuarantineUpsertInput[];
25
+ listed: string[];
26
+ } => {
27
+ const writes: QuarantineUpsertInput[] = [];
28
+ const listed: string[] = [];
29
+ const repository = {
30
+ listByAccountConfigId: async (accountConfigId: string) => {
31
+ listed.push(accountConfigId);
32
+ return [] as QuarantineItem[];
33
+ },
34
+ upsert: async (input: QuarantineUpsertInput) => {
35
+ writes.push(input);
36
+ },
37
+ } satisfies IQuarantineRepository;
38
+
39
+ const mailboxSpecialUseService = {
40
+ listByMailboxId: async () => specialUse.map((use) => ({ specialUse: use })),
41
+ } as unknown as IMailboxSpecialUseRepository;
42
+
43
+ return {
44
+ service: new QuarantineService(
45
+ repository,
46
+ mailboxSpecialUseService,
47
+ "sha-abc",
48
+ noopLog,
49
+ ),
50
+ writes,
51
+ listed,
52
+ };
53
+ };
54
+
55
+ const context = {
56
+ accountId: "acct-1",
57
+ accountConfigId: "cfg-1",
58
+ mailboxId: "mbx-1",
59
+ mailboxPath: "INBOX",
60
+ uidValidity: 1_712_000_000,
61
+ attempts: 2,
62
+ };
63
+
64
+ const failure = {
65
+ stage: "BodyParse" as const,
66
+ code: "UnreadableBody" as const,
67
+ message: "the parser said no",
68
+ };
69
+
70
+ describe("QuarantineService.record", () => {
71
+ it("stamps the worker build, not the client's", async () => {
72
+ const { service, writes } = buildService();
73
+ await service.record(context, 40217, failure);
74
+ assert.equal(writes[0]?.workerVersion, "sha-abc");
75
+ });
76
+
77
+ it("names the message by mailbox, uidValidity and uid", async () => {
78
+ const { service, writes } = buildService();
79
+ await service.record(context, 40217, failure);
80
+ assert.deepEqual(
81
+ {
82
+ mailboxId: writes[0]?.mailboxId,
83
+ uidValidity: writes[0]?.uidValidity,
84
+ uid: writes[0]?.uid,
85
+ },
86
+ { mailboxId: "mbx-1", uidValidity: 1_712_000_000, uid: 40217 },
87
+ );
88
+ });
89
+
90
+ it("supplies no quarantineId, so the random-id default is unreachable", async () => {
91
+ const { service, writes } = buildService();
92
+ await service.record(context, 40217, failure);
93
+ assert.ok(!("quarantineId" in (writes[0] as object)));
94
+ });
95
+
96
+ it("omits an absent diagnostic rather than writing a null through", async () => {
97
+ const { service, writes } = buildService();
98
+ await service.record(
99
+ { ...context, mailboxPath: "Clients/Acme" },
100
+ 1,
101
+ failure,
102
+ );
103
+ assert.ok(!("failurePartPath" in (writes[0] as object)));
104
+ assert.ok(!("mailboxRole" in (writes[0] as object)));
105
+ });
106
+
107
+ it("reads the folder's role off the server's SPECIAL-USE", async () => {
108
+ const { service, writes } = buildService(["Junk"]);
109
+ await service.record({ ...context, mailboxPath: "Spam" }, 1, failure);
110
+ assert.equal(writes[0]?.mailboxRole, "Junk");
111
+ });
112
+ });
113
+
114
+ describe("resolveMailboxRole", () => {
115
+ it("gives INBOX its role without a SPECIAL-USE flag, which it never has", () => {
116
+ assert.equal(resolveMailboxRole("INBOX", []), "Inbox");
117
+ });
118
+
119
+ it("leaves a plain folder roleless instead of inventing one", () => {
120
+ assert.equal(resolveMailboxRole("Clients/Acme", []), undefined);
121
+ });
122
+
123
+ it("drops a SPECIAL-USE that is not a canonical role", () => {
124
+ assert.equal(resolveMailboxRole("Priority", ["Important"]), undefined);
125
+ });
126
+ });
127
+
128
+ describe("QuarantinedUids", () => {
129
+ const entries = [
130
+ { mailboxId: "mbx-1", uidValidity: 10, uid: 5 },
131
+ ] as QuarantineItem[];
132
+
133
+ it("matches a uid on the same mailbox and UIDVALIDITY", () => {
134
+ assert.equal(new QuarantinedUids(entries).has("mbx-1", 10, 5), true);
135
+ });
136
+
137
+ it("does not match the same uid on a new UIDVALIDITY, which is a different message", () => {
138
+ assert.equal(new QuarantinedUids(entries).has("mbx-1", 11, 5), false);
139
+ });
140
+
141
+ it("does not match the same uid in another mailbox", () => {
142
+ assert.equal(new QuarantinedUids(entries).has("mbx-2", 10, 5), false);
143
+ });
144
+ });
145
+
146
+ describe("shapeFromMessageData", () => {
147
+ const message = {
148
+ rfc822Size: 2048,
149
+ messageIdHeader: "<xyz@example.com>",
150
+ } as MessageItem;
151
+
152
+ const data = {
153
+ bodyPart: [
154
+ {
155
+ bodyPartId: "bp-0",
156
+ partPath: "0",
157
+ mediaType: "multipart",
158
+ mediaSubtype: "alternative",
159
+ transferEncoding: "7bit",
160
+ },
161
+ {
162
+ bodyPartId: "bp-1",
163
+ partPath: "1",
164
+ mediaType: "text",
165
+ mediaSubtype: "plain",
166
+ transferEncoding: "quoted-printable",
167
+ },
168
+ ],
169
+ bodyPartParameter: [
170
+ {
171
+ bodyPartId: "bp-0",
172
+ parameterName: "charset",
173
+ parameterValue: "iso-8859-1",
174
+ },
175
+ ],
176
+ } as unknown as MessageData;
177
+
178
+ it("rebuilds the tree from the rows metadata sync already wrote", () => {
179
+ assert.deepEqual(shapeFromMessageData(message, data).structure, [
180
+ { depth: 0, contentType: "multipart/alternative" },
181
+ { depth: 1, contentType: "text/plain" },
182
+ ]);
183
+ });
184
+
185
+ it("takes the root part's declared charset and encoding", () => {
186
+ const shape = shapeFromMessageData(message, data);
187
+ assert.equal(shape.charset, "iso-8859-1");
188
+ assert.equal(shape.transferEncoding, "7bit");
189
+ assert.equal(shape.contentType, "multipart/alternative");
190
+ });
191
+ });
@@ -0,0 +1,228 @@
1
+ import type {
2
+ IMailboxSpecialUseRepository,
3
+ IQuarantineRepository,
4
+ MessageData,
5
+ MessageItem,
6
+ QuarantineItem,
7
+ QuarantineMimeNodeItem,
8
+ } from "@remit/data-ports";
9
+ import { quarantineMessageIdHash, ROOT_PART_PATH } from "@remit/data-ports/id";
10
+ import { CanonicalMailboxRole } from "@remit/domain-enums";
11
+
12
+ type CanonicalRole = NonNullable<QuarantineItem["mailboxRole"]>;
13
+ type FailureStage = QuarantineItem["failureStage"];
14
+ type FailureCode = QuarantineItem["failureCode"];
15
+
16
+ const CANONICAL_ROLES = new Set<string>(Object.values(CanonicalMailboxRole));
17
+
18
+ /**
19
+ * The canonical role of the folder a quarantined message arrived in, read from
20
+ * the server's own RFC 6154 SPECIAL-USE declaration — the same source the
21
+ * appointment flow seeds from. A folder the server declares nothing about has
22
+ * no role, which is the normal state for a plain folder and is why the field
23
+ * is optional. `Important` is a SPECIAL-USE with no canonical role and drops
24
+ * out here rather than being invented into one.
25
+ */
26
+ export const resolveMailboxRole = (
27
+ mailboxPath: string,
28
+ specialUse: readonly string[],
29
+ ): CanonicalRole | undefined => {
30
+ if (mailboxPath.toUpperCase() === "INBOX") return CanonicalMailboxRole.Inbox;
31
+ const matched = specialUse.find((use) => CANONICAL_ROLES.has(use));
32
+ return matched as CanonicalRole | undefined;
33
+ };
34
+
35
+ /** Everything about the quarantine that is the same for every uid in a round. */
36
+ export interface QuarantineContext {
37
+ accountId: string;
38
+ accountConfigId: string;
39
+ mailboxId: string;
40
+ mailboxPath: string;
41
+ uidValidity: number;
42
+ /** Rounds tried before the message was set aside. */
43
+ attempts: number;
44
+ }
45
+
46
+ /**
47
+ * The repro fingerprint: what the message declared itself to be. Every field
48
+ * is optional-by-absence because a message can fail before its shape was read,
49
+ * and a required column would force the writer to invent a value or drop the
50
+ * record.
51
+ */
52
+ export interface QuarantineMessageShape {
53
+ contentType?: string;
54
+ transferEncoding?: string;
55
+ charset?: string;
56
+ sizeBytes?: number;
57
+ structure: QuarantineMimeNodeItem[];
58
+ messageIdHash?: string;
59
+ }
60
+
61
+ export interface QuarantineFailure {
62
+ stage: FailureStage;
63
+ code: FailureCode;
64
+ /** Parser error text. Stored, shown on screen, never published. */
65
+ message: string;
66
+ partPath?: string;
67
+ }
68
+
69
+ const EMPTY_SHAPE: QuarantineMessageShape = { structure: [] };
70
+
71
+ /**
72
+ * Message shape off the rows metadata sync already wrote. The body path has no
73
+ * FETCH result to read — it streams raw bytes — but the MIME tree it needs was
74
+ * walked into BodyPart rows when the message's metadata was synced, so the
75
+ * fingerprint comes from there rather than from re-reading headers the parser
76
+ * has just refused.
77
+ */
78
+ export const shapeFromMessageData = (
79
+ message: MessageItem,
80
+ data: MessageData,
81
+ ): QuarantineMessageShape => {
82
+ const parts = [...data.bodyPart].sort((a, b) =>
83
+ a.partPath.localeCompare(b.partPath, "en", { numeric: true }),
84
+ );
85
+ const root = parts.find((part) => part.partPath === ROOT_PART_PATH);
86
+ const rootCharset = root
87
+ ? data.bodyPartParameter.find(
88
+ (param) =>
89
+ param.bodyPartId === root.bodyPartId &&
90
+ param.parameterName.toLowerCase() === "charset",
91
+ )?.parameterValue
92
+ : undefined;
93
+
94
+ return {
95
+ ...(root ? { contentType: `${root.mediaType}/${root.mediaSubtype}` } : {}),
96
+ ...(root ? { transferEncoding: root.transferEncoding } : {}),
97
+ ...(rootCharset ? { charset: rootCharset } : {}),
98
+ ...(message.rfc822Size ? { sizeBytes: message.rfc822Size } : {}),
99
+ structure: parts.map((part) => ({
100
+ depth: partPathDepth(part.partPath),
101
+ contentType: `${part.mediaType}/${part.mediaSubtype}`,
102
+ })),
103
+ ...hashOf(message.messageIdHeader),
104
+ };
105
+ };
106
+
107
+ const partPathDepth = (partPath: string): number =>
108
+ partPath === ROOT_PART_PATH ? 0 : partPath.split(".").length;
109
+
110
+ const hashOf = (
111
+ messageIdHeader: string | undefined,
112
+ ): { messageIdHash?: string } => {
113
+ const messageIdHash = quarantineMessageIdHash(messageIdHeader);
114
+ return messageIdHash ? { messageIdHash } : {};
115
+ };
116
+
117
+ /**
118
+ * The set of messages already set aside, held for the duration of one sync
119
+ * round. A round loads it once and filters against it in memory: the list is
120
+ * small by design — a growing one is a bug being reported, not a page to
121
+ * paginate — and a lookup per message would put a query on the hot path for a
122
+ * state that is almost always empty.
123
+ */
124
+ export class QuarantinedUids {
125
+ private readonly keys: ReadonlySet<string>;
126
+
127
+ constructor(entries: readonly QuarantineItem[]) {
128
+ this.keys = new Set(
129
+ entries.map((entry) =>
130
+ uidKey(entry.mailboxId, entry.uidValidity, entry.uid),
131
+ ),
132
+ );
133
+ }
134
+
135
+ get size(): number {
136
+ return this.keys.size;
137
+ }
138
+
139
+ has(mailboxId: string, uidValidity: number, uid: number): boolean {
140
+ return this.keys.has(uidKey(mailboxId, uidValidity, uid));
141
+ }
142
+ }
143
+
144
+ const uidKey = (mailboxId: string, uidValidity: number, uid: number): string =>
145
+ `${mailboxId}:${uidValidity}:${uid}`;
146
+
147
+ export interface QuarantineLogger {
148
+ info(obj: Record<string, unknown>, msg: string): void;
149
+ warn(obj: Record<string, unknown>, msg: string): void;
150
+ }
151
+
152
+ /**
153
+ * Writes the record a message becomes when the sync path cannot apply it
154
+ * (issue #72), and loads the set a round filters against.
155
+ *
156
+ * Only a message defect reaches this class. Deciding that is the caller's job
157
+ * and it is made at exactly one kind of catch site — one narrow enough that
158
+ * the error can only have come from the message itself. An S3, queue or
159
+ * database failure propagates; writing one here would advance a cursor past
160
+ * mail that is fine and tell the user it was unreadable.
161
+ */
162
+ export class QuarantineService {
163
+ constructor(
164
+ private readonly repository: IQuarantineRepository,
165
+ private readonly mailboxSpecialUseService: IMailboxSpecialUseRepository,
166
+ private readonly workerVersion: string,
167
+ private readonly log: QuarantineLogger,
168
+ ) {}
169
+
170
+ async load(accountConfigId: string): Promise<QuarantinedUids> {
171
+ return new QuarantinedUids(
172
+ await this.repository.listByAccountConfigId(accountConfigId),
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Set a message aside. Resolves only once the row is durable, because the
178
+ * caller's next act is to let a cursor move past this uid — and a cursor
179
+ * that moves past work no record survives is the silent loss this whole
180
+ * feature exists to end.
181
+ */
182
+ async record(
183
+ context: QuarantineContext,
184
+ uid: number,
185
+ failure: QuarantineFailure,
186
+ shape: QuarantineMessageShape = EMPTY_SHAPE,
187
+ ): Promise<void> {
188
+ // Resolved here rather than per round: a round almost never writes a row,
189
+ // so the lookup belongs on the write and not on the hot path.
190
+ const specialUse = await this.mailboxSpecialUseService.listByMailboxId(
191
+ context.mailboxId,
192
+ );
193
+ const mailboxRole = resolveMailboxRole(
194
+ context.mailboxPath,
195
+ specialUse.map((entry) => entry.specialUse),
196
+ );
197
+
198
+ await this.repository.upsert({
199
+ accountConfigId: context.accountConfigId,
200
+ accountId: context.accountId,
201
+ mailboxId: context.mailboxId,
202
+ uidValidity: context.uidValidity,
203
+ uid,
204
+ ...(mailboxRole ? { mailboxRole } : {}),
205
+ mailboxPath: context.mailboxPath,
206
+ quarantinedAt: Date.now(),
207
+ attempts: context.attempts,
208
+ failureStage: failure.stage,
209
+ failureCode: failure.code,
210
+ failureMessage: failure.message,
211
+ ...(failure.partPath ? { failurePartPath: failure.partPath } : {}),
212
+ workerVersion: this.workerVersion,
213
+ ...shape,
214
+ });
215
+
216
+ this.log.warn(
217
+ {
218
+ mailboxId: context.mailboxId,
219
+ mailboxPath: context.mailboxPath,
220
+ uid,
221
+ uidValidity: context.uidValidity,
222
+ failureStage: failure.stage,
223
+ failureCode: failure.code,
224
+ },
225
+ "Message quarantined; cursor may advance past it",
226
+ );
227
+ }
228
+ }