@remit/mailbox-service 0.0.70 → 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 +1 -1
- package/src/imapflow-connection.test.ts +106 -0
- package/src/imapflow-connection.ts +98 -79
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
|
*
|