@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.
@@ -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);
@@ -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
- spamClassified: providerSpam?.classified === true,
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
- spamClassified: true,
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("compares nothing when the provider did not call the message spam", async () => {
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
- spamClassified: false,
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
- spamClassified: true,
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
- spamClassified: true,
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 (see {@link extractSenderMismatch}).
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
- spamClassified: boolean;
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 spam verdict.
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.spamClassified) return {};
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
+ };