@remit/mailbox-service 0.0.62 → 0.0.64
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
|
@@ -71,7 +71,7 @@ describe("classifyDisplayNameCorrespondence", () => {
|
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("claims nothing when the display name is
|
|
74
|
+
it("claims nothing when the display name is an address at the sending domain", () => {
|
|
75
75
|
assert.equal(
|
|
76
76
|
classifyDisplayNameCorrespondence(
|
|
77
77
|
"billing@serviceupdatebank.atlassian.net",
|
|
@@ -81,6 +81,60 @@ describe("classifyDisplayNameCorrespondence", () => {
|
|
|
81
81
|
);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it("claims nothing when the display name spells the envelope address itself", () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
classifyDisplayNameCorrespondence("matthijs@ischen.nl", "ischen.nl"),
|
|
87
|
+
DisplayNameCorrespondence.NoClaim,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("claims nothing when the display name is an address on a sibling subdomain", () => {
|
|
92
|
+
assert.equal(
|
|
93
|
+
classifyDisplayNameCorrespondence(
|
|
94
|
+
"noreply@example.co.uk",
|
|
95
|
+
"mail.example.co.uk",
|
|
96
|
+
),
|
|
97
|
+
DisplayNameCorrespondence.NoClaim,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("is a foreign address when the display name spells an address over another domain", () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
classifyDisplayNameCorrespondence(
|
|
104
|
+
"matthijs@ischen.nl",
|
|
105
|
+
"secresaludguaviare.gov.co",
|
|
106
|
+
),
|
|
107
|
+
DisplayNameCorrespondence.ForeignAddress,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is a foreign address when the spelled-out address is decorated with a name", () => {
|
|
112
|
+
assert.equal(
|
|
113
|
+
classifyDisplayNameCorrespondence(
|
|
114
|
+
"Matthijs (matthijs@ischen.nl)",
|
|
115
|
+
"secresaludguaviare.gov.co",
|
|
116
|
+
),
|
|
117
|
+
DisplayNameCorrespondence.ForeignAddress,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("compares an at sign that spells no address as an ordinary name", () => {
|
|
122
|
+
assert.equal(
|
|
123
|
+
classifyDisplayNameCorrespondence(
|
|
124
|
+
"Support @ InfoMedics",
|
|
125
|
+
"infomedics.nl",
|
|
126
|
+
),
|
|
127
|
+
DisplayNameCorrespondence.Corresponds,
|
|
128
|
+
);
|
|
129
|
+
assert.equal(
|
|
130
|
+
classifyDisplayNameCorrespondence(
|
|
131
|
+
"Support @ InfoMedics",
|
|
132
|
+
"serviceupdatebank.atlassian.net",
|
|
133
|
+
),
|
|
134
|
+
DisplayNameCorrespondence.Unrelated,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
84
138
|
it("does not read the public suffix as a match for a brand containing it", () => {
|
|
85
139
|
assert.equal(
|
|
86
140
|
classifyDisplayNameCorrespondence("Netflix", "mailer.example.net"),
|
|
@@ -213,6 +267,25 @@ describe("extractSenderMismatch", () => {
|
|
|
213
267
|
);
|
|
214
268
|
});
|
|
215
269
|
|
|
270
|
+
it("flags a display name spelling the recipient's own address over a foreign domain", async () => {
|
|
271
|
+
const parsed = await parse([
|
|
272
|
+
"From: matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>",
|
|
273
|
+
"To: matthijs@ischen.nl",
|
|
274
|
+
"Subject: Re: factura",
|
|
275
|
+
"X-HalOne-Spam-Probability: 1",
|
|
276
|
+
"",
|
|
277
|
+
"hola",
|
|
278
|
+
]);
|
|
279
|
+
assert.equal(
|
|
280
|
+
extractSenderMismatch(parsed, {
|
|
281
|
+
fromDomain: "secresaludguaviare.gov.co",
|
|
282
|
+
spamClassified: true,
|
|
283
|
+
bulkSender: false,
|
|
284
|
+
}).displayNameCorrespondence,
|
|
285
|
+
DisplayNameCorrespondence.ForeignAddress,
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
216
289
|
it("leaves the display name uncompared for a bulk sender", async () => {
|
|
217
290
|
const parsed = await parse([
|
|
218
291
|
"From: Dutch Cycling Weekly <bounce-9f2@mailer.esp.example>",
|
|
@@ -76,6 +76,22 @@ const editDistance = (a: string, b: string): number => {
|
|
|
76
76
|
return previous[b.length];
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
const ADDRESS_SHAPE = /[^\s<>@,;:"]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/i;
|
|
80
|
+
|
|
81
|
+
const registrableDomain = (host: string): string =>
|
|
82
|
+
getDomain(host) ?? host.toLowerCase();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The registrable domain of the address a display name spells out, or
|
|
86
|
+
* `undefined` when it holds an `@` without an address behind it — `Bob @ Acme`
|
|
87
|
+
* asserts no address and is left to the ordinary comparison.
|
|
88
|
+
*/
|
|
89
|
+
const claimedAddressDomain = (displayName: string): string | undefined => {
|
|
90
|
+
const host = ADDRESS_SHAPE.exec(displayName)?.[1];
|
|
91
|
+
if (host === undefined) return undefined;
|
|
92
|
+
return registrableDomain(host);
|
|
93
|
+
};
|
|
94
|
+
|
|
79
95
|
/**
|
|
80
96
|
* The distance a name of this length may be from a domain label and still be
|
|
81
97
|
* read as an imitation of it. Tight on purpose: an unrelated brand name and an
|
|
@@ -109,14 +125,27 @@ const lookalikeThreshold = (length: number): number => {
|
|
|
109
125
|
* A bounded edit distance is the secondary test, and only reaches names that
|
|
110
126
|
* nearly match a label — `InfoMedics` against `1nfomedics.nl`. It cannot promote
|
|
111
127
|
* an unrelated name on its own.
|
|
128
|
+
*
|
|
129
|
+
* A name that spells out an address is decided before any of that. Over the
|
|
130
|
+
* sending domain it claims nothing — `billing@` shown over
|
|
131
|
+
* `serviceupdatebank.atlassian.net` names the same party the envelope does.
|
|
132
|
+
* Over any other registrable domain it is the strongest non-correspondence
|
|
133
|
+
* signal there is: the message asserts, in the one field the recipient reads,
|
|
134
|
+
* that it comes from an address that cannot have sent it.
|
|
112
135
|
*/
|
|
113
136
|
export const classifyDisplayNameCorrespondence = (
|
|
114
137
|
displayName: string | undefined,
|
|
115
138
|
fromDomain: string,
|
|
116
139
|
): CorrespondenceValue => {
|
|
117
140
|
const raw = (displayName ?? "").trim();
|
|
118
|
-
if (raw === ""
|
|
119
|
-
|
|
141
|
+
if (raw === "") return DisplayNameCorrespondence.NoClaim;
|
|
142
|
+
|
|
143
|
+
const claimed = claimedAddressDomain(raw);
|
|
144
|
+
if (claimed !== undefined) {
|
|
145
|
+
if (claimed === registrableDomain(fromDomain)) {
|
|
146
|
+
return DisplayNameCorrespondence.NoClaim;
|
|
147
|
+
}
|
|
148
|
+
return DisplayNameCorrespondence.ForeignAddress;
|
|
120
149
|
}
|
|
121
150
|
|
|
122
151
|
const name = normalize(raw);
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -342,6 +342,152 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
342
342
|
});
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
+
describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the user keeps mail in (#837)", () => {
|
|
346
|
+
const namespaces: ImapNamespaces = {
|
|
347
|
+
personal: [{ prefix: "", delimiter: "/" }],
|
|
348
|
+
other: [],
|
|
349
|
+
shared: [],
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const serverConnection = (
|
|
353
|
+
folders: Array<{ fullPath: string; attributes: string[] }>,
|
|
354
|
+
): IImapConnection =>
|
|
355
|
+
({
|
|
356
|
+
getNamespaces: async () => namespaces,
|
|
357
|
+
listMailboxes: async () =>
|
|
358
|
+
folders.map((folder) => ({
|
|
359
|
+
fullPath: folder.fullPath,
|
|
360
|
+
name: folder.fullPath.split("/").pop() ?? folder.fullPath,
|
|
361
|
+
delimiter: "/",
|
|
362
|
+
attributes: folder.attributes,
|
|
363
|
+
parentPath: null,
|
|
364
|
+
})),
|
|
365
|
+
getMailboxStatus: async () => ({
|
|
366
|
+
messages: 0,
|
|
367
|
+
recent: 0,
|
|
368
|
+
unseen: 0,
|
|
369
|
+
uidNext: 1,
|
|
370
|
+
uidValidity: 1,
|
|
371
|
+
highestModseq: "0",
|
|
372
|
+
deletedCount: 0,
|
|
373
|
+
}),
|
|
374
|
+
}) as unknown as IImapConnection;
|
|
375
|
+
|
|
376
|
+
const buildServices = (
|
|
377
|
+
existing: Array<{ mailboxId: string; fullPath: string }>,
|
|
378
|
+
) => {
|
|
379
|
+
const deleted: string[] = [];
|
|
380
|
+
const mailboxService = {
|
|
381
|
+
listByAccount: async () => ({
|
|
382
|
+
items: existing.map((m) => ({
|
|
383
|
+
mailboxId: m.mailboxId,
|
|
384
|
+
fullPath: m.fullPath,
|
|
385
|
+
uidNext: 1,
|
|
386
|
+
uidValidity: 1,
|
|
387
|
+
messageCount: 0,
|
|
388
|
+
unseenCount: 0,
|
|
389
|
+
deletedCount: 0,
|
|
390
|
+
highestModseq: "0",
|
|
391
|
+
specialUse: undefined,
|
|
392
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
393
|
+
})),
|
|
394
|
+
continuationToken: undefined,
|
|
395
|
+
}),
|
|
396
|
+
update: async () => ({}),
|
|
397
|
+
delete: async (_accountId: string, mailboxId: string) => {
|
|
398
|
+
deleted.push(mailboxId);
|
|
399
|
+
},
|
|
400
|
+
create: async () => ({}),
|
|
401
|
+
} as unknown as IMailboxRepository;
|
|
402
|
+
|
|
403
|
+
const specialUseService = {
|
|
404
|
+
listByMailboxId: async () => [],
|
|
405
|
+
deleteByMailboxId: async () => undefined,
|
|
406
|
+
createMany: async () => undefined,
|
|
407
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
408
|
+
|
|
409
|
+
return { mailboxService, specialUseService, deleted };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const syncWith = async (
|
|
413
|
+
folders: Array<{ fullPath: string; attributes: string[] }>,
|
|
414
|
+
existing: Array<{ mailboxId: string; fullPath: string }>,
|
|
415
|
+
): Promise<string[]> => {
|
|
416
|
+
const { mailboxService, specialUseService, deleted } =
|
|
417
|
+
buildServices(existing);
|
|
418
|
+
const service = new MailboxSyncService(
|
|
419
|
+
mailboxService,
|
|
420
|
+
specialUseService,
|
|
421
|
+
silentLogger,
|
|
422
|
+
);
|
|
423
|
+
await service.syncMailboxes(
|
|
424
|
+
{ accountId: "acc-1" },
|
|
425
|
+
serverConnection(folders),
|
|
426
|
+
);
|
|
427
|
+
return deleted;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
it("keeps a folder called Deleted beside the folder the server flagged", async () => {
|
|
431
|
+
// `deleted` and `bin` were Trash names in this file's own copy of the hint
|
|
432
|
+
// table long after #843 dropped them from the shared one. Every sync of an
|
|
433
|
+
// account holding a flagged Trash and a user folder called `Deleted` took
|
|
434
|
+
// the user's folder, and its mail, out of the client.
|
|
435
|
+
const deleted = await syncWith(
|
|
436
|
+
[
|
|
437
|
+
{ fullPath: "INBOX", attributes: [] },
|
|
438
|
+
{ fullPath: "Trash", attributes: ["\\Trash"] },
|
|
439
|
+
{ fullPath: "Deleted", attributes: [] },
|
|
440
|
+
{ fullPath: "Bin", attributes: [] },
|
|
441
|
+
],
|
|
442
|
+
[
|
|
443
|
+
{ mailboxId: "inbox", fullPath: "INBOX" },
|
|
444
|
+
{ mailboxId: "trash", fullPath: "Trash" },
|
|
445
|
+
{ mailboxId: "keepsakes", fullPath: "Deleted" },
|
|
446
|
+
{ mailboxId: "bin", fullPath: "Bin" },
|
|
447
|
+
],
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
assert.deepEqual(deleted, []);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("still hides the unflagged twin of a folder the server flagged", async () => {
|
|
454
|
+
const deleted = await syncWith(
|
|
455
|
+
[
|
|
456
|
+
{ fullPath: "INBOX", attributes: [] },
|
|
457
|
+
{ fullPath: "[Gmail]/Trash", attributes: ["\\Trash"] },
|
|
458
|
+
{ fullPath: "Trash", attributes: [] },
|
|
459
|
+
],
|
|
460
|
+
[
|
|
461
|
+
{ mailboxId: "inbox", fullPath: "INBOX" },
|
|
462
|
+
{ mailboxId: "gmail-trash", fullPath: "[Gmail]/Trash" },
|
|
463
|
+
{ mailboxId: "lookalike", fullPath: "Trash" },
|
|
464
|
+
],
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
assert.deepEqual(deleted, ["lookalike"]);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("keeps a folder that holds a role of its own", async () => {
|
|
471
|
+
// `all mail` is a conventional name for Archive as well as for All, and the
|
|
472
|
+
// account already has a flagged Archive. The folder holds the All role, so
|
|
473
|
+
// it is a folder in its own right rather than a lookalike of Archive.
|
|
474
|
+
const deleted = await syncWith(
|
|
475
|
+
[
|
|
476
|
+
{ fullPath: "INBOX", attributes: [] },
|
|
477
|
+
{ fullPath: "Archive", attributes: ["\\Archive"] },
|
|
478
|
+
{ fullPath: "INBOX/All Mail", attributes: [] },
|
|
479
|
+
],
|
|
480
|
+
[
|
|
481
|
+
{ mailboxId: "inbox", fullPath: "INBOX" },
|
|
482
|
+
{ mailboxId: "archive", fullPath: "Archive" },
|
|
483
|
+
{ mailboxId: "all-mail", fullPath: "INBOX/All Mail" },
|
|
484
|
+
],
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
assert.deepEqual(deleted, []);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
345
491
|
/**
|
|
346
492
|
* The folder set can also change under a running sweep: a delete asked for while
|
|
347
493
|
* the account is enumerating lands between the LIST and one folder's STATUS.
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -10,9 +10,15 @@ import type {
|
|
|
10
10
|
IMailboxSpecialUseRepository,
|
|
11
11
|
MailboxItem,
|
|
12
12
|
} from "@remit/data-ports";
|
|
13
|
+
import {
|
|
14
|
+
CANONICAL_ROLES,
|
|
15
|
+
type CanonicalMailboxRoleValue,
|
|
16
|
+
ROLE_NAME_HINTS,
|
|
17
|
+
ROLE_SPECIAL_USE,
|
|
18
|
+
} from "@remit/data-ports/folder-role";
|
|
13
19
|
import {
|
|
14
20
|
MailboxCursorState,
|
|
15
|
-
MailboxSpecialUse,
|
|
21
|
+
type MailboxSpecialUse,
|
|
16
22
|
MailboxSyncStatus,
|
|
17
23
|
NamespaceType,
|
|
18
24
|
} from "@remit/domain-enums";
|
|
@@ -51,23 +57,27 @@ const areSpecialUseSetsEqual = (
|
|
|
51
57
|
};
|
|
52
58
|
|
|
53
59
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
60
|
+
* The role a folder's own leaf name is most conventionally for, read from the
|
|
61
|
+
* one hint table every special-folder lookup shares (`@remit/data-ports`,
|
|
62
|
+
* #837). A name that several roles list goes to the role that ranks it highest,
|
|
63
|
+
* so `All Mail` is an All folder rather than a lookalike of Archive.
|
|
64
|
+
*
|
|
65
|
+
* A second copy of these names lived here, and it is the one that could destroy
|
|
66
|
+
* something: it still called `Deleted` and `Bin` Trash names, which #843 dropped
|
|
67
|
+
* precisely because they are ordinary folders a user keeps mail in — and this
|
|
68
|
+
* lookup does not merely skip a folder, it deletes the row and everything the
|
|
69
|
+
* client can see in it.
|
|
56
70
|
*/
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
junk: MailboxSpecialUse.Junk,
|
|
68
|
-
spam: MailboxSpecialUse.Junk,
|
|
69
|
-
archive: MailboxSpecialUse.Archive,
|
|
70
|
-
archives: MailboxSpecialUse.Archive,
|
|
71
|
+
const roleForFolderName = (
|
|
72
|
+
name: string,
|
|
73
|
+
): CanonicalMailboxRoleValue | undefined => {
|
|
74
|
+
let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
|
|
75
|
+
for (const role of CANONICAL_ROLES) {
|
|
76
|
+
const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
|
|
77
|
+
if (rank < 0) continue;
|
|
78
|
+
if (!best || rank < best.rank) best = { role, rank };
|
|
79
|
+
}
|
|
80
|
+
return best?.role;
|
|
71
81
|
};
|
|
72
82
|
|
|
73
83
|
/**
|
|
@@ -451,8 +461,8 @@ export class MailboxSyncService {
|
|
|
451
461
|
|
|
452
462
|
const mailbox = await this.mailboxService.create(input);
|
|
453
463
|
|
|
454
|
-
// Keep the MailboxSpecialUseEntry table in sync —
|
|
455
|
-
//
|
|
464
|
+
// Keep the MailboxSpecialUseEntry table in sync — every backend and worker
|
|
465
|
+
// special-folder lookup reads it through MailboxSpecialUseRepo. Denormalized
|
|
456
466
|
// copy on Mailbox is the read-side optimization, the entries remain the
|
|
457
467
|
// authoritative join source for cross-mailbox lookups.
|
|
458
468
|
if (parsed.specialUse.length > 0) {
|
|
@@ -653,21 +663,28 @@ export class MailboxSyncService {
|
|
|
653
663
|
const folderName = mailbox.fullPath.split(mailbox.delimiter).pop() ?? "";
|
|
654
664
|
const normalizedName = folderName.toLowerCase();
|
|
655
665
|
|
|
656
|
-
// Check if this folder name
|
|
657
|
-
const
|
|
658
|
-
if (!
|
|
666
|
+
// Check if this folder name is a conventional name for a role
|
|
667
|
+
const role = roleForFolderName(normalizedName);
|
|
668
|
+
if (!role) {
|
|
659
669
|
return false; // Not a special-use folder name
|
|
660
670
|
}
|
|
661
671
|
|
|
672
|
+
const expectedSpecialUse = ROLE_SPECIAL_USE[role];
|
|
673
|
+
if (!expectedSpecialUse) {
|
|
674
|
+
return false; // The role has no SPECIAL-USE flag to be a duplicate of
|
|
675
|
+
}
|
|
676
|
+
|
|
662
677
|
// Check if this mailbox has the special-use attribute
|
|
663
678
|
const parsed = parseImapAttributes(mailbox.attributes);
|
|
664
|
-
if (parsed.specialUse.
|
|
679
|
+
if (parsed.specialUse.some((use) => use === expectedSpecialUse)) {
|
|
665
680
|
return false; // This IS the canonical folder
|
|
666
681
|
}
|
|
667
682
|
|
|
668
683
|
// Check if another folder already claimed this special-use
|
|
669
|
-
const
|
|
670
|
-
|
|
684
|
+
const claimed = [...claimedSpecialUse].find(
|
|
685
|
+
([specialUse]) => specialUse === expectedSpecialUse,
|
|
686
|
+
);
|
|
687
|
+
if (!claimed) {
|
|
671
688
|
return false; // No other folder has this special-use
|
|
672
689
|
}
|
|
673
690
|
|
|
@@ -675,7 +692,7 @@ export class MailboxSyncService {
|
|
|
675
692
|
this.log.debug(
|
|
676
693
|
{
|
|
677
694
|
fullPath: mailbox.fullPath,
|
|
678
|
-
claimedPath,
|
|
695
|
+
claimedPath: claimed[1],
|
|
679
696
|
specialUse: expectedSpecialUse,
|
|
680
697
|
},
|
|
681
698
|
"Skipping duplicate folder",
|