@remit/mailbox-service 0.0.53 → 0.0.54
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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harvesting takes the display name straight off the envelope, and a sender
|
|
3
|
+
* chooses that string. A 2021 spam message reached this account's INBOX with
|
|
4
|
+
* `matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>`: the account's own
|
|
5
|
+
* address as the label on a stranger's. It was stored, autocomplete offered it
|
|
6
|
+
* back when the account holder typed his own address, and a private reply left
|
|
7
|
+
* the instance (issue #826).
|
|
8
|
+
*
|
|
9
|
+
* The name lands in three columns on this one save — the address book, the
|
|
10
|
+
* envelope the message header renders, and the sender label the message list
|
|
11
|
+
* shows and search indexes — so all three are asserted here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { describe, it } from "node:test";
|
|
16
|
+
import type {
|
|
17
|
+
CreateAddressInput,
|
|
18
|
+
CreateEnvelopeAddressInput,
|
|
19
|
+
CreateThreadMessageInput,
|
|
20
|
+
IAddressRepository,
|
|
21
|
+
IEnvelopeRepository,
|
|
22
|
+
IMailboxRepository,
|
|
23
|
+
IMessageRepository,
|
|
24
|
+
IThreadMessageRepository,
|
|
25
|
+
MessageItem,
|
|
26
|
+
ThreadMessageItem,
|
|
27
|
+
} from "@remit/data-ports";
|
|
28
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
29
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
30
|
+
import type { ImapAddress, ImapEnvelope, ImapMessage } from "./types.js";
|
|
31
|
+
|
|
32
|
+
const stub = <T>(): T => ({}) as T;
|
|
33
|
+
|
|
34
|
+
const emptyEnvelope: ImapEnvelope = {
|
|
35
|
+
date: new Date(0).toISOString(),
|
|
36
|
+
messageId: "<root@example.com>",
|
|
37
|
+
subject: "Subject",
|
|
38
|
+
from: [],
|
|
39
|
+
sender: [],
|
|
40
|
+
replyTo: [],
|
|
41
|
+
to: [],
|
|
42
|
+
cc: [],
|
|
43
|
+
bcc: [],
|
|
44
|
+
inReplyTo: "",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface Saved {
|
|
48
|
+
addresses: CreateAddressInput[];
|
|
49
|
+
envelopeAddresses: CreateEnvelopeAddressInput[];
|
|
50
|
+
threadMessages: CreateThreadMessageInput[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drive the real save path so the stored value is the one the write path
|
|
55
|
+
* produces, not one a test handed to a private method.
|
|
56
|
+
*/
|
|
57
|
+
const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
|
|
58
|
+
const saved: Saved = {
|
|
59
|
+
addresses: [],
|
|
60
|
+
envelopeAddresses: [],
|
|
61
|
+
threadMessages: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const messageService = {
|
|
65
|
+
upsertWithStatus: async (input: unknown) => ({
|
|
66
|
+
item: input as MessageItem,
|
|
67
|
+
created: true,
|
|
68
|
+
}),
|
|
69
|
+
} as unknown as IMessageRepository;
|
|
70
|
+
|
|
71
|
+
const threadMessageService = {
|
|
72
|
+
create: async (input: CreateThreadMessageInput) => {
|
|
73
|
+
saved.threadMessages.push(input);
|
|
74
|
+
return input as unknown as ThreadMessageItem;
|
|
75
|
+
},
|
|
76
|
+
} as unknown as IThreadMessageRepository;
|
|
77
|
+
|
|
78
|
+
const envelopeService = {
|
|
79
|
+
upsertEnvelope: async () => {},
|
|
80
|
+
upsertBodyParts: async () => {},
|
|
81
|
+
} as unknown as IEnvelopeRepository;
|
|
82
|
+
|
|
83
|
+
const addressService = {
|
|
84
|
+
upsertAddress: async (input: CreateAddressInput) => {
|
|
85
|
+
saved.addresses.push(input);
|
|
86
|
+
},
|
|
87
|
+
upsertEnvelopeAddress: async (input: CreateEnvelopeAddressInput) => {
|
|
88
|
+
saved.envelopeAddresses.push(input);
|
|
89
|
+
},
|
|
90
|
+
} as unknown as IAddressRepository;
|
|
91
|
+
|
|
92
|
+
const service = new MessageSyncService(
|
|
93
|
+
stub<ManagedConnectionFactory>(),
|
|
94
|
+
stub<IMailboxRepository>(),
|
|
95
|
+
messageService,
|
|
96
|
+
envelopeService,
|
|
97
|
+
addressService,
|
|
98
|
+
threadMessageService,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const msg = {
|
|
102
|
+
uid: 42,
|
|
103
|
+
seq: 1,
|
|
104
|
+
size: 100,
|
|
105
|
+
internalDate: new Date(0),
|
|
106
|
+
flags: [],
|
|
107
|
+
envelope: { ...emptyEnvelope, ...envelope },
|
|
108
|
+
} as unknown as ImapMessage;
|
|
109
|
+
|
|
110
|
+
await (
|
|
111
|
+
service as unknown as {
|
|
112
|
+
saveMessage: (
|
|
113
|
+
mailboxId: string,
|
|
114
|
+
accountId: string,
|
|
115
|
+
accountConfigId: string,
|
|
116
|
+
msg: ImapMessage,
|
|
117
|
+
) => Promise<unknown>;
|
|
118
|
+
}
|
|
119
|
+
).saveMessage("mbx-1", "acct-1", "cfg-1", msg);
|
|
120
|
+
|
|
121
|
+
return saved;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const spoof: ImapAddress = {
|
|
125
|
+
name: "matthijs@ischen.nl",
|
|
126
|
+
mailbox: "aramirez",
|
|
127
|
+
host: "secresaludguaviare.gov.co",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const onlyAddress = async (
|
|
131
|
+
envelope: Partial<ImapEnvelope>,
|
|
132
|
+
): Promise<CreateAddressInput> => {
|
|
133
|
+
const { addresses } = await harvest(envelope);
|
|
134
|
+
assert.equal(addresses.length, 1);
|
|
135
|
+
const [input] = addresses;
|
|
136
|
+
assert.ok(input);
|
|
137
|
+
return input;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
describe("harvesting an envelope display name", () => {
|
|
141
|
+
it("drops a name that is an address the row does not own", async () => {
|
|
142
|
+
const input = await onlyAddress({ from: [spoof] });
|
|
143
|
+
|
|
144
|
+
assert.equal(input.displayName, "");
|
|
145
|
+
assert.equal(input.normalizedEmail, "aramirez@secresaludguaviare.gov.co");
|
|
146
|
+
assert.equal(
|
|
147
|
+
input.normalizedCompound,
|
|
148
|
+
"aramirez@secresaludguaviare.gov.co",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The incident with one word prepended. An anchored guard reads this as an
|
|
154
|
+
* ordinary name and stores it, and autocomplete then ties it with the real
|
|
155
|
+
* address on the term `matthijs` and breaks the tie on correspondence. The
|
|
156
|
+
* word itself is not the lie, so it survives the address being removed.
|
|
157
|
+
*/
|
|
158
|
+
it("keeps what a name says besides the address it claims", async () => {
|
|
159
|
+
const input = await onlyAddress({
|
|
160
|
+
from: [{ ...spoof, name: "Matthijs <matthijs@ischen.nl>" }],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
assert.equal(input.displayName, "Matthijs");
|
|
164
|
+
assert.equal(
|
|
165
|
+
input.normalizedCompound,
|
|
166
|
+
"matthijs aramirez@secresaludguaviare.gov.co",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps a name a sender wrote its own support address into", async () => {
|
|
171
|
+
const input = await onlyAddress({
|
|
172
|
+
from: [
|
|
173
|
+
{
|
|
174
|
+
name: "Support (support@acme.com)",
|
|
175
|
+
mailbox: "noreply",
|
|
176
|
+
host: "acme.com",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
assert.equal(input.displayName, "Support");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("keeps a name that is the address it labels", async () => {
|
|
185
|
+
const input = await onlyAddress({
|
|
186
|
+
from: [
|
|
187
|
+
{
|
|
188
|
+
name: "ING@ing-nl-mailing.nl",
|
|
189
|
+
mailbox: "ing",
|
|
190
|
+
host: "ing-nl-mailing.nl",
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
assert.equal(input.displayName, "ING@ing-nl-mailing.nl");
|
|
196
|
+
assert.equal(
|
|
197
|
+
input.normalizedCompound,
|
|
198
|
+
"ing@ing-nl-mailing.nl ing@ing-nl-mailing.nl",
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("keeps a non-ASCII name that is the address it labels", async () => {
|
|
203
|
+
const input = await onlyAddress({
|
|
204
|
+
from: [
|
|
205
|
+
{ name: "Özcan@example.com", mailbox: "Özcan", host: "example.com" },
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
assert.equal(input.displayName, "Özcan@example.com");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("keeps an ordinary human name", async () => {
|
|
213
|
+
const input = await onlyAddress({
|
|
214
|
+
from: [
|
|
215
|
+
{ name: "Matthijs van Henten", mailbox: "matthijs", host: "ischen.nl" },
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
assert.equal(input.displayName, "Matthijs van Henten");
|
|
220
|
+
assert.equal(
|
|
221
|
+
input.normalizedCompound,
|
|
222
|
+
"matthijs van henten matthijs@ischen.nl",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("drops the name on every harvested envelope role", async () => {
|
|
227
|
+
const { addresses } = await harvest({
|
|
228
|
+
from: [spoof],
|
|
229
|
+
sender: [spoof],
|
|
230
|
+
replyTo: [spoof],
|
|
231
|
+
to: [spoof],
|
|
232
|
+
cc: [spoof],
|
|
233
|
+
bcc: [spoof],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
assert.equal(addresses.length, 6);
|
|
237
|
+
for (const input of addresses) {
|
|
238
|
+
assert.equal(input.displayName, "");
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("still harvests the address from every envelope role", async () => {
|
|
243
|
+
const named = (mailbox: string): ImapAddress[] => [
|
|
244
|
+
{ name: "Someone", mailbox, host: "example.com" },
|
|
245
|
+
];
|
|
246
|
+
const { addresses } = await harvest({
|
|
247
|
+
from: named("from"),
|
|
248
|
+
sender: named("sender"),
|
|
249
|
+
replyTo: named("reply-to"),
|
|
250
|
+
to: named("to"),
|
|
251
|
+
cc: named("cc"),
|
|
252
|
+
bcc: named("bcc"),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
addresses.map((input) => input.normalizedEmail),
|
|
257
|
+
[
|
|
258
|
+
"from@example.com",
|
|
259
|
+
"sender@example.com",
|
|
260
|
+
"reply-to@example.com",
|
|
261
|
+
"to@example.com",
|
|
262
|
+
"cc@example.com",
|
|
263
|
+
"bcc@example.com",
|
|
264
|
+
],
|
|
265
|
+
);
|
|
266
|
+
for (const input of addresses) {
|
|
267
|
+
assert.equal(input.displayName, "Someone");
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("drops the name on the envelope the message header renders", async () => {
|
|
272
|
+
const { envelopeAddresses } = await harvest({ from: [spoof] });
|
|
273
|
+
|
|
274
|
+
assert.equal(envelopeAddresses.length, 1);
|
|
275
|
+
assert.equal(envelopeAddresses[0]?.displayName, "");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("keeps the sender label's text and drops only the address", async () => {
|
|
279
|
+
const { threadMessages } = await harvest({
|
|
280
|
+
from: [{ ...spoof, name: "Support <matthijs@ischen.nl>" }],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
assert.equal(threadMessages[0]?.fromName, "Support");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("drops the sender label the message list shows", async () => {
|
|
287
|
+
const { threadMessages } = await harvest({ from: [spoof] });
|
|
288
|
+
|
|
289
|
+
assert.equal(threadMessages.length, 1);
|
|
290
|
+
assert.equal(threadMessages[0]?.fromName, undefined);
|
|
291
|
+
assert.equal(
|
|
292
|
+
threadMessages[0]?.fromEmail,
|
|
293
|
+
"aramirez@secresaludguaviare.gov.co",
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps the sender label when it is an ordinary name", async () => {
|
|
298
|
+
const { threadMessages } = await harvest({
|
|
299
|
+
from: [
|
|
300
|
+
{ name: "Alejandro Ramirez", mailbox: "aramirez", host: "example.gov" },
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
assert.equal(threadMessages[0]?.fromName, "Alejandro Ramirez");
|
|
305
|
+
});
|
|
306
|
+
});
|
package/src/message-sync.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
MailboxItem,
|
|
12
12
|
ThreadMessageItem,
|
|
13
13
|
} from "@remit/data-ports";
|
|
14
|
+
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
14
15
|
import {
|
|
15
16
|
deriveAddressId,
|
|
16
17
|
deriveBodyPartId,
|
|
@@ -85,6 +86,19 @@ export const isParseableEmailAddress = (
|
|
|
85
86
|
return host.includes(".");
|
|
86
87
|
};
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The name an envelope carries for an address, as it should be stored: any
|
|
91
|
+
* claim to be some other address removed (issue #826), the rest of the name
|
|
92
|
+
* kept. Refused wherever a name lands — the address book, the envelope the
|
|
93
|
+
* message header renders, and the sender label on the thread row.
|
|
94
|
+
*/
|
|
95
|
+
export const harvestedDisplayName = (
|
|
96
|
+
address: ImapAddress | undefined,
|
|
97
|
+
): string => {
|
|
98
|
+
if (!address?.name) return "";
|
|
99
|
+
return storedDisplayName(address.name, `${address.mailbox}@${address.host}`);
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
/**
|
|
89
103
|
* Parse an external `Date:` header into an epoch-millisecond integer.
|
|
90
104
|
*
|
|
@@ -1402,7 +1416,7 @@ export class MessageSyncService {
|
|
|
1402
1416
|
addressData.push({
|
|
1403
1417
|
localPart: addr.mailbox,
|
|
1404
1418
|
domain: addr.host,
|
|
1405
|
-
displayName: addr
|
|
1419
|
+
displayName: harvestedDisplayName(addr),
|
|
1406
1420
|
order: i,
|
|
1407
1421
|
});
|
|
1408
1422
|
}
|
|
@@ -1505,12 +1519,14 @@ export class MessageSyncService {
|
|
|
1505
1519
|
|
|
1506
1520
|
// Extract sender info. When the server could not parse the From address,
|
|
1507
1521
|
// omit fromEmail rather than persist a fabricated string — a display name
|
|
1508
|
-
// may still be present and useful, so keep it.
|
|
1522
|
+
// may still be present and useful, so keep it. This is the sender label the
|
|
1523
|
+
// message list shows and the search index tokenizes, so it takes the same
|
|
1524
|
+
// guard as the address book (issue #826).
|
|
1509
1525
|
const fromAddr = envelope.from?.[0];
|
|
1510
1526
|
const fromEmail = isParseableEmailAddress(fromAddr)
|
|
1511
1527
|
? `${fromAddr?.mailbox}@${fromAddr?.host}`.toLowerCase()
|
|
1512
1528
|
: undefined;
|
|
1513
|
-
const fromName = fromAddr
|
|
1529
|
+
const fromName = harvestedDisplayName(fromAddr) || undefined;
|
|
1514
1530
|
|
|
1515
1531
|
// Calculate reference order (position in the thread chain)
|
|
1516
1532
|
// references.length gives the position since References = [root, parent1, parent2, ...]
|