@remit/mailbox-service 0.0.69 → 0.0.71

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.69",
3
+ "version": "0.0.71",
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
  *
@@ -231,7 +231,21 @@ export class OutboxQueueService {
231
231
  OutboxMessageStatus.queued,
232
232
  );
233
233
 
234
- await this.enqueueSend(existing.accountId, outboxMessageId);
234
+ // `queued` is a dead end for a row the queue never accepted: `send` takes
235
+ // draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
236
+ // row parked at `queued` by a failed enqueue is neither sendable nor
237
+ // discardable (#845.8). Put it back where it came from and let the enqueue
238
+ // failure surface — the row stays reachable, the caller still hears no.
239
+ await this.enqueueSend(existing.accountId, outboxMessageId).catch(
240
+ async (error: unknown) => {
241
+ await this.outboxMessageService.updateStatus(
242
+ accountConfigId,
243
+ outboxMessageId,
244
+ existing.status,
245
+ );
246
+ throw error;
247
+ },
248
+ );
235
249
 
236
250
  this.log.info(
237
251
  { outboxMessageId, accountId: existing.accountId },