@remit/storage-service 0.0.10 → 0.0.12

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/storage-service",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -29,9 +29,6 @@ import {
29
29
  buildBodyPartKey,
30
30
  buildDeduplicatedKey,
31
31
  buildExportArchiveKey,
32
- buildExtractedPrefix,
33
- buildExtractedSkippedKey,
34
- buildExtractedTextKey,
35
32
  buildMessageBodyKey,
36
33
  buildOutboxAttachmentKey,
37
34
  buildOutboxAttachmentPrefix,
@@ -174,105 +171,6 @@ export const createFilesystemStorageService = (
174
171
  : buffer;
175
172
  };
176
173
 
177
- const storeExtractedText: StorageService["storeExtractedText"] = (params) => {
178
- const { accountConfigId, accountId, messageId, partPath, text } = params;
179
- return storeInternal({
180
- key: buildExtractedTextKey(
181
- accountConfigId,
182
- accountId,
183
- messageId,
184
- partPath,
185
- ),
186
- content: Buffer.from(text, "utf8"),
187
- });
188
- };
189
-
190
- const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = (
191
- params,
192
- ) => {
193
- const { accountConfigId, accountId, messageId, partPath, marker } = params;
194
- return storeInternal({
195
- key: buildExtractedSkippedKey(
196
- accountConfigId,
197
- accountId,
198
- messageId,
199
- partPath,
200
- ),
201
- content: Buffer.from(JSON.stringify(marker), "utf8"),
202
- compress: false,
203
- });
204
- };
205
-
206
- const extractedResultExists: StorageService["extractedResultExists"] = async (
207
- accountConfigId,
208
- accountId,
209
- messageId,
210
- partPath,
211
- ) => {
212
- const textPath = join(
213
- basePath,
214
- buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
215
- );
216
- const skippedPath = join(
217
- basePath,
218
- buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath),
219
- );
220
- const exists = (fullPath: string): Promise<boolean> =>
221
- stat(fullPath)
222
- .then(() => true)
223
- .catch(() => false);
224
-
225
- const [textExists, skippedExists] = await Promise.all([
226
- exists(textPath),
227
- exists(skippedPath),
228
- ]);
229
- return textExists || skippedExists;
230
- };
231
-
232
- const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
233
- accountConfigId,
234
- accountId,
235
- messageId,
236
- partPath,
237
- ) => {
238
- const fullPath = join(
239
- basePath,
240
- buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
241
- );
242
- const buffer = await readFile(fullPath).catch((error: unknown) => {
243
- if (isStorageNotFoundError(error)) return null;
244
- throw error;
245
- });
246
- if (!buffer) return null;
247
- const decoded =
248
- buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer;
249
- return decoded.toString("utf8");
250
- };
251
-
252
- const listExtractedTexts: StorageService["listExtractedTexts"] = async (
253
- accountConfigId,
254
- accountId,
255
- messageId,
256
- ) => {
257
- const prefix = buildExtractedPrefix(accountConfigId, accountId, messageId);
258
- const suffix = ".txt.gz";
259
- const dirPath = join(basePath, prefix);
260
-
261
- const entries = await readdir(dirPath, { recursive: true }).catch(
262
- (error: unknown) => {
263
- if (isStorageNotFoundError(error)) return [];
264
- throw error;
265
- },
266
- );
267
-
268
- return entries
269
- .filter((entry) => entry.endsWith(suffix))
270
- .map((entry) => ({
271
- partPath: entry.slice(0, -suffix.length),
272
- key: `${prefix}${entry}`,
273
- }));
274
- };
275
-
276
174
  const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
277
175
  params,
278
176
  ) => {
@@ -400,6 +298,27 @@ export const createFilesystemStorageService = (
400
298
  return stats ? { sizeBytes: stats.size } : null;
401
299
  };
402
300
 
301
+ const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] = (
302
+ accountConfigId,
303
+ accountId,
304
+ outboxMessageId,
305
+ outboxAttachmentId,
306
+ ) =>
307
+ readFile(
308
+ join(
309
+ basePath,
310
+ buildOutboxAttachmentKey(
311
+ accountConfigId,
312
+ accountId,
313
+ outboxMessageId,
314
+ outboxAttachmentId,
315
+ ),
316
+ ),
317
+ ).catch((error: unknown) => {
318
+ if (isStorageNotFoundError(error)) return null;
319
+ throw error;
320
+ });
321
+
403
322
  // This backend has no presigned anything, so the URL addresses the
404
323
  // deployment's own upload route. Its authority is the same HMAC the read side
405
324
  // uses on /content, under the write label, covering the storage key, the
@@ -540,15 +459,11 @@ export const createFilesystemStorageService = (
540
459
  deleteOutboxAttachments,
541
460
  deleteOutboxAttachment,
542
461
  statOutboxAttachment,
462
+ retrieveOutboxAttachment,
543
463
  createOutboxAttachmentUploadUrl,
544
464
  storeDeduplicated,
545
465
  storeParsedBody,
546
466
  retrieveParsedBody,
547
- storeExtractedText,
548
- storeExtractedSkipped,
549
- extractedResultExists,
550
- retrieveExtractedText,
551
- listExtractedTexts,
552
467
  retrieveMessageBody: async (accountConfigId, accountId, messageId) => {
553
468
  const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
554
469
  const fullPath = join(basePath, key);
@@ -367,160 +367,6 @@ describe("createS3StorageService", () => {
367
367
  );
368
368
  assert.strictEqual(miss, null);
369
369
  });
370
-
371
- test("storeExtractedText writes gzipped text keyed under .../extracted/{partPath}.txt.gz and round-trips", async () => {
372
- const { client, commands, stored } = createFakeS3Client();
373
- const storage = createS3StorageService(client, "bucket");
374
-
375
- const ref = await storage.storeExtractedText({
376
- accountConfigId: "cfg-x",
377
- accountId: "acc-x",
378
- messageId: "msg-x",
379
- partPath: "1.2",
380
- text: "extracted attachment text",
381
- });
382
-
383
- assert.strictEqual(
384
- ref.storageKey,
385
- "accounts/cfg-x/acc-x/messages/msg-x/extracted/1.2.txt.gz",
386
- );
387
- const put = commands.find((c) => c.name === "PutObjectCommand");
388
- assert.strictEqual(put?.input.ContentEncoding, "gzip");
389
- assert.strictEqual(put?.input.ContentType, "text/plain; charset=utf-8");
390
-
391
- const entry = stored.get(
392
- "accounts/cfg-x/acc-x/messages/msg-x/extracted/1.2.txt.gz",
393
- );
394
- assert.ok(entry);
395
- assert.strictEqual(
396
- gunzipSync(entry.body).toString("utf8"),
397
- "extracted attachment text",
398
- );
399
-
400
- const retrieved = await storage.retrieveExtractedText(
401
- "cfg-x",
402
- "acc-x",
403
- "msg-x",
404
- "1.2",
405
- );
406
- assert.strictEqual(retrieved, "extracted attachment text");
407
- });
408
-
409
- test("retrieveExtractedText returns null on miss", async () => {
410
- const { client } = createFakeS3Client();
411
- const storage = createS3StorageService(client, "bucket");
412
- const result = await storage.retrieveExtractedText(
413
- "cfg-x",
414
- "acc-x",
415
- "msg-x",
416
- "nope",
417
- );
418
- assert.strictEqual(result, null);
419
- });
420
-
421
- test("storeExtractedSkipped writes an uncompressed JSON marker keyed under .../extracted/{partPath}.skipped.json", async () => {
422
- const { client, commands, stored } = createFakeS3Client();
423
- const storage = createS3StorageService(client, "bucket");
424
-
425
- const ref = await storage.storeExtractedSkipped({
426
- accountConfigId: "cfg-s",
427
- accountId: "acc-s",
428
- messageId: "msg-s",
429
- partPath: "3",
430
- marker: { status: "failed", reason: "pdf: corrupt" },
431
- });
432
-
433
- assert.strictEqual(
434
- ref.storageKey,
435
- "accounts/cfg-s/acc-s/messages/msg-s/extracted/3.skipped.json",
436
- );
437
- const put = commands.find((c) => c.name === "PutObjectCommand");
438
- assert.strictEqual(put?.input.ContentEncoding, undefined);
439
- assert.strictEqual(put?.input.ContentType, "application/json");
440
-
441
- const entry = stored.get(
442
- "accounts/cfg-s/acc-s/messages/msg-s/extracted/3.skipped.json",
443
- );
444
- assert.ok(entry);
445
- assert.deepStrictEqual(JSON.parse(entry.body.toString("utf8")), {
446
- status: "failed",
447
- reason: "pdf: corrupt",
448
- });
449
- });
450
-
451
- test("extractedResultExists is true once either the text artifact or the skip marker is stored", async () => {
452
- const { client } = createFakeS3Client();
453
- const storage = createS3StorageService(client, "bucket");
454
-
455
- assert.strictEqual(
456
- await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "1"),
457
- false,
458
- );
459
-
460
- await storage.storeExtractedText({
461
- accountConfigId: "cfg-e",
462
- accountId: "acc-e",
463
- messageId: "msg-e",
464
- partPath: "1",
465
- text: "hi",
466
- });
467
- assert.strictEqual(
468
- await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "1"),
469
- true,
470
- );
471
-
472
- await storage.storeExtractedSkipped({
473
- accountConfigId: "cfg-e",
474
- accountId: "acc-e",
475
- messageId: "msg-e",
476
- partPath: "2",
477
- marker: { status: "skipped", reason: "too-large" },
478
- });
479
- assert.strictEqual(
480
- await storage.extractedResultExists("cfg-e", "acc-e", "msg-e", "2"),
481
- true,
482
- );
483
- });
484
-
485
- test("listExtractedTexts returns only .txt.gz artifacts for the given message, excluding skip markers and other messages", async () => {
486
- const { client } = createFakeS3Client();
487
- const storage = createS3StorageService(client, "bucket");
488
-
489
- await storage.storeExtractedText({
490
- accountConfigId: "cfg-l",
491
- accountId: "acc-l",
492
- messageId: "msg-l",
493
- partPath: "1",
494
- text: "one",
495
- });
496
- await storage.storeExtractedText({
497
- accountConfigId: "cfg-l",
498
- accountId: "acc-l",
499
- messageId: "msg-l",
500
- partPath: "2.1",
501
- text: "two",
502
- });
503
- await storage.storeExtractedSkipped({
504
- accountConfigId: "cfg-l",
505
- accountId: "acc-l",
506
- messageId: "msg-l",
507
- partPath: "3",
508
- marker: { status: "failed", reason: "doc: corrupt" },
509
- });
510
- await storage.storeExtractedText({
511
- accountConfigId: "cfg-l",
512
- accountId: "acc-l",
513
- messageId: "other-msg",
514
- partPath: "1",
515
- text: "unrelated",
516
- });
517
-
518
- const items = await storage.listExtractedTexts("cfg-l", "acc-l", "msg-l");
519
- assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
520
- for (const item of items) {
521
- assert.ok(item.key.endsWith(".txt.gz"));
522
- }
523
- });
524
370
  });
525
371
 
526
372
  describe("createS3StorageService: outbox attachments", () => {
@@ -15,7 +15,6 @@ import { Upload } from "@aws-sdk/lib-storage";
15
15
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
16
16
  import { ContentEncoding, StorageType } from "@remit/domain-enums";
17
17
  import type {
18
- ExtractedTextListItem,
19
18
  OutboxAttachmentListItem,
20
19
  ParsedBody,
21
20
  StorageReference,
@@ -25,9 +24,6 @@ import {
25
24
  buildBodyPartKey,
26
25
  buildDeduplicatedKey,
27
26
  buildExportArchiveKey,
28
- buildExtractedPrefix,
29
- buildExtractedSkippedKey,
30
- buildExtractedTextKey,
31
27
  buildMessageBodyKey,
32
28
  buildOutboxAttachmentKey,
33
29
  buildOutboxAttachmentPrefix,
@@ -201,134 +197,6 @@ export const createS3StorageService = (
201
197
  return response.ContentEncoding === "gzip" ? gunzipSync(buffer) : buffer;
202
198
  };
203
199
 
204
- const storeExtractedText: StorageService["storeExtractedText"] = (params) => {
205
- const { accountConfigId, accountId, messageId, partPath, text } = params;
206
- return storeInternal({
207
- key: buildExtractedTextKey(
208
- accountConfigId,
209
- accountId,
210
- messageId,
211
- partPath,
212
- ),
213
- content: Buffer.from(text, "utf8"),
214
- contentType: "text/plain; charset=utf-8",
215
- });
216
- };
217
-
218
- const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = (
219
- params,
220
- ) => {
221
- const { accountConfigId, accountId, messageId, partPath, marker } = params;
222
- return storeInternal({
223
- key: buildExtractedSkippedKey(
224
- accountConfigId,
225
- accountId,
226
- messageId,
227
- partPath,
228
- ),
229
- content: Buffer.from(JSON.stringify(marker), "utf8"),
230
- contentType: "application/json",
231
- compress: false,
232
- });
233
- };
234
-
235
- const extractedResultExists: StorageService["extractedResultExists"] = async (
236
- accountConfigId,
237
- accountId,
238
- messageId,
239
- partPath,
240
- ) => {
241
- const textKey = buildExtractedTextKey(
242
- accountConfigId,
243
- accountId,
244
- messageId,
245
- partPath,
246
- );
247
- const skippedKey = buildExtractedSkippedKey(
248
- accountConfigId,
249
- accountId,
250
- messageId,
251
- partPath,
252
- );
253
-
254
- const headExists = (key: string): Promise<boolean> =>
255
- client
256
- .send(new HeadObjectCommand({ Bucket: bucketName, Key: key }))
257
- .then(() => true)
258
- .catch(() => false);
259
-
260
- const [textExists, skippedExists] = await Promise.all([
261
- headExists(textKey),
262
- headExists(skippedKey),
263
- ]);
264
- return textExists || skippedExists;
265
- };
266
-
267
- const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
268
- accountConfigId,
269
- accountId,
270
- messageId,
271
- partPath,
272
- ) => {
273
- const key = buildExtractedTextKey(
274
- accountConfigId,
275
- accountId,
276
- messageId,
277
- partPath,
278
- );
279
-
280
- const response = await client
281
- .send(new GetObjectCommand({ Bucket: bucketName, Key: key }))
282
- .catch((error: unknown) => {
283
- if (isStorageNotFoundError(error)) return null;
284
- throw error;
285
- });
286
-
287
- if (!response) return null;
288
- if (!response.Body) {
289
- throw new Error(`Empty response body for extracted text: ${key}`);
290
- }
291
-
292
- const body = await response.Body.transformToByteArray();
293
- const buffer = Buffer.from(body);
294
- const decoded =
295
- response.ContentEncoding === "gzip" ? gunzipSync(buffer) : buffer;
296
- return decoded.toString("utf8");
297
- };
298
-
299
- const listExtractedTexts: StorageService["listExtractedTexts"] = async (
300
- accountConfigId,
301
- accountId,
302
- messageId,
303
- ) => {
304
- const prefix = buildExtractedPrefix(accountConfigId, accountId, messageId);
305
- const suffix = ".txt.gz";
306
- const items: ExtractedTextListItem[] = [];
307
- let continuationToken: string | undefined;
308
-
309
- do {
310
- const response = await client.send(
311
- new ListObjectsV2Command({
312
- Bucket: bucketName,
313
- Prefix: prefix,
314
- ContinuationToken: continuationToken,
315
- }),
316
- );
317
- for (const object of response.Contents ?? []) {
318
- if (!object.Key || !object.Key.endsWith(suffix)) continue;
319
- items.push({
320
- partPath: object.Key.slice(prefix.length, -suffix.length),
321
- key: object.Key,
322
- });
323
- }
324
- continuationToken = response.IsTruncated
325
- ? response.NextContinuationToken
326
- : undefined;
327
- } while (continuationToken);
328
-
329
- return items;
330
- };
331
-
332
200
  const storeOutboxAttachment: StorageService["storeOutboxAttachment"] = (
333
201
  params,
334
202
  ) => {
@@ -467,6 +335,28 @@ export const createS3StorageService = (
467
335
  return { sizeBytes: head.ContentLength ?? 0 };
468
336
  };
469
337
 
338
+ const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] =
339
+ async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) => {
340
+ const key = buildOutboxAttachmentKey(
341
+ accountConfigId,
342
+ accountId,
343
+ outboxMessageId,
344
+ outboxAttachmentId,
345
+ );
346
+ const response = await client
347
+ .send(new GetObjectCommand({ Bucket: bucketName, Key: key }))
348
+ .catch((error: unknown) => {
349
+ if (isStorageNotFoundError(error)) return null;
350
+ throw error;
351
+ });
352
+ if (!response) return null;
353
+ if (!response.Body) {
354
+ throw new Error(`Empty response body for outbox attachment: ${key}`);
355
+ }
356
+ const bytes = await response.Body.transformToByteArray();
357
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
358
+ };
359
+
470
360
  // The browser PUTs straight to the bucket and these bytes never touch the
471
361
  // API. `ContentLength` on the command hoists `content-length` into
472
362
  // X-Amz-SignedHeaders, so S3 recomputes the signature over the length it was
@@ -718,15 +608,11 @@ export const createS3StorageService = (
718
608
  deleteOutboxAttachments,
719
609
  deleteOutboxAttachment,
720
610
  statOutboxAttachment,
611
+ retrieveOutboxAttachment,
721
612
  createOutboxAttachmentUploadUrl,
722
613
  storeDeduplicated,
723
614
  storeParsedBody,
724
615
  retrieveParsedBody,
725
- storeExtractedText,
726
- storeExtractedSkipped,
727
- extractedResultExists,
728
- retrieveExtractedText,
729
- listExtractedTexts,
730
616
  retrieveMessageBody,
731
617
  retrieveMessageBodyStream,
732
618
  retrieve,
package/src/index.ts CHANGED
@@ -20,9 +20,6 @@ export {
20
20
  buildBodyPartKey,
21
21
  buildDeduplicatedKey,
22
22
  buildExportArchiveKey,
23
- buildExtractedPrefix,
24
- buildExtractedSkippedKey,
25
- buildExtractedTextKey,
26
23
  buildMessageBodyKey,
27
24
  buildOutboxAttachmentKey,
28
25
  buildOutboxAttachmentPrefix,
@@ -31,8 +28,6 @@ export {
31
28
  type CreateOutboxAttachmentUploadUrlParams,
32
29
  computeChecksum,
33
30
  createMockStorageService,
34
- type ExtractedTextListItem,
35
- type ExtractedTextMarker,
36
31
  isStorageNotFoundError,
37
32
  type OutboxAttachmentListItem,
38
33
  type OutboxAttachmentUploadTarget,
@@ -47,8 +42,6 @@ export {
47
42
  type StorageTypeValue,
48
43
  type StoreBodyPartParams,
49
44
  type StoreDeduplicatedParams,
50
- type StoreExtractedSkippedParams,
51
- type StoreExtractedTextParams,
52
45
  type StoreMessageBodyParams,
53
46
  type StoreOutboxAttachmentParams,
54
47
  type StoreParsedBodyParams,
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { createFilesystemStorageService } from "./backends/filesystem.js";
7
+ import { createMockStorageService, type StorageService } from "./storage.js";
8
+
9
+ const OWNER = {
10
+ accountConfigId: "cfg-1",
11
+ accountId: "acc-1",
12
+ outboxMessageId: "ob-1",
13
+ };
14
+
15
+ const readsBackWhatWasStored = async (storage: StorageService) => {
16
+ const content = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff]);
17
+ await storage.storeOutboxAttachment({
18
+ ...OWNER,
19
+ outboxAttachmentId: "att-1",
20
+ content,
21
+ });
22
+
23
+ assert.deepEqual(
24
+ await storage.retrieveOutboxAttachment(
25
+ OWNER.accountConfigId,
26
+ OWNER.accountId,
27
+ OWNER.outboxMessageId,
28
+ "att-1",
29
+ ),
30
+ content,
31
+ );
32
+ assert.equal(
33
+ await storage.retrieveOutboxAttachment(
34
+ OWNER.accountConfigId,
35
+ OWNER.accountId,
36
+ OWNER.outboxMessageId,
37
+ "att-missing",
38
+ ),
39
+ null,
40
+ );
41
+ };
42
+
43
+ describe("retrieveOutboxAttachment", () => {
44
+ it("reads back the exact bytes on the filesystem backend, and null for none", async () => {
45
+ const basePath = await mkdtemp(join(tmpdir(), "remit-outbox-retrieve-"));
46
+ try {
47
+ await readsBackWhatWasStored(createFilesystemStorageService(basePath));
48
+ } finally {
49
+ await rm(basePath, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ it("reads back the exact bytes on the mock backend, and null for none", async () => {
54
+ await readsBackWhatWasStored(createMockStorageService());
55
+ });
56
+ });
@@ -10,9 +10,6 @@ import { createFilesystemStorageService } from "./backends/filesystem.js";
10
10
  import {
11
11
  buildBodyPartKey,
12
12
  buildDeduplicatedKey,
13
- buildExtractedPrefix,
14
- buildExtractedSkippedKey,
15
- buildExtractedTextKey,
16
13
  buildMessageBodyKey,
17
14
  buildOutboxAttachmentKey,
18
15
  buildOutboxAttachmentPrefix,
@@ -120,38 +117,6 @@ describe("path builders", () => {
120
117
  assert.strictEqual(segments[1], "CFG");
121
118
  assert.strictEqual(segments[2], "ACC");
122
119
  });
123
-
124
- test("buildExtractedTextKey nests accountId under accountConfigId and suffixes .txt.gz", () => {
125
- const key = buildExtractedTextKey("cfg1", "acc123", "msg456", "1.2");
126
- assert.strictEqual(
127
- key,
128
- "accounts/cfg1/acc123/messages/msg456/extracted/1.2.txt.gz",
129
- );
130
- });
131
-
132
- test("buildExtractedSkippedKey nests accountId under accountConfigId and suffixes .skipped.json", () => {
133
- const key = buildExtractedSkippedKey("cfg1", "acc123", "msg456", "1.2");
134
- assert.strictEqual(
135
- key,
136
- "accounts/cfg1/acc123/messages/msg456/extracted/1.2.skipped.json",
137
- );
138
- });
139
-
140
- test("buildExtractedTextKey and buildExtractedSkippedKey share the same prefix", () => {
141
- const prefix = buildExtractedPrefix("cfg1", "acc123", "msg456");
142
- assert.strictEqual(
143
- prefix,
144
- "accounts/cfg1/acc123/messages/msg456/extracted/",
145
- );
146
- assert.ok(
147
- buildExtractedTextKey("cfg1", "acc123", "msg456", "1").startsWith(prefix),
148
- );
149
- assert.ok(
150
- buildExtractedSkippedKey("cfg1", "acc123", "msg456", "1").startsWith(
151
- prefix,
152
- ),
153
- );
154
- });
155
120
  });
156
121
 
157
122
  describe("parseContentStorageKey", () => {
@@ -374,115 +339,6 @@ describe("createMockStorageService", () => {
374
339
  );
375
340
  assert.strictEqual(miss, null);
376
341
  });
377
-
378
- test("storeExtractedText / retrieveExtractedText round-trip", async () => {
379
- const storage = createMockStorageService();
380
-
381
- const ref = await storage.storeExtractedText({
382
- accountConfigId: "cfg1",
383
- accountId: "acc123",
384
- messageId: "msg456",
385
- partPath: "1.2",
386
- text: "extracted attachment text",
387
- });
388
-
389
- assert.strictEqual(
390
- ref.storageKey,
391
- "accounts/cfg1/acc123/messages/msg456/extracted/1.2.txt.gz",
392
- );
393
-
394
- const retrieved = await storage.retrieveExtractedText(
395
- "cfg1",
396
- "acc123",
397
- "msg456",
398
- "1.2",
399
- );
400
- assert.strictEqual(retrieved, "extracted attachment text");
401
- });
402
-
403
- test("retrieveExtractedText returns null on miss", async () => {
404
- const storage = createMockStorageService();
405
- const result = await storage.retrieveExtractedText(
406
- "cfg1",
407
- "acc123",
408
- "msg456",
409
- "nope",
410
- );
411
- assert.strictEqual(result, null);
412
- });
413
-
414
- test("extractedResultExists is true after either the text artifact or the skip marker is stored", async () => {
415
- const storage = createMockStorageService();
416
-
417
- assert.strictEqual(
418
- await storage.extractedResultExists("cfg1", "acc123", "msg456", "1"),
419
- false,
420
- );
421
-
422
- await storage.storeExtractedText({
423
- accountConfigId: "cfg1",
424
- accountId: "acc123",
425
- messageId: "msg456",
426
- partPath: "1",
427
- text: "hi",
428
- });
429
- assert.strictEqual(
430
- await storage.extractedResultExists("cfg1", "acc123", "msg456", "1"),
431
- true,
432
- );
433
-
434
- await storage.storeExtractedSkipped({
435
- accountConfigId: "cfg1",
436
- accountId: "acc123",
437
- messageId: "msg456",
438
- partPath: "2",
439
- marker: { status: "skipped", reason: "type-not-allowed" },
440
- });
441
- assert.strictEqual(
442
- await storage.extractedResultExists("cfg1", "acc123", "msg456", "2"),
443
- true,
444
- );
445
- });
446
-
447
- test("listExtractedTexts returns only .txt.gz artifacts, not skip markers", async () => {
448
- const storage = createMockStorageService();
449
-
450
- await storage.storeExtractedText({
451
- accountConfigId: "cfg1",
452
- accountId: "acc123",
453
- messageId: "msg456",
454
- partPath: "1",
455
- text: "first",
456
- });
457
- await storage.storeExtractedText({
458
- accountConfigId: "cfg1",
459
- accountId: "acc123",
460
- messageId: "msg456",
461
- partPath: "2.1",
462
- text: "second",
463
- });
464
- await storage.storeExtractedSkipped({
465
- accountConfigId: "cfg1",
466
- accountId: "acc123",
467
- messageId: "msg456",
468
- partPath: "3",
469
- marker: { status: "failed", reason: "pdf: corrupt" },
470
- });
471
- // A different message must not leak into the list.
472
- await storage.storeExtractedText({
473
- accountConfigId: "cfg1",
474
- accountId: "acc123",
475
- messageId: "other-msg",
476
- partPath: "1",
477
- text: "unrelated",
478
- });
479
-
480
- const items = await storage.listExtractedTexts("cfg1", "acc123", "msg456");
481
- assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
482
- for (const item of items) {
483
- assert.ok(item.key.endsWith(".txt.gz"));
484
- }
485
- });
486
342
  });
487
343
 
488
344
  describe("createFilesystemStorageService", () => {
@@ -670,102 +526,6 @@ describe("createFilesystemStorageService", () => {
670
526
  assert.strictEqual(miss, null);
671
527
  });
672
528
 
673
- test("storeExtractedText / retrieveExtractedText round-trip with gzip", async () => {
674
- const storage = createFilesystemStorageService(testBasePath);
675
-
676
- const ref = await storage.storeExtractedText({
677
- accountConfigId: "cfg1",
678
- accountId: "acc123",
679
- messageId: "msg-extracted-fs",
680
- partPath: "1",
681
- text: "fs extracted text",
682
- });
683
-
684
- assert.strictEqual(
685
- ref.storageKey,
686
- "accounts/cfg1/acc123/messages/msg-extracted-fs/extracted/1.txt.gz",
687
- );
688
- assert.strictEqual(ref.contentEncoding, ContentEncoding.Gzip);
689
-
690
- const retrieved = await storage.retrieveExtractedText(
691
- "cfg1",
692
- "acc123",
693
- "msg-extracted-fs",
694
- "1",
695
- );
696
- assert.strictEqual(retrieved, "fs extracted text");
697
- });
698
-
699
- test("storeExtractedSkipped writes an uncompressed JSON marker that satisfies extractedResultExists", async () => {
700
- const storage = createFilesystemStorageService(testBasePath);
701
-
702
- assert.strictEqual(
703
- await storage.extractedResultExists("cfg1", "acc123", "msg-skip-fs", "1"),
704
- false,
705
- );
706
-
707
- const ref = await storage.storeExtractedSkipped({
708
- accountConfigId: "cfg1",
709
- accountId: "acc123",
710
- messageId: "msg-skip-fs",
711
- partPath: "1",
712
- marker: { status: "skipped", reason: "too-large" },
713
- });
714
-
715
- assert.strictEqual(
716
- ref.storageKey,
717
- "accounts/cfg1/acc123/messages/msg-skip-fs/extracted/1.skipped.json",
718
- );
719
- assert.strictEqual(ref.contentEncoding, ContentEncoding.None);
720
- assert.strictEqual(
721
- await storage.extractedResultExists("cfg1", "acc123", "msg-skip-fs", "1"),
722
- true,
723
- );
724
- });
725
-
726
- test("listExtractedTexts lists only .txt.gz artifacts for the given message", async () => {
727
- const storage = createFilesystemStorageService(testBasePath);
728
-
729
- await storage.storeExtractedText({
730
- accountConfigId: "cfg1",
731
- accountId: "acc123",
732
- messageId: "msg-list-fs",
733
- partPath: "1",
734
- text: "one",
735
- });
736
- await storage.storeExtractedText({
737
- accountConfigId: "cfg1",
738
- accountId: "acc123",
739
- messageId: "msg-list-fs",
740
- partPath: "2.1",
741
- text: "two",
742
- });
743
- await storage.storeExtractedSkipped({
744
- accountConfigId: "cfg1",
745
- accountId: "acc123",
746
- messageId: "msg-list-fs",
747
- partPath: "3",
748
- marker: { status: "failed", reason: "doc: corrupt" },
749
- });
750
-
751
- const items = await storage.listExtractedTexts(
752
- "cfg1",
753
- "acc123",
754
- "msg-list-fs",
755
- );
756
- assert.deepStrictEqual(items.map((i) => i.partPath).sort(), ["1", "2.1"]);
757
- });
758
-
759
- test("listExtractedTexts returns an empty array when no extraction has happened yet", async () => {
760
- const storage = createFilesystemStorageService(testBasePath);
761
- const items = await storage.listExtractedTexts(
762
- "cfg1",
763
- "acc123",
764
- "msg-never-scanned",
765
- );
766
- assert.deepStrictEqual(items, []);
767
- });
768
-
769
529
  test("storeOutboxAttachment writes the bytes uncompressed so the size on disk is the size the cap counts", async () => {
770
530
  const storage = createFilesystemStorageService(testBasePath);
771
531
  // Highly compressible: gzip would make the stored size disagree with the
package/src/storage.ts CHANGED
@@ -128,41 +128,6 @@ export interface StoreParsedBodyParams {
128
128
  parsed: ParsedBody;
129
129
  }
130
130
 
131
- /** Parameters for storing extracted attachment text (attachment-scan worker, #450) */
132
- export interface StoreExtractedTextParams {
133
- accountConfigId: string;
134
- accountId: string;
135
- messageId: string;
136
- partPath: string;
137
- text: string;
138
- }
139
-
140
- /**
141
- * Marker written in place of extracted text when extraction is skipped or
142
- * fails, so the idempotency check terminates retries on a poison or
143
- * unsupported attachment instead of re-running extraction on every
144
- * redelivery.
145
- */
146
- export interface ExtractedTextMarker {
147
- status: "skipped" | "failed";
148
- reason: string;
149
- }
150
-
151
- /** Parameters for storing an extraction skip/failure marker */
152
- export interface StoreExtractedSkippedParams {
153
- accountConfigId: string;
154
- accountId: string;
155
- messageId: string;
156
- partPath: string;
157
- marker: ExtractedTextMarker;
158
- }
159
-
160
- /** One extracted-text artifact found under a message's `extracted/` prefix */
161
- export interface ExtractedTextListItem {
162
- partPath: string;
163
- key: string;
164
- }
165
-
166
131
  export interface StorageService {
167
132
  /** Store a message body (raw RFC822 content) */
168
133
  storeMessageBody(params: StoreMessageBodyParams): Promise<StorageReference>;
@@ -266,6 +231,17 @@ export interface StorageService {
266
231
  outboxAttachmentId: string,
267
232
  ): Promise<{ sizeBytes: number } | null>;
268
233
 
234
+ /**
235
+ * The bytes of one attachment, for building the MIME part it goes out as.
236
+ * Null when nothing is stored under the key.
237
+ */
238
+ retrieveOutboxAttachment(
239
+ accountConfigId: string,
240
+ accountId: string,
241
+ outboxMessageId: string,
242
+ outboxAttachmentId: string,
243
+ ): Promise<Buffer | null>;
244
+
269
245
  /**
270
246
  * Mint somewhere to PUT one file. The backend decides what that is — block
271
247
  * storage direct, or this deployment's upload route — and the caller never
@@ -290,48 +266,6 @@ export interface StorageService {
290
266
  messageId: string,
291
267
  ): Promise<ParsedBody | null>;
292
268
 
293
- /** Store extracted attachment text (attachment-scan worker, #450) */
294
- storeExtractedText(
295
- params: StoreExtractedTextParams,
296
- ): Promise<StorageReference>;
297
-
298
- /** Store a skip/failure marker in place of extracted text */
299
- storeExtractedSkipped(
300
- params: StoreExtractedSkippedParams,
301
- ): Promise<StorageReference>;
302
-
303
- /**
304
- * Whether an extraction result — either the text artifact or the
305
- * skip/failure marker — already exists for this part. Makes extraction
306
- * idempotent: a redelivered SQS message short-circuits instead of
307
- * re-running extraction (#450).
308
- */
309
- extractedResultExists(
310
- accountConfigId: string,
311
- accountId: string,
312
- messageId: string,
313
- partPath: string,
314
- ): Promise<boolean>;
315
-
316
- /** Retrieve previously extracted text; returns null on NoSuchKey */
317
- retrieveExtractedText(
318
- accountConfigId: string,
319
- accountId: string,
320
- messageId: string,
321
- partPath: string,
322
- ): Promise<string | null>;
323
-
324
- /**
325
- * List every extracted-text artifact stored for a message — the
326
- * `.skipped.json` markers are excluded. Used by the search-index worker
327
- * (#452) to fold attachment text into a message's vectors.
328
- */
329
- listExtractedTexts(
330
- accountConfigId: string,
331
- accountId: string,
332
- messageId: string,
333
- ): Promise<ExtractedTextListItem[]>;
334
-
335
269
  /** Retrieve a raw message body (RFC822 .eml) by account/message id; returns null on NoSuchKey */
336
270
  retrieveMessageBody(
337
271
  accountConfigId: string,
@@ -409,29 +343,6 @@ export const buildBodyPartKey = (
409
343
  ): string =>
410
344
  `accounts/${accountConfigId}/${accountId}/messages/${messageId}/parts/${partPath}`;
411
345
 
412
- export const buildExtractedTextKey = (
413
- accountConfigId: string,
414
- accountId: string,
415
- messageId: string,
416
- partPath: string,
417
- ): string =>
418
- `${buildExtractedPrefix(accountConfigId, accountId, messageId)}${partPath}.txt.gz`;
419
-
420
- export const buildExtractedSkippedKey = (
421
- accountConfigId: string,
422
- accountId: string,
423
- messageId: string,
424
- partPath: string,
425
- ): string =>
426
- `${buildExtractedPrefix(accountConfigId, accountId, messageId)}${partPath}.skipped.json`;
427
-
428
- export const buildExtractedPrefix = (
429
- accountConfigId: string,
430
- accountId: string,
431
- messageId: string,
432
- ): string =>
433
- `accounts/${accountConfigId}/${accountId}/messages/${messageId}/extracted/`;
434
-
435
346
  export const buildOutboxAttachmentPrefix = (
436
347
  accountConfigId: string,
437
348
  accountId: string,
@@ -650,6 +561,12 @@ export const createMockStorageService = (): StorageService => {
650
561
  return content ? { sizeBytes: content.length } : null;
651
562
  };
652
563
 
564
+ const retrieveOutboxAttachment: StorageService["retrieveOutboxAttachment"] =
565
+ async (accountConfigId, accountId, outboxMessageId, outboxAttachmentId) =>
566
+ storage.get(
567
+ `mock://${buildOutboxAttachmentKey(accountConfigId, accountId, outboxMessageId, outboxAttachmentId)}`,
568
+ ) ?? null;
569
+
653
570
  const createOutboxAttachmentUploadUrl: StorageService["createOutboxAttachmentUploadUrl"] =
654
571
  async (params) => ({
655
572
  uploadUrl: `mock://upload/${buildOutboxAttachmentKey(params.accountConfigId, params.accountId, params.outboxMessageId, params.outboxAttachmentId)}?max=${params.sizeBytes}&exp=${params.expiresAt}`,
@@ -704,68 +621,6 @@ export const createMockStorageService = (): StorageService => {
704
621
  return storage.get(uri) ?? null;
705
622
  };
706
623
 
707
- const storeExtractedText: StorageService["storeExtractedText"] = async (
708
- params,
709
- ) => {
710
- const { accountConfigId, accountId, messageId, partPath, text } = params;
711
- return storeInternal(
712
- buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
713
- Buffer.from(text, "utf8"),
714
- );
715
- };
716
-
717
- const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = async (
718
- params,
719
- ) => {
720
- const { accountConfigId, accountId, messageId, partPath, marker } = params;
721
- return storeInternal(
722
- buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath),
723
- Buffer.from(JSON.stringify(marker), "utf8"),
724
- );
725
- };
726
-
727
- const extractedResultExists: StorageService["extractedResultExists"] = async (
728
- accountConfigId,
729
- accountId,
730
- messageId,
731
- partPath,
732
- ) => {
733
- const textUri = `mock://${buildExtractedTextKey(accountConfigId, accountId, messageId, partPath)}`;
734
- const skippedUri = `mock://${buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath)}`;
735
- return storage.has(textUri) || storage.has(skippedUri);
736
- };
737
-
738
- const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
739
- accountConfigId,
740
- accountId,
741
- messageId,
742
- partPath,
743
- ) => {
744
- const uri = `mock://${buildExtractedTextKey(accountConfigId, accountId, messageId, partPath)}`;
745
- const content = storage.get(uri);
746
- if (!content) return null;
747
- return content.toString("utf8");
748
- };
749
-
750
- const listExtractedTexts: StorageService["listExtractedTexts"] = async (
751
- accountConfigId,
752
- accountId,
753
- messageId,
754
- ) => {
755
- const prefix = `mock://${buildExtractedPrefix(accountConfigId, accountId, messageId)}`;
756
- const suffix = ".txt.gz";
757
- const items: ExtractedTextListItem[] = [];
758
- for (const uri of storage.keys()) {
759
- if (!uri.startsWith(prefix) || !uri.endsWith(suffix)) continue;
760
- const key = uri.slice("mock://".length);
761
- items.push({
762
- partPath: uri.slice(prefix.length, -suffix.length),
763
- key,
764
- });
765
- }
766
- return items;
767
- };
768
-
769
624
  const retrieveMessageBody: StorageService["retrieveMessageBody"] = async (
770
625
  accountConfigId,
771
626
  accountId,
@@ -805,15 +660,11 @@ export const createMockStorageService = (): StorageService => {
805
660
  deleteOutboxAttachments,
806
661
  deleteOutboxAttachment,
807
662
  statOutboxAttachment,
663
+ retrieveOutboxAttachment,
808
664
  createOutboxAttachmentUploadUrl,
809
665
  storeDeduplicated,
810
666
  storeParsedBody,
811
667
  retrieveParsedBody,
812
- storeExtractedText,
813
- storeExtractedSkipped,
814
- extractedResultExists,
815
- retrieveExtractedText,
816
- listExtractedTexts,
817
668
  retrieveMessageBody,
818
669
  retrieveMessageBodyStream,
819
670
  retrieve: async (uri) => {