@serve.zone/dcrouter 16.4.1 → 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 (82) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +852 -852
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +5 -1
  5. package/dist_ts/classes.dcrouter.js +42 -9
  6. package/dist_ts/db/documents/classes.dns-record.doc.d.ts +4 -0
  7. package/dist_ts/db/documents/classes.dns-record.doc.js +33 -2
  8. package/dist_ts/db/documents/classes.email-domain.doc.d.ts +5 -1
  9. package/dist_ts/db/documents/classes.email-domain.doc.js +26 -2
  10. package/dist_ts/db/documents/classes.smartmta-storage.doc.d.ts +8 -0
  11. package/dist_ts/db/documents/classes.smartmta-storage.doc.js +91 -0
  12. package/dist_ts/db/documents/index.d.ts +1 -0
  13. package/dist_ts/db/documents/index.js +2 -1
  14. package/dist_ts/dns/manager.dns.d.ts +6 -0
  15. package/dist_ts/dns/manager.dns.js +81 -32
  16. package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
  17. package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
  18. package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
  19. package/dist_ts/email/classes.email-domain.manager.js +391 -324
  20. package/dist_ts/email/classes.email-settings.manager.js +2 -1
  21. package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
  22. package/dist_ts/email/classes.mail-dns-sync.js +857 -141
  23. package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
  24. package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
  25. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
  26. package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
  27. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +27 -0
  28. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +109 -0
  29. package/dist_ts/email/classes.smartmta-storage-manager.d.ts +6 -8
  30. package/dist_ts/email/classes.smartmta-storage-manager.js +63 -86
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +63 -14
  33. package/dist_ts/email/index.d.ts +3 -0
  34. package/dist_ts/email/index.js +4 -1
  35. package/dist_ts/email/smartmta-storage-policy.d.ts +9 -0
  36. package/dist_ts/email/smartmta-storage-policy.js +58 -0
  37. package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
  38. package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
  39. package/dist_ts/opsserver/handlers/stats.handler.js +3 -5
  40. package/dist_ts/plugins.d.ts +2 -1
  41. package/dist_ts/plugins.js +3 -2
  42. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  43. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
  44. package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
  45. package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
  46. package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
  47. package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
  48. package/dist_ts_migrations/index.d.ts +2 -0
  49. package/dist_ts_migrations/index.js +441 -16
  50. package/dist_ts_web/00_commitinfo_data.js +1 -1
  51. package/dist_ts_web/appstate/email-domains.d.ts +16 -0
  52. package/dist_ts_web/appstate/email-domains.js +140 -19
  53. package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
  54. package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
  55. package/package.json +4 -3
  56. package/readme.md +13 -1
  57. package/ts/00_commitinfo_data.ts +1 -1
  58. package/ts/classes.dcrouter.ts +55 -11
  59. package/ts/db/documents/classes.dns-record.doc.ts +20 -0
  60. package/ts/db/documents/classes.email-domain.doc.ts +14 -0
  61. package/ts/db/documents/classes.smartmta-storage.doc.ts +25 -0
  62. package/ts/db/documents/index.ts +1 -0
  63. package/ts/dns/manager.dns.ts +89 -29
  64. package/ts/dns/providers/cloudflare.provider.ts +46 -27
  65. package/ts/email/classes.email-domain.manager.ts +416 -368
  66. package/ts/email/classes.email-settings.manager.ts +1 -0
  67. package/ts/email/classes.mail-dns-sync.ts +913 -146
  68. package/ts/email/classes.mail-edge-eligibility.ts +205 -0
  69. package/ts/email/classes.mail-egress-coordinator.ts +46 -23
  70. package/ts/email/classes.smartmta-blob-storage-manager.ts +138 -0
  71. package/ts/email/classes.smartmta-storage-manager.ts +86 -83
  72. package/ts/email/classes.workapp-mail-manager.ts +82 -16
  73. package/ts/email/index.ts +3 -0
  74. package/ts/email/smartmta-storage-policy.ts +73 -0
  75. package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
  76. package/ts/opsserver/handlers/stats.handler.ts +2 -4
  77. package/ts/plugins.ts +2 -1
  78. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  79. package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
  80. package/ts_web/00_commitinfo_data.ts +1 -1
  81. package/ts_web/appstate/email-domains.ts +187 -18
  82. package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
@@ -0,0 +1,205 @@
1
+ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
2
+ import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
3
+ import type { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
4
+ import type { DcRouter } from '../classes.dcrouter.js';
5
+
6
+ export const MAIL_EGRESS_IDENTITY_MAX_AGE_MS = 45_000;
7
+
8
+ export interface IMailEgressIdentity {
9
+ edgeId: string;
10
+ family: 4 | 6;
11
+ address: string;
12
+ heloHostname: string;
13
+ proof: 'sourceBound';
14
+ /** Edge-side time at which the configured source address was successfully bound. */
15
+ observedAt: number;
16
+ /** Hub-side heartbeat receipt time. */
17
+ hubReceivedAt: number;
18
+ }
19
+
20
+ export interface IMailEgressIdentitySnapshot {
21
+ supported: boolean;
22
+ reason?: string;
23
+ identities: IMailEgressIdentity[];
24
+ }
25
+
26
+ /** Boundary implemented against a released RemoteIngress identity-proof API. */
27
+ export interface IEgressIdentitySource {
28
+ getSnapshot(): Promise<IMailEgressIdentitySnapshot>;
29
+ }
30
+
31
+ /**
32
+ * Reads the versioned RemoteIngress identity capability through TunnelManager.
33
+ * Older hubs do not expose `egressIdentityV1`; they remain explicitly
34
+ * unsupported instead of falling back to configured or peer addresses.
35
+ */
36
+ export class ReleasedRemoteIngressEgressIdentitySource implements IEgressIdentitySource {
37
+ constructor(private dcRouterRef: DcRouter) {}
38
+
39
+ public async getSnapshot(): Promise<IMailEgressIdentitySnapshot> {
40
+ const status = await this.dcRouterRef.tunnelManager?.getAuthoritativeHubStatus();
41
+ const connectedEdges = status?.connectedEdges ?? [];
42
+ const capableEdges = connectedEdges.filter((edge) => (
43
+ Array.isArray(edge.capabilities) && edge.capabilities.includes('egressIdentityV1')
44
+ ));
45
+ if (capableEdges.length > 0) {
46
+ const identities: IMailEgressIdentity[] = [];
47
+ for (const edge of capableEdges) {
48
+ const report = edge.egressIdentity;
49
+ if (!report || report.proofMethod !== 'localSocketBind') continue;
50
+ for (const [family, observation] of [[4, report.ipv4], [6, report.ipv6]] as const) {
51
+ if (!observation || observation.stale === true) continue;
52
+ if (
53
+ typeof observation.address !== 'string'
54
+ || !Number.isFinite(observation.edgeObservedAtUnixMs)
55
+ || !Number.isFinite(observation.hubReceivedAtUnixMs)
56
+ ) continue;
57
+ const configured = this.dcRouterRef.remoteIngressManager?.getEdge(String(edge.edgeId));
58
+ const heloHostname = configured?.mailHostname?.trim();
59
+ if (!heloHostname) continue;
60
+ identities.push({
61
+ edgeId: String(edge.edgeId),
62
+ family,
63
+ address: observation.address,
64
+ heloHostname,
65
+ proof: 'sourceBound',
66
+ observedAt: observation.edgeObservedAtUnixMs,
67
+ hubReceivedAt: observation.hubReceivedAtUnixMs,
68
+ });
69
+ }
70
+ }
71
+ return { supported: true, identities };
72
+ }
73
+ return {
74
+ supported: false,
75
+ reason: 'RemoteIngress egress identity proof is unavailable until the capability release is consumed',
76
+ identities: [],
77
+ };
78
+ }
79
+ }
80
+
81
+ export interface IEligibleMailEdge {
82
+ edge: IRemoteIngress;
83
+ hostname: string;
84
+ identities: IMailEgressIdentity[];
85
+ }
86
+
87
+ export interface IMailEdgeEligibilityResult {
88
+ supported: boolean;
89
+ reason?: string;
90
+ edges: IEligibleMailEdge[];
91
+ }
92
+
93
+ /** Shared topology predicate used by DNS publication and outbound selection. */
94
+ export class MailEdgeEligibility {
95
+ private lastUnscopedResult: IMailEdgeEligibilityResult = {
96
+ supported: false,
97
+ reason: 'mail edge eligibility has not been evaluated yet',
98
+ edges: [],
99
+ };
100
+
101
+ constructor(
102
+ private dcRouterRef: DcRouter,
103
+ private identitySource: IEgressIdentitySource,
104
+ ) {}
105
+
106
+ public async resolveForDomain(doc?: EmailDomainDoc): Promise<IMailEdgeEligibilityResult> {
107
+ const snapshot = await this.identitySource.getSnapshot();
108
+ if (!snapshot.supported) {
109
+ return this.recordUnscoped(doc, { supported: false, reason: snapshot.reason, edges: [] });
110
+ }
111
+
112
+ const manager = this.dcRouterRef.remoteIngressManager;
113
+ const tunnelManager = this.dcRouterRef.tunnelManager;
114
+ if (!manager || !tunnelManager) {
115
+ return this.recordUnscoped(doc, { supported: true, reason: 'RemoteIngress hub is not running', edges: [] });
116
+ }
117
+
118
+ const pin = doc?.remoteIngress?.edgeFilter || [];
119
+ const pinIds = pin.length > 0
120
+ ? new Set(manager.resolveEdgesByFilter(pin).map((edge) => edge.id))
121
+ : undefined;
122
+ const identitiesByEdge = new Map<string, IMailEgressIdentity[]>();
123
+ const now = Date.now();
124
+ for (const identity of snapshot.identities) {
125
+ if (identity.proof !== 'sourceBound') continue;
126
+ if (now - identity.observedAt > MAIL_EGRESS_IDENTITY_MAX_AGE_MS) continue;
127
+ if (now - identity.hubReceivedAt > MAIL_EGRESS_IDENTITY_MAX_AGE_MS) continue;
128
+ const entries = identitiesByEdge.get(identity.edgeId) || [];
129
+ entries.push(identity);
130
+ identitiesByEdge.set(identity.edgeId, entries);
131
+ }
132
+
133
+ const edges: IEligibleMailEdge[] = [];
134
+ for (const edge of manager.getAllEdges()) {
135
+ if (!edge.enabled || !edge.tags?.includes(REMOTE_INGRESS_MAIL_TAG)) continue;
136
+ // A domain pin narrows the mail-tagged pool; it never replaces the mail tag.
137
+ if (pinIds && !pinIds.has(edge.id)) continue;
138
+ if (!edge.egress?.enabled || !edge.egress.allowedPorts?.includes(25)) continue;
139
+ const status = tunnelManager.getEdgeStatus(edge.id);
140
+ if (!status?.connected || !status.lastHeartbeat) continue;
141
+ if (now - status.lastHeartbeat > MAIL_EGRESS_IDENTITY_MAX_AGE_MS) continue;
142
+ if (status.egressEnabled !== true || !status.capabilities?.includes('egressTcpV1')) continue;
143
+ const nativeQuic = status.transportMode === 'quic'
144
+ || (status.transportMode === 'quicWithFallback' && status.fallbackUsed === false);
145
+ if (!nativeQuic) continue;
146
+ const hostname = edge.mailHostname?.toLowerCase().replace(/\.$/, '');
147
+ if (!hostname) continue;
148
+
149
+ const identities = (identitiesByEdge.get(edge.id) || []).filter((identity) => {
150
+ if (identity.heloHostname.toLowerCase().replace(/\.$/, '') !== hostname) return false;
151
+ return identity.family === 4
152
+ ? Boolean(edge.publicIp && edge.publicIp === identity.address)
153
+ : Boolean(edge.publicIpV6 && edge.publicIpV6 === identity.address);
154
+ });
155
+ if (identities.length > 0) {
156
+ edges.push({ edge, hostname, identities });
157
+ }
158
+ }
159
+
160
+ edges.sort((left, right) => left.edge.id.localeCompare(right.edge.id));
161
+ return this.recordUnscoped(doc, {
162
+ supported: true,
163
+ ...(edges.length === 0 ? { reason: 'no live mail-tagged edge has a fresh source-bound identity' } : {}),
164
+ edges,
165
+ });
166
+ }
167
+
168
+ public getLastUnscopedResult(): IMailEdgeEligibilityResult {
169
+ return this.lastUnscopedResult;
170
+ }
171
+
172
+ private recordUnscoped(
173
+ doc: EmailDomainDoc | undefined,
174
+ result: IMailEdgeEligibilityResult,
175
+ ): IMailEdgeEligibilityResult {
176
+ if (!doc) this.lastUnscopedResult = result;
177
+ return result;
178
+ }
179
+
180
+ public async resolveActiveForDomain(doc: EmailDomainDoc): Promise<IMailEdgeEligibilityResult> {
181
+ const live = await this.resolveForDomain(doc);
182
+ const active = doc.reconciliation?.activeRevision;
183
+ if (!live.supported || !active) {
184
+ return {
185
+ supported: live.supported,
186
+ reason: live.reason || 'email domain has no validated active DNS revision',
187
+ edges: [],
188
+ };
189
+ }
190
+ const activeByEdge = new Map(active.edgeIdentities.map((identity) => [identity.edgeId, identity]));
191
+ const edges = live.edges.filter((candidate) => {
192
+ const activeIdentity = activeByEdge.get(candidate.edge.id);
193
+ if (!activeIdentity || activeIdentity.hostname !== candidate.hostname) return false;
194
+ return candidate.identities.length > 0 && candidate.identities.every((identity) => (
195
+ (identity.family === 4 && identity.address === activeIdentity.ipv4)
196
+ || (identity.family === 6 && identity.address === activeIdentity.ipv6)
197
+ ));
198
+ });
199
+ return {
200
+ supported: live.supported,
201
+ ...(edges.length === 0 ? { reason: 'no live edge is present in the validated active DNS revision' } : {}),
202
+ edges,
203
+ };
204
+ }
205
+ }
@@ -4,9 +4,9 @@ import type {
4
4
  } from '@push.rocks/smartmta';
5
5
  import * as plugins from '../plugins.js';
6
6
  import { logger } from '../logger.js';
7
- import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
8
7
  import type { IEmailOutboundEgressStatus, TEmailOutboundMode } from '../../ts_interfaces/data/email-settings.js';
9
8
  import type { DcRouter } from '../classes.dcrouter.js';
9
+ import type { MailEdgeEligibility } from './classes.mail-edge-eligibility.js';
10
10
 
11
11
  /**
12
12
  * Routes outbound SMTP deliveries over RemoteIngress edges: resolves the
@@ -17,7 +17,10 @@ import type { DcRouter } from '../classes.dcrouter.js';
17
17
  export class MailEgressCoordinator {
18
18
  private dnsClient?: plugins.smartdns.dnsClientMod.Smartdns;
19
19
 
20
- constructor(private dcRouterRef: DcRouter) {}
20
+ constructor(
21
+ private dcRouterRef: DcRouter,
22
+ private edgeEligibility: MailEdgeEligibility,
23
+ ) {}
21
24
 
22
25
  /**
23
26
  * smartmta connectionProxyProvider. Wired unconditionally so the outbound
@@ -30,6 +33,11 @@ export class MailEgressCoordinator {
30
33
  ): Promise<IOutboundConnectionProxy | null> {
31
34
  const mode = this.resolveOutboundModeStrict();
32
35
  if (mode === 'direct') {
36
+ if (context.senderDomain && await this.dcRouterRef.emailDomainManager?.getByDomain(context.senderDomain)) {
37
+ throw new Error(
38
+ `Managed sender domain ${context.senderDomain} has no validated direct-egress identity policy; refusing direct hub delivery`,
39
+ );
40
+ }
33
41
  return null;
34
42
  }
35
43
  return await this.createRemoteIngressConnectionProxy(context);
@@ -54,11 +62,14 @@ export class MailEgressCoordinator {
54
62
  * Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
55
63
  */
56
64
  public getOutboundEgressStatus(): IEmailOutboundEgressStatus {
57
- const tunnelManager = this.dcRouterRef.tunnelManager;
58
- if (!tunnelManager) {
59
- return { ready: false, eligibleEdgeCount: 0, reason: 'RemoteIngress hub is not running' };
60
- }
61
- return tunnelManager.getEgressReadiness([REMOTE_INGRESS_MAIL_TAG]);
65
+ const eligibility = this.edgeEligibility.getLastUnscopedResult();
66
+ return {
67
+ ready: eligibility.supported && eligibility.edges.length > 0,
68
+ eligibleEdgeCount: eligibility.edges.length,
69
+ ...(eligibility.edges.length === 0
70
+ ? { reason: eligibility.reason || 'no validated RemoteIngress mail edge is available' }
71
+ : {}),
72
+ };
62
73
  }
63
74
 
64
75
  /**
@@ -89,15 +100,19 @@ export class MailEgressCoordinator {
89
100
 
90
101
  await this.assertTargetIsNotOwnMailEdge(context);
91
102
 
92
- // Per-domain pinning: a sender domain with a configured edgeFilter uses only
93
- // matching edges; everything else uses the mail-tagged edge pool.
94
- let edgeFilter: string[] = [REMOTE_INGRESS_MAIL_TAG];
95
- if (context.senderDomain && this.dcRouterRef.emailDomainManager) {
96
- const domain = await this.dcRouterRef.emailDomainManager.getByDomain(context.senderDomain);
97
- if (domain?.remoteIngress?.edgeFilter?.length) {
98
- edgeFilter = domain.remoteIngress.edgeFilter;
99
- }
103
+ const managedDoc = context.senderDomain
104
+ ? await (await import('../db/documents/classes.email-domain.doc.js')).EmailDomainDoc.findByDomain(context.senderDomain)
105
+ : null;
106
+ const eligible = managedDoc
107
+ ? await this.edgeEligibility.resolveActiveForDomain(managedDoc)
108
+ : await this.edgeEligibility.resolveForDomain();
109
+ if (!eligible.supported || eligible.edges.length === 0) {
110
+ throw new Error(
111
+ `Outbound SMTP is not eligible${context.senderDomain ? ` for ${context.senderDomain}` : ''}: ${eligible.reason || 'no validated mail edge'}`,
112
+ );
100
113
  }
114
+ const eligibleById = new Map(eligible.edges.map((candidate) => [candidate.edge.id, candidate]));
115
+ const edgeFilter = [...eligibleById.keys()];
101
116
 
102
117
  const connectionTimeoutMs = this.dcRouterRef.options.emailConfig?.outbound?.connectionTimeout ?? 30_000;
103
118
  const proxy = await tunnelManager.startEgressTcpProxy({
@@ -108,12 +123,17 @@ export class MailEgressCoordinator {
108
123
  acceptTimeoutMs: connectionTimeoutMs,
109
124
  edgeFilter,
110
125
  });
126
+ const selected = eligibleById.get(proxy.edgeId);
127
+ if (!selected) {
128
+ await tunnelManager.stopEgressTcpProxy(proxy.proxyId).catch(() => undefined);
129
+ throw new Error(`RemoteIngress selected edge ${proxy.edgeId} outside the validated mail pool`);
130
+ }
111
131
  let closed = false;
112
132
  return {
113
133
  connectHost: proxy.listenHost,
114
134
  connectPort: proxy.listenPort,
115
135
  poolKey: `remoteIngress:${proxy.edgeId}:${context.host}:${context.port}`,
116
- ehloHostname: this.dcRouterRef.remoteIngressManager?.getEdge(proxy.edgeId)?.mailHostname,
136
+ ehloHostname: selected.hostname,
117
137
  close: async () => {
118
138
  if (closed) {
119
139
  return;
@@ -138,11 +158,11 @@ export class MailEgressCoordinator {
138
158
  * allowed to become an availability hazard.
139
159
  */
140
160
  private async assertTargetIsNotOwnMailEdge(context: IOutboundConnectionProxyContext): Promise<void> {
141
- const edgeIps = new Set(
142
- (this.dcRouterRef.remoteIngressManager?.getAllEdges?.() || [])
143
- .map((edge) => edge.publicIp)
144
- .filter((ip): ip is string => Boolean(ip)),
145
- );
161
+ const edgeIps = new Set<string>();
162
+ for (const edge of this.dcRouterRef.remoteIngressManager?.getAllEdges?.() || []) {
163
+ if (edge.publicIp) edgeIps.add(edge.publicIp);
164
+ if (edge.publicIpV6) edgeIps.add(edge.publicIpV6);
165
+ }
146
166
  if (edgeIps.size === 0) {
147
167
  return;
148
168
  }
@@ -155,8 +175,11 @@ export class MailEgressCoordinator {
155
175
  if (!this.dnsClient) {
156
176
  this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({});
157
177
  }
158
- const records = await this.dnsClient.getRecordsA(context.host);
159
- targetIps = (records || []).map((record) => record.value);
178
+ const [aRecords, aaaaRecords] = await Promise.all([
179
+ this.dnsClient.getRecordsA(context.host),
180
+ this.dnsClient.getRecordsAAAA(context.host),
181
+ ]);
182
+ targetIps = [...(aRecords || []), ...(aaaaRecords || [])].map((record) => record.value);
160
183
  } catch {
161
184
  return;
162
185
  }
@@ -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
  }