@remit/mailbox-service 0.0.102 → 0.0.104
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 +5 -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/outbox-attachment-content.test.ts +145 -0
- package/src/outbox-attachment-content.ts +99 -0
- package/src/outbox-attachment.ts +32 -0
- package/src/outbox-failed-recovery.test.ts +3 -1
- package/src/outbox-queue-settled-row.test.ts +1 -0
- package/src/outbox-queue.test.ts +79 -2
- package/src/outbox-queue.ts +29 -0
|
@@ -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,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { OutboxAttachmentItem } from "@remit/data-ports";
|
|
4
|
+
import { createMockStorageService } from "@remit/storage-service";
|
|
5
|
+
import {
|
|
6
|
+
loadOutboxAttachmentContents,
|
|
7
|
+
OutboxAttachmentUnavailableError,
|
|
8
|
+
} from "./outbox-attachment-content.js";
|
|
9
|
+
|
|
10
|
+
const OWNER = {
|
|
11
|
+
accountConfigId: "cfg-1",
|
|
12
|
+
accountId: "acc-1",
|
|
13
|
+
outboxMessageId: "ob-1",
|
|
14
|
+
};
|
|
15
|
+
const NOW = 1_000;
|
|
16
|
+
|
|
17
|
+
const row = (
|
|
18
|
+
outboxAttachmentId: string,
|
|
19
|
+
overrides: Partial<OutboxAttachmentItem> = {},
|
|
20
|
+
): OutboxAttachmentItem => ({
|
|
21
|
+
outboxAttachmentId,
|
|
22
|
+
outboxMessageId: OWNER.outboxMessageId,
|
|
23
|
+
accountId: OWNER.accountId,
|
|
24
|
+
accountConfigId: OWNER.accountConfigId,
|
|
25
|
+
filename: `${outboxAttachmentId}.txt`,
|
|
26
|
+
contentType: "text/plain",
|
|
27
|
+
sizeBytes: 5,
|
|
28
|
+
state: "Stored",
|
|
29
|
+
storageKey: `key/${outboxAttachmentId}`,
|
|
30
|
+
reservationExpiresAt: 0,
|
|
31
|
+
createdAt: 0,
|
|
32
|
+
updatedAt: 0,
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const setup = async (rows: OutboxAttachmentItem[], stored: string[]) => {
|
|
37
|
+
const storage = createMockStorageService();
|
|
38
|
+
for (const outboxAttachmentId of stored) {
|
|
39
|
+
await storage.storeOutboxAttachment({
|
|
40
|
+
...OWNER,
|
|
41
|
+
outboxAttachmentId,
|
|
42
|
+
content: Buffer.from(`bytes of ${outboxAttachmentId}`),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
attachments: { listByOutboxMessage: async () => rows },
|
|
47
|
+
storage,
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("loadOutboxAttachmentContents", () => {
|
|
52
|
+
it("returns every stored file with its bytes, in the order it was attached", async () => {
|
|
53
|
+
const deps = await setup(
|
|
54
|
+
[row("second", { createdAt: 20 }), row("first", { createdAt: 10 })],
|
|
55
|
+
["first", "second"],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const contents = await loadOutboxAttachmentContents(deps, OWNER, NOW);
|
|
59
|
+
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
contents.map((item) => [
|
|
62
|
+
item.filename,
|
|
63
|
+
item.contentType,
|
|
64
|
+
item.content.toString(),
|
|
65
|
+
]),
|
|
66
|
+
[
|
|
67
|
+
["first.txt", "text/plain", "bytes of first"],
|
|
68
|
+
["second.txt", "text/plain", "bytes of second"],
|
|
69
|
+
],
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves out a reservation that lapsed without its bytes", async () => {
|
|
74
|
+
const deps = await setup(
|
|
75
|
+
[
|
|
76
|
+
row("kept"),
|
|
77
|
+
row("lapsed", { state: "Pending", reservationExpiresAt: NOW - 1 }),
|
|
78
|
+
],
|
|
79
|
+
["kept"],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const contents = await loadOutboxAttachmentContents(deps, OWNER, NOW);
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
contents.map((item) => item.filename),
|
|
86
|
+
["kept.txt"],
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("refuses to build a message while an upload is still open", async () => {
|
|
91
|
+
const deps = await setup(
|
|
92
|
+
[row("open", { state: "Pending", reservationExpiresAt: NOW + 60 })],
|
|
93
|
+
[],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await assert.rejects(
|
|
97
|
+
() => loadOutboxAttachmentContents(deps, OWNER, NOW),
|
|
98
|
+
(error: unknown) => {
|
|
99
|
+
assert.ok(error instanceof OutboxAttachmentUnavailableError);
|
|
100
|
+
assert.equal(error.filename, "open.txt");
|
|
101
|
+
assert.match(error.message, /never finished uploading/);
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refuses to build a message whose stored file has no bytes behind it", async () => {
|
|
108
|
+
const deps = await setup([row("gone")], []);
|
|
109
|
+
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
() => loadOutboxAttachmentContents(deps, OWNER, NOW),
|
|
112
|
+
(error: unknown) => {
|
|
113
|
+
assert.ok(error instanceof OutboxAttachmentUnavailableError);
|
|
114
|
+
assert.equal(
|
|
115
|
+
error.message,
|
|
116
|
+
'"gone.txt" could not be read: nothing is stored at key/gone',
|
|
117
|
+
);
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("names the file when storage itself fails", async () => {
|
|
124
|
+
const deps = {
|
|
125
|
+
attachments: { listByOutboxMessage: async () => [row("payroll")] },
|
|
126
|
+
storage: {
|
|
127
|
+
retrieveOutboxAttachment: async (): Promise<Buffer | null> => {
|
|
128
|
+
throw new Error("S3 is down");
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await assert.rejects(
|
|
134
|
+
() => loadOutboxAttachmentContents(deps, OWNER, NOW),
|
|
135
|
+
(error: unknown) => {
|
|
136
|
+
assert.ok(error instanceof OutboxAttachmentUnavailableError);
|
|
137
|
+
assert.equal(
|
|
138
|
+
error.message,
|
|
139
|
+
'"payroll.txt" could not be read: storage failed (S3 is down)',
|
|
140
|
+
);
|
|
141
|
+
return true;
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { IOutboxAttachmentRepository } from "@remit/data-ports";
|
|
2
|
+
import { holdsRoom } from "@remit/data-ports";
|
|
3
|
+
import type { StorageService } from "@remit/storage-service";
|
|
4
|
+
|
|
5
|
+
export interface OutboxAttachmentContent {
|
|
6
|
+
filename: string;
|
|
7
|
+
contentType: string;
|
|
8
|
+
content: Buffer;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A file on a message that cannot be put into it. Carries the file's name so
|
|
13
|
+
* whoever settles the message can tell the sender which file to fix.
|
|
14
|
+
*/
|
|
15
|
+
export class OutboxAttachmentUnavailableError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly filename: string,
|
|
18
|
+
readonly detail: string,
|
|
19
|
+
options?: { cause: unknown },
|
|
20
|
+
) {
|
|
21
|
+
super(`"${filename}" could not be read: ${detail}`, options);
|
|
22
|
+
this.name = "OutboxAttachmentUnavailableError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const describeCause = (error: unknown): string =>
|
|
27
|
+
error instanceof Error ? error.message : String(error);
|
|
28
|
+
|
|
29
|
+
export interface OutboxAttachmentContentDeps {
|
|
30
|
+
attachments: Pick<IOutboxAttachmentRepository, "listByOutboxMessage">;
|
|
31
|
+
storage: Pick<StorageService, "retrieveOutboxAttachment">;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OutboxAttachmentOwner {
|
|
35
|
+
accountConfigId: string;
|
|
36
|
+
accountId: string;
|
|
37
|
+
outboxMessageId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every file a message goes out with, bytes included, in the order they were
|
|
42
|
+
* attached. The send op refuses a draft with an upload still open, so a live
|
|
43
|
+
* `Pending` row here, or a `Stored` row with nothing behind it, is a message
|
|
44
|
+
* that cannot be built as the sender saw it — and sending it without the file
|
|
45
|
+
* would be worse than not sending it.
|
|
46
|
+
*/
|
|
47
|
+
export const loadOutboxAttachmentContents = async (
|
|
48
|
+
deps: OutboxAttachmentContentDeps,
|
|
49
|
+
owner: OutboxAttachmentOwner,
|
|
50
|
+
nowSeconds: number,
|
|
51
|
+
): Promise<OutboxAttachmentContent[]> => {
|
|
52
|
+
const held = await deps.attachments.listByOutboxMessage(
|
|
53
|
+
owner.accountConfigId,
|
|
54
|
+
owner.outboxMessageId,
|
|
55
|
+
);
|
|
56
|
+
const live = held
|
|
57
|
+
.filter((item) => holdsRoom(item, nowSeconds))
|
|
58
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
59
|
+
|
|
60
|
+
const unfinished = live.find((item) => item.state !== "Stored");
|
|
61
|
+
if (unfinished) {
|
|
62
|
+
throw new OutboxAttachmentUnavailableError(
|
|
63
|
+
unfinished.filename,
|
|
64
|
+
`attachment ${unfinished.outboxAttachmentId} never finished uploading`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Promise.all(
|
|
69
|
+
live.map(async (item) => {
|
|
70
|
+
const content = await Promise.resolve()
|
|
71
|
+
.then(() =>
|
|
72
|
+
deps.storage.retrieveOutboxAttachment(
|
|
73
|
+
owner.accountConfigId,
|
|
74
|
+
owner.accountId,
|
|
75
|
+
owner.outboxMessageId,
|
|
76
|
+
item.outboxAttachmentId,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
.catch((error: unknown) => {
|
|
80
|
+
throw new OutboxAttachmentUnavailableError(
|
|
81
|
+
item.filename,
|
|
82
|
+
`storage failed (${describeCause(error)})`,
|
|
83
|
+
{ cause: error },
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
if (content === null) {
|
|
87
|
+
throw new OutboxAttachmentUnavailableError(
|
|
88
|
+
item.filename,
|
|
89
|
+
`nothing is stored at ${item.storageKey}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
filename: item.filename,
|
|
94
|
+
contentType: item.contentType,
|
|
95
|
+
content,
|
|
96
|
+
};
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
};
|