@serve.zone/dcrouter 16.5.0 → 17.0.0

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.
Files changed (38) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +4 -1
  4. package/dist_ts/classes.dcrouter.js +24 -7
  5. package/dist_ts/db/documents/classes.smartmta-storage.doc.d.ts +8 -0
  6. package/dist_ts/db/documents/classes.smartmta-storage.doc.js +91 -0
  7. package/dist_ts/db/documents/index.d.ts +1 -0
  8. package/dist_ts/db/documents/index.js +2 -1
  9. package/dist_ts/email/classes.email-settings.manager.js +2 -1
  10. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +27 -0
  11. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +109 -0
  12. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +6 -8
  13. package/dist_ts/email/classes.smartmta-storage-manager.js +63 -86
  14. package/dist_ts/email/classes.workapp-mail-manager.js +6 -3
  15. package/dist_ts/email/index.d.ts +2 -0
  16. package/dist_ts/email/index.js +3 -1
  17. package/dist_ts/email/smartmta-storage-policy.d.ts +9 -0
  18. package/dist_ts/email/smartmta-storage-policy.js +58 -0
  19. package/dist_ts/opsserver/handlers/stats.handler.js +3 -5
  20. package/dist_ts/plugins.d.ts +2 -1
  21. package/dist_ts/plugins.js +3 -2
  22. package/dist_ts_migrations/index.d.ts +2 -0
  23. package/dist_ts_migrations/index.js +294 -12
  24. package/dist_ts_web/00_commitinfo_data.js +1 -1
  25. package/package.json +3 -2
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/classes.dcrouter.ts +34 -9
  28. package/ts/db/documents/classes.smartmta-storage.doc.ts +25 -0
  29. package/ts/db/documents/index.ts +1 -0
  30. package/ts/email/classes.email-settings.manager.ts +1 -0
  31. package/ts/email/classes.smartmta-blob-storage-manager.ts +138 -0
  32. package/ts/email/classes.smartmta-storage-manager.ts +86 -83
  33. package/ts/email/classes.workapp-mail-manager.ts +13 -3
  34. package/ts/email/index.ts +2 -0
  35. package/ts/email/smartmta-storage-policy.ts +73 -0
  36. package/ts/opsserver/handlers/stats.handler.ts +2 -4
  37. package/ts/plugins.ts +2 -1
  38. package/ts_web/00_commitinfo_data.ts +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "16.5.0",
4
+ "version": "17.0.0",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -37,6 +37,7 @@
37
37
  "@push.rocks/projectinfo": "^5.1.0",
38
38
  "@push.rocks/qenv": "^6.1.4",
39
39
  "@push.rocks/smartacme": "^9.5.0",
40
+ "@push.rocks/smartbucket": "^4.6.1",
40
41
  "@push.rocks/smartchallenge": "^1.1.0",
41
42
  "@push.rocks/smartdata": "^7.2.0",
42
43
  "@push.rocks/smartdb": "^2.12.3",
@@ -48,7 +49,7 @@
48
49
  "@push.rocks/smartmcp": "^0.2.0",
49
50
  "@push.rocks/smartmetrics": "^3.0.3",
50
51
  "@push.rocks/smartmigration": "1.4.1",
51
- "@push.rocks/smartmta": "^6.5.2",
52
+ "@push.rocks/smartmta": "^7.0.0",
52
53
  "@push.rocks/smartnetwork": "^4.8.0",
53
54
  "@push.rocks/smartpath": "^6.0.0",
54
55
  "@push.rocks/smartpromise": "^4.2.4",
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '16.5.0',
6
+ version: '17.0.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -37,7 +37,7 @@ import { DnsManager } from './dns/manager.dns.js';
37
37
  import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js';
38
38
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
39
39
  import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
40
- import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaStorageManager, WorkAppMailManager, type TSmartMtaQueueItemLike } from './email/index.js';
40
+ import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
41
41
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
42
42
  import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
43
43
  import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
@@ -62,6 +62,9 @@ export interface IDcRouterOptions {
62
62
  */
63
63
  emailConfig?: IUnifiedEmailServerOptions;
64
64
 
65
+ /** SmartBucket configuration for durable SmartMTA queue and attachment blobs. */
66
+ emailBlobStorage?: ISmartMtaBlobStorageConfig;
67
+
65
68
  /** Outbound SMTP connection mode. Defaults to direct unless DB settings override it. */
66
69
  emailOutboundMode?: TEmailOutboundMode;
67
70
 
@@ -278,7 +281,8 @@ export class DcRouter {
278
281
  listener: (...args: any[]) => void;
279
282
  }> = [];
280
283
 
281
- public storageManager: IStorageManagerLike;
284
+ public storageManager!: IStorageManagerLike;
285
+ private smartMtaBlobStorageManager?: SmartMtaBlobStorageManager;
282
286
 
283
287
  // Unified database (smartdata + LocalSmartDb or external MongoDB)
284
288
  public dcRouterDb?: DcRouterDb;
@@ -373,9 +377,6 @@ export class DcRouter {
373
377
  // Resolve all data paths from baseDir
374
378
  this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
375
379
  paths.ensureDataDirectories(this.resolvedPaths);
376
- this.storageManager = new SmartMtaStorageManager(
377
- plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
378
- );
379
380
  this.workAppMailManager = new WorkAppMailManager(this);
380
381
  this.acceptedEmailSpool = new AcceptedEmailSpool(this);
381
382
  const mailEgressIdentitySource = new ReleasedRemoteIngressEgressIdentitySource(this);
@@ -1030,6 +1031,10 @@ export class DcRouter {
1030
1031
  const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, {
1031
1032
  remoteIngressHubSettings: this.getRemoteIngressHubSettingsMigrationSeed(),
1032
1033
  emailServerSettings: this.getEmailSettingsMigrationSeed(),
1034
+ legacySmartMtaStoragePath: plugins.path.join(
1035
+ this.resolvedPaths.dataDir,
1036
+ 'smartmta-storage',
1037
+ ),
1033
1038
  });
1034
1039
  const migrationResult = await migration.run();
1035
1040
  if (migrationResult.stepsApplied.length > 0) {
@@ -1041,6 +1046,8 @@ export class DcRouter {
1041
1046
  logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`);
1042
1047
  }
1043
1048
 
1049
+ this.storageManager = new SmartMtaStorageManager(this.dcRouterDb.getDb());
1050
+
1044
1051
  // Start the cache cleaner for TTL-based document cleanup
1045
1052
  const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
1046
1053
  this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
@@ -1648,14 +1655,32 @@ export class DcRouter {
1648
1655
  : mappedEmailPorts.includes(configuredSecurePort) && !baseEmailConfig.ports.includes(configuredSecurePort)
1649
1656
  ? configuredSecurePort
1650
1657
  : portMapping[configuredSecurePort] || configuredSecurePort + 10000;
1658
+ const queueBehavior = { ...baseEmailConfig.queue } as Record<string, unknown>;
1659
+ delete queueBehavior.storageMode;
1660
+ delete queueBehavior.storageManager;
1661
+ delete queueBehavior.storageType;
1662
+ delete queueBehavior.persistentPath;
1663
+ if (this.options.emailBlobStorage && !this.smartMtaBlobStorageManager) {
1664
+ this.smartMtaBlobStorageManager = await SmartMtaBlobStorageManager.create(
1665
+ this.options.emailBlobStorage,
1666
+ );
1667
+ }
1668
+ const queueOptions: IUnifiedEmailServerOptions['queue'] = this.smartMtaBlobStorageManager
1669
+ ? {
1670
+ ...queueBehavior,
1671
+ storageMode: 'managed',
1672
+ storageManager: this.smartMtaBlobStorageManager,
1673
+ }
1674
+ : {
1675
+ ...queueBehavior,
1676
+ storageMode: 'memory',
1677
+ };
1678
+
1651
1679
  const emailConfig: IUnifiedEmailServerOptions = await this.workAppMailManager.applyStoredIdentitiesToEmailConfig({
1652
1680
  ...this.options.emailConfig,
1653
1681
  ports: mappedEmailPorts,
1654
1682
  persistRoutes: this.options.emailConfig.persistRoutes ?? false,
1655
- queue: {
1656
- ...this.options.emailConfig.queue,
1657
- storageType: 'memory',
1658
- },
1683
+ queue: queueOptions,
1659
1684
  outbound: {
1660
1685
  ...baseEmailConfig.outbound,
1661
1686
  connectionProxyProvider: (context) => this.mailEgressCoordinator.provideConnectionProxy(context),
@@ -0,0 +1,25 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+
4
+ const getDb = () => DcRouterDb.getInstance().getDb();
5
+
6
+ @plugins.smartdata.Collection(() => getDb())
7
+ export class SmartMtaStorageDoc extends plugins.smartdata.SmartDataDbDoc<
8
+ SmartMtaStorageDoc,
9
+ SmartMtaStorageDoc
10
+ > {
11
+ @plugins.smartdata.svDb()
12
+ public id!: string;
13
+
14
+ @plugins.smartdata.svDb()
15
+ public key!: string;
16
+
17
+ @plugins.smartdata.svDb()
18
+ public value!: string;
19
+
20
+ @plugins.smartdata.svDb()
21
+ public createdAt!: string;
22
+
23
+ @plugins.smartdata.svDb()
24
+ public updatedAt!: string;
25
+ }
@@ -41,3 +41,4 @@ export * from './classes.acme-config.doc.js';
41
41
  // Email domain management
42
42
  export * from './classes.email-domain.doc.js';
43
43
  export * from './classes.email-server-settings.doc.js';
44
+ export * from './classes.smartmta-storage.doc.js';
@@ -155,6 +155,7 @@ export class EmailSettingsManager {
155
155
  const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
156
156
  hostname: 'localhost',
157
157
  ports: [...defaultEmailPorts],
158
+ queue: { storageMode: 'memory' },
158
159
  domains: [],
159
160
  routes: [],
160
161
  } : undefined);
@@ -0,0 +1,138 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type { IBlobStorageManager } from '@push.rocks/smartmta';
3
+
4
+ const SMARTMTA_BLOB_NAMESPACES = [
5
+ '/email/queue/',
6
+ '/email/attachments/',
7
+ ] as const;
8
+ const DEFAULT_OBJECT_PREFIX = 'dcrouter/smartmta/';
9
+ const MAX_STORAGE_KEY_BYTES = 1024;
10
+
11
+ type TBlobBucket = Pick<
12
+ plugins.smartbucket.Bucket,
13
+ 'fastExists' | 'fastGet' | 'fastPut' | 'fastRemove' | 'listAllObjects'
14
+ >;
15
+
16
+ export interface ISmartMtaBlobStorageConfig {
17
+ storageDescriptor: plugins.tsclass.storage.IStorageDescriptor;
18
+ bucketName: string;
19
+ objectPrefix?: string;
20
+ createBucketIfMissing?: boolean;
21
+ }
22
+
23
+ function normalizeObjectPrefix(prefixArg = DEFAULT_OBJECT_PREFIX): string {
24
+ const prefix = prefixArg.trim().replace(/^\/+|\/+$/g, '');
25
+ if (!prefix || prefix.includes('\\') || prefix.includes('\0')) {
26
+ throw new Error('SmartMTA blob objectPrefix must be a non-empty object-key prefix');
27
+ }
28
+ const segments = prefix.split('/');
29
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
30
+ throw new Error('SmartMTA blob objectPrefix contains an invalid path segment');
31
+ }
32
+ return `${prefix}/`;
33
+ }
34
+
35
+ function normalizeBlobKey(keyArg: string, allowNamespaceRoot = false): string {
36
+ if (
37
+ typeof keyArg !== 'string' ||
38
+ !keyArg.startsWith('/') ||
39
+ keyArg.includes('\\') ||
40
+ keyArg.includes('\0') ||
41
+ Buffer.byteLength(keyArg, 'utf8') > MAX_STORAGE_KEY_BYTES
42
+ ) {
43
+ throw new Error('Invalid SmartMTA blob storage key');
44
+ }
45
+ const namespace = SMARTMTA_BLOB_NAMESPACES.find((candidate) => keyArg.startsWith(candidate));
46
+ if (!namespace) {
47
+ throw new Error(`Unsupported SmartMTA blob storage namespace: ${keyArg}`);
48
+ }
49
+ const remainder = keyArg.slice(namespace.length);
50
+ if (!allowNamespaceRoot && !remainder) {
51
+ throw new Error('SmartMTA blob storage key must identify an object');
52
+ }
53
+ if (remainder && remainder.split('/').some((segment) => !segment || segment === '.' || segment === '..')) {
54
+ throw new Error('SmartMTA blob storage key contains an invalid path segment');
55
+ }
56
+ return keyArg;
57
+ }
58
+
59
+ /**
60
+ * SmartMTA blob persistence backed by a SmartBucket bucket.
61
+ *
62
+ * The adapter accepts only SmartMTA queue and attachment namespaces and never
63
+ * maps object keys to local filesystem paths.
64
+ */
65
+ export class SmartMtaBlobStorageManager implements IBlobStorageManager {
66
+ private readonly objectPrefix: string;
67
+
68
+ public constructor(
69
+ private readonly bucketRef: TBlobBucket,
70
+ objectPrefix = DEFAULT_OBJECT_PREFIX,
71
+ ) {
72
+ this.objectPrefix = normalizeObjectPrefix(objectPrefix);
73
+ }
74
+
75
+ public static async create(
76
+ configArg: ISmartMtaBlobStorageConfig,
77
+ ): Promise<SmartMtaBlobStorageManager> {
78
+ const bucketName = configArg.bucketName?.trim();
79
+ if (!bucketName) {
80
+ throw new Error('SmartMTA blob storage requires a bucketName');
81
+ }
82
+
83
+ const smartBucket = new plugins.smartbucket.SmartBucket(configArg.storageDescriptor);
84
+ let bucket: plugins.smartbucket.Bucket;
85
+ if (configArg.createBucketIfMissing) {
86
+ bucket = await smartBucket.bucketExists(bucketName)
87
+ ? await smartBucket.getBucketByName(bucketName)
88
+ : await smartBucket.createBucket(bucketName);
89
+ } else {
90
+ bucket = await smartBucket.getBucketByName(bucketName);
91
+ }
92
+
93
+ return new SmartMtaBlobStorageManager(bucket, configArg.objectPrefix);
94
+ }
95
+
96
+ public async get(keyArg: string): Promise<Buffer | null> {
97
+ const objectKey = this.toObjectKey(normalizeBlobKey(keyArg));
98
+ if (!(await this.bucketRef.fastExists({ path: objectKey }))) {
99
+ return null;
100
+ }
101
+ return Buffer.from(await this.bucketRef.fastGet({ path: objectKey }));
102
+ }
103
+
104
+ public async set(keyArg: string, valueArg: Buffer): Promise<void> {
105
+ if (!Buffer.isBuffer(valueArg)) {
106
+ throw new TypeError('SmartMTA blob storage values must be Buffers');
107
+ }
108
+ const objectKey = this.toObjectKey(normalizeBlobKey(keyArg));
109
+ await this.bucketRef.fastPut({
110
+ path: objectKey,
111
+ contents: Buffer.from(valueArg),
112
+ overwrite: true,
113
+ });
114
+ }
115
+
116
+ public async list(prefixArg: string): Promise<string[]> {
117
+ const prefix = normalizeBlobKey(prefixArg, true);
118
+ const objectPrefix = this.toObjectKey(prefix);
119
+ const keys: string[] = [];
120
+ for await (const objectKey of this.bucketRef.listAllObjects(objectPrefix)) {
121
+ if (!objectKey.startsWith(this.objectPrefix)) {
122
+ throw new Error(`SmartBucket returned an object outside the configured prefix: ${objectKey}`);
123
+ }
124
+ const key = `/${objectKey.slice(this.objectPrefix.length)}`;
125
+ keys.push(normalizeBlobKey(key));
126
+ }
127
+ return keys.sort();
128
+ }
129
+
130
+ public async delete(keyArg: string): Promise<void> {
131
+ const objectKey = this.toObjectKey(normalizeBlobKey(keyArg));
132
+ await this.bucketRef.fastRemove({ path: objectKey });
133
+ }
134
+
135
+ private toObjectKey(keyArg: string): string {
136
+ return `${this.objectPrefix}${keyArg.slice(1)}`;
137
+ }
138
+ }
@@ -1,108 +1,111 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IStorageManagerLike } from '@push.rocks/smartmta';
2
+ import type { IStorageManager } from '@push.rocks/smartmta';
3
+ import {
4
+ getSmartMtaTextNamespace,
5
+ hashSmartMtaStorageKey,
6
+ normalizeSmartMtaStorageKey,
7
+ validateSmartMtaTextValue,
8
+ } from './smartmta-storage-policy.js';
3
9
 
4
- export class SmartMtaStorageManager implements IStorageManagerLike {
5
- private readonly resolvedRootDir: string;
10
+ interface ISmartMtaStorageDocument {
11
+ _id: string;
12
+ id: string;
13
+ key: string;
14
+ value: string;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ }
6
18
 
7
- constructor(private rootDir: string) {
8
- this.resolvedRootDir = plugins.path.resolve(rootDir);
9
- plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
10
- }
19
+ export class SmartMtaStorageManager implements IStorageManager {
20
+ private readonly collection: any;
11
21
 
12
- private normalizeKey(key: string): string {
13
- return key.replace(/^\/+/, '').replace(/\\/g, '/');
22
+ constructor(smartdataDb: plugins.smartdata.SmartdataDb) {
23
+ this.collection = smartdataDb.mongoDb.collection('SmartMtaStorageDoc');
14
24
  }
15
25
 
16
- private resolvePathForKey(key: string): string {
17
- const normalizedKey = this.normalizeKey(key);
18
- const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
26
+ private validateDocument(
27
+ document: ISmartMtaStorageDocument,
28
+ expectedKey?: string,
29
+ ): ISmartMtaStorageDocument {
30
+ const normalizedKey = normalizeSmartMtaStorageKey(document.key);
31
+ const expectedId = hashSmartMtaStorageKey(normalizedKey);
19
32
  if (
20
- resolvedPath !== this.resolvedRootDir
21
- && !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
33
+ document._id !== expectedId
34
+ || document.id !== expectedId
35
+ || document.key !== normalizedKey
36
+ || (expectedKey && document.key !== expectedKey)
37
+ || typeof document.value !== 'string'
22
38
  ) {
23
- throw new Error(`Storage key escapes root directory: ${key}`);
39
+ throw new Error(`Corrupt SmartMTA storage document: ${document._id}`);
24
40
  }
25
- return resolvedPath;
26
- }
27
-
28
- private toStorageKey(filePath: string): string {
29
- const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
30
- return `/${relativePath}`;
41
+ validateSmartMtaTextValue(document.key, document.value);
42
+ return document;
31
43
  }
32
44
 
33
45
  public async get(key: string): Promise<string | null> {
34
- const filePath = this.resolvePathForKey(key);
35
- try {
36
- return await plugins.fs.promises.readFile(filePath, 'utf8');
37
- } catch (error: unknown) {
38
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
39
- return null;
40
- }
41
- throw error;
42
- }
46
+ const normalizedKey = normalizeSmartMtaStorageKey(key);
47
+ getSmartMtaTextNamespace(normalizedKey);
48
+ const id = hashSmartMtaStorageKey(normalizedKey);
49
+ const document = await this.collection.findOne({ _id: id });
50
+ return document ? this.validateDocument(document, normalizedKey).value : null;
43
51
  }
44
52
 
45
53
  public async set(key: string, value: string): Promise<void> {
46
- const filePath = this.resolvePathForKey(key);
47
- await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
48
- await plugins.fs.promises.writeFile(filePath, value, 'utf8');
49
- }
54
+ const normalizedKey = normalizeSmartMtaStorageKey(key);
55
+ validateSmartMtaTextValue(normalizedKey, value);
56
+ const id = hashSmartMtaStorageKey(normalizedKey);
57
+ const existing = await this.collection.findOne({ _id: id });
58
+ if (existing) {
59
+ this.validateDocument(existing, normalizedKey);
60
+ }
50
61
 
51
- public async list(prefix: string): Promise<string[]> {
52
- const prefixPath = this.resolvePathForKey(prefix);
53
- try {
54
- const stat = await plugins.fs.promises.stat(prefixPath);
55
- if (stat.isFile()) {
56
- return [this.toStorageKey(prefixPath)];
57
- }
58
- } catch (error: unknown) {
59
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
60
- return [];
61
- }
62
- throw error;
62
+ const now = new Date().toISOString();
63
+ await this.collection.updateOne(
64
+ { _id: id },
65
+ {
66
+ $set: {
67
+ id,
68
+ key: normalizedKey,
69
+ value,
70
+ updatedAt: now,
71
+ },
72
+ $setOnInsert: {
73
+ createdAt: now,
74
+ },
75
+ },
76
+ { upsert: true },
77
+ );
78
+
79
+ const stored = await this.collection.findOne({ _id: id });
80
+ if (!stored || this.validateDocument(stored, normalizedKey).value !== value) {
81
+ throw new Error(`SmartMTA storage write verification failed: ${normalizedKey}`);
63
82
  }
83
+ }
64
84
 
65
- const results: string[] = [];
66
- const walk = async (currentPath: string): Promise<void> => {
67
- const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
68
- for (const entry of entries) {
69
- const entryPath = plugins.path.join(currentPath, entry.name);
70
- if (entry.isDirectory()) {
71
- await walk(entryPath);
72
- } else if (entry.isFile()) {
73
- results.push(this.toStorageKey(entryPath));
74
- }
75
- }
76
- };
85
+ public async list(prefix: string): Promise<string[]> {
86
+ const normalizedPrefix = normalizeSmartMtaStorageKey(prefix);
87
+ getSmartMtaTextNamespace(normalizedPrefix);
88
+ const documents = await this.collection.find({
89
+ key: {
90
+ $gte: normalizedPrefix,
91
+ $lt: `${normalizedPrefix}\uffff`,
92
+ },
93
+ }).toArray();
77
94
 
78
- await walk(prefixPath);
79
- return results.sort();
95
+ return documents
96
+ .map((document: ISmartMtaStorageDocument) => this.validateDocument(document).key)
97
+ .filter((key: string) => key.startsWith(normalizedPrefix))
98
+ .sort();
80
99
  }
81
100
 
82
101
  public async delete(key: string): Promise<void> {
83
- const targetPath = this.resolvePathForKey(key);
84
- try {
85
- const stat = await plugins.fs.promises.stat(targetPath);
86
- if (stat.isDirectory()) {
87
- await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
88
- } else {
89
- await plugins.fs.promises.unlink(targetPath);
90
- }
91
- } catch (error: unknown) {
92
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
93
- return;
94
- }
95
- throw error;
96
- }
97
-
98
- let currentDir = plugins.path.dirname(targetPath);
99
- while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
100
- const entries = await plugins.fs.promises.readdir(currentDir);
101
- if (entries.length > 0) {
102
- break;
103
- }
104
- await plugins.fs.promises.rmdir(currentDir);
105
- currentDir = plugins.path.dirname(currentDir);
102
+ const normalizedKey = normalizeSmartMtaStorageKey(key);
103
+ getSmartMtaTextNamespace(normalizedKey);
104
+ const id = hashSmartMtaStorageKey(normalizedKey);
105
+ const existing = await this.collection.findOne({ _id: id });
106
+ if (existing) {
107
+ this.validateDocument(existing, normalizedKey);
108
+ await this.collection.deleteOne({ _id: id });
106
109
  }
107
110
  }
108
111
  }
@@ -4,6 +4,7 @@ import type {
4
4
  IMessageAcceptanceContext,
5
5
  IMessageAcceptanceDecision,
6
6
  IUnifiedEmailServerOptions,
7
+ IStorageManagerLike,
7
8
  } from '@push.rocks/smartmta';
8
9
  import type { Buffer } from 'node:buffer';
9
10
  import * as plugins from '../plugins.js';
@@ -611,11 +612,20 @@ export class WorkAppMailManager {
611
612
  await storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
612
613
  }
613
614
 
614
- private getRequiredStorageManager() {
615
- if (!plugins.smartmta.hasStorageManagerMethods(this.dcRouterRef.storageManager, ['get', 'set'])) {
615
+ private getRequiredStorageManager(): IStorageManagerLike & Required<
616
+ Pick<IStorageManagerLike, 'get' | 'set'>
617
+ > {
618
+ const storageManager = this.dcRouterRef.storageManager;
619
+ if (
620
+ !storageManager
621
+ || typeof storageManager.get !== 'function'
622
+ || typeof storageManager.set !== 'function'
623
+ ) {
616
624
  throw new Error('DcRouter storage manager does not support managed mail identity persistence');
617
625
  }
618
- return this.dcRouterRef.storageManager;
626
+ return storageManager as IStorageManagerLike & Required<
627
+ Pick<IStorageManagerLike, 'get' | 'set'>
628
+ >;
619
629
  }
620
630
 
621
631
  private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
package/ts/email/index.ts CHANGED
@@ -5,6 +5,8 @@ export * from './classes.mail-dns-sync.js';
5
5
  export * from './classes.mail-egress-coordinator.js';
6
6
  export * from './classes.mail-edge-eligibility.js';
7
7
  export * from './classes.email-settings.manager.js';
8
+ export * from './classes.smartmta-blob-storage-manager.js';
8
9
  export * from './classes.smartmta-storage-manager.js';
10
+ export * from './smartmta-storage-policy.js';
9
11
  export * from './classes.workapp-mail-manager.js';
10
12
  export * from './email-dns-records.js';
@@ -0,0 +1,73 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ export interface ISmartMtaTextNamespace {
4
+ prefix: string;
5
+ maxBytes: number;
6
+ }
7
+
8
+ export const smartMtaTextNamespaces: readonly ISmartMtaTextNamespace[] = Object.freeze([
9
+ { prefix: '/email/dkim/', maxBytes: 128 * 1024 },
10
+ { prefix: '/email/dns/', maxBytes: 1024 * 1024 },
11
+ { prefix: '/email/bounces/', maxBytes: 8 * 1024 * 1024 },
12
+ { prefix: '/email/templates/', maxBytes: 4 * 1024 * 1024 },
13
+ { prefix: '/email/routes/config.json', maxBytes: 4 * 1024 * 1024 },
14
+ { prefix: '/security/ip-reputation-cache.json', maxBytes: 8 * 1024 * 1024 },
15
+ { prefix: '/workhosters/mail-identities.json', maxBytes: 4 * 1024 * 1024 },
16
+ ]);
17
+
18
+ export function normalizeSmartMtaStorageKey(key: string): string {
19
+ if (typeof key !== 'string' || key.length === 0) {
20
+ throw new Error('SmartMTA storage key must be a non-empty string');
21
+ }
22
+ if (key.includes('\\') || key.includes('\0')) {
23
+ throw new Error(`Invalid SmartMTA storage key: ${key}`);
24
+ }
25
+
26
+ const withLeadingSlash = key.startsWith('/') ? key : `/${key}`;
27
+ const segments = withLeadingSlash.split('/').slice(1);
28
+ if (
29
+ segments.length === 0
30
+ || segments.some((segment, index) => segment === '.' || segment === '..' || (segment === '' && index < segments.length - 1))
31
+ ) {
32
+ throw new Error(`Invalid SmartMTA storage key: ${key}`);
33
+ }
34
+
35
+ const normalized = `/${segments.filter(Boolean).join('/')}`;
36
+ if (normalized.length > 1024) {
37
+ throw new Error('SmartMTA storage key exceeds 1024 characters');
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ export function getSmartMtaTextNamespace(key: string): ISmartMtaTextNamespace {
43
+ const normalizedKey = normalizeSmartMtaStorageKey(key);
44
+ const namespace = smartMtaTextNamespaces.find((candidate) => {
45
+ return candidate.prefix.endsWith('/')
46
+ ? normalizedKey.startsWith(candidate.prefix)
47
+ : normalizedKey === candidate.prefix;
48
+ });
49
+ if (!namespace) {
50
+ throw new Error(`Unsupported SmartMTA text storage namespace: ${normalizedKey}`);
51
+ }
52
+ return namespace;
53
+ }
54
+
55
+ export function validateSmartMtaTextValue(key: string, value: string): void {
56
+ if (typeof value !== 'string') {
57
+ throw new Error('SmartMTA text storage only accepts string values');
58
+ }
59
+ const namespace = getSmartMtaTextNamespace(key);
60
+ const size = Buffer.byteLength(value, 'utf8');
61
+ if (size > namespace.maxBytes) {
62
+ throw new Error(
63
+ `SmartMTA storage value for ${normalizeSmartMtaStorageKey(key)} exceeds ${namespace.maxBytes} bytes`,
64
+ );
65
+ }
66
+ }
67
+
68
+ export function hashSmartMtaStorageKey(key: string): string {
69
+ return plugins.crypto
70
+ .createHash('sha256')
71
+ .update(normalizeSmartMtaStorageKey(key))
72
+ .digest('hex');
73
+ }
@@ -562,10 +562,8 @@ export class StatsHandler {
562
562
  const emailLike = item.processingResult;
563
563
  const recipients = Array.isArray(emailLike?.to)
564
564
  ? emailLike.to
565
- : Array.isArray(emailLike?.email?.to)
566
- ? emailLike.email.to
567
- : [];
568
- const subject = emailLike?.subject || emailLike?.email?.subject || '';
565
+ : [];
566
+ const subject = emailLike?.subject || '';
569
567
  return {
570
568
  id: item.id,
571
569
  recipient: recipients[0] || '',
package/ts/plugins.ts CHANGED
@@ -54,6 +54,7 @@ export {
54
54
  import * as projectinfo from '@push.rocks/projectinfo';
55
55
  import * as qenv from '@push.rocks/qenv';
56
56
  import * as smartacme from '@push.rocks/smartacme';
57
+ import * as smartbucket from '@push.rocks/smartbucket';
57
58
  import * as smartchallenge from '@push.rocks/smartchallenge';
58
59
  import * as smartdata from '@push.rocks/smartdata';
59
60
  import * as smartdns from '@push.rocks/smartdns';
@@ -76,7 +77,7 @@ import * as smartrx from '@push.rocks/smartrx';
76
77
  import * as smartunique from '@push.rocks/smartunique';
77
78
  import * as taskbuffer from '@push.rocks/taskbuffer';
78
79
 
79
- export { projectinfo, qenv, smartacme, smartchallenge, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartmcp, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
80
+ export { projectinfo, qenv, smartacme, smartbucket, smartchallenge, smartdata, smartdns, smartfs, smartguard, smartjwt, smartlog, smartmetrics, smartmcp, smartdb, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique, smartvpn, taskbuffer };
80
81
 
81
82
  // Define SmartLog types for use in error handling
82
83
  export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '16.5.0',
6
+ version: '17.0.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }