@remit/storage-service 0.0.1
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/README.md +267 -0
- package/package.json +44 -0
- package/src/backends/filesystem.ts +399 -0
- package/src/backends/s3.test.ts +463 -0
- package/src/backends/s3.ts +524 -0
- package/src/filesystem.ts +1 -0
- package/src/index.ts +34 -0
- package/src/s3.ts +20 -0
- package/src/storage.test.ts +750 -0
- package/src/storage.ts +589 -0
- package/src/uri.ts +47 -0
- package/tsconfig.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# @remit/storage-service
|
|
2
|
+
|
|
3
|
+
Abstract storage service for email body parts and raw message content. Provides a unified interface for storing and retrieving binary content with configurable backends (S3 or local filesystem).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -w packages/storage-service
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createStorageService } from "@remit/storage-service";
|
|
15
|
+
import { ContentEncoding } from "@remit/domain-enums";
|
|
16
|
+
|
|
17
|
+
const storage = createStorageService();
|
|
18
|
+
|
|
19
|
+
// Store content
|
|
20
|
+
const ref = await storage.store(Buffer.from("Hello, world!"), {
|
|
21
|
+
key: "accounts/abc123/messages/def456/body",
|
|
22
|
+
contentEncoding: ContentEncoding.Gzip,
|
|
23
|
+
contentType: "text/plain; charset=utf-8",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Retrieve content
|
|
27
|
+
const content = await storage.retrieve(ref.uri);
|
|
28
|
+
|
|
29
|
+
// Check existence
|
|
30
|
+
if (await storage.exists(ref.uri)) {
|
|
31
|
+
await storage.delete(ref.uri);
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Environment Variables
|
|
36
|
+
|
|
37
|
+
| Variable | Required | Description |
|
|
38
|
+
| -------------------- | -------- | -------------------------------------------- |
|
|
39
|
+
| `S3_BUCKET_NAME` | No | S3 bucket name. If set, S3 backend is used |
|
|
40
|
+
| `S3_ENDPOINT` | No | Custom S3 endpoint (for LocalStack/MinIO) |
|
|
41
|
+
| `STORAGE_LOCAL_PATH` | No | Local filesystem path. Default: `.remit/storage` |
|
|
42
|
+
|
|
43
|
+
### Backend Selection
|
|
44
|
+
|
|
45
|
+
The factory function automatically selects the backend:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
if (S3_BUCKET_NAME is defined) → S3 backend
|
|
49
|
+
else → Filesystem backend
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
### StorageService
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
interface StorageService {
|
|
58
|
+
store(content: Buffer, options: StoreOptions): Promise<StorageReference>;
|
|
59
|
+
retrieve(uri: string): Promise<Buffer>;
|
|
60
|
+
exists(uri: string): Promise<boolean>;
|
|
61
|
+
delete(uri: string): Promise<void>;
|
|
62
|
+
contentAddressableKey(content: Buffer, prefix?: string): string;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### StoreOptions
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
interface StoreOptions {
|
|
70
|
+
key: string; // Storage path
|
|
71
|
+
contentEncoding?: ContentEncoding; // Compression (default: none)
|
|
72
|
+
contentType?: string; // MIME type for S3 metadata
|
|
73
|
+
contentAddressable?: boolean; // Use content hash as key
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### StorageReference
|
|
78
|
+
|
|
79
|
+
Returned by `store()`, contains all information needed to retrieve or reference the stored content:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
interface StorageReference {
|
|
83
|
+
uri: string; // Full URI: s3://bucket/key or file:///path
|
|
84
|
+
storageType: StorageTypeValue; // "s3" | "filesystem"
|
|
85
|
+
storageLocation: string; // Bucket name or base path
|
|
86
|
+
storageKey: string; // Object key or relative path
|
|
87
|
+
sizeBytes: number; // Size after compression
|
|
88
|
+
checksumSha256: string; // Hex-encoded SHA-256 of original content
|
|
89
|
+
contentEncoding: ContentEncoding;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## URI Format
|
|
94
|
+
|
|
95
|
+
Storage references use URIs that encode all information needed to retrieve content:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
s3://bucket-name/path/to/object
|
|
99
|
+
file:///var/data/remit/path/to/file
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### URI Utilities
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { parseStorageUri, buildStorageUri } from "@remit/storage-service";
|
|
106
|
+
import { StorageType } from "@remit/domain-enums";
|
|
107
|
+
|
|
108
|
+
// Parse URI into components
|
|
109
|
+
const parsed = parseStorageUri("s3://my-bucket/path/to/object");
|
|
110
|
+
// { storageType: "s3", storageLocation: "my-bucket", storageKey: "path/to/object" }
|
|
111
|
+
|
|
112
|
+
// Build URI from components
|
|
113
|
+
const uri = buildStorageUri(StorageType.S3, "my-bucket", "path/to/object");
|
|
114
|
+
// "s3://my-bucket/path/to/object"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Content-Addressable Storage
|
|
118
|
+
|
|
119
|
+
For deduplication of attachments that may appear in multiple messages:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
const storage = createStorageService();
|
|
123
|
+
const attachmentBuffer = Buffer.from(/* ... */);
|
|
124
|
+
|
|
125
|
+
// Generate content-addressable key
|
|
126
|
+
const key = storage.contentAddressableKey(attachmentBuffer);
|
|
127
|
+
// "dedup/a3/a3f2b8c9d0e1f2..."
|
|
128
|
+
|
|
129
|
+
// Check if already stored
|
|
130
|
+
const dedupUri = `s3://${bucketName}/${key}`;
|
|
131
|
+
if (await storage.exists(dedupUri)) {
|
|
132
|
+
// Content already stored, just reference it
|
|
133
|
+
return { uri: dedupUri, isDeduped: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Store with content-addressable key
|
|
137
|
+
const ref = await storage.store(attachmentBuffer, {
|
|
138
|
+
key: "ignored-when-content-addressable",
|
|
139
|
+
contentAddressable: true,
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Compression
|
|
144
|
+
|
|
145
|
+
Gzip compression is automatically handled:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// Store with compression
|
|
149
|
+
const ref = await storage.store(content, {
|
|
150
|
+
key: "messages/123/body",
|
|
151
|
+
contentEncoding: ContentEncoding.Gzip,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Retrieve automatically decompresses
|
|
155
|
+
const original = await storage.retrieve(ref.uri);
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Using Specific Backends
|
|
159
|
+
|
|
160
|
+
For cases where you need direct access to a specific backend:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import {
|
|
164
|
+
createS3StorageService,
|
|
165
|
+
createFilesystemStorageService,
|
|
166
|
+
} from "@remit/storage-service";
|
|
167
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
168
|
+
|
|
169
|
+
// S3 backend
|
|
170
|
+
const s3Client = new S3Client({ endpoint: "http://localhost:4566" });
|
|
171
|
+
const s3Storage = createS3StorageService(s3Client, "my-bucket");
|
|
172
|
+
|
|
173
|
+
// Filesystem backend
|
|
174
|
+
const fsStorage = createFilesystemStorageService("/var/data/remit");
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Testing
|
|
178
|
+
|
|
179
|
+
### Mock Service
|
|
180
|
+
|
|
181
|
+
For unit tests, use the mock implementation:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { createMockStorageService } from "@remit/storage-service";
|
|
185
|
+
|
|
186
|
+
const storage = createMockStorageService();
|
|
187
|
+
|
|
188
|
+
// Works like the real service but stores in memory
|
|
189
|
+
const ref = await storage.store(Buffer.from("test"), { key: "test.txt" });
|
|
190
|
+
const content = await storage.retrieve(ref.uri);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Running Tests
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# Run package tests
|
|
197
|
+
npm test -w packages/storage-service
|
|
198
|
+
|
|
199
|
+
# With LocalStack for S3 tests
|
|
200
|
+
S3_BUCKET_NAME=test-bucket S3_ENDPOINT=http://localhost:4566 npm test -w packages/storage-service
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Key Path Conventions
|
|
204
|
+
|
|
205
|
+
Consistent key paths enable efficient listing and cleanup:
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
accounts/{accountId}/
|
|
209
|
+
messages/{messageId}/
|
|
210
|
+
raw # Raw RFC822 message
|
|
211
|
+
parts/
|
|
212
|
+
{partPath} # Body part content (1, 1.1, 2.1.3, etc.)
|
|
213
|
+
|
|
214
|
+
dedup/
|
|
215
|
+
{hash[0:2]}/
|
|
216
|
+
{hash} # Content-addressable storage for attachments
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Integration with ElectroDB Entities
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
import { BodyPartStorageService } from "@remit/remit-electrodb-service";
|
|
223
|
+
import { createStorageService } from "@remit/storage-service";
|
|
224
|
+
|
|
225
|
+
const storage = createStorageService();
|
|
226
|
+
const bodyPartStorageService = new BodyPartStorageService(config);
|
|
227
|
+
|
|
228
|
+
// Store content and create entity reference
|
|
229
|
+
const ref = await storage.store(content, {
|
|
230
|
+
key: `messages/${messageId}/parts/${partPath}`,
|
|
231
|
+
contentEncoding: ContentEncoding.Gzip,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await bodyPartStorageService.create({
|
|
235
|
+
messageId,
|
|
236
|
+
bodyPartId,
|
|
237
|
+
storageType: ref.storageType,
|
|
238
|
+
storageLocation: ref.storageLocation,
|
|
239
|
+
storageKey: ref.storageKey,
|
|
240
|
+
decodedSizeBytes: content.length,
|
|
241
|
+
checksumSha256: ref.checksumSha256,
|
|
242
|
+
contentEncoding: ref.contentEncoding,
|
|
243
|
+
isDeduped: false,
|
|
244
|
+
storedAt: Date.now(),
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Exports
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// Factory functions
|
|
252
|
+
export { createStorageService, createMockStorageService } from "./storage.js";
|
|
253
|
+
export { createFilesystemStorageService } from "./backends/filesystem.js";
|
|
254
|
+
export { createS3StorageService } from "./backends/s3.js";
|
|
255
|
+
|
|
256
|
+
// URI utilities
|
|
257
|
+
export { parseStorageUri, buildStorageUri } from "./uri.js";
|
|
258
|
+
|
|
259
|
+
// Types
|
|
260
|
+
export type {
|
|
261
|
+
StorageService,
|
|
262
|
+
StorageReference,
|
|
263
|
+
StoreOptions,
|
|
264
|
+
} from "./storage.js";
|
|
265
|
+
export type { StorageTypeValue, ContentEncodingValue } from "./storage.js";
|
|
266
|
+
export type { ParsedStorageUri } from "./uri.js";
|
|
267
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/storage-service",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./filesystem": {
|
|
13
|
+
"types": "./src/filesystem.ts",
|
|
14
|
+
"default": "./src/filesystem.ts"
|
|
15
|
+
},
|
|
16
|
+
"./s3": {
|
|
17
|
+
"types": "./src/s3.ts",
|
|
18
|
+
"default": "./src/s3.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test:typecheck": "tsgo --noEmit",
|
|
23
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.test.ts'",
|
|
24
|
+
"test": "npm run test:typecheck && npm run test:run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@aws-sdk/client-s3": "*",
|
|
28
|
+
"@aws-sdk/lib-storage": "*",
|
|
29
|
+
"@aws-sdk/s3-request-presigner": "*"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@remit/domain-enums": "*",
|
|
33
|
+
"aws-sdk-client-mock": "*"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/remit-mail/remit.git",
|
|
42
|
+
"directory": "packages/storage-service"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
mkdir,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
stat,
|
|
8
|
+
unlink,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { PassThrough, Readable } from "node:stream";
|
|
13
|
+
import { pipeline } from "node:stream/promises";
|
|
14
|
+
import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
|
|
15
|
+
import { ContentEncoding, StorageType } from "@remit/domain-enums";
|
|
16
|
+
import type {
|
|
17
|
+
ParsedBody,
|
|
18
|
+
StorageReference,
|
|
19
|
+
StorageService,
|
|
20
|
+
} from "../storage.js";
|
|
21
|
+
import {
|
|
22
|
+
buildBodyPartKey,
|
|
23
|
+
buildDeduplicatedKey,
|
|
24
|
+
buildExportArchiveKey,
|
|
25
|
+
buildExtractedPrefix,
|
|
26
|
+
buildExtractedSkippedKey,
|
|
27
|
+
buildExtractedTextKey,
|
|
28
|
+
buildMessageBodyKey,
|
|
29
|
+
buildParsedBodyKey,
|
|
30
|
+
computeChecksum,
|
|
31
|
+
isStorageNotFoundError,
|
|
32
|
+
} from "../storage.js";
|
|
33
|
+
|
|
34
|
+
interface StoreParams {
|
|
35
|
+
key: string;
|
|
36
|
+
content: Buffer;
|
|
37
|
+
compress?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const createFilesystemStorageService = (
|
|
41
|
+
basePath: string,
|
|
42
|
+
): StorageService => {
|
|
43
|
+
const storeInternal = async (
|
|
44
|
+
params: StoreParams,
|
|
45
|
+
): Promise<StorageReference> => {
|
|
46
|
+
const { key, content, compress = true } = params;
|
|
47
|
+
const checksumSha256 = computeChecksum(content);
|
|
48
|
+
const contentEncoding = compress
|
|
49
|
+
? ContentEncoding.Gzip
|
|
50
|
+
: ContentEncoding.None;
|
|
51
|
+
const body = compress ? gzipSync(content) : content;
|
|
52
|
+
|
|
53
|
+
const fullPath = join(basePath, key);
|
|
54
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
55
|
+
await writeFile(fullPath, body);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
uri: `file://${fullPath}`,
|
|
59
|
+
storageType: StorageType.Filesystem,
|
|
60
|
+
storageLocation: basePath,
|
|
61
|
+
storageKey: key,
|
|
62
|
+
sizeBytes: body.length,
|
|
63
|
+
checksumSha256,
|
|
64
|
+
contentEncoding,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const storeMessageBody: StorageService["storeMessageBody"] = (params) => {
|
|
69
|
+
const { accountConfigId, accountId, messageId, content } = params;
|
|
70
|
+
return storeInternal({
|
|
71
|
+
key: buildMessageBodyKey(accountConfigId, accountId, messageId),
|
|
72
|
+
content,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const storeMessageBodyStream: StorageService["storeMessageBodyStream"] =
|
|
77
|
+
async (params) => {
|
|
78
|
+
const { accountConfigId, accountId, messageId, content } = params;
|
|
79
|
+
const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
|
|
80
|
+
const fullPath = join(basePath, key);
|
|
81
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
82
|
+
|
|
83
|
+
const hash = createHash("sha256");
|
|
84
|
+
const hashTap = new PassThrough();
|
|
85
|
+
hashTap.on("data", (chunk: Buffer) => hash.update(chunk));
|
|
86
|
+
|
|
87
|
+
await pipeline(
|
|
88
|
+
content,
|
|
89
|
+
hashTap,
|
|
90
|
+
createGzip(),
|
|
91
|
+
createWriteStream(fullPath),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const { size } = await stat(fullPath);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
uri: `file://${fullPath}`,
|
|
98
|
+
storageType: StorageType.Filesystem,
|
|
99
|
+
storageLocation: basePath,
|
|
100
|
+
storageKey: key,
|
|
101
|
+
sizeBytes: size,
|
|
102
|
+
checksumSha256: hash.digest("hex"),
|
|
103
|
+
contentEncoding: ContentEncoding.Gzip,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const storeBodyPart: StorageService["storeBodyPart"] = (params) => {
|
|
108
|
+
const { accountConfigId, accountId, messageId, partPath, content } = params;
|
|
109
|
+
return storeInternal({
|
|
110
|
+
key: buildBodyPartKey(accountConfigId, accountId, messageId, partPath),
|
|
111
|
+
content,
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const bodyPartExists: StorageService["bodyPartExists"] = (
|
|
116
|
+
accountConfigId,
|
|
117
|
+
accountId,
|
|
118
|
+
messageId,
|
|
119
|
+
partPath,
|
|
120
|
+
) => {
|
|
121
|
+
const key = buildBodyPartKey(
|
|
122
|
+
accountConfigId,
|
|
123
|
+
accountId,
|
|
124
|
+
messageId,
|
|
125
|
+
partPath,
|
|
126
|
+
);
|
|
127
|
+
const fullPath = join(basePath, key);
|
|
128
|
+
return stat(fullPath)
|
|
129
|
+
.then(() => true)
|
|
130
|
+
.catch(() => false);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const retrieveBodyPart: StorageService["retrieveBodyPart"] = async (
|
|
134
|
+
accountConfigId,
|
|
135
|
+
accountId,
|
|
136
|
+
messageId,
|
|
137
|
+
partPath,
|
|
138
|
+
) => {
|
|
139
|
+
const key = buildBodyPartKey(
|
|
140
|
+
accountConfigId,
|
|
141
|
+
accountId,
|
|
142
|
+
messageId,
|
|
143
|
+
partPath,
|
|
144
|
+
);
|
|
145
|
+
const fullPath = join(basePath, key);
|
|
146
|
+
const buffer = await readFile(fullPath).catch((error: unknown) => {
|
|
147
|
+
if (isStorageNotFoundError(error)) return null;
|
|
148
|
+
throw error;
|
|
149
|
+
});
|
|
150
|
+
if (!buffer) return null;
|
|
151
|
+
return buffer[0] === 0x1f && buffer[1] === 0x8b
|
|
152
|
+
? gunzipSync(buffer)
|
|
153
|
+
: buffer;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const storeExtractedText: StorageService["storeExtractedText"] = (params) => {
|
|
157
|
+
const { accountConfigId, accountId, messageId, partPath, text } = params;
|
|
158
|
+
return storeInternal({
|
|
159
|
+
key: buildExtractedTextKey(
|
|
160
|
+
accountConfigId,
|
|
161
|
+
accountId,
|
|
162
|
+
messageId,
|
|
163
|
+
partPath,
|
|
164
|
+
),
|
|
165
|
+
content: Buffer.from(text, "utf8"),
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const storeExtractedSkipped: StorageService["storeExtractedSkipped"] = (
|
|
170
|
+
params,
|
|
171
|
+
) => {
|
|
172
|
+
const { accountConfigId, accountId, messageId, partPath, marker } = params;
|
|
173
|
+
return storeInternal({
|
|
174
|
+
key: buildExtractedSkippedKey(
|
|
175
|
+
accountConfigId,
|
|
176
|
+
accountId,
|
|
177
|
+
messageId,
|
|
178
|
+
partPath,
|
|
179
|
+
),
|
|
180
|
+
content: Buffer.from(JSON.stringify(marker), "utf8"),
|
|
181
|
+
compress: false,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const extractedResultExists: StorageService["extractedResultExists"] = async (
|
|
186
|
+
accountConfigId,
|
|
187
|
+
accountId,
|
|
188
|
+
messageId,
|
|
189
|
+
partPath,
|
|
190
|
+
) => {
|
|
191
|
+
const textPath = join(
|
|
192
|
+
basePath,
|
|
193
|
+
buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
|
|
194
|
+
);
|
|
195
|
+
const skippedPath = join(
|
|
196
|
+
basePath,
|
|
197
|
+
buildExtractedSkippedKey(accountConfigId, accountId, messageId, partPath),
|
|
198
|
+
);
|
|
199
|
+
const exists = (fullPath: string): Promise<boolean> =>
|
|
200
|
+
stat(fullPath)
|
|
201
|
+
.then(() => true)
|
|
202
|
+
.catch(() => false);
|
|
203
|
+
|
|
204
|
+
const [textExists, skippedExists] = await Promise.all([
|
|
205
|
+
exists(textPath),
|
|
206
|
+
exists(skippedPath),
|
|
207
|
+
]);
|
|
208
|
+
return textExists || skippedExists;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const retrieveExtractedText: StorageService["retrieveExtractedText"] = async (
|
|
212
|
+
accountConfigId,
|
|
213
|
+
accountId,
|
|
214
|
+
messageId,
|
|
215
|
+
partPath,
|
|
216
|
+
) => {
|
|
217
|
+
const fullPath = join(
|
|
218
|
+
basePath,
|
|
219
|
+
buildExtractedTextKey(accountConfigId, accountId, messageId, partPath),
|
|
220
|
+
);
|
|
221
|
+
const buffer = await readFile(fullPath).catch((error: unknown) => {
|
|
222
|
+
if (isStorageNotFoundError(error)) return null;
|
|
223
|
+
throw error;
|
|
224
|
+
});
|
|
225
|
+
if (!buffer) return null;
|
|
226
|
+
const decoded =
|
|
227
|
+
buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer;
|
|
228
|
+
return decoded.toString("utf8");
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const listExtractedTexts: StorageService["listExtractedTexts"] = async (
|
|
232
|
+
accountConfigId,
|
|
233
|
+
accountId,
|
|
234
|
+
messageId,
|
|
235
|
+
) => {
|
|
236
|
+
const prefix = buildExtractedPrefix(accountConfigId, accountId, messageId);
|
|
237
|
+
const suffix = ".txt.gz";
|
|
238
|
+
const dirPath = join(basePath, prefix);
|
|
239
|
+
|
|
240
|
+
const entries = await readdir(dirPath, { recursive: true }).catch(
|
|
241
|
+
(error: unknown) => {
|
|
242
|
+
if (isStorageNotFoundError(error)) return [];
|
|
243
|
+
throw error;
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return entries
|
|
248
|
+
.filter((entry) => entry.endsWith(suffix))
|
|
249
|
+
.map((entry) => ({
|
|
250
|
+
partPath: entry.slice(0, -suffix.length),
|
|
251
|
+
key: `${prefix}${entry}`,
|
|
252
|
+
}));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const storeDeduplicated: StorageService["storeDeduplicated"] = (params) => {
|
|
256
|
+
const { accountConfigId, accountId, content } = params;
|
|
257
|
+
const checksumSha256 = computeChecksum(content);
|
|
258
|
+
return storeInternal({
|
|
259
|
+
key: buildDeduplicatedKey(accountConfigId, accountId, checksumSha256),
|
|
260
|
+
content,
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const storeParsedBody: StorageService["storeParsedBody"] = (params) => {
|
|
265
|
+
const { accountConfigId, accountId, messageId, parsed } = params;
|
|
266
|
+
const content = Buffer.from(JSON.stringify(parsed), "utf8");
|
|
267
|
+
return storeInternal({
|
|
268
|
+
key: buildParsedBodyKey(accountConfigId, accountId, messageId),
|
|
269
|
+
content,
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const retrieveParsedBody: StorageService["retrieveParsedBody"] = async (
|
|
274
|
+
accountConfigId,
|
|
275
|
+
accountId,
|
|
276
|
+
messageId,
|
|
277
|
+
) => {
|
|
278
|
+
const key = buildParsedBodyKey(accountConfigId, accountId, messageId);
|
|
279
|
+
const fullPath = join(basePath, key);
|
|
280
|
+
|
|
281
|
+
const buffer = await readFile(fullPath).catch((error: unknown) => {
|
|
282
|
+
if (isStorageNotFoundError(error)) return null;
|
|
283
|
+
throw error;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (!buffer) return null;
|
|
287
|
+
|
|
288
|
+
const decoded =
|
|
289
|
+
buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer;
|
|
290
|
+
return JSON.parse(decoded.toString("utf8")) as ParsedBody;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Resolve path from URI, handling both absolute and relative paths
|
|
294
|
+
// Note: URIs like file://.remit/storage/... parse incorrectly - '.remit' becomes hostname
|
|
295
|
+
// We reconstruct the original path by combining hostname + pathname
|
|
296
|
+
const resolvePathFromUri = (uri: string): string => {
|
|
297
|
+
const url = new URL(uri);
|
|
298
|
+
const hostname = url.hostname;
|
|
299
|
+
const pathname = url.pathname;
|
|
300
|
+
|
|
301
|
+
// If hostname exists, it was part of a relative path (e.g., file://.remit/... -> hostname='.remit')
|
|
302
|
+
if (hostname) {
|
|
303
|
+
const relativePath = hostname + pathname;
|
|
304
|
+
return resolve(relativePath);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Absolute path (file:///absolute/path)
|
|
308
|
+
return pathname;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const retrieve = async (uri: string): Promise<Buffer> => {
|
|
312
|
+
const fullPath = resolvePathFromUri(uri);
|
|
313
|
+
|
|
314
|
+
const buffer = await readFile(fullPath);
|
|
315
|
+
|
|
316
|
+
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
317
|
+
return gunzipSync(buffer);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return buffer;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const exists = async (uri: string): Promise<boolean> => {
|
|
324
|
+
const fullPath = resolvePathFromUri(uri);
|
|
325
|
+
return stat(fullPath)
|
|
326
|
+
.then(() => true)
|
|
327
|
+
.catch(() => false);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const del = async (uri: string): Promise<void> => {
|
|
331
|
+
const fullPath = resolvePathFromUri(uri);
|
|
332
|
+
await unlink(fullPath);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
storeMessageBody,
|
|
337
|
+
storeMessageBodyStream,
|
|
338
|
+
storeBodyPart,
|
|
339
|
+
bodyPartExists,
|
|
340
|
+
retrieveBodyPart,
|
|
341
|
+
storeDeduplicated,
|
|
342
|
+
storeParsedBody,
|
|
343
|
+
retrieveParsedBody,
|
|
344
|
+
storeExtractedText,
|
|
345
|
+
storeExtractedSkipped,
|
|
346
|
+
extractedResultExists,
|
|
347
|
+
retrieveExtractedText,
|
|
348
|
+
listExtractedTexts,
|
|
349
|
+
retrieveMessageBody: async (accountConfigId, accountId, messageId) => {
|
|
350
|
+
const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
|
|
351
|
+
const fullPath = join(basePath, key);
|
|
352
|
+
const buffer = await readFile(fullPath).catch((error: unknown) => {
|
|
353
|
+
if (isStorageNotFoundError(error)) return null;
|
|
354
|
+
throw error;
|
|
355
|
+
});
|
|
356
|
+
if (!buffer) return null;
|
|
357
|
+
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
358
|
+
return gunzipSync(buffer);
|
|
359
|
+
}
|
|
360
|
+
return buffer;
|
|
361
|
+
},
|
|
362
|
+
retrieveMessageBodyStream: async (
|
|
363
|
+
accountConfigId,
|
|
364
|
+
accountId,
|
|
365
|
+
messageId,
|
|
366
|
+
) => {
|
|
367
|
+
const key = buildMessageBodyKey(accountConfigId, accountId, messageId);
|
|
368
|
+
const fullPath = join(basePath, key);
|
|
369
|
+
const buffer = await readFile(fullPath).catch((error: unknown) => {
|
|
370
|
+
if (isStorageNotFoundError(error)) return null;
|
|
371
|
+
throw error;
|
|
372
|
+
});
|
|
373
|
+
if (!buffer) return null;
|
|
374
|
+
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
375
|
+
return Readable.from(buffer).pipe(createGunzip());
|
|
376
|
+
}
|
|
377
|
+
return Readable.from(buffer);
|
|
378
|
+
},
|
|
379
|
+
retrieve,
|
|
380
|
+
exists,
|
|
381
|
+
delete: del,
|
|
382
|
+
storeExportArchiveStream: async (
|
|
383
|
+
accountConfigId,
|
|
384
|
+
exportRequestId,
|
|
385
|
+
body,
|
|
386
|
+
) => {
|
|
387
|
+
const key = buildExportArchiveKey(accountConfigId, exportRequestId);
|
|
388
|
+
const fullPath = join(basePath, key);
|
|
389
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
390
|
+
await pipeline(body, createWriteStream(fullPath));
|
|
391
|
+
return key;
|
|
392
|
+
},
|
|
393
|
+
getPresignedDownloadUrl: (_key: string, _expiresInSeconds: number) => {
|
|
394
|
+
throw new Error(
|
|
395
|
+
"getPresignedDownloadUrl is not supported on the filesystem backend",
|
|
396
|
+
);
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
};
|