@remit/mailbox-service 0.0.101 → 0.0.103
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/body-sync-auto-read-writeonce.test.ts +3 -3
- package/src/body-sync-calendar-suggestion.test.ts +1 -1
- package/src/body-sync-category-denormalize.test.ts +2 -2
- package/src/body-sync-category-override.test.ts +9 -9
- package/src/body-sync-category-writeonce.test.ts +3 -3
- package/src/body-sync-filter.test.ts +1 -1
- package/src/body-sync-placement-flags.test.ts +1 -1
- package/src/body-sync-placement-writeonce.test.ts +4 -4
- package/src/body-sync-quarantine.test.ts +7 -3
- package/src/body-sync-resync.test.ts +5 -5
- package/src/body-sync-unclassifiable-once.test.ts +6 -2
- package/src/body-sync-unsubscribed-auto-read.test.ts +1 -1
- package/src/body-sync.ts +45 -21
- package/src/classification-backfill.test.ts +72 -0
- package/src/classification-backfill.ts +18 -0
- package/src/classify-message.test.ts +59 -0
- package/src/classify-message.ts +5 -2
- package/src/folder-placement.ts +56 -0
- package/src/heuristics/senderMismatch.test.ts +44 -6
- package/src/heuristics/senderMismatch.ts +10 -4
- package/src/index.ts +6 -0
- package/src/mailbox-management.test.ts +48 -3
- package/src/mailbox-management.ts +25 -0
- package/src/mailbox-queue.ts +7 -5
- package/src/mailbox-sync.test.ts +52 -5
- package/src/mailbox-sync.ts +13 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IAccountConfigRepository,
|
|
3
|
+
IAccountRepository,
|
|
3
4
|
IAddressRepository,
|
|
4
5
|
IMessageRepository,
|
|
5
6
|
IThreadMessageRepository,
|
|
@@ -11,6 +12,11 @@ import { parseMessageBody } from "./body-parse.js";
|
|
|
11
12
|
import { extractSnippet } from "./body-sync.js";
|
|
12
13
|
import { classifyParsedMessage } from "./classify-message.js";
|
|
13
14
|
import { extractListId } from "./filters/list-id.js";
|
|
15
|
+
import {
|
|
16
|
+
type FolderPlacementLookup,
|
|
17
|
+
resolveAccountFolderPlacements,
|
|
18
|
+
} from "./folder-placement.js";
|
|
19
|
+
import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
|
|
14
20
|
import {
|
|
15
21
|
denormalizeMessageCategory,
|
|
16
22
|
hasDecidedCategory,
|
|
@@ -44,6 +50,8 @@ export interface ClassificationBackfillCheckpointStore {
|
|
|
44
50
|
|
|
45
51
|
export interface ClassificationBackfillDeps {
|
|
46
52
|
accountConfigService: Pick<IAccountConfigRepository, "listAll">;
|
|
53
|
+
accountService: Pick<IAccountRepository, "listAllByAccountConfig">;
|
|
54
|
+
mailboxSpecialUseService: FolderPlacementLookup;
|
|
47
55
|
/** Reads the sender's `Address.flags.category` override (issue #299). */
|
|
48
56
|
addressService: Pick<IAddressRepository, "getAddress">;
|
|
49
57
|
threadMessageService: Pick<
|
|
@@ -116,6 +124,7 @@ const classifyAndApply = async (
|
|
|
116
124
|
accountConfigId: string,
|
|
117
125
|
row: ThreadMessageItem,
|
|
118
126
|
bodyStorageKey: string,
|
|
127
|
+
placement: FolderPlacement,
|
|
119
128
|
): Promise<void> => {
|
|
120
129
|
const body = await deps.storageService.retrieve(bodyStorageKey);
|
|
121
130
|
const parsed = await parseMessageBody(body);
|
|
@@ -123,6 +132,7 @@ const classifyAndApply = async (
|
|
|
123
132
|
deps.addressService,
|
|
124
133
|
accountConfigId,
|
|
125
134
|
parsed,
|
|
135
|
+
placement,
|
|
126
136
|
);
|
|
127
137
|
|
|
128
138
|
// The same denormalized fields the body-store path writes (see
|
|
@@ -223,6 +233,13 @@ export const backfillClassifications = async (
|
|
|
223
233
|
const account = accounts[accountIndex];
|
|
224
234
|
let continuationToken: string | undefined =
|
|
225
235
|
accountIndex === startIndex ? startContinuationToken : undefined;
|
|
236
|
+
const mailAccounts = await deps.accountService.listAllByAccountConfig(
|
|
237
|
+
account.accountConfigId,
|
|
238
|
+
);
|
|
239
|
+
const placements = await resolveAccountFolderPlacements(
|
|
240
|
+
deps.mailboxSpecialUseService,
|
|
241
|
+
mailAccounts.map((mailAccount) => mailAccount.accountId),
|
|
242
|
+
);
|
|
226
243
|
|
|
227
244
|
do {
|
|
228
245
|
const page = await deps.threadMessageService.listByAccount(
|
|
@@ -297,6 +314,7 @@ export const backfillClassifications = async (
|
|
|
297
314
|
account.accountConfigId,
|
|
298
315
|
row,
|
|
299
316
|
message.bodyStorageKey,
|
|
317
|
+
placements.get(row.mailboxId) ?? "other",
|
|
300
318
|
).then(
|
|
301
319
|
() => null,
|
|
302
320
|
(error: unknown) => error,
|
|
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
|
|
|
13
13
|
import { describe, it } from "node:test";
|
|
14
14
|
import type { AddressItem, IAddressRepository } from "@remit/data-ports";
|
|
15
15
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
16
|
+
import { DisplayNameCorrespondence } from "@remit/domain-enums";
|
|
16
17
|
import { parseMessageBody } from "./body-parse.js";
|
|
17
18
|
import {
|
|
18
19
|
classifyParsedMessage,
|
|
@@ -43,6 +44,18 @@ const LIST_EML = Buffer.from(
|
|
|
43
44
|
].join("\r\n"),
|
|
44
45
|
);
|
|
45
46
|
|
|
47
|
+
const IMPERSONATION_EML = Buffer.from(
|
|
48
|
+
[
|
|
49
|
+
"From: InfoMedics <billing@serviceupdatebank.atlassian.net>",
|
|
50
|
+
"To: me@example.com",
|
|
51
|
+
"Subject: Openstaande factuur",
|
|
52
|
+
"DKIM-Signature: v=1; a=rsa-sha256; d=atlassian.net; s=sel; b=xxx",
|
|
53
|
+
"Content-Type: text/html",
|
|
54
|
+
"",
|
|
55
|
+
'<a href="https://betaal-vordering.example.org/pay">Betaal uw factuur</a>',
|
|
56
|
+
].join("\r\n"),
|
|
57
|
+
);
|
|
58
|
+
|
|
46
59
|
const addressService = (
|
|
47
60
|
behavior: (accountConfigId: string, addressId: string) => unknown,
|
|
48
61
|
): Pick<IAddressRepository, "getAddress"> => ({
|
|
@@ -68,6 +81,7 @@ describe("classifyParsedMessage", () => {
|
|
|
68
81
|
}),
|
|
69
82
|
"acc-1",
|
|
70
83
|
parsed,
|
|
84
|
+
"inbox",
|
|
71
85
|
);
|
|
72
86
|
|
|
73
87
|
assert.equal(classification.category, "newsletter");
|
|
@@ -81,6 +95,7 @@ describe("classifyParsedMessage", () => {
|
|
|
81
95
|
addressService(withFlags({ category: { value: "marketing", setAt: 1 } })),
|
|
82
96
|
"acc-1",
|
|
83
97
|
parsed,
|
|
98
|
+
"inbox",
|
|
84
99
|
);
|
|
85
100
|
|
|
86
101
|
// The header table alone would answer `personal`; the override wins
|
|
@@ -98,6 +113,7 @@ describe("classifyParsedMessage", () => {
|
|
|
98
113
|
}),
|
|
99
114
|
"acc-1",
|
|
100
115
|
parsed,
|
|
116
|
+
"inbox",
|
|
101
117
|
),
|
|
102
118
|
/throttled/,
|
|
103
119
|
);
|
|
@@ -124,6 +140,7 @@ describe("classifyParsedMessage", () => {
|
|
|
124
140
|
}),
|
|
125
141
|
"acc-1",
|
|
126
142
|
parsed,
|
|
143
|
+
"inbox",
|
|
127
144
|
);
|
|
128
145
|
|
|
129
146
|
assert.equal(reads, 0);
|
|
@@ -131,6 +148,48 @@ describe("classifyParsedMessage", () => {
|
|
|
131
148
|
});
|
|
132
149
|
});
|
|
133
150
|
|
|
151
|
+
describe("classifyParsedMessage sender mismatch", () => {
|
|
152
|
+
const noAddress = addressService(async () => {
|
|
153
|
+
throw new NotFoundError("no Address row");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("compares the sender of mail in Junk the provider rated clean", async () => {
|
|
157
|
+
const parsed = await parseMessageBody(IMPERSONATION_EML);
|
|
158
|
+
|
|
159
|
+
const classification = await classifyParsedMessage(
|
|
160
|
+
noAddress,
|
|
161
|
+
"acc-1",
|
|
162
|
+
parsed,
|
|
163
|
+
"junk",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
assert.equal(
|
|
167
|
+
classification.authenticity?.displayNameCorrespondence,
|
|
168
|
+
DisplayNameCorrespondence.Unrelated,
|
|
169
|
+
);
|
|
170
|
+
assert.deepEqual(classification.authenticity?.offDomainLinkDomains, [
|
|
171
|
+
"example.org",
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("compares nothing for the same mail outside Junk without a provider verdict", async () => {
|
|
176
|
+
const parsed = await parseMessageBody(IMPERSONATION_EML);
|
|
177
|
+
|
|
178
|
+
const classification = await classifyParsedMessage(
|
|
179
|
+
noAddress,
|
|
180
|
+
"acc-1",
|
|
181
|
+
parsed,
|
|
182
|
+
"inbox",
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
assert.equal(
|
|
186
|
+
classification.authenticity?.displayNameCorrespondence,
|
|
187
|
+
undefined,
|
|
188
|
+
);
|
|
189
|
+
assert.equal(classification.authenticity?.offDomainLinkDomains, undefined);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
134
193
|
describe("extractPrimaryFromEmail", () => {
|
|
135
194
|
it("reads the first From address", async () => {
|
|
136
195
|
const parsed = await parseMessageBody(PLAIN_EML);
|
package/src/classify-message.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
extractHasListUnsubscribe,
|
|
14
14
|
extractProviderSpam,
|
|
15
15
|
} from "./heuristics/classifyByHeaders.js";
|
|
16
|
+
import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
|
|
16
17
|
import { extractSenderMismatch } from "./heuristics/senderMismatch.js";
|
|
17
18
|
|
|
18
19
|
type ThreadMessageCategory = ThreadMessageItem["category"];
|
|
@@ -75,6 +76,7 @@ export const classifyParsedMessage = async (
|
|
|
75
76
|
addressService: Pick<IAddressRepository, "getAddress">,
|
|
76
77
|
accountConfigId: string,
|
|
77
78
|
parsed: ParsedMail,
|
|
79
|
+
placement: FolderPlacement,
|
|
78
80
|
): Promise<UpdateMessageInput & { category: ThreadMessageCategory }> => {
|
|
79
81
|
const headerCategory = classifyByHeaders(parsed);
|
|
80
82
|
const authenticity = extractAuthenticity(parsed);
|
|
@@ -86,13 +88,14 @@ export const classifyParsedMessage = async (
|
|
|
86
88
|
// identity the message claims — on a shared-tenant host the verified
|
|
87
89
|
// subdomain belongs to whoever signed up. These two comparisons say
|
|
88
90
|
// whether the claim holds, and run only over mail the provider already
|
|
89
|
-
// called spam.
|
|
91
|
+
// called spam or that sits in the Junk folder.
|
|
90
92
|
const senderMismatch =
|
|
91
93
|
authenticity === null
|
|
92
94
|
? {}
|
|
93
95
|
: extractSenderMismatch(parsed, {
|
|
94
96
|
fromDomain: authenticity.fromDomain,
|
|
95
|
-
|
|
97
|
+
providerSpamClassified: providerSpam?.classified === true,
|
|
98
|
+
placement,
|
|
96
99
|
bulkSender: hasListUnsubscribe,
|
|
97
100
|
});
|
|
98
101
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
|
|
2
|
+
import type { FolderPlacement } from "./heuristics/classifyPlacement.js";
|
|
3
|
+
|
|
4
|
+
export type FolderPlacementLookup = Pick<
|
|
5
|
+
IMailboxSpecialUseRepository,
|
|
6
|
+
"findJunkMailbox" | "findInboxMailbox"
|
|
7
|
+
>;
|
|
8
|
+
|
|
9
|
+
type RoleMailbox = Awaited<
|
|
10
|
+
ReturnType<IMailboxSpecialUseRepository["findJunkMailbox"]>
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export interface FolderRoleMailboxes {
|
|
14
|
+
junkMailbox: RoleMailbox;
|
|
15
|
+
inboxMailbox: RoleMailbox;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const resolveFolderRoleMailboxes = async (
|
|
19
|
+
lookup: FolderPlacementLookup,
|
|
20
|
+
accountId: string,
|
|
21
|
+
): Promise<FolderRoleMailboxes> => ({
|
|
22
|
+
junkMailbox: await lookup.findJunkMailbox(accountId),
|
|
23
|
+
inboxMailbox: await lookup.findInboxMailbox(accountId),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const placementOf = (
|
|
27
|
+
mailboxId: string,
|
|
28
|
+
{ junkMailbox, inboxMailbox }: FolderRoleMailboxes,
|
|
29
|
+
): FolderPlacement => {
|
|
30
|
+
if (junkMailbox?.mailboxId === mailboxId) return "junk";
|
|
31
|
+
if (inboxMailbox?.mailboxId === mailboxId) return "inbox";
|
|
32
|
+
return "other";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const resolveFolderPlacement = async (
|
|
36
|
+
lookup: FolderPlacementLookup,
|
|
37
|
+
accountId: string,
|
|
38
|
+
mailboxId: string,
|
|
39
|
+
): Promise<FolderPlacement> =>
|
|
40
|
+
placementOf(mailboxId, await resolveFolderRoleMailboxes(lookup, accountId));
|
|
41
|
+
|
|
42
|
+
export const resolveAccountFolderPlacements = async (
|
|
43
|
+
lookup: FolderPlacementLookup,
|
|
44
|
+
accountIds: readonly string[],
|
|
45
|
+
): Promise<ReadonlyMap<string, FolderPlacement>> => {
|
|
46
|
+
const placements = new Map<string, FolderPlacement>();
|
|
47
|
+
for (const accountId of accountIds) {
|
|
48
|
+
const { junkMailbox, inboxMailbox } = await resolveFolderRoleMailboxes(
|
|
49
|
+
lookup,
|
|
50
|
+
accountId,
|
|
51
|
+
);
|
|
52
|
+
if (inboxMailbox) placements.set(inboxMailbox.mailboxId, "inbox");
|
|
53
|
+
if (junkMailbox) placements.set(junkMailbox.mailboxId, "junk");
|
|
54
|
+
}
|
|
55
|
+
return placements;
|
|
56
|
+
};
|
|
@@ -245,7 +245,8 @@ describe("extractSenderMismatch", () => {
|
|
|
245
245
|
assert.deepEqual(
|
|
246
246
|
extractSenderMismatch(parsed, {
|
|
247
247
|
fromDomain: "serviceupdatebank.atlassian.net",
|
|
248
|
-
|
|
248
|
+
providerSpamClassified: true,
|
|
249
|
+
placement: "other",
|
|
249
250
|
bulkSender: false,
|
|
250
251
|
}),
|
|
251
252
|
{
|
|
@@ -255,18 +256,53 @@ describe("extractSenderMismatch", () => {
|
|
|
255
256
|
);
|
|
256
257
|
});
|
|
257
258
|
|
|
258
|
-
it("
|
|
259
|
+
it("flags a message sitting in Junk that the provider rated clean", async () => {
|
|
259
260
|
const parsed = await parse(infoMedicsPhish);
|
|
260
261
|
assert.deepEqual(
|
|
261
262
|
extractSenderMismatch(parsed, {
|
|
262
263
|
fromDomain: "serviceupdatebank.atlassian.net",
|
|
263
|
-
|
|
264
|
+
providerSpamClassified: false,
|
|
265
|
+
placement: "junk",
|
|
264
266
|
bulkSender: false,
|
|
265
267
|
}),
|
|
266
|
-
{
|
|
268
|
+
{
|
|
269
|
+
displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
|
|
270
|
+
offDomainLinkDomains: ["example.org"],
|
|
271
|
+
},
|
|
267
272
|
);
|
|
268
273
|
});
|
|
269
274
|
|
|
275
|
+
it("flags a provider-classified message outside Junk", async () => {
|
|
276
|
+
const parsed = await parse(infoMedicsPhish);
|
|
277
|
+
assert.deepEqual(
|
|
278
|
+
extractSenderMismatch(parsed, {
|
|
279
|
+
fromDomain: "serviceupdatebank.atlassian.net",
|
|
280
|
+
providerSpamClassified: true,
|
|
281
|
+
placement: "inbox",
|
|
282
|
+
bulkSender: false,
|
|
283
|
+
}),
|
|
284
|
+
{
|
|
285
|
+
displayNameCorrespondence: DisplayNameCorrespondence.Unrelated,
|
|
286
|
+
offDomainLinkDomains: ["example.org"],
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
for (const placement of ["inbox", "other"] as const) {
|
|
292
|
+
it(`compares nothing in ${placement} when the provider did not call the message spam`, async () => {
|
|
293
|
+
const parsed = await parse(infoMedicsPhish);
|
|
294
|
+
assert.deepEqual(
|
|
295
|
+
extractSenderMismatch(parsed, {
|
|
296
|
+
fromDomain: "serviceupdatebank.atlassian.net",
|
|
297
|
+
providerSpamClassified: false,
|
|
298
|
+
placement,
|
|
299
|
+
bulkSender: false,
|
|
300
|
+
}),
|
|
301
|
+
{},
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
270
306
|
it("flags a display name spelling the recipient's own address over a foreign domain", async () => {
|
|
271
307
|
const parsed = await parse([
|
|
272
308
|
"From: matthijs@ischen.nl <aramirez@secresaludguaviare.gov.co>",
|
|
@@ -279,7 +315,8 @@ describe("extractSenderMismatch", () => {
|
|
|
279
315
|
assert.equal(
|
|
280
316
|
extractSenderMismatch(parsed, {
|
|
281
317
|
fromDomain: "secresaludguaviare.gov.co",
|
|
282
|
-
|
|
318
|
+
providerSpamClassified: true,
|
|
319
|
+
placement: "other",
|
|
283
320
|
bulkSender: false,
|
|
284
321
|
}).displayNameCorrespondence,
|
|
285
322
|
DisplayNameCorrespondence.ForeignAddress,
|
|
@@ -299,7 +336,8 @@ describe("extractSenderMismatch", () => {
|
|
|
299
336
|
]);
|
|
300
337
|
const signals = extractSenderMismatch(parsed, {
|
|
301
338
|
fromDomain: "mailer.esp.example",
|
|
302
|
-
|
|
339
|
+
providerSpamClassified: true,
|
|
340
|
+
placement: "other",
|
|
303
341
|
bulkSender: true,
|
|
304
342
|
});
|
|
305
343
|
assert.equal(signals.displayNameCorrespondence, undefined);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DisplayNameCorrespondence } from "@remit/domain-enums";
|
|
2
2
|
import type { ParsedMail } from "mailparser";
|
|
3
3
|
import { getDomain, parse as parseHost } from "tldts";
|
|
4
|
+
import type { FolderPlacement } from "./classifyPlacement.js";
|
|
4
5
|
|
|
5
6
|
type CorrespondenceValue =
|
|
6
7
|
(typeof DisplayNameCorrespondence)[keyof typeof DisplayNameCorrespondence];
|
|
@@ -10,7 +11,8 @@ type CorrespondenceValue =
|
|
|
10
11
|
* that belongs to nobody at the sending domain, and body links that leave it.
|
|
11
12
|
*
|
|
12
13
|
* Both are deliberately aggressive, and both are computed only for mail the
|
|
13
|
-
* provider's own filter already called spam
|
|
14
|
+
* provider's own filter already called spam or that sits in the Junk folder
|
|
15
|
+
* (see {@link extractSenderMismatch}).
|
|
14
16
|
* On a shared-tenant host — a free Atlassian, Salesforce or Zendesk instance —
|
|
15
17
|
* the verified subdomain is chosen by whoever signed up, so a passing signature
|
|
16
18
|
* proves the domain and nothing about the identity the message claims.
|
|
@@ -22,7 +24,8 @@ export interface SenderMismatchSignals {
|
|
|
22
24
|
|
|
23
25
|
export interface SenderMismatchContext {
|
|
24
26
|
fromDomain: string;
|
|
25
|
-
|
|
27
|
+
providerSpamClassified: boolean;
|
|
28
|
+
placement: FolderPlacement;
|
|
26
29
|
/**
|
|
27
30
|
* List-Unsubscribe present. Bulk mail routinely shows a brand name over an
|
|
28
31
|
* ESP's sending domain, so the display-name comparison says nothing there and
|
|
@@ -223,7 +226,8 @@ export const extractOffDomainLinkDomains = (
|
|
|
223
226
|
};
|
|
224
227
|
|
|
225
228
|
/**
|
|
226
|
-
* Both signals, gated on the provider's own
|
|
229
|
+
* Both signals, gated on a spam verdict: the provider's own, or the message
|
|
230
|
+
* sitting in the Junk folder.
|
|
227
231
|
*
|
|
228
232
|
* The gate is what lets the checks be this aggressive: ordinary mail never
|
|
229
233
|
* reaches them, so a brand name over an ESP domain or a newsletter full of
|
|
@@ -234,7 +238,9 @@ export const extractSenderMismatch = (
|
|
|
234
238
|
parsed: ParsedMail,
|
|
235
239
|
context: SenderMismatchContext,
|
|
236
240
|
): SenderMismatchSignals => {
|
|
237
|
-
if (!context.
|
|
241
|
+
if (!context.providerSpamClassified && context.placement !== "junk") {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
238
244
|
|
|
239
245
|
const offDomainLinkDomains = extractOffDomainLinkDomains(
|
|
240
246
|
parsed,
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export {
|
|
|
25
25
|
parseMessageBody,
|
|
26
26
|
} from "./body-parse.js";
|
|
27
27
|
export {
|
|
28
|
+
type BodySyncFolder,
|
|
28
29
|
type BodySyncLogger,
|
|
29
30
|
BodySyncService,
|
|
30
31
|
type ConnectionGetter,
|
|
@@ -125,6 +126,11 @@ export {
|
|
|
125
126
|
type UpdateFlagsInput,
|
|
126
127
|
type UpdateFlagsResult,
|
|
127
128
|
} from "./flag-queue.js";
|
|
129
|
+
export {
|
|
130
|
+
type FolderPlacementLookup,
|
|
131
|
+
resolveAccountFolderPlacements,
|
|
132
|
+
resolveFolderPlacement,
|
|
133
|
+
} from "./folder-placement.js";
|
|
128
134
|
export {
|
|
129
135
|
classifyByHeaders,
|
|
130
136
|
extractAuthenticity,
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type {
|
|
4
4
|
IMailboxRepository,
|
|
5
|
+
MailboxItem,
|
|
5
6
|
MailboxTransitionIntent,
|
|
6
7
|
} from "@remit/data-ports";
|
|
7
8
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
@@ -99,7 +100,10 @@ const boxStatus = (path: string): ImapBoxStatus => ({
|
|
|
99
100
|
newKeywords: false,
|
|
100
101
|
});
|
|
101
102
|
|
|
102
|
-
const recordingRepo = (
|
|
103
|
+
const recordingRepo = (
|
|
104
|
+
held: Pick<MailboxItem, "mailboxId" | "fullPath">[] = [],
|
|
105
|
+
) => {
|
|
106
|
+
const removed: string[] = [];
|
|
103
107
|
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
104
108
|
[];
|
|
105
109
|
const settles: Array<{
|
|
@@ -108,7 +112,11 @@ const recordingRepo = () => {
|
|
|
108
112
|
}> = [];
|
|
109
113
|
const repo: Pick<
|
|
110
114
|
IMailboxRepository,
|
|
111
|
-
|
|
115
|
+
| "update"
|
|
116
|
+
| "transition"
|
|
117
|
+
| "findByPathPrefix"
|
|
118
|
+
| "findByPath"
|
|
119
|
+
| "deleteMailboxWithMail"
|
|
112
120
|
> = {
|
|
113
121
|
update: async (
|
|
114
122
|
_accountId: string,
|
|
@@ -127,8 +135,13 @@ const recordingRepo = () => {
|
|
|
127
135
|
return {} as never;
|
|
128
136
|
},
|
|
129
137
|
findByPathPrefix: async () => [],
|
|
138
|
+
findByPath: async (_accountId: string, fullPath: string) =>
|
|
139
|
+
(held.find((row) => row.fullPath === fullPath) as MailboxItem) ?? null,
|
|
140
|
+
deleteMailboxWithMail: async (_accountId: string, mailboxId: string) => {
|
|
141
|
+
removed.push(mailboxId);
|
|
142
|
+
},
|
|
130
143
|
};
|
|
131
|
-
return { repo: repo as IMailboxRepository, updates, settles };
|
|
144
|
+
return { repo: repo as IMailboxRepository, updates, settles, removed };
|
|
132
145
|
};
|
|
133
146
|
|
|
134
147
|
const stubConnection = (
|
|
@@ -185,6 +198,38 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
185
198
|
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
186
199
|
});
|
|
187
200
|
|
|
201
|
+
it("drops the pending row when the prefixed path already has a folder (#318)", async () => {
|
|
202
|
+
const { repo, settles, updates, removed } = recordingRepo([
|
|
203
|
+
{ mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
|
|
204
|
+
]);
|
|
205
|
+
const connection = {
|
|
206
|
+
createMailbox: async (_path: string) => ({
|
|
207
|
+
path: "INBOX/Notifications",
|
|
208
|
+
created: false,
|
|
209
|
+
}),
|
|
210
|
+
subscribeMailbox: async () => undefined,
|
|
211
|
+
listMailboxes: async (): Promise<FlatMailboxInfo[]> => [],
|
|
212
|
+
openBox: async (path: string) => boxStatus(path),
|
|
213
|
+
closeBox: async () => undefined,
|
|
214
|
+
} as unknown as IImapConnection;
|
|
215
|
+
const service = new MailboxManagementService(repo);
|
|
216
|
+
|
|
217
|
+
const result = await service.syncCreate(
|
|
218
|
+
"acc-1",
|
|
219
|
+
"mbx-recreated",
|
|
220
|
+
"Notifications",
|
|
221
|
+
async () => connection,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.deepStrictEqual(result, {
|
|
225
|
+
success: true,
|
|
226
|
+
existing: { mailboxId: "mbx-existing", fullPath: "INBOX/Notifications" },
|
|
227
|
+
});
|
|
228
|
+
assert.deepStrictEqual(removed, ["mbx-recreated"]);
|
|
229
|
+
assert.deepStrictEqual(settles, []);
|
|
230
|
+
assert.deepStrictEqual(updates, []);
|
|
231
|
+
});
|
|
232
|
+
|
|
188
233
|
it("leaves the path untouched when the server keeps it as requested", async () => {
|
|
189
234
|
const { repo, settles } = recordingRepo();
|
|
190
235
|
const { connection } = stubConnection("Work", ["Work"]);
|
|
@@ -48,6 +48,7 @@ export interface MailboxManagementSyncResult {
|
|
|
48
48
|
* which is how a caller tells the two apart without a second read.
|
|
49
49
|
*/
|
|
50
50
|
renamed?: { oldPath: string; newPath: string; delimiter: string };
|
|
51
|
+
existing?: { mailboxId: string; fullPath: string };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -304,6 +305,30 @@ export class MailboxManagementService {
|
|
|
304
305
|
this.log.info({ mailboxId, path: serverPath }, "Subscribed to mailbox");
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
const holder =
|
|
309
|
+
confirmedPath === undefined
|
|
310
|
+
? null
|
|
311
|
+
: await this.mailboxService.findByPath(accountId, confirmedPath);
|
|
312
|
+
if (holder && holder.mailboxId !== mailboxId) {
|
|
313
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
314
|
+
this.log.info(
|
|
315
|
+
{
|
|
316
|
+
accountId,
|
|
317
|
+
mailboxId,
|
|
318
|
+
intent: "create",
|
|
319
|
+
path,
|
|
320
|
+
serverPath,
|
|
321
|
+
existingMailboxId: holder.mailboxId,
|
|
322
|
+
outcome: "already-exists",
|
|
323
|
+
},
|
|
324
|
+
"Folder already exists at the path the server resolved",
|
|
325
|
+
);
|
|
326
|
+
return {
|
|
327
|
+
success: true,
|
|
328
|
+
existing: { mailboxId: holder.mailboxId, fullPath: holder.fullPath },
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
307
332
|
// Refresh mailbox list to get UIDVALIDITY and other attributes
|
|
308
333
|
const mailboxes = await connection.listMailboxes();
|
|
309
334
|
const mailboxInfo = mailboxes.find((m) => m.fullPath === serverPath);
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
3
|
import type {
|
|
4
4
|
CreateMailboxInput,
|
|
5
|
+
CreateMailboxResult,
|
|
5
6
|
IMailboxRepository,
|
|
6
7
|
MailboxItem,
|
|
7
8
|
} from "@remit/data-ports";
|
|
@@ -120,18 +121,19 @@ export class MailboxQueueService {
|
|
|
120
121
|
* @param input - The mailbox creation input (without syncStatus)
|
|
121
122
|
* @param accountId - The account ID for the IMAP sync event
|
|
122
123
|
* @param subscribe - Whether to subscribe to the mailbox after creation
|
|
123
|
-
* @returns The created mailbox
|
|
124
|
+
* @returns The created mailbox, or `PathTaken` when the account already has a folder at that path
|
|
124
125
|
*/
|
|
125
126
|
createMailbox = async (
|
|
126
127
|
input: CreateMailboxQueueInput,
|
|
127
128
|
accountId: string,
|
|
128
129
|
subscribe?: boolean,
|
|
129
|
-
): Promise<
|
|
130
|
-
|
|
131
|
-
const mailbox = await this.mailboxService.create({
|
|
130
|
+
): Promise<CreateMailboxResult> => {
|
|
131
|
+
const created = await this.mailboxService.create({
|
|
132
132
|
...input,
|
|
133
133
|
syncStatus: MailboxSyncStatus.pending,
|
|
134
134
|
});
|
|
135
|
+
if (created.outcome === "PathTaken") return created;
|
|
136
|
+
const { mailbox } = created;
|
|
135
137
|
|
|
136
138
|
this.log.info(
|
|
137
139
|
{ mailboxId: mailbox.mailboxId, path: mailbox.fullPath },
|
|
@@ -149,7 +151,7 @@ export class MailboxQueueService {
|
|
|
149
151
|
subscribe,
|
|
150
152
|
});
|
|
151
153
|
|
|
152
|
-
return
|
|
154
|
+
return created;
|
|
153
155
|
};
|
|
154
156
|
|
|
155
157
|
/**
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
import { parseImapAttributes } from "./attribute-mapper.js";
|
|
14
14
|
import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
|
|
15
15
|
|
|
16
|
-
const silentLogger: MailboxSyncLogger = {
|
|
16
|
+
const silentLogger: MailboxSyncLogger = {
|
|
17
|
+
info: () => {},
|
|
18
|
+
debug: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
import type { IImapConnection, ImapNamespaces } from "./types.js";
|
|
19
23
|
|
|
@@ -110,7 +114,10 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
110
114
|
return {};
|
|
111
115
|
},
|
|
112
116
|
delete: async () => undefined,
|
|
113
|
-
create: async () => ({
|
|
117
|
+
create: async () => ({
|
|
118
|
+
outcome: "Created",
|
|
119
|
+
mailbox: { mailboxId: "new" },
|
|
120
|
+
}),
|
|
114
121
|
} as unknown as IMailboxRepository;
|
|
115
122
|
|
|
116
123
|
const specialUseService = {
|
|
@@ -257,6 +264,7 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
257
264
|
syncStatus?: string;
|
|
258
265
|
pendingPath?: string;
|
|
259
266
|
}>,
|
|
267
|
+
createOutcome: "Created" | "PathTaken" = "Created",
|
|
260
268
|
) => {
|
|
261
269
|
const deleted: string[] = [];
|
|
262
270
|
const created: Array<Record<string, unknown>> = [];
|
|
@@ -283,7 +291,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
283
291
|
},
|
|
284
292
|
create: async (input: Record<string, unknown>) => {
|
|
285
293
|
created.push(input);
|
|
286
|
-
return {
|
|
294
|
+
if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
|
|
295
|
+
return {
|
|
296
|
+
outcome: "Created",
|
|
297
|
+
mailbox: { ...input, mailboxId: `new-${created.length}` },
|
|
298
|
+
};
|
|
287
299
|
},
|
|
288
300
|
} as unknown as IMailboxRepository;
|
|
289
301
|
|
|
@@ -401,6 +413,35 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
401
413
|
assert.deepEqual(deleted, []);
|
|
402
414
|
});
|
|
403
415
|
|
|
416
|
+
it("counts no insert and keeps the account sweep going when the path is already taken", async () => {
|
|
417
|
+
const { mailboxService, specialUseService, created } = buildServices(
|
|
418
|
+
[
|
|
419
|
+
{
|
|
420
|
+
mailboxId: "inbox",
|
|
421
|
+
fullPath: "INBOX",
|
|
422
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
"PathTaken",
|
|
426
|
+
);
|
|
427
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
428
|
+
const service = new MailboxSyncService(mailboxService, specialUseService, {
|
|
429
|
+
...silentLogger,
|
|
430
|
+
error: (obj) => {
|
|
431
|
+
errors.push(obj);
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const result = await service.syncMailboxes(
|
|
436
|
+
{ accountId: "acc-1" },
|
|
437
|
+
serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
assert.equal(created.length, 1);
|
|
441
|
+
assert.equal(result.created, 0);
|
|
442
|
+
assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
|
|
443
|
+
});
|
|
444
|
+
|
|
404
445
|
it("lets a failed rename's target be discovered as a folder of its own", async () => {
|
|
405
446
|
// A failed rename keeps its target so the UI can name what it was aiming
|
|
406
447
|
// at (T6), and nothing clears it until the user retries or dismisses. If
|
|
@@ -539,7 +580,10 @@ describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the u
|
|
|
539
580
|
delete: async (_accountId: string, mailboxId: string) => {
|
|
540
581
|
deleted.push(mailboxId);
|
|
541
582
|
},
|
|
542
|
-
create: async () => ({
|
|
583
|
+
create: async () => ({
|
|
584
|
+
outcome: "Created",
|
|
585
|
+
mailbox: { mailboxId: "new" },
|
|
586
|
+
}),
|
|
543
587
|
} as unknown as IMailboxRepository;
|
|
544
588
|
|
|
545
589
|
const specialUseService = {
|
|
@@ -717,7 +761,10 @@ describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)
|
|
|
717
761
|
return {};
|
|
718
762
|
},
|
|
719
763
|
delete: async () => undefined,
|
|
720
|
-
create: async () => ({
|
|
764
|
+
create: async () => ({
|
|
765
|
+
outcome: "Created",
|
|
766
|
+
mailbox: { mailboxId: "new" },
|
|
767
|
+
}),
|
|
721
768
|
} as unknown as IMailboxRepository;
|
|
722
769
|
|
|
723
770
|
const specialUseService = {
|