@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
@@ -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: '17.0.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -8,10 +8,26 @@ import { getActionContext } from './login.js';
8
8
  // Email Domains State
9
9
  // ============================================================================
10
10
 
11
+ export type TEmailDomainOperationName =
12
+ | 'create'
13
+ | 'update'
14
+ | 'delete'
15
+ | 'validate'
16
+ | 'provision'
17
+ | 'reconcile'
18
+ | 'rotate';
19
+
20
+ export interface IEmailDomainOperationState extends interfaces.requests.IEmailDomainActionResult {
21
+ operation: TEmailDomainOperationName;
22
+ completedAt: number;
23
+ }
24
+
11
25
  export interface IEmailDomainsState {
12
26
  domains: interfaces.data.IEmailDomain[];
13
27
  settings: interfaces.data.IEmailServerSettings | null;
14
28
  isLoading: boolean;
29
+ error: string | null;
30
+ lastOperation: IEmailDomainOperationState | null;
15
31
  lastUpdated: number;
16
32
  }
17
33
 
@@ -21,11 +37,84 @@ export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsSt
21
37
  domains: [],
22
38
  settings: null,
23
39
  isLoading: false,
40
+ error: null,
41
+ lastOperation: null,
24
42
  lastUpdated: 0,
25
43
  },
26
44
  'soft',
27
45
  );
28
46
 
47
+ const createOperationState = (
48
+ operationArg: TEmailDomainOperationName,
49
+ responseArg: interfaces.requests.IEmailDomainActionResult,
50
+ ): IEmailDomainOperationState => ({
51
+ operation: operationArg,
52
+ completedAt: Date.now(),
53
+ ...responseArg,
54
+ });
55
+
56
+ const createFailedOperationState = (
57
+ operationArg: TEmailDomainOperationName,
58
+ errorArg: unknown,
59
+ fallbackMessageArg: string,
60
+ ): IEmailDomainOperationState => ({
61
+ operation: operationArg,
62
+ completedAt: Date.now(),
63
+ success: false,
64
+ message: errorArg instanceof Error ? errorArg.message : fallbackMessageArg,
65
+ code: 'EMAIL_DOMAIN_REQUEST_FAILED',
66
+ errors: [
67
+ {
68
+ code: 'EMAIL_DOMAIN_REQUEST_FAILED',
69
+ message: errorArg instanceof Error ? errorArg.message : fallbackMessageArg,
70
+ retryable: true,
71
+ },
72
+ ],
73
+ });
74
+
75
+ export const formatEmailDomainOperationMessage = (
76
+ operationArg: IEmailDomainOperationState,
77
+ fallbackMessageArg: string,
78
+ ): string => {
79
+ const primaryMessage = operationArg.message || fallbackMessageArg;
80
+ const messageParts = [
81
+ operationArg.code ? `${primaryMessage} [${operationArg.code}]` : primaryMessage,
82
+ ];
83
+ for (const error of operationArg.errors || []) {
84
+ const location = error.recordKey ? `${error.recordKey}: ` : '';
85
+ const detail = `${location}${error.message} [${error.code}]`;
86
+ const duplicatesPrimary =
87
+ !error.recordKey &&
88
+ error.message === primaryMessage &&
89
+ error.code === operationArg.code;
90
+ if (!duplicatesPrimary && !messageParts.includes(detail)) {
91
+ messageParts.push(detail);
92
+ }
93
+ }
94
+ if (operationArg.lifecycleStatus && operationArg.lifecycleStatus !== 'active') {
95
+ messageParts.push(`Lifecycle: ${operationArg.lifecycleStatus}`);
96
+ }
97
+ if (operationArg.capability === 'unsupported' && !messageParts.some((part) => part.includes('unsupported'))) {
98
+ messageParts.push('Required capability is unsupported');
99
+ }
100
+ if (operationArg.retryAt) {
101
+ messageParts.push(`Retry scheduled for ${operationArg.retryAt}`);
102
+ }
103
+ return messageParts.join(' — ');
104
+ };
105
+
106
+ const applyOperationState = (
107
+ stateArg: IEmailDomainsState,
108
+ operationArg: IEmailDomainOperationState,
109
+ fallbackMessageArg: string,
110
+ ): IEmailDomainsState => ({
111
+ ...stateArg,
112
+ error: operationArg.success
113
+ ? null
114
+ : formatEmailDomainOperationMessage(operationArg, fallbackMessageArg),
115
+ lastOperation: operationArg,
116
+ });
117
+
29
118
  export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
30
119
  async (statePartArg): Promise<IEmailDomainsState> => {
31
120
  const context = getActionContext();
@@ -46,10 +135,15 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
46
135
  domains: response.domains,
47
136
  settings: settingsResponse.settings,
48
137
  isLoading: false,
138
+ error: null,
49
139
  lastUpdated: Date.now(),
50
140
  };
51
- } catch {
52
- return { ...currentState, isLoading: false };
141
+ } catch (error: unknown) {
142
+ return {
143
+ ...currentState,
144
+ isLoading: false,
145
+ error: error instanceof Error ? error.message : 'Failed to fetch email domains',
146
+ };
53
147
  }
54
148
  },
55
149
  );
@@ -61,17 +155,62 @@ export const createEmailDomainAction = emailDomainsStatePart.createAction<{
61
155
  dkimKeySize?: number;
62
156
  rotateKeys?: boolean;
63
157
  rotationIntervalDays?: number;
158
+ remoteIngress?: interfaces.data.IEmailDomainRemoteIngress;
64
159
  }>(async (statePartArg, args, actionContext) => {
65
160
  const context = getActionContext();
66
161
  const currentState = statePartArg.getState()!;
162
+ if (!context.identity) {
163
+ const operation = createFailedOperationState('create', new Error('Not authenticated'), 'Not authenticated');
164
+ return applyOperationState(currentState, operation, 'Failed to create email domain');
165
+ }
67
166
  try {
68
167
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
69
168
  interfaces.requests.IReq_CreateEmailDomain
70
169
  >('/typedrequest', 'createEmailDomain');
71
- await request.fire({ identity: context.identity!, ...args });
72
- return await actionContext!.dispatch(fetchEmailDomainsAction, null);
73
- } catch {
74
- return currentState;
170
+ const response = await request.fire({ identity: context.identity, ...args });
171
+ const operation = createOperationState('create', response);
172
+ if (!response.success) {
173
+ return applyOperationState(currentState, operation, 'Failed to create email domain');
174
+ }
175
+ const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
176
+ return applyOperationState(refreshedState, operation, 'Email domain created');
177
+ } catch (error: unknown) {
178
+ const operation = createFailedOperationState('create', error, 'Failed to create email domain');
179
+ return applyOperationState(currentState, operation, 'Failed to create email domain');
180
+ }
181
+ });
182
+
183
+ export const updateEmailDomainAction = emailDomainsStatePart.createAction<{
184
+ id: string;
185
+ rotateKeys?: boolean;
186
+ rotationIntervalDays?: number;
187
+ rateLimits?: interfaces.data.IEmailDomain['rateLimits'];
188
+ remoteIngress?: interfaces.data.IEmailDomainRemoteIngress | null;
189
+ }>(async (statePartArg, args, actionContext) => {
190
+ const context = getActionContext();
191
+ const currentState = statePartArg.getState()!;
192
+ if (!context.identity) {
193
+ const operation = createFailedOperationState(
194
+ 'update',
195
+ new Error('Not authenticated'),
196
+ 'Not authenticated',
197
+ );
198
+ return applyOperationState(currentState, operation, 'Failed to update email domain');
199
+ }
200
+ try {
201
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
202
+ interfaces.requests.IReq_UpdateEmailDomain
203
+ >('/typedrequest', 'updateEmailDomain');
204
+ const response = await request.fire({ identity: context.identity, ...args });
205
+ const operation = createOperationState('update', response);
206
+ if (!response.success) {
207
+ return applyOperationState(currentState, operation, 'Failed to update email domain');
208
+ }
209
+ const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
210
+ return applyOperationState(refreshedState, operation, 'Email domain updated');
211
+ } catch (error: unknown) {
212
+ const operation = createFailedOperationState('update', error, 'Failed to update email domain');
213
+ return applyOperationState(currentState, operation, 'Failed to update email domain');
75
214
  }
76
215
  });
77
216
 
@@ -98,14 +237,24 @@ export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string
98
237
  async (statePartArg, id, actionContext) => {
99
238
  const context = getActionContext();
100
239
  const currentState = statePartArg.getState()!;
240
+ if (!context.identity) {
241
+ const operation = createFailedOperationState('delete', new Error('Not authenticated'), 'Not authenticated');
242
+ return applyOperationState(currentState, operation, 'Failed to delete email domain');
243
+ }
101
244
  try {
102
245
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
103
246
  interfaces.requests.IReq_DeleteEmailDomain
104
247
  >('/typedrequest', 'deleteEmailDomain');
105
- await request.fire({ identity: context.identity!, id });
106
- return await actionContext!.dispatch(fetchEmailDomainsAction, null);
107
- } catch {
108
- return currentState;
248
+ const response = await request.fire({ identity: context.identity, id });
249
+ const operation = createOperationState('delete', response);
250
+ if (!response.success) {
251
+ return applyOperationState(currentState, operation, 'Failed to delete email domain');
252
+ }
253
+ const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
254
+ return applyOperationState(refreshedState, operation, 'Email domain deleted');
255
+ } catch (error: unknown) {
256
+ const operation = createFailedOperationState('delete', error, 'Failed to delete email domain');
257
+ return applyOperationState(currentState, operation, 'Failed to delete email domain');
109
258
  }
110
259
  },
111
260
  );
@@ -114,14 +263,24 @@ export const validateEmailDomainAction = emailDomainsStatePart.createAction<stri
114
263
  async (statePartArg, id, actionContext) => {
115
264
  const context = getActionContext();
116
265
  const currentState = statePartArg.getState()!;
266
+ if (!context.identity) {
267
+ const operation = createFailedOperationState('validate', new Error('Not authenticated'), 'Not authenticated');
268
+ return applyOperationState(currentState, operation, 'Failed to validate email domain DNS');
269
+ }
117
270
  try {
118
271
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
119
272
  interfaces.requests.IReq_ValidateEmailDomain
120
273
  >('/typedrequest', 'validateEmailDomain');
121
- await request.fire({ identity: context.identity!, id });
122
- return await actionContext!.dispatch(fetchEmailDomainsAction, null);
123
- } catch {
124
- return currentState;
274
+ const response = await request.fire({ identity: context.identity, id });
275
+ const operation = createOperationState('validate', response);
276
+ if (!response.success) {
277
+ return applyOperationState(currentState, operation, 'Failed to validate email domain DNS');
278
+ }
279
+ const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
280
+ return applyOperationState(refreshedState, operation, 'Email domain DNS validated');
281
+ } catch (error: unknown) {
282
+ const operation = createFailedOperationState('validate', error, 'Failed to validate email domain DNS');
283
+ return applyOperationState(currentState, operation, 'Failed to validate email domain DNS');
125
284
  }
126
285
  },
127
286
  );
@@ -130,14 +289,24 @@ export const provisionEmailDomainDnsAction = emailDomainsStatePart.createAction<
130
289
  async (statePartArg, id, actionContext) => {
131
290
  const context = getActionContext();
132
291
  const currentState = statePartArg.getState()!;
292
+ if (!context.identity) {
293
+ const operation = createFailedOperationState('provision', new Error('Not authenticated'), 'Not authenticated');
294
+ return applyOperationState(currentState, operation, 'Failed to provision email domain DNS');
295
+ }
133
296
  try {
134
297
  const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
135
298
  interfaces.requests.IReq_ProvisionEmailDomainDns
136
299
  >('/typedrequest', 'provisionEmailDomainDns');
137
- await request.fire({ identity: context.identity!, id });
138
- return await actionContext!.dispatch(fetchEmailDomainsAction, null);
139
- } catch {
140
- return currentState;
300
+ const response = await request.fire({ identity: context.identity, id });
301
+ const operation = createOperationState('provision', response);
302
+ if (!response.success) {
303
+ return applyOperationState(currentState, operation, 'Failed to provision email domain DNS');
304
+ }
305
+ const refreshedState = await actionContext!.dispatch(fetchEmailDomainsAction, null);
306
+ return applyOperationState(refreshedState, operation, 'Email domain DNS provisioned');
307
+ } catch (error: unknown) {
308
+ const operation = createFailedOperationState('provision', error, 'Failed to provision email domain DNS');
309
+ return applyOperationState(currentState, operation, 'Failed to provision email domain DNS');
141
310
  }
142
311
  },
143
312
  );
@@ -218,12 +218,11 @@ export class OpsViewEmailDomains extends DeesElement {
218
218
  type: ['inRow', 'contextmenu'] as any,
219
219
  actionFunc: async (actionData: any) => {
220
220
  const d = actionData.item as interfaces.data.IEmailDomain;
221
- await appstate.emailDomainsStatePart.dispatchAction(
221
+ const nextState = await appstate.emailDomainsStatePart.dispatchAction(
222
222
  appstate.validateEmailDomainAction,
223
223
  d.id,
224
224
  );
225
- const { DeesToast } = await import('@design.estate/dees-catalog');
226
- DeesToast.show({ message: `DNS validated for ${d.domain}`, type: 'success', duration: 2500 });
225
+ await this.showOperationToast(nextState, `DNS validated for ${d.domain}`);
227
226
  },
228
227
  },
229
228
  {
@@ -232,12 +231,11 @@ export class OpsViewEmailDomains extends DeesElement {
232
231
  type: ['inRow', 'contextmenu'] as any,
233
232
  actionFunc: async (actionData: any) => {
234
233
  const d = actionData.item as interfaces.data.IEmailDomain;
235
- await appstate.emailDomainsStatePart.dispatchAction(
234
+ const nextState = await appstate.emailDomainsStatePart.dispatchAction(
236
235
  appstate.provisionEmailDomainDnsAction,
237
236
  d.id,
238
237
  );
239
- const { DeesToast } = await import('@design.estate/dees-catalog');
240
- DeesToast.show({ message: `DNS records provisioned for ${d.domain}`, type: 'success', duration: 2500 });
238
+ await this.showOperationToast(nextState, `DNS records provisioned for ${d.domain}`);
241
239
  },
242
240
  },
243
241
  {
@@ -255,10 +253,11 @@ export class OpsViewEmailDomains extends DeesElement {
255
253
  type: ['inRow', 'contextmenu'] as any,
256
254
  actionFunc: async (actionData: any) => {
257
255
  const d = actionData.item as interfaces.data.IEmailDomain;
258
- await appstate.emailDomainsStatePart.dispatchAction(
256
+ const nextState = await appstate.emailDomainsStatePart.dispatchAction(
259
257
  appstate.deleteEmailDomainAction,
260
258
  d.id,
261
259
  );
260
+ await this.showOperationToast(nextState, `Email domain ${d.domain} deleted`);
262
261
  },
263
262
  },
264
263
  ]}
@@ -282,6 +281,32 @@ export class OpsViewEmailDomains extends DeesElement {
282
281
  return html`<span class="sourceBadge">${label}</span>`;
283
282
  }
284
283
 
284
+ private async showOperationToast(
285
+ stateArg: appstate.IEmailDomainsState,
286
+ successMessageArg: string,
287
+ ): Promise<boolean> {
288
+ const { DeesToast } = await import('@design.estate/dees-catalog');
289
+ const operation = stateArg.lastOperation;
290
+ if (!operation) {
291
+ DeesToast.show({
292
+ message: stateArg.error || 'Email domain operation returned no result',
293
+ type: 'error',
294
+ duration: 5000,
295
+ });
296
+ return false;
297
+ }
298
+
299
+ const isPending = operation.success &&
300
+ operation.lifecycleStatus !== undefined &&
301
+ operation.lifecycleStatus !== 'active';
302
+ DeesToast.show({
303
+ message: appstate.formatEmailDomainOperationMessage(operation, successMessageArg),
304
+ type: operation.success ? (isPending ? 'warning' : 'success') : 'error',
305
+ duration: operation.success && !isPending ? 3000 : 6000,
306
+ });
307
+ return operation.success;
308
+ }
309
+
285
310
  private parsePortList(value: string): number[] {
286
311
  return value
287
312
  .split(',')
@@ -454,7 +479,7 @@ export class OpsViewEmailDomains extends DeesElement {
454
479
  : parseInt(data.dkimKeySize || '2048', 10);
455
480
 
456
481
  const subdomain = data.subdomain?.trim() || undefined;
457
- await appstate.emailDomainsStatePart.dispatchAction(
482
+ const nextState = await appstate.emailDomainsStatePart.dispatchAction(
458
483
  appstate.createEmailDomainAction,
459
484
  {
460
485
  linkedDomainId,
@@ -464,7 +489,11 @@ export class OpsViewEmailDomains extends DeesElement {
464
489
  rotateKeys: Boolean(data.rotateKeys),
465
490
  },
466
491
  );
467
- m.destroy();
492
+ const wasAccepted = await this.showOperationToast(
493
+ nextState,
494
+ `Email domain created`,
495
+ );
496
+ if (wasAccepted) m.destroy();
468
497
  },
469
498
  },
470
499
  ],
@@ -513,12 +542,15 @@ export class OpsViewEmailDomains extends DeesElement {
513
542
  {
514
543
  name: 'Auto-Provision All',
515
544
  action: async (m: any) => {
516
- await appstate.emailDomainsStatePart.dispatchAction(
545
+ const nextState = await appstate.emailDomainsStatePart.dispatchAction(
517
546
  appstate.provisionEmailDomainDnsAction,
518
547
  emailDomain.id,
519
548
  );
520
- DeesToast.show({ message: 'DNS records provisioned', type: 'success', duration: 2500 });
521
- m.destroy();
549
+ const wasAccepted = await this.showOperationToast(
550
+ nextState,
551
+ `DNS records provisioned for ${emailDomain.domain}`,
552
+ );
553
+ if (wasAccepted) m.destroy();
522
554
  },
523
555
  },
524
556
  { name: 'Close', action: async (m: any) => m.destroy() },