@serve.zone/dcrouter 15.10.0 → 16.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 (81) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +34 -34
  3. package/dist_ts/00_commitinfo_data.js +2 -2
  4. package/dist_ts/classes.dcrouter.d.ts +10 -3
  5. package/dist_ts/classes.dcrouter.js +40 -44
  6. package/dist_ts/db/documents/classes.cached.email.d.ts +32 -0
  7. package/dist_ts/db/documents/classes.cached.email.js +67 -3
  8. package/dist_ts/db/documents/classes.email-domain.doc.d.ts +2 -1
  9. package/dist_ts/db/documents/classes.email-domain.doc.js +8 -2
  10. package/dist_ts/db/documents/classes.email-server-settings.doc.js +2 -2
  11. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +3 -0
  12. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +20 -2
  13. package/dist_ts/dns/classes.dns-server-runtime.js +3 -1
  14. package/dist_ts/email/classes.accepted-email-spool.d.ts +9 -2
  15. package/dist_ts/email/classes.accepted-email-spool.js +53 -2
  16. package/dist_ts/email/classes.email-domain.manager.d.ts +17 -1
  17. package/dist_ts/email/classes.email-domain.manager.js +100 -17
  18. package/dist_ts/email/classes.email-route-builder.js +5 -2
  19. package/dist_ts/email/classes.email-settings.manager.js +3 -3
  20. package/dist_ts/email/classes.mail-dns-sync.d.ts +27 -0
  21. package/dist_ts/email/classes.mail-dns-sync.js +196 -0
  22. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +37 -0
  23. package/dist_ts/email/classes.mail-egress-coordinator.js +113 -0
  24. package/dist_ts/email/classes.workapp-mail-manager.d.ts +14 -1
  25. package/dist_ts/email/classes.workapp-mail-manager.js +138 -1
  26. package/dist_ts/email/index.d.ts +2 -0
  27. package/dist_ts/email/index.js +3 -1
  28. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  29. package/dist_ts/opsserver/handlers/email-domain.handler.js +3 -1
  30. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +20 -3
  31. package/dist_ts/opsserver/handlers/email-ops.handler.js +141 -19
  32. package/dist_ts/opsserver/handlers/email-settings.handler.js +11 -6
  33. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
  34. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +30 -1
  35. package/dist_ts/opsserver/handlers/remoteingress.handler.js +16 -7
  36. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +33 -1
  37. package/dist_ts/remoteingress/classes.remoteingress-manager.js +94 -2
  38. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +18 -0
  39. package/dist_ts/remoteingress/classes.tunnel-manager.js +114 -12
  40. package/dist_ts/remoteingress/edge-derivation-routes.d.ts +10 -0
  41. package/dist_ts/remoteingress/edge-derivation-routes.js +28 -0
  42. package/dist_ts/remoteingress/index.d.ts +1 -0
  43. package/dist_ts/remoteingress/index.js +2 -1
  44. package/dist_ts_interfaces/data/email-domain.d.ts +11 -1
  45. package/dist_ts_interfaces/data/email-settings.d.ts +11 -0
  46. package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
  47. package/dist_ts_interfaces/data/remoteingress.js +5 -2
  48. package/dist_ts_interfaces/requests/email-domains.d.ts +5 -1
  49. package/dist_ts_interfaces/requests/email-ops.d.ts +4 -0
  50. package/dist_ts_interfaces/requests/remoteingress.d.ts +11 -0
  51. package/dist_ts_migrations/index.js +91 -9
  52. package/dist_ts_web/00_commitinfo_data.js +2 -2
  53. package/package.json +5 -5
  54. package/readme.md +1 -1
  55. package/ts/00_commitinfo_data.ts +1 -1
  56. package/ts/classes.dcrouter.ts +42 -49
  57. package/ts/db/documents/classes.cached.email.ts +72 -1
  58. package/ts/db/documents/classes.email-domain.doc.ts +4 -0
  59. package/ts/db/documents/classes.email-server-settings.doc.ts +1 -1
  60. package/ts/db/documents/classes.remote-ingress-edge.doc.ts +9 -0
  61. package/ts/dns/classes.dns-server-runtime.ts +2 -0
  62. package/ts/email/classes.accepted-email-spool.ts +80 -4
  63. package/ts/email/classes.email-domain.manager.ts +118 -17
  64. package/ts/email/classes.email-route-builder.ts +4 -1
  65. package/ts/email/classes.email-settings.manager.ts +2 -2
  66. package/ts/email/classes.mail-dns-sync.ts +218 -0
  67. package/ts/email/classes.mail-egress-coordinator.ts +125 -0
  68. package/ts/email/classes.workapp-mail-manager.ts +194 -1
  69. package/ts/email/index.ts +2 -0
  70. package/ts/opsserver/handlers/config.handler.ts +3 -1
  71. package/ts/opsserver/handlers/email-domain.handler.ts +2 -0
  72. package/ts/opsserver/handlers/email-ops.handler.ts +156 -18
  73. package/ts/opsserver/handlers/email-settings.handler.ts +10 -5
  74. package/ts/opsserver/handlers/gatewayclient.handler.ts +44 -0
  75. package/ts/opsserver/handlers/remoteingress.handler.ts +15 -5
  76. package/ts/readme.md +1 -1
  77. package/ts/remoteingress/classes.remoteingress-manager.ts +111 -0
  78. package/ts/remoteingress/classes.tunnel-manager.ts +125 -10
  79. package/ts/remoteingress/edge-derivation-routes.ts +29 -0
  80. package/ts/remoteingress/index.ts +1 -0
  81. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -0,0 +1,218 @@
1
+ import { logger } from '../logger.js';
2
+ import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
3
+ import { DomainDoc } from '../db/documents/classes.domain.doc.js';
4
+ import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
5
+ import type { DcRouter } from '../classes.dcrouter.js';
6
+ import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
7
+
8
+ export const MAIL_DNS_SYNC_CREATED_BY = 'mail-edge-sync';
9
+
10
+ interface IDesiredMailDnsRecord {
11
+ name: string;
12
+ type: 'A' | 'AAAA';
13
+ value: string;
14
+ }
15
+
16
+ /**
17
+ * Reconciles the mail-facing A/AAAA records that make edges — never the hub —
18
+ * the public identity of the mail platform:
19
+ * (a) shared mail hostname → publicIp/publicIpV6 of every enabled mail edge,
20
+ * (b) each edge's mailHostname → that edge's IPs (FCrDNS for outbound EHLO),
21
+ * (c) mx.<domain> → the pinned edges' IPs for every pinned email domain.
22
+ * Records are DB-backed DnsRecordDoc rows owned via createdBy 'mail-edge-sync';
23
+ * provider-hosted zones (e.g. Cloudflare) are written through DnsManager's
24
+ * provider path, dcrouter-authoritative zones serve immediately.
25
+ */
26
+ export class MailDnsSync {
27
+ private syncInFlight?: Promise<void>;
28
+ private resyncQueued = false;
29
+
30
+ constructor(private dcRouterRef: DcRouter) {}
31
+
32
+ /** Fire-and-forget trigger for CRUD hooks; failures are logged, not thrown. */
33
+ public requestSync(reason: string): void {
34
+ this.sync(reason).catch((err: unknown) => {
35
+ logger.log('error', `MailDnsSync failed (${reason}): ${(err as Error).message}`);
36
+ });
37
+ }
38
+
39
+ /** Coalescing entry point: concurrent triggers fold into one follow-up pass. */
40
+ public async sync(reason: string): Promise<void> {
41
+ if (this.syncInFlight) {
42
+ this.resyncQueued = true;
43
+ return this.syncInFlight;
44
+ }
45
+ this.syncInFlight = this.reconcile(reason).finally(() => {
46
+ this.syncInFlight = undefined;
47
+ if (this.resyncQueued) {
48
+ this.resyncQueued = false;
49
+ this.requestSync(`${reason} (coalesced)`);
50
+ }
51
+ });
52
+ return this.syncInFlight;
53
+ }
54
+
55
+ private async reconcile(reason: string): Promise<void> {
56
+ if (this.dcRouterRef.options.dbConfig?.enabled === false) {
57
+ return;
58
+ }
59
+ const dnsManager = this.dcRouterRef.dnsManager;
60
+ const remoteIngressManager = this.dcRouterRef.remoteIngressManager;
61
+ if (!dnsManager || !remoteIngressManager) {
62
+ // Without both managers the edge registry / record store is not
63
+ // authoritative — skip entirely rather than delete managed records.
64
+ return;
65
+ }
66
+
67
+ const desired = await this.computeDesiredRecords();
68
+ const zones = await DomainDoc.findAll();
69
+
70
+ // Place each desired record in its zone (longest-suffix match). Records
71
+ // without a managed zone are reported — the operator must onboard the zone.
72
+ const placed = new Map<string, { domainId: string; record: IDesiredMailDnsRecord }>();
73
+ const unplacedNames = new Set<string>();
74
+ for (const record of desired) {
75
+ const zone = this.findZoneForName(record.name, zones);
76
+ if (!zone) {
77
+ unplacedNames.add(record.name);
78
+ continue;
79
+ }
80
+ placed.set(`${zone.id}|${record.name}|${record.type}|${record.value}`, {
81
+ domainId: zone.id,
82
+ record,
83
+ });
84
+ }
85
+ for (const name of unplacedNames) {
86
+ logger.log('warn', `MailDnsSync: no managed DNS zone covers ${name} — record skipped`);
87
+ }
88
+
89
+ const existing = (await DnsRecordDoc.findAll())
90
+ .filter((record) => record.createdBy === MAIL_DNS_SYNC_CREATED_BY);
91
+
92
+ let created = 0;
93
+ let deleted = 0;
94
+
95
+ for (const record of existing) {
96
+ const key = `${record.domainId}|${record.name}|${record.type}|${record.value}`;
97
+ if (placed.has(key)) {
98
+ placed.delete(key); // already present — nothing to create
99
+ continue;
100
+ }
101
+ const result = await dnsManager.deleteRecord(record.id);
102
+ if (result.success) {
103
+ deleted++;
104
+ } else {
105
+ logger.log('warn', `MailDnsSync: failed to delete stale ${record.type} ${record.name} → ${record.value}: ${result.message}`);
106
+ }
107
+ }
108
+
109
+ for (const { domainId, record } of placed.values()) {
110
+ const result = await dnsManager.createRecord({
111
+ domainId,
112
+ name: record.name,
113
+ type: record.type,
114
+ value: record.value,
115
+ // Short TTL: edge IPs are burnable identities and failover heals via DNS.
116
+ ttl: 300,
117
+ createdBy: MAIL_DNS_SYNC_CREATED_BY,
118
+ });
119
+ if (result.success) {
120
+ created++;
121
+ } else {
122
+ logger.log('warn', `MailDnsSync: failed to create ${record.type} ${record.name} → ${record.value}: ${result.message}`);
123
+ }
124
+ }
125
+
126
+ if (created > 0 || deleted > 0) {
127
+ logger.log('info', `MailDnsSync (${reason}): ${created} record(s) created, ${deleted} deleted`);
128
+ }
129
+ }
130
+
131
+ private async computeDesiredRecords(): Promise<IDesiredMailDnsRecord[]> {
132
+ const remoteIngressManager = this.dcRouterRef.remoteIngressManager!;
133
+ const desired: IDesiredMailDnsRecord[] = [];
134
+
135
+ const pushEdgeIps = (name: string | undefined, edge: IRemoteIngress) => {
136
+ if (!name) return;
137
+ const normalizedName = name.toLowerCase();
138
+ if (edge.publicIp) {
139
+ desired.push({ name: normalizedName, type: 'A', value: edge.publicIp });
140
+ }
141
+ if (edge.publicIpV6) {
142
+ desired.push({ name: normalizedName, type: 'AAAA', value: edge.publicIpV6 });
143
+ }
144
+ };
145
+
146
+ // (a) shared mail hostname → every enabled mail edge
147
+ const sharedHostname = this.getSharedMailHostname();
148
+ const mailEdges = remoteIngressManager.getMailEdges();
149
+ for (const edge of mailEdges) {
150
+ pushEdgeIps(sharedHostname, edge);
151
+ }
152
+
153
+ // (b) per-edge FCrDNS hostname → that edge only
154
+ for (const edge of remoteIngressManager.getAllEdges()) {
155
+ if (!edge.enabled) continue;
156
+ pushEdgeIps(edge.mailHostname, edge);
157
+ }
158
+
159
+ // (c) mx.<domain> → pinned edges for every pinned email domain
160
+ const emailDomainDocs = await EmailDomainDoc.findAll();
161
+ for (const doc of emailDomainDocs) {
162
+ const edgeFilter = doc.remoteIngress?.edgeFilter;
163
+ if (!edgeFilter?.length) continue;
164
+ const pinnedEdges = remoteIngressManager.resolveEdgesByFilter(edgeFilter);
165
+ if (pinnedEdges.length === 0) {
166
+ logger.log('warn', `MailDnsSync: email domain ${doc.domain} pin [${edgeFilter.join(', ')}] matches no enabled edge — mx.${doc.domain} not published`);
167
+ continue;
168
+ }
169
+ for (const edge of pinnedEdges) {
170
+ pushEdgeIps(`mx.${doc.domain}`, edge);
171
+ }
172
+ }
173
+
174
+ // Hard hub-hiding guard: a hub IP must never become a mail-facing answer.
175
+ const hubIps = new Set(
176
+ [this.dcRouterRef.options.publicIp, this.dcRouterRef.detectedPublicIp].filter(Boolean),
177
+ );
178
+ const guarded = desired.filter((record) => {
179
+ if (hubIps.has(record.value)) {
180
+ logger.log('error', `MailDnsSync: refusing to publish ${record.type} ${record.name} → ${record.value} — value is the hub public IP`);
181
+ return false;
182
+ }
183
+ return true;
184
+ });
185
+
186
+ // Dedupe (several edges may share a tag and a pin may overlap the mail pool)
187
+ const seen = new Set<string>();
188
+ return guarded.filter((record) => {
189
+ const key = `${record.name}|${record.type}|${record.value}`;
190
+ if (seen.has(key)) return false;
191
+ seen.add(key);
192
+ return true;
193
+ });
194
+ }
195
+
196
+ private getSharedMailHostname(): string | undefined {
197
+ const hostname = this.dcRouterRef.options.emailConfig?.hostname
198
+ || this.dcRouterRef.options.tls?.domain;
199
+ if (!hostname || hostname === 'localhost') {
200
+ return undefined;
201
+ }
202
+ return hostname;
203
+ }
204
+
205
+ /** Longest-suffix zone match: 'mx.gated.one' → zone 'gated.one'. */
206
+ private findZoneForName(name: string, zones: DomainDoc[]): DomainDoc | undefined {
207
+ let best: DomainDoc | undefined;
208
+ for (const zone of zones) {
209
+ const zoneName = zone.name.toLowerCase();
210
+ if (name === zoneName || name.endsWith(`.${zoneName}`)) {
211
+ if (!best || zoneName.length > best.name.length) {
212
+ best = zone;
213
+ }
214
+ }
215
+ }
216
+ return best;
217
+ }
218
+ }
@@ -0,0 +1,125 @@
1
+ import type {
2
+ IOutboundConnectionProxy,
3
+ IOutboundConnectionProxyContext,
4
+ } from '@push.rocks/smartmta';
5
+ import { logger } from '../logger.js';
6
+ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
7
+ import type { IEmailOutboundEgressStatus, TEmailOutboundMode } from '../../ts_interfaces/data/email-settings.js';
8
+ import type { DcRouter } from '../classes.dcrouter.js';
9
+
10
+ /**
11
+ * Routes outbound SMTP deliveries over RemoteIngress edges: resolves the
12
+ * outbound mode per attempt (no silent direct fallback), maps sender-domain
13
+ * pins to an edgeFilter, opens the per-delivery QUIC egress proxy, and
14
+ * presents the selected edge's FCrDNS hostname as the EHLO identity.
15
+ */
16
+ export class MailEgressCoordinator {
17
+ constructor(private dcRouterRef: DcRouter) {}
18
+
19
+ /**
20
+ * smartmta connectionProxyProvider. Wired unconditionally so the outbound
21
+ * mode is resolved per delivery attempt: a failed/late settings manager
22
+ * defers mail loudly instead of silently degrading to direct-from-hub
23
+ * egress, and mode changes take effect without an email server restart.
24
+ */
25
+ public async provideConnectionProxy(
26
+ context: IOutboundConnectionProxyContext,
27
+ ): Promise<IOutboundConnectionProxy | null> {
28
+ const mode = this.resolveOutboundModeStrict();
29
+ if (mode === 'direct') {
30
+ return null;
31
+ }
32
+ return await this.createRemoteIngressConnectionProxy(context);
33
+ }
34
+
35
+ /**
36
+ * Resolve the effective email outbound mode for a delivery attempt.
37
+ * Throws when the settings manager is unavailable — deferring the delivery —
38
+ * because guessing here is what silently degraded outbound to direct egress.
39
+ */
40
+ public resolveOutboundModeStrict(): TEmailOutboundMode {
41
+ if (this.dcRouterRef.emailSettingsManager) {
42
+ return this.dcRouterRef.emailSettingsManager.getOutboundMode();
43
+ }
44
+ if (this.dcRouterRef.options.emailOutboundMode) {
45
+ return this.dcRouterRef.options.emailOutboundMode;
46
+ }
47
+ throw new Error('EmailSettingsManager unavailable: refusing to resolve email outbound mode implicitly');
48
+ }
49
+
50
+ /**
51
+ * Readiness of the RemoteIngress outbound mail egress path (mail-tagged edges).
52
+ */
53
+ public getOutboundEgressStatus(): IEmailOutboundEgressStatus {
54
+ const tunnelManager = this.dcRouterRef.tunnelManager;
55
+ if (!tunnelManager) {
56
+ return { ready: false, eligibleEdgeCount: 0, reason: 'RemoteIngress hub is not running' };
57
+ }
58
+ return tunnelManager.getEgressReadiness([REMOTE_INGRESS_MAIL_TAG]);
59
+ }
60
+
61
+ /**
62
+ * Post-start preflight: with remoteIngress outbound (the default), mail
63
+ * defers until a QUIC mail egress edge connects. Surface that loudly
64
+ * instead of silently queueing.
65
+ */
66
+ public logEgressPreflight(): void {
67
+ try {
68
+ if (this.resolveOutboundModeStrict() === 'remoteIngress') {
69
+ const egressStatus = this.getOutboundEgressStatus();
70
+ if (!egressStatus.ready) {
71
+ logger.log('warn', `Outbound mail egress is NOT ready (${egressStatus.reason}); outbound mail will defer until a QUIC mail egress edge connects`);
72
+ }
73
+ }
74
+ } catch (error: unknown) {
75
+ logger.log('error', `Outbound mail mode could not be resolved: ${(error as Error).message}; outbound deliveries will defer until EmailSettingsManager is available`);
76
+ }
77
+ }
78
+
79
+ private async createRemoteIngressConnectionProxy(
80
+ context: IOutboundConnectionProxyContext,
81
+ ): Promise<IOutboundConnectionProxy> {
82
+ const tunnelManager = this.dcRouterRef.tunnelManager;
83
+ if (!tunnelManager) {
84
+ throw new Error('RemoteIngress outbound SMTP egress is enabled, but the hub is not running');
85
+ }
86
+
87
+ // Per-domain pinning: a sender domain with a configured edgeFilter uses only
88
+ // matching edges; everything else uses the mail-tagged edge pool.
89
+ let edgeFilter: string[] = [REMOTE_INGRESS_MAIL_TAG];
90
+ if (context.senderDomain && this.dcRouterRef.emailDomainManager) {
91
+ const domain = await this.dcRouterRef.emailDomainManager.getByDomain(context.senderDomain);
92
+ if (domain?.remoteIngress?.edgeFilter?.length) {
93
+ edgeFilter = domain.remoteIngress.edgeFilter;
94
+ }
95
+ }
96
+
97
+ const connectionTimeoutMs = this.dcRouterRef.options.emailConfig?.outbound?.connectionTimeout ?? 30_000;
98
+ const proxy = await tunnelManager.startEgressTcpProxy({
99
+ logicalHost: context.host,
100
+ port: context.port,
101
+ serverFirst: !context.secure,
102
+ connectTimeoutMs: connectionTimeoutMs,
103
+ acceptTimeoutMs: connectionTimeoutMs,
104
+ edgeFilter,
105
+ });
106
+ let closed = false;
107
+ return {
108
+ connectHost: proxy.listenHost,
109
+ connectPort: proxy.listenPort,
110
+ poolKey: `remoteIngress:${proxy.edgeId}:${context.host}:${context.port}`,
111
+ ehloHostname: this.dcRouterRef.remoteIngressManager?.getEdge(proxy.edgeId)?.mailHostname,
112
+ close: async () => {
113
+ if (closed) {
114
+ return;
115
+ }
116
+ closed = true;
117
+ try {
118
+ await tunnelManager.stopEgressTcpProxy(proxy.proxyId);
119
+ } catch (err: unknown) {
120
+ logger.log('warn', `Failed to stop RemoteIngress egress proxy ${proxy.proxyId}: ${(err as Error).message}`);
121
+ }
122
+ },
123
+ };
124
+ }
125
+ }
@@ -7,7 +7,7 @@ import type {
7
7
  } from '@push.rocks/smartmta';
8
8
  import type { Buffer } from 'node:buffer';
9
9
  import * as plugins from '../plugins.js';
10
- import type { CachedEmail } from '../db/index.js';
10
+ import { CachedEmail } from '../db/index.js';
11
11
  import type { DcRouter } from '../classes.dcrouter.js';
12
12
  import type * as interfaces from '../../ts_interfaces/index.js';
13
13
 
@@ -30,8 +30,38 @@ type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail
30
30
  type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
31
31
  type TMailCredentialRotateResponse = plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential['response'];
32
32
  type TMailEnqueueResponse = plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail['response'];
33
+ type TMailDeliveryStatusResponse = plugins.servezoneInterfaces.requests.mail.IReq_GetMailDeliveryStatus['response'];
33
34
  type TMailEndpointRegistrationResponse = plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response'];
34
35
  type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
36
+ type TCachedEmailWithTimestamps = CachedEmail & {
37
+ createdAt?: Date | number | string;
38
+ updatedAt?: Date | number | string;
39
+ };
40
+ type TSmartMtaStatusQueueItem = {
41
+ status?: 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
42
+ attempts?: number;
43
+ nextAttempt?: Date;
44
+ lastError?: string;
45
+ processingResult?: {
46
+ headers?: Record<string, string>;
47
+ email?: {
48
+ headers?: Record<string, string>;
49
+ };
50
+ };
51
+ };
52
+ type TCachedEmailRouteData = {
53
+ session?: {
54
+ user?: {
55
+ username?: string;
56
+ };
57
+ };
58
+ smartMta?: {
59
+ status?: 'queued' | 'deferred' | 'delivered' | 'failed';
60
+ nextAttempt?: string;
61
+ lastError?: string;
62
+ updatedAt?: string;
63
+ };
64
+ };
35
65
 
36
66
  interface IStoredWorkAppMailIdentity {
37
67
  id: string;
@@ -370,6 +400,31 @@ export class WorkAppMailManager {
370
400
  }
371
401
  }
372
402
 
403
+ public async getMailDeliveryStatus(
404
+ spoolItemIdArg: string,
405
+ ): Promise<TMailDeliveryStatusResponse> {
406
+ const cachedEmail = await this.getCachedEmailById(spoolItemIdArg);
407
+ if (!cachedEmail) {
408
+ return { attempts: [] };
409
+ }
410
+ return await this.buildMailDeliveryStatus(cachedEmail);
411
+ }
412
+
413
+ public async getMailDeliveryStatusForCredential(
414
+ authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
415
+ spoolItemIdArg: string,
416
+ ): Promise<TMailDeliveryStatusResponse> {
417
+ const identity = await this.authenticateMailCredential(authArg);
418
+ const cachedEmail = await this.getCachedEmailById(spoolItemIdArg);
419
+ if (!cachedEmail) {
420
+ return { attempts: [] };
421
+ }
422
+ if (!this.cachedEmailBelongsToIdentity(cachedEmail, identity)) {
423
+ throw new Error('Mail credential is not authorized for this spool item');
424
+ }
425
+ return await this.buildMailDeliveryStatus(cachedEmail);
426
+ }
427
+
373
428
  public async registerServiceMailEndpoint(
374
429
  authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
375
430
  peerArg: { tags?: Set<string> },
@@ -889,6 +944,144 @@ export class WorkAppMailManager {
889
944
  return this.normalizeInboundTarget(identity.inboundTarget || this.toMailInboundTarget(identity.inbound));
890
945
  }
891
946
 
947
+ private async getCachedEmailById(spoolItemIdArg: string): Promise<CachedEmail | null> {
948
+ const spoolItemId = String(spoolItemIdArg || '').trim();
949
+ if (!spoolItemId || !this.dcRouterRef.dcRouterDb?.isReady()) {
950
+ return null;
951
+ }
952
+ return await CachedEmail.findById(spoolItemId);
953
+ }
954
+
955
+ private async buildMailDeliveryStatus(
956
+ cachedEmailArg: CachedEmail,
957
+ ): Promise<TMailDeliveryStatusResponse> {
958
+ const queueItem = this.findQueueItemForCachedEmail(cachedEmailArg.id);
959
+ const routeData = this.parseCachedEmailRouteData(cachedEmailArg);
960
+ const owner = await this.resolveCachedEmailOwner(cachedEmailArg);
961
+ const status = this.mapMailSpoolStatus(cachedEmailArg, queueItem);
962
+ const cachedEmailWithTimestamps = cachedEmailArg as TCachedEmailWithTimestamps;
963
+ const createdAt = this.toTimestamp(cachedEmailWithTimestamps.createdAt) || Date.now();
964
+ const updatedAt = this.toTimestamp(cachedEmailWithTimestamps.updatedAt) || createdAt;
965
+ const nextAttemptAt = status === 'delivered' || status === 'failed'
966
+ ? undefined
967
+ : this.toTimestamp(queueItem?.nextAttempt) || this.toTimestamp(routeData.smartMta?.nextAttempt) || this.toTimestamp(cachedEmailArg.nextAttempt);
968
+ const lastError = queueItem?.lastError || cachedEmailArg.lastError || routeData.smartMta?.lastError || undefined;
969
+
970
+ return {
971
+ spoolItem: {
972
+ id: cachedEmailArg.id,
973
+ owner,
974
+ direction: this.getCachedEmailSessionUsername(cachedEmailArg) ? 'outbound' : 'inbound',
975
+ status,
976
+ envelope: {
977
+ mailFrom: cachedEmailArg.from,
978
+ rcptTo: cachedEmailArg.to || [],
979
+ },
980
+ messageId: cachedEmailArg.messageId,
981
+ subject: cachedEmailArg.subject,
982
+ target: this.getCachedEmailSessionUsername(cachedEmailArg)
983
+ ? { type: 'mtaRelay', label: 'SmartMTA outbound relay' }
984
+ : undefined,
985
+ attempts: Math.max(cachedEmailArg.attempts || 0, queueItem?.attempts || 0),
986
+ nextAttemptAt,
987
+ lastError,
988
+ createdAt,
989
+ updatedAt,
990
+ },
991
+ attempts: [],
992
+ };
993
+ }
994
+
995
+ private findQueueItemForCachedEmail(cachedEmailIdArg: string): TSmartMtaStatusQueueItem | undefined {
996
+ const emailServer = this.dcRouterRef.emailServer;
997
+ if (!emailServer?.getQueueItems) {
998
+ return undefined;
999
+ }
1000
+ return (emailServer.getQueueItems() as TSmartMtaStatusQueueItem[]).find((itemArg) => {
1001
+ return this.getQueueItemCachedEmailId(itemArg) === cachedEmailIdArg;
1002
+ });
1003
+ }
1004
+
1005
+ private getQueueItemCachedEmailId(itemArg: TSmartMtaStatusQueueItem): string | undefined {
1006
+ const headerName = 'x-dcrouter-cached-email-id';
1007
+ const headers = itemArg?.processingResult?.headers || itemArg?.processingResult?.email?.headers;
1008
+ if (!headers) {
1009
+ return undefined;
1010
+ }
1011
+ const matchingHeaderName = Object.keys(headers).find((keyArg) => keyArg.toLowerCase() === headerName);
1012
+ return matchingHeaderName ? headers[matchingHeaderName] : undefined;
1013
+ }
1014
+
1015
+ private async resolveCachedEmailOwner(
1016
+ cachedEmailArg: CachedEmail,
1017
+ ): Promise<plugins.servezoneInterfaces.data.IMailResourceOwner | undefined> {
1018
+ const username = this.getCachedEmailSessionUsername(cachedEmailArg);
1019
+ if (!username) {
1020
+ return undefined;
1021
+ }
1022
+ const identity = (await this.readStoredIdentities()).find((identityArg) => identityArg.smtp.username === username);
1023
+ return identity ? this.toMailOwner(identity.ownership) : undefined;
1024
+ }
1025
+
1026
+ private cachedEmailBelongsToIdentity(
1027
+ cachedEmailArg: CachedEmail,
1028
+ identityArg: IStoredWorkAppMailIdentity,
1029
+ ): boolean {
1030
+ return this.normalizeAddress(cachedEmailArg.from || '') === identityArg.address
1031
+ && this.getCachedEmailSessionUsername(cachedEmailArg) === identityArg.smtp.username;
1032
+ }
1033
+
1034
+ private getCachedEmailSessionUsername(cachedEmailArg: CachedEmail): string | undefined {
1035
+ const routeData = this.parseCachedEmailRouteData(cachedEmailArg);
1036
+ const username = routeData.session?.user?.username;
1037
+ return typeof username === 'string' && username ? username : undefined;
1038
+ }
1039
+
1040
+ private parseCachedEmailRouteData(cachedEmailArg: CachedEmail): TCachedEmailRouteData {
1041
+ try {
1042
+ return cachedEmailArg.routeData ? JSON.parse(cachedEmailArg.routeData) : {};
1043
+ } catch {
1044
+ return {};
1045
+ }
1046
+ }
1047
+
1048
+ private mapMailSpoolStatus(
1049
+ cachedEmailArg: CachedEmail,
1050
+ queueItemArg?: TSmartMtaStatusQueueItem,
1051
+ ): plugins.servezoneInterfaces.data.TMailSpoolStatus {
1052
+ if (!queueItemArg && cachedEmailArg.status === 'pending') {
1053
+ return 'accepted';
1054
+ }
1055
+ const status = queueItemArg?.status || cachedEmailArg.status;
1056
+ switch (status) {
1057
+ case 'processing':
1058
+ return 'delivering';
1059
+ case 'delivered':
1060
+ return 'delivered';
1061
+ case 'deferred':
1062
+ return 'deferred';
1063
+ case 'failed':
1064
+ return 'failed';
1065
+ case 'pending':
1066
+ case 'queued':
1067
+ default:
1068
+ return 'queued';
1069
+ }
1070
+ }
1071
+
1072
+ private toTimestamp(valueArg: unknown): number | undefined {
1073
+ if (!valueArg) {
1074
+ return undefined;
1075
+ }
1076
+ if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
1077
+ return valueArg;
1078
+ }
1079
+ const timestamp = valueArg instanceof Date
1080
+ ? valueArg.getTime()
1081
+ : new Date(String(valueArg)).getTime();
1082
+ return Number.isFinite(timestamp) ? timestamp : undefined;
1083
+ }
1084
+
892
1085
  private async authenticateMailCredential(
893
1086
  authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
894
1087
  addressArg?: string,
package/ts/email/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './classes.accepted-email-spool.js';
2
2
  export * from './classes.email-domain.manager.js';
3
3
  export * from './classes.email-route-builder.js';
4
+ export * from './classes.mail-dns-sync.js';
5
+ export * from './classes.mail-egress-coordinator.js';
4
6
  export * from './classes.email-settings.manager.js';
5
7
  export * from './classes.smartmta-storage-manager.js';
6
8
  export * from './classes.workapp-mail-manager.js';
@@ -107,7 +107,9 @@ export class ConfigHandler {
107
107
  ports: emailSettings?.ports || opts.emailConfig?.ports || [],
108
108
  portMapping,
109
109
  hostname: emailSettings?.hostname || opts.emailConfig?.hostname || null,
110
- outboundMode: emailSettings?.outboundMode || opts.emailOutboundMode || 'direct',
110
+ // Absent everywhere means the 'remoteIngress' default applies — never
111
+ // silently claim 'direct' (that masking was the outbound activation bug).
112
+ outboundMode: emailSettings?.outboundMode || opts.emailOutboundMode || 'remoteIngress',
111
113
  domains: emailDomains,
112
114
  emailRouteCount: emailSettings?.routeCount ?? opts.emailConfig?.routes?.length ?? 0,
113
115
  receivedEmailsPath: emailSettings?.receivedEmailsPath || opts.emailPortConfig?.receivedEmailsPath || null,
@@ -73,6 +73,7 @@ export class EmailDomainHandler {
73
73
  dkimKeySize: dataArg.dkimKeySize,
74
74
  rotateKeys: dataArg.rotateKeys,
75
75
  rotationIntervalDays: dataArg.rotationIntervalDays,
76
+ remoteIngress: dataArg.remoteIngress,
76
77
  });
77
78
  return { success: true, domain };
78
79
  } catch (err: unknown) {
@@ -96,6 +97,7 @@ export class EmailDomainHandler {
96
97
  rotateKeys: dataArg.rotateKeys,
97
98
  rotationIntervalDays: dataArg.rotationIntervalDays,
98
99
  rateLimits: dataArg.rateLimits,
100
+ remoteIngress: dataArg.remoteIngress,
99
101
  });
100
102
  return { success: true };
101
103
  } catch (err: unknown) {