@serve.zone/dcrouter 16.4.1 → 16.5.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 (59) 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 +2 -1
  5. package/dist_ts/classes.dcrouter.js +20 -4
  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/dns/manager.dns.d.ts +6 -0
  11. package/dist_ts/dns/manager.dns.js +81 -32
  12. package/dist_ts/dns/providers/cloudflare.provider.d.ts +2 -0
  13. package/dist_ts/dns/providers/cloudflare.provider.js +45 -28
  14. package/dist_ts/email/classes.email-domain.manager.d.ts +43 -38
  15. package/dist_ts/email/classes.email-domain.manager.js +391 -324
  16. package/dist_ts/email/classes.mail-dns-sync.d.ts +58 -17
  17. package/dist_ts/email/classes.mail-dns-sync.js +857 -141
  18. package/dist_ts/email/classes.mail-edge-eligibility.d.ts +55 -0
  19. package/dist_ts/email/classes.mail-edge-eligibility.js +168 -0
  20. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +3 -1
  21. package/dist_ts/email/classes.mail-egress-coordinator.js +43 -22
  22. package/dist_ts/email/classes.workapp-mail-manager.d.ts +3 -0
  23. package/dist_ts/email/classes.workapp-mail-manager.js +58 -12
  24. package/dist_ts/email/index.d.ts +1 -0
  25. package/dist_ts/email/index.js +2 -1
  26. package/dist_ts/opsserver/handlers/email-domain.handler.d.ts +1 -0
  27. package/dist_ts/opsserver/handlers/email-domain.handler.js +138 -22
  28. package/dist_ts/remoteingress/classes.hub-lifecycle.js +2 -1
  29. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
  30. package/dist_ts/remoteingress/classes.tunnel-manager.js +40 -2
  31. package/dist_ts_interfaces/data/dns-record.d.ts +6 -0
  32. package/dist_ts_interfaces/data/email-domain.d.ts +81 -0
  33. package/dist_ts_interfaces/requests/email-domains.d.ts +25 -17
  34. package/dist_ts_migrations/index.js +152 -9
  35. package/dist_ts_web/00_commitinfo_data.js +1 -1
  36. package/dist_ts_web/appstate/email-domains.d.ts +16 -0
  37. package/dist_ts_web/appstate/email-domains.js +140 -19
  38. package/dist_ts_web/elements/email/ops-view-email-domains.d.ts +1 -0
  39. package/dist_ts_web/elements/email/ops-view-email-domains.js +36 -13
  40. package/package.json +3 -3
  41. package/readme.md +13 -1
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/classes.dcrouter.ts +22 -3
  44. package/ts/db/documents/classes.dns-record.doc.ts +20 -0
  45. package/ts/db/documents/classes.email-domain.doc.ts +14 -0
  46. package/ts/dns/manager.dns.ts +89 -29
  47. package/ts/dns/providers/cloudflare.provider.ts +46 -27
  48. package/ts/email/classes.email-domain.manager.ts +416 -368
  49. package/ts/email/classes.mail-dns-sync.ts +913 -146
  50. package/ts/email/classes.mail-edge-eligibility.ts +205 -0
  51. package/ts/email/classes.mail-egress-coordinator.ts +46 -23
  52. package/ts/email/classes.workapp-mail-manager.ts +69 -13
  53. package/ts/email/index.ts +1 -0
  54. package/ts/opsserver/handlers/email-domain.handler.ts +154 -21
  55. package/ts/remoteingress/classes.hub-lifecycle.ts +1 -0
  56. package/ts/remoteingress/classes.tunnel-manager.ts +53 -7
  57. package/ts_web/00_commitinfo_data.ts +1 -1
  58. package/ts_web/appstate/email-domains.ts +187 -18
  59. package/ts_web/elements/email/ops-view-email-domains.ts +44 -12
@@ -3,6 +3,91 @@ import type { OpsServer } from '../classes.opsserver.js';
3
3
  import * as interfaces from '../../../ts_interfaces/index.js';
4
4
  import { requireOpsAuth } from '../helpers/auth.js';
5
5
 
6
+ const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
7
+ return typeof valueArg === 'object' && valueArg !== null;
8
+ };
9
+
10
+ const normalizeActionErrors = (
11
+ valueArg: unknown,
12
+ ): interfaces.requests.IEmailDomainActionError[] | undefined => {
13
+ if (!Array.isArray(valueArg)) return undefined;
14
+
15
+ const errors: interfaces.requests.IEmailDomainActionError[] = [];
16
+ for (const entry of valueArg) {
17
+ if (!isRecord(entry) || typeof entry.message !== 'string') continue;
18
+ errors.push({
19
+ code: typeof entry.code === 'string' ? entry.code : 'EMAIL_DOMAIN_ACTION_FAILED',
20
+ message: entry.message,
21
+ recordKey: typeof entry.recordKey === 'string' ? entry.recordKey : undefined,
22
+ retryable: typeof entry.retryable === 'boolean' ? entry.retryable : undefined,
23
+ });
24
+ }
25
+ return errors.length > 0 ? errors : undefined;
26
+ };
27
+
28
+ const normalizeEmailDomainActionResult = (
29
+ valueArg: unknown,
30
+ defaultSuccess = true,
31
+ ): interfaces.requests.IEmailDomainActionResult => {
32
+ const value = isRecord(valueArg) ? valueArg : {};
33
+ const directDomain = isRecord(value.domain)
34
+ ? value.domain
35
+ : typeof value.id === 'string' && typeof value.domain === 'string'
36
+ ? value
37
+ : undefined;
38
+ const reconciliation = isRecord(directDomain?.reconciliation)
39
+ ? directDomain.reconciliation
40
+ : undefined;
41
+ const rawLifecycle = value.lifecycleStatus ?? reconciliation?.lifecycleStatus;
42
+ const lifecycleStatus =
43
+ rawLifecycle === 'active' ||
44
+ rawLifecycle === 'pending' ||
45
+ rawLifecycle === 'failed' ||
46
+ rawLifecycle === 'deleting'
47
+ ? rawLifecycle
48
+ : undefined;
49
+ const reconciliationCapability = isRecord(reconciliation?.capability)
50
+ ? reconciliation.capability.egressIdentityProof
51
+ : undefined;
52
+ const rawCapability = value.capability ?? reconciliationCapability;
53
+ const capability =
54
+ rawCapability === 'supported' || rawCapability === 'unsupported'
55
+ ? rawCapability
56
+ : undefined;
57
+ const errors = normalizeActionErrors(value.errors ?? reconciliation?.errors);
58
+
59
+ return {
60
+ success: typeof value.success === 'boolean'
61
+ ? value.success
62
+ : lifecycleStatus === 'failed'
63
+ ? false
64
+ : defaultSuccess,
65
+ message: typeof value.message === 'string' ? value.message : errors?.[0]?.message,
66
+ lifecycleStatus,
67
+ code: typeof value.code === 'string' ? value.code : errors?.[0]?.code,
68
+ errors,
69
+ retryAt: typeof value.retryAt === 'string'
70
+ ? value.retryAt
71
+ : typeof reconciliation?.retryAt === 'string'
72
+ ? reconciliation.retryAt
73
+ : undefined,
74
+ capability,
75
+ };
76
+ };
77
+
78
+ const normalizeEmailDomainActionFailure = (
79
+ errorArg: unknown,
80
+ fallbackMessageArg: string,
81
+ ): interfaces.requests.IEmailDomainActionResult => {
82
+ const result = normalizeEmailDomainActionResult(errorArg, false);
83
+ const errorMessage = errorArg instanceof Error ? errorArg.message : undefined;
84
+ return {
85
+ ...result,
86
+ success: false,
87
+ message: result.message || errorMessage || result.errors?.[0]?.message || fallbackMessageArg,
88
+ };
89
+ };
90
+
6
91
  /**
7
92
  * CRUD + DNS provisioning handler for email domains.
8
93
  *
@@ -31,6 +116,23 @@ export class EmailDomainHandler {
31
116
  return this.opsServerRef.dcRouterRef.emailDomainManager;
32
117
  }
33
118
 
119
+ private managerUnavailable(): interfaces.requests.IEmailDomainActionResult {
120
+ const message = 'EmailDomainManager not initialized';
121
+ return {
122
+ success: false,
123
+ message,
124
+ code: 'EMAIL_DOMAIN_MANAGER_UNAVAILABLE',
125
+ capability: 'unsupported',
126
+ errors: [
127
+ {
128
+ code: 'EMAIL_DOMAIN_MANAGER_UNAVAILABLE',
129
+ message,
130
+ retryable: true,
131
+ },
132
+ ],
133
+ };
134
+ }
135
+
34
136
  private registerHandlers(): void {
35
137
  // List all email domains
36
138
  this.typedrouter.addTypedHandler(
@@ -63,10 +165,10 @@ export class EmailDomainHandler {
63
165
  async (dataArg) => {
64
166
  await this.requireAuth(dataArg, 'email-domains:write' as any);
65
167
  if (!this.manager) {
66
- return { success: false, message: 'EmailDomainManager not initialized' };
168
+ return this.managerUnavailable();
67
169
  }
68
170
  try {
69
- const domain = await this.manager.createEmailDomain({
171
+ const rawResult: unknown = await this.manager.createEmailDomain({
70
172
  linkedDomainId: dataArg.linkedDomainId,
71
173
  subdomain: dataArg.subdomain,
72
174
  dkimSelector: dataArg.dkimSelector,
@@ -75,9 +177,18 @@ export class EmailDomainHandler {
75
177
  rotationIntervalDays: dataArg.rotationIntervalDays,
76
178
  remoteIngress: dataArg.remoteIngress,
77
179
  });
78
- return { success: true, domain };
180
+ const result = normalizeEmailDomainActionResult(rawResult);
181
+ if (!result.success) return result;
182
+
183
+ const resultRecord = isRecord(rawResult) ? rawResult : undefined;
184
+ const domain = isRecord(resultRecord?.domain)
185
+ ? resultRecord.domain as unknown as interfaces.data.IEmailDomain
186
+ : typeof resultRecord?.id === 'string'
187
+ ? rawResult as interfaces.data.IEmailDomain
188
+ : undefined;
189
+ return { ...result, domain };
79
190
  } catch (err: unknown) {
80
- return { success: false, message: (err as Error).message };
191
+ return normalizeEmailDomainActionFailure(err, 'Failed to create email domain');
81
192
  }
82
193
  },
83
194
  ),
@@ -90,18 +201,18 @@ export class EmailDomainHandler {
90
201
  async (dataArg) => {
91
202
  await this.requireAuth(dataArg, 'email-domains:write' as any);
92
203
  if (!this.manager) {
93
- return { success: false, message: 'EmailDomainManager not initialized' };
204
+ return this.managerUnavailable();
94
205
  }
95
206
  try {
96
- await this.manager.updateEmailDomain(dataArg.id, {
207
+ const rawResult: unknown = await this.manager.updateEmailDomain(dataArg.id, {
97
208
  rotateKeys: dataArg.rotateKeys,
98
209
  rotationIntervalDays: dataArg.rotationIntervalDays,
99
210
  rateLimits: dataArg.rateLimits,
100
211
  remoteIngress: dataArg.remoteIngress,
101
212
  });
102
- return { success: true };
213
+ return normalizeEmailDomainActionResult(rawResult);
103
214
  } catch (err: unknown) {
104
- return { success: false, message: (err as Error).message };
215
+ return normalizeEmailDomainActionFailure(err, 'Failed to update email domain');
105
216
  }
106
217
  },
107
218
  ),
@@ -114,13 +225,13 @@ export class EmailDomainHandler {
114
225
  async (dataArg) => {
115
226
  await this.requireAuth(dataArg, 'email-domains:write' as any);
116
227
  if (!this.manager) {
117
- return { success: false, message: 'EmailDomainManager not initialized' };
228
+ return this.managerUnavailable();
118
229
  }
119
230
  try {
120
- await this.manager.deleteEmailDomain(dataArg.id);
121
- return { success: true };
231
+ const rawResult: unknown = await this.manager.deleteEmailDomain(dataArg.id);
232
+ return normalizeEmailDomainActionResult(rawResult);
122
233
  } catch (err: unknown) {
123
- return { success: false, message: (err as Error).message };
234
+ return normalizeEmailDomainActionFailure(err, 'Failed to delete email domain');
124
235
  }
125
236
  },
126
237
  ),
@@ -133,14 +244,27 @@ export class EmailDomainHandler {
133
244
  async (dataArg) => {
134
245
  await this.requireAuth(dataArg, 'email-domains:read' as any);
135
246
  if (!this.manager) {
136
- return { success: false, message: 'EmailDomainManager not initialized' };
247
+ return this.managerUnavailable();
137
248
  }
138
249
  try {
139
- const records = await this.manager.validateDns(dataArg.id);
140
- const domain = await this.manager.getById(dataArg.id);
141
- return { success: true, domain: domain ?? undefined, records };
250
+ const rawResult: unknown = await this.manager.validateDns(dataArg.id);
251
+ const result = normalizeEmailDomainActionResult(rawResult);
252
+ if (!result.success) return result;
253
+
254
+ const resultRecord = isRecord(rawResult) ? rawResult : undefined;
255
+ const records = (
256
+ Array.isArray(rawResult)
257
+ ? rawResult
258
+ : Array.isArray(resultRecord?.records)
259
+ ? resultRecord.records
260
+ : undefined
261
+ ) as interfaces.data.IEmailDnsRecord[] | undefined;
262
+ const domain = isRecord(resultRecord?.domain)
263
+ ? resultRecord.domain as unknown as interfaces.data.IEmailDomain
264
+ : await this.manager.getById(dataArg.id) ?? undefined;
265
+ return { ...result, domain, records };
142
266
  } catch (err: unknown) {
143
- return { success: false, message: (err as Error).message };
267
+ return normalizeEmailDomainActionFailure(err, 'Failed to validate email domain DNS');
144
268
  }
145
269
  },
146
270
  ),
@@ -165,13 +289,22 @@ export class EmailDomainHandler {
165
289
  async (dataArg) => {
166
290
  await this.requireAuth(dataArg, 'email-domains:write' as any);
167
291
  if (!this.manager) {
168
- return { success: false, message: 'EmailDomainManager not initialized' };
292
+ return this.managerUnavailable();
169
293
  }
170
294
  try {
171
- const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
172
- return { success: true, provisioned };
295
+ const rawResult: unknown = await this.manager.provisionDnsRecords(dataArg.id);
296
+ const result = normalizeEmailDomainActionResult(rawResult);
297
+ if (!result.success) return result;
298
+
299
+ const resultRecord = isRecord(rawResult) ? rawResult : undefined;
300
+ const provisioned = typeof rawResult === 'number'
301
+ ? rawResult
302
+ : typeof resultRecord?.provisioned === 'number'
303
+ ? resultRecord.provisioned
304
+ : undefined;
305
+ return { ...result, provisioned };
173
306
  } catch (err: unknown) {
174
- return { success: false, message: (err as Error).message };
307
+ return normalizeEmailDomainActionFailure(err, 'Failed to provision email domain DNS');
175
308
  }
176
309
  },
177
310
  ),
@@ -223,6 +223,7 @@ export class RemoteIngressHubLifecycle {
223
223
  tls: tlsConfig,
224
224
  performance: manager.getHubPerformanceConfig(),
225
225
  });
226
+ tunnelManager.setOnTopologyChanged((reason) => this.dcRouterRef.mailDnsSync.requestSync(reason));
226
227
  try {
227
228
  await tunnelManager.start();
228
229
  } catch (err) {
@@ -20,8 +20,14 @@ export type TRemoteIngressEgressProxyRequest = Omit<plugins.remoteingress.IEgres
20
20
  edgeFilter?: string[];
21
21
  };
22
22
 
23
+ export type TRemoteIngressHubStatus = Awaited<ReturnType<
24
+ InstanceType<typeof plugins.remoteingress.RemoteIngressHub>['getStatus']
25
+ >>;
26
+ type TRemoteIngressHubEdgeStatus = TRemoteIngressHubStatus['connectedEdges'][number];
27
+
23
28
  /** Cooldown applied to an edge after a failed egress open before it is deprioritized. */
24
29
  const egressFailureCooldownMs = 30_000;
30
+ const egressStatusFreshnessMs = 45_000;
25
31
 
26
32
  /**
27
33
  * Manages the RemoteIngressHub instance and tracks connected edge statuses.
@@ -38,6 +44,7 @@ export class TunnelManager {
38
44
  private egressProxyEdgeByProxyId: Map<string, string> = new Map();
39
45
  private activeEgressProxiesByEdge: Map<string, number> = new Map();
40
46
  private egressFailureCooldownUntil: Map<string, number> = new Map();
47
+ private onTopologyChanged?: (reason: string) => void;
41
48
 
42
49
  constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
43
50
  this.manager = manager;
@@ -54,10 +61,12 @@ export class TunnelManager {
54
61
  lastHeartbeat: Date.now(),
55
62
  connectedAt: Date.now(),
56
63
  });
64
+ this.onTopologyChanged?.(`RemoteIngress edge connected: ${data.edgeId}`);
57
65
  });
58
66
 
59
67
  this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
60
68
  this.edgeStatuses.delete(data.edgeId);
69
+ this.onTopologyChanged?.(`RemoteIngress edge disconnected: ${data.edgeId}`);
61
70
  });
62
71
 
63
72
  this.hub.on('streamSummary', (data: {
@@ -94,6 +103,14 @@ export class TunnelManager {
94
103
  this.egressFailureCooldownUntil.set(data.edgeId, Date.now() + egressFailureCooldownMs);
95
104
  }
96
105
  });
106
+
107
+ this.hub.on('egressIdentityChanged', (data) => {
108
+ this.onTopologyChanged?.(`RemoteIngress egress identity changed: ${data.edgeId}`);
109
+ });
110
+ }
111
+
112
+ public setOnTopologyChanged(callback: (reason: string) => void): void {
113
+ this.onTopologyChanged = callback;
97
114
  }
98
115
 
99
116
  /**
@@ -165,6 +182,7 @@ export class TunnelManager {
165
182
  if (this.stopped) return;
166
183
  if (!hubStatus || !hubStatus.connectedEdges) return;
167
184
 
185
+ const before = this.getTopologySignature();
168
186
  const rustEdgeIds = new Set<string>();
169
187
 
170
188
  for (const rustEdge of hubStatus.connectedEdges) {
@@ -199,6 +217,10 @@ export class TunnelManager {
199
217
  this.edgeStatuses.delete(edgeId);
200
218
  }
201
219
  }
220
+ const after = this.getTopologySignature();
221
+ if (before !== after) {
222
+ this.onTopologyChanged?.('RemoteIngress mail egress topology changed');
223
+ }
202
224
  }
203
225
 
204
226
  /**
@@ -260,33 +282,43 @@ export class TunnelManager {
260
282
  );
261
283
  }
262
284
 
285
+ /** Authoritative hub state used by the source-bound mail identity adapter. */
286
+ public async getAuthoritativeHubStatus(): Promise<TRemoteIngressHubStatus | undefined> {
287
+ if (this.stopped) return undefined;
288
+ return await this.hub.getStatus();
289
+ }
290
+
263
291
  /**
264
292
  * Eligibility (connected + egressEnabled + egressTcpV1 + native QUIC), scoped by
265
293
  * edgeId/edgeFilter, capped by per-edge maxConcurrentStreams, ordered deterministically:
266
294
  * not-in-cooldown first, then fewest active tunnels, then edgeId.
267
295
  */
268
296
  private selectEgressCandidates(
269
- connectedEdges: any[],
297
+ connectedEdges: TRemoteIngressHubEdgeStatus[],
270
298
  request: TRemoteIngressEgressProxyRequest,
271
299
  ): Array<{ edgeId: string; activeStreams: number }> {
272
300
  const eligible = connectedEdges.filter((edge: any) => {
273
301
  const hasEgressCapability = Array.isArray(edge.capabilities) && edge.capabilities.includes('egressTcpV1');
274
302
  const usesQuic = edge.transportMode === 'quic'
275
303
  || (edge.transportMode === 'quicWithFallback' && edge.fallbackUsed === false);
276
- return edge.egressEnabled === true && hasEgressCapability && usesQuic;
304
+ const observed = this.edgeStatuses.get(edge.edgeId);
305
+ const fresh = observed?.connected
306
+ && observed.lastHeartbeat !== null
307
+ && Date.now() - observed.lastHeartbeat <= egressStatusFreshnessMs;
308
+ return edge.egressEnabled === true && hasEgressCapability && usesQuic && fresh;
277
309
  });
278
310
 
279
311
  let scoped = eligible;
280
312
  if (request.edgeId) {
281
- scoped = eligible.filter((edge: any) => edge.edgeId === request.edgeId);
313
+ scoped = eligible.filter((edge) => edge.edgeId === request.edgeId);
282
314
  } else if (request.edgeFilter && request.edgeFilter.length > 0) {
283
315
  const allowedIds = new Set(
284
316
  this.manager.resolveEdgesByFilter(request.edgeFilter).map((edge) => edge.id),
285
317
  );
286
- scoped = eligible.filter((edge: any) => allowedIds.has(edge.edgeId));
318
+ scoped = eligible.filter((edge) => allowedIds.has(edge.edgeId));
287
319
  }
288
320
 
289
- const underCap = scoped.filter((edge: any) => {
321
+ const underCap = scoped.filter((edge) => {
290
322
  const cap = this.manager.getEdge(edge.edgeId)?.egress?.maxConcurrentStreams;
291
323
  if (!cap) return true;
292
324
  return (this.activeEgressProxiesByEdge.get(edge.edgeId) || 0) < cap;
@@ -295,7 +327,7 @@ export class TunnelManager {
295
327
  const now = Date.now();
296
328
  const inCooldown = (edgeId: string) => (this.egressFailureCooldownUntil.get(edgeId) || 0) > now;
297
329
  return underCap
298
- .map((edge: any) => ({ edgeId: edge.edgeId as string, activeStreams: Number(edge.activeStreams) || 0 }))
330
+ .map((edge) => ({ edgeId: edge.edgeId, activeStreams: Number(edge.activeStreams) || 0 }))
299
331
  .sort((a, b) => {
300
332
  const cooldownDelta = Number(inCooldown(a.edgeId)) - Number(inCooldown(b.edgeId));
301
333
  if (cooldownDelta !== 0) return cooldownDelta;
@@ -355,7 +387,7 @@ export class TunnelManager {
355
387
  return await this.hub.stopEgressTcpProxy(proxyId);
356
388
  }
357
389
 
358
- private applyRustStatus(status: IRemoteIngressStatus, rustEdge: any): void {
390
+ private applyRustStatus(status: IRemoteIngressStatus, rustEdge: TRemoteIngressHubEdgeStatus): void {
359
391
  status.transportMode = rustEdge.transportMode;
360
392
  status.fallbackUsed = rustEdge.fallbackUsed;
361
393
  status.capabilities = rustEdge.capabilities;
@@ -367,6 +399,20 @@ export class TunnelManager {
367
399
  status.udp = rustEdge.udp;
368
400
  }
369
401
 
402
+ private getTopologySignature(): string {
403
+ return [...this.edgeStatuses.values()]
404
+ .map((status) => [
405
+ status.edgeId,
406
+ status.connected,
407
+ status.egressEnabled,
408
+ status.transportMode,
409
+ status.fallbackUsed,
410
+ [...(status.capabilities || [])].sort().join(','),
411
+ ].join('|'))
412
+ .sort()
413
+ .join(';');
414
+ }
415
+
370
416
  /**
371
417
  * Get runtime statuses for all known edges.
372
418
  */
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '16.4.1',
6
+ version: '16.5.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }