@remit/mailbox-service 0.0.70 → 0.0.72

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.70",
3
+ "version": "0.0.72",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
+ convertSearchCriteria,
4
5
  ImapFlowConnection,
5
6
  toInternalDate,
6
7
  toIsoDateString,
@@ -112,6 +113,96 @@ const fakeMailbox = (path: string, exists: number) => ({
112
113
  readOnly: true,
113
114
  });
114
115
 
116
+ describe("convertSearchCriteria — a criterion never silently widens to ALL (#912)", () => {
117
+ it("compiles a Message-ID header criterion into a header query", () => {
118
+ assert.deepStrictEqual(
119
+ convertSearchCriteria([["HEADER", "Message-ID", "<abc@example.com>"]]),
120
+ { header: { "message-id": "<abc@example.com>" } },
121
+ );
122
+ });
123
+
124
+ it("carries a Message-ID holding a quote and a CRLF as a value, not as syntax", () => {
125
+ const hostile = '<a"b@example.com>\r\nUID 1';
126
+
127
+ assert.deepStrictEqual(
128
+ convertSearchCriteria([["HEADER", "Message-ID", hostile]]),
129
+ { header: { "message-id": hostile } },
130
+ );
131
+ });
132
+
133
+ it("keeps a __proto__ header name as an own query term instead of losing it to the prototype setter", () => {
134
+ const query = convertSearchCriteria([["HEADER", "__proto__", "x"]]);
135
+ const header = query.header;
136
+
137
+ assert.ok(typeof header === "object" && header !== null);
138
+ assert.deepStrictEqual(Object.getOwnPropertyNames(header), ["__proto__"]);
139
+ assert.strictEqual(
140
+ Object.getOwnPropertyDescriptor(header, "__proto__")?.value,
141
+ "x",
142
+ );
143
+ assert.notDeepStrictEqual(query, {});
144
+ });
145
+
146
+ it("throws on an empty criteria list rather than compiling to {}", () => {
147
+ assert.throws(
148
+ () => convertSearchCriteria([]),
149
+ /Empty IMAP search criteria list/,
150
+ );
151
+ });
152
+
153
+ it("merges several header criteria into one header query", () => {
154
+ assert.deepStrictEqual(
155
+ convertSearchCriteria([
156
+ ["HEADER", "Message-ID", "<a@example.com>"],
157
+ ["HEADER", "X-Remit-Trace", "abc"],
158
+ ]),
159
+ { header: { "message-id": "<a@example.com>", "x-remit-trace": "abc" } },
160
+ );
161
+ });
162
+
163
+ it("still compiles the flag and value criteria every caller uses", () => {
164
+ assert.deepStrictEqual(convertSearchCriteria(["ALL"]), {});
165
+ assert.deepStrictEqual(convertSearchCriteria(["DELETED"]), {
166
+ deleted: true,
167
+ });
168
+ assert.deepStrictEqual(convertSearchCriteria([["UID", "42"]]), {
169
+ uid: "42",
170
+ });
171
+ });
172
+
173
+ it("throws on an unrecognised string criterion instead of compiling to {}", () => {
174
+ assert.throws(
175
+ () => convertSearchCriteria(['HEADER Message-ID "<abc@example.com>"']),
176
+ /Unsupported IMAP search criterion/,
177
+ );
178
+ });
179
+
180
+ it("throws on an unrecognised keyed criterion", () => {
181
+ assert.throws(
182
+ () => convertSearchCriteria([["BODY", "invoice"]]),
183
+ /Unsupported IMAP search criterion/,
184
+ );
185
+ });
186
+
187
+ it("throws on a keyed criterion carrying the wrong number of values", () => {
188
+ assert.throws(
189
+ () => convertSearchCriteria([["UID"]]),
190
+ /Unsupported IMAP search criterion/,
191
+ );
192
+ assert.throws(
193
+ () => convertSearchCriteria([["HEADER", "Message-ID"]]),
194
+ /Unsupported IMAP search criterion/,
195
+ );
196
+ });
197
+
198
+ it("throws on a criterion that is neither a string nor an array", () => {
199
+ assert.throws(
200
+ () => convertSearchCriteria([{ header: "Message-ID" }]),
201
+ /Unsupported IMAP search criterion/,
202
+ );
203
+ });
204
+ });
205
+
115
206
  describe("ImapFlowConnection.search — the probe every stale-row reconcile rests on (#102)", () => {
116
207
  const buildSearchConnection = (
117
208
  result: unknown,
@@ -140,6 +231,21 @@ describe("ImapFlowConnection.search — the probe every stale-row reconcile rest
140
231
  ]);
141
232
  });
142
233
 
234
+ it("hands imapflow a header query for a Message-ID probe, not an empty object", async () => {
235
+ const calls: Array<{ query: unknown; options: unknown }> = [];
236
+ const connection = buildSearchConnection([], calls);
237
+ await connection.openBox("Archive", true);
238
+
239
+ await connection.search([["HEADER", "Message-ID", "<abc@example.com>"]]);
240
+
241
+ assert.deepStrictEqual(calls, [
242
+ {
243
+ query: { header: { "message-id": "<abc@example.com>" } },
244
+ options: { uid: true },
245
+ },
246
+ ]);
247
+ });
248
+
143
249
  it("answers an empty array when the server matched nothing", async () => {
144
250
  const connection = buildSearchConnection([]);
145
251
  await connection.openBox("INBOX", true);
@@ -68,6 +68,103 @@ export const toInternalDate = (value: unknown): Date | null => {
68
68
  return new Date();
69
69
  };
70
70
 
71
+ const FLAG_CRITERIA: Record<string, Record<string, boolean>> = {
72
+ ALL: {},
73
+ SEEN: { seen: true },
74
+ UNSEEN: { seen: false },
75
+ FLAGGED: { flagged: true },
76
+ UNFLAGGED: { flagged: false },
77
+ DELETED: { deleted: true },
78
+ UNDELETED: { deleted: false },
79
+ ANSWERED: { answered: true },
80
+ UNANSWERED: { answered: false },
81
+ DRAFT: { draft: true },
82
+ UNDRAFT: { draft: false },
83
+ };
84
+
85
+ const VALUE_CRITERIA: Record<string, string> = {
86
+ UID: "uid",
87
+ FROM: "from",
88
+ TO: "to",
89
+ SUBJECT: "subject",
90
+ SINCE: "since",
91
+ BEFORE: "before",
92
+ };
93
+
94
+ const unsupported = (criterion: unknown): Error =>
95
+ new Error(
96
+ `Unsupported IMAP search criterion ${JSON.stringify(criterion)} — dropping it would widen the query to SEARCH ALL`,
97
+ );
98
+
99
+ const applyValueCriterion = (
100
+ result: Record<string, unknown>,
101
+ header: Map<string, string>,
102
+ criterion: unknown[],
103
+ ): void => {
104
+ const [key, ...values] = criterion;
105
+ if (typeof key !== "string") throw unsupported(criterion);
106
+
107
+ if (key.toUpperCase() === "HEADER") {
108
+ const [name, value] = values;
109
+ if (values.length !== 2 || typeof name !== "string") {
110
+ throw unsupported(criterion);
111
+ }
112
+ if (typeof value !== "string" && value !== true) {
113
+ throw unsupported(criterion);
114
+ }
115
+ header.set(name.toLowerCase(), value === true ? "" : value);
116
+ return;
117
+ }
118
+
119
+ const field = VALUE_CRITERIA[key.toUpperCase()];
120
+ if (field === undefined || values.length !== 1) throw unsupported(criterion);
121
+ result[field] = values[0];
122
+ };
123
+
124
+ /**
125
+ * Compile node-imap style search criteria into an ImapFlow search object.
126
+ *
127
+ * A criterion this does not understand throws, and so does an empty criteria
128
+ * list. imapflow reads an empty object as `SEARCH ALL`
129
+ * (`imapflow/lib/commands/search.js`), so quietly skipping one turns a narrow
130
+ * probe into "every message in the mailbox" — the reconcile and move paths then
131
+ * act on a stranger's UID (#912). Header names accumulate in a Map so that a
132
+ * name like `__proto__` lands as a query term rather than hitting the
133
+ * prototype setter and vanishing.
134
+ *
135
+ * Values travel as structured tokens, never interpolated into a criterion
136
+ * string, so a quote or a CRLF in a header value cannot alter the query.
137
+ */
138
+ export const convertSearchCriteria = (
139
+ criteria: unknown[],
140
+ ): Record<string, unknown> => {
141
+ if (criteria.length === 0) {
142
+ throw new Error(
143
+ "Empty IMAP search criteria list — it would compile to SEARCH ALL",
144
+ );
145
+ }
146
+
147
+ const result: Record<string, unknown> = {};
148
+ const header = new Map<string, string>();
149
+
150
+ for (const criterion of criteria) {
151
+ if (typeof criterion === "string") {
152
+ const flags = FLAG_CRITERIA[criterion.toUpperCase()];
153
+ if (flags === undefined) throw unsupported(criterion);
154
+ Object.assign(result, flags);
155
+ continue;
156
+ }
157
+ if (Array.isArray(criterion)) {
158
+ applyValueCriterion(result, header, criterion);
159
+ continue;
160
+ }
161
+ throw unsupported(criterion);
162
+ }
163
+
164
+ if (header.size > 0) result.header = Object.fromEntries(header);
165
+ return result;
166
+ };
167
+
71
168
  /**
72
169
  * ImapFlow-based IMAP connection
73
170
  *
@@ -414,8 +511,7 @@ export class ImapFlowConnection {
414
511
  throw new Error("No mailbox selected");
415
512
  }
416
513
 
417
- // Convert node-imap style criteria to ImapFlow search object
418
- const searchQuery = this.convertSearchCriteria(criteria);
514
+ const searchQuery = convertSearchCriteria(criteria);
419
515
 
420
516
  const result = await this.client?.search(searchQuery, { uid: true });
421
517
  if (!Array.isArray(result)) {
@@ -424,83 +520,6 @@ export class ImapFlowConnection {
424
520
  return result;
425
521
  };
426
522
 
427
- /**
428
- * Convert node-imap style search criteria to ImapFlow format
429
- */
430
- private convertSearchCriteria = (
431
- criteria: unknown[],
432
- ): Record<string, unknown> => {
433
- const result: Record<string, unknown> = {};
434
-
435
- for (const criterion of criteria) {
436
- if (typeof criterion === "string") {
437
- // Simple flags like "ALL", "UNSEEN", etc.
438
- switch (criterion.toUpperCase()) {
439
- case "ALL":
440
- // ALL is default, no filter needed
441
- break;
442
- case "UNSEEN":
443
- result.seen = false;
444
- break;
445
- case "SEEN":
446
- result.seen = true;
447
- break;
448
- case "FLAGGED":
449
- result.flagged = true;
450
- break;
451
- case "UNFLAGGED":
452
- result.flagged = false;
453
- break;
454
- case "DELETED":
455
- result.deleted = true;
456
- break;
457
- case "UNDELETED":
458
- result.deleted = false;
459
- break;
460
- case "ANSWERED":
461
- result.answered = true;
462
- break;
463
- case "UNANSWERED":
464
- result.answered = false;
465
- break;
466
- case "DRAFT":
467
- result.draft = true;
468
- break;
469
- case "UNDRAFT":
470
- result.draft = false;
471
- break;
472
- }
473
- } else if (Array.isArray(criterion)) {
474
- // Criteria with values like ["UID", "1:*"]
475
- const [key, value] = criterion;
476
- if (typeof key === "string") {
477
- switch (key.toUpperCase()) {
478
- case "UID":
479
- result.uid = value;
480
- break;
481
- case "FROM":
482
- result.from = value;
483
- break;
484
- case "TO":
485
- result.to = value;
486
- break;
487
- case "SUBJECT":
488
- result.subject = value;
489
- break;
490
- case "SINCE":
491
- result.since = value;
492
- break;
493
- case "BEFORE":
494
- result.before = value;
495
- break;
496
- }
497
- }
498
- }
499
- }
500
-
501
- return result;
502
- };
503
-
504
523
  /**
505
524
  * Fetch messages by UID
506
525
  *
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A row the SMTP worker settled has to be a row the user can still act on.
3
+ *
4
+ * `sending` is not: `send` takes draft, failed and blocked, and `deleteDraft`
5
+ * those three plus `unfiled`, so a send that never reached the server left a
6
+ * row that 409s on both buttons forever (#951). The worker now settles that
7
+ * row instead of dead-lettering it, and these are the two moves the settled
8
+ * status has to accept for the settle to be worth anything.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type {
14
+ IAccountRepository,
15
+ IOutboxMessageRepository,
16
+ OutboxMessageItem,
17
+ } from "@remit/data-ports";
18
+ import { OutboxMessageStatus } from "@remit/domain-enums";
19
+ import type { OutboxAttachmentService } from "./outbox-attachment.js";
20
+ import { OutboxQueueService } from "./outbox-queue.js";
21
+
22
+ const ACCOUNT_CONFIG_ID = "cfg-1";
23
+ const ACCOUNT_ID = "acc-1";
24
+ const OUTBOX_MESSAGE_ID = "ob-1";
25
+
26
+ const row = (status: OutboxMessageItem["status"]): OutboxMessageItem =>
27
+ ({
28
+ outboxMessageId: OUTBOX_MESSAGE_ID,
29
+ accountId: ACCOUNT_ID,
30
+ accountConfigId: ACCOUNT_CONFIG_ID,
31
+ fromAddress: "me@example.com",
32
+ toAddresses: ["them@example.com"],
33
+ ccAddresses: [],
34
+ bccAddresses: [],
35
+ references: [],
36
+ messageIdValue: "<m1@example.com>",
37
+ lastError: "SMTP connection failed: ECONNREFUSED",
38
+ status,
39
+ createdAt: 0,
40
+ updatedAt: 0,
41
+ }) as unknown as OutboxMessageItem;
42
+
43
+ interface Harness {
44
+ service: OutboxQueueService;
45
+ enqueued: string[];
46
+ statusWrites: string[];
47
+ deleted: string[];
48
+ discarded: string[];
49
+ }
50
+
51
+ const createHarness = (stored: OutboxMessageItem): Harness => {
52
+ const harness: Harness = {
53
+ service: undefined as unknown as OutboxQueueService,
54
+ enqueued: [],
55
+ statusWrites: [],
56
+ deleted: [],
57
+ discarded: [],
58
+ };
59
+
60
+ const outboxMessageService = {
61
+ get: async () => stored,
62
+ updateStatus: async (
63
+ _configId: string,
64
+ _id: string,
65
+ status: OutboxMessageItem["status"],
66
+ ) => {
67
+ harness.statusWrites.push(status);
68
+ return { ...stored, status };
69
+ },
70
+ delete: async (_configId: string, id: string) => {
71
+ harness.deleted.push(id);
72
+ },
73
+ } as unknown as IOutboxMessageRepository;
74
+
75
+ harness.service = new OutboxQueueService({
76
+ outboxMessageService,
77
+ outboxAttachmentService: {
78
+ discardAll: async (_configId: string, _accountId: string, id: string) => {
79
+ harness.discarded.push(id);
80
+ },
81
+ } as unknown as OutboxAttachmentService,
82
+ accountService: {} as unknown as IAccountRepository,
83
+ sqsSmtpQueueUrl: "http://localhost/queue",
84
+ sqsClient: {
85
+ send: async (command: { input: { MessageBody: string } }) => {
86
+ harness.enqueued.push(command.input.MessageBody);
87
+ return {};
88
+ },
89
+ } as never,
90
+ });
91
+
92
+ return harness;
93
+ };
94
+
95
+ describe("a row the worker settled at `failed`", () => {
96
+ it("is sendable again — Retry is the whole point of settling there", async () => {
97
+ const harness = createHarness(row(OutboxMessageStatus.failed));
98
+
99
+ await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
100
+
101
+ assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
102
+ assert.equal(harness.enqueued.length, 1);
103
+ });
104
+
105
+ it("is discardable — the other way out of the Outbox", async () => {
106
+ const harness = createHarness(row(OutboxMessageStatus.failed));
107
+
108
+ await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
109
+
110
+ assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
111
+ assert.deepEqual(harness.discarded, [OUTBOX_MESSAGE_ID]);
112
+ });
113
+ });
114
+
115
+ describe("a row the worker settled at `unfiled`", () => {
116
+ it("is discardable but never sendable — the server may already hold it", async () => {
117
+ const harness = createHarness(row(OutboxMessageStatus.unfiled));
118
+
119
+ await assert.rejects(
120
+ () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
121
+ /cannot be sent again/,
122
+ );
123
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
124
+
125
+ await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
126
+ assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
127
+ });
128
+ });