@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,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
|
-
|
|
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
|
+
});
|