@serve.zone/dcrouter 18.3.0 → 18.4.1

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 (33) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1827 -1726
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.js +4 -1
  5. package/dist_ts/db/documents/classes.cached.email.d.ts +44 -0
  6. package/dist_ts/db/documents/classes.cached.email.js +24 -4
  7. package/dist_ts/email/classes.accepted-email-spool.js +16 -13
  8. package/dist_ts/email/classes.mail-egress-coordinator.js +10 -1
  9. package/dist_ts/email/helpers.inbound-security.js +7 -8
  10. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +3 -0
  11. package/dist_ts/opsserver/handlers/email-ops.handler.js +89 -22
  12. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +4 -0
  13. package/dist_ts/remoteingress/classes.tunnel-manager.js +10 -1
  14. package/dist_ts_interfaces/requests/email-ops.d.ts +44 -0
  15. package/dist_ts_migrations/index.js +111 -11
  16. package/dist_ts_web/00_commitinfo_data.js +1 -1
  17. package/dist_ts_web/appstate/email-ops.d.ts +15 -0
  18. package/dist_ts_web/appstate/email-ops.js +24 -1
  19. package/dist_ts_web/elements/email/ops-view-emails.d.ts +7 -0
  20. package/dist_ts_web/elements/email/ops-view-emails.js +63 -13
  21. package/package.json +5 -5
  22. package/readme.md +19 -0
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/classes.dcrouter.ts +7 -0
  25. package/ts/db/documents/classes.cached.email.ts +60 -2
  26. package/ts/email/classes.accepted-email-spool.ts +20 -14
  27. package/ts/email/classes.mail-egress-coordinator.ts +8 -0
  28. package/ts/email/helpers.inbound-security.ts +6 -7
  29. package/ts/opsserver/handlers/email-ops.handler.ts +105 -18
  30. package/ts/remoteingress/classes.tunnel-manager.ts +14 -0
  31. package/ts_web/00_commitinfo_data.ts +1 -1
  32. package/ts_web/appstate/email-ops.ts +30 -0
  33. package/ts_web/elements/email/ops-view-emails.ts +67 -11
@@ -33,6 +33,8 @@ export class EmailOpsHandler {
33
33
  dataArg.direction,
34
34
  dataArg.limit,
35
35
  dataArg.search,
36
+ dataArg.from,
37
+ dataArg.to,
36
38
  );
37
39
  }
38
40
  )
@@ -116,11 +118,18 @@ export class EmailOpsHandler {
116
118
  direction?: interfaces.requests.TEmailDirection,
117
119
  limitArg?: number,
118
120
  searchArg?: string,
121
+ fromArg?: number,
122
+ toArg?: number,
119
123
  ): Promise<interfaces.requests.IEmail[]> {
120
124
  const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 1000);
121
125
  const search = normalizeEmailLogSearch(searchArg);
126
+ const range = this.normalizeEmailLogRange(fromArg, toArg);
122
127
  const cachedDocs = this.isCachedEmailStoreAvailable()
123
- ? await CachedEmail.findRecent(limit, { direction, search: search || undefined })
128
+ ? await CachedEmail.findRecent(limit, {
129
+ direction,
130
+ search: search || undefined,
131
+ ...(range || {}),
132
+ })
124
133
  : [];
125
134
  const emails = cachedDocs.map((doc) => this.mapCachedEmailToEmail(doc));
126
135
  const representedIds = new Set(cachedDocs.map((doc) => doc.id));
@@ -136,7 +145,11 @@ export class EmailOpsHandler {
136
145
  continue;
137
146
  }
138
147
  const email = this.mapQueueItemToEmail(item);
139
- if (this.matchesEmailSearch(email, search)) {
148
+ const emailTimestamp = new Date(email.timestamp).getTime();
149
+ if (
150
+ this.matchesEmailSearch(email, search)
151
+ && (!range || (emailTimestamp >= range.from && emailTimestamp <= range.to))
152
+ ) {
140
153
  emails.push(email);
141
154
  }
142
155
  }
@@ -152,17 +165,23 @@ export class EmailOpsHandler {
152
165
  direction?: interfaces.requests.TEmailDirection,
153
166
  limitArg?: number,
154
167
  searchArg?: string,
168
+ fromArg?: number,
169
+ toArg?: number,
155
170
  ): Promise<interfaces.requests.IReq_GetAllEmails['response']> {
156
171
  const search = normalizeEmailLogSearch(searchArg);
157
172
  const now = Date.now();
173
+ const storeAvailable = this.isCachedEmailStoreAvailable();
158
174
  const filter = { direction, search: search || undefined };
159
- const emails = await this.getAllEmails(direction, limitArg, search);
175
+ const emails = await this.getAllEmails(direction, limitArg, search, fromArg, toArg);
176
+ const trafficContextEmails = !storeAvailable && (fromArg !== undefined || toArg !== undefined)
177
+ ? await this.getAllEmails(direction, 1000, search)
178
+ : emails;
160
179
 
161
180
  let oldestMatchAt: number | null = null;
162
181
  if (search) {
163
- oldestMatchAt = this.isCachedEmailStoreAvailable()
182
+ oldestMatchAt = storeAvailable
164
183
  ? await CachedEmail.findOldestAcceptedAt(filter, now - EMAIL_LOG_RETENTION_MS)
165
- : emails.reduce<number | null>((oldest, email) => {
184
+ : trafficContextEmails.reduce<number | null>((oldest, email) => {
166
185
  const timestamp = new Date(email.timestamp).getTime();
167
186
  if (!Number.isFinite(timestamp)) return oldest;
168
187
  return oldest === null ? timestamp : Math.min(oldest, timestamp);
@@ -171,14 +190,14 @@ export class EmailOpsHandler {
171
190
 
172
191
  const descriptor = selectEmailLogTrafficWindow(search, oldestMatchAt, now);
173
192
  const emptyTraffic = buildEmailLogTraffic(descriptor, [], now);
174
- const sparseTraffic = this.isCachedEmailStoreAvailable()
193
+ const sparseTraffic = storeAvailable
175
194
  ? await CachedEmail.aggregateEmailLogTraffic(
176
195
  filter,
177
196
  emptyTraffic.windowStart,
178
197
  emptyTraffic.windowEnd,
179
198
  descriptor.bucketSizeMs,
180
199
  )
181
- : aggregateEmailLogTrafficInMemory(emails, descriptor, now);
200
+ : aggregateEmailLogTrafficInMemory(trafficContextEmails, descriptor, now);
182
201
 
183
202
  return {
184
203
  emails,
@@ -186,6 +205,26 @@ export class EmailOpsHandler {
186
205
  };
187
206
  }
188
207
 
208
+ private normalizeEmailLogRange(
209
+ fromArg?: number,
210
+ toArg?: number,
211
+ ): { from: number; to: number } | undefined {
212
+ if (fromArg === undefined && toArg === undefined) return undefined;
213
+ const now = Date.now();
214
+ if (
215
+ !Number.isSafeInteger(fromArg)
216
+ || !Number.isSafeInteger(toArg)
217
+ || (fromArg as number) < now - EMAIL_LOG_RETENTION_MS
218
+ || (toArg as number) > now + 60_000
219
+ || (toArg as number) < (fromArg as number)
220
+ ) {
221
+ throw new plugins.typedrequest.TypedResponseError(
222
+ 'Email log time range is invalid or outside retention.',
223
+ );
224
+ }
225
+ return { from: fromArg as number, to: toArg as number };
226
+ }
227
+
189
228
  private async getEmailSecurityFindings(
190
229
  limitArg?: number,
191
230
  ): Promise<interfaces.requests.IEmailSecurityFinding[]> {
@@ -261,10 +300,15 @@ export class EmailOpsHandler {
261
300
  * Map a CachedEmail row to catalog IEmail format
262
301
  */
263
302
  private mapCachedEmailToEmail(doc: CachedEmail): interfaces.requests.IEmail {
303
+ const mappedStatus = this.mapStatus(doc.status);
264
304
  return {
265
305
  id: doc.id,
266
306
  direction: doc.direction,
267
- status: this.mapStatus(doc.status),
307
+ status: doc.direction === 'inbound'
308
+ && mappedStatus === 'accepted'
309
+ && doc.inboundSecurityDisposition === 'flagged'
310
+ ? 'flagged'
311
+ : mappedStatus,
268
312
  from: doc.from || '',
269
313
  to: doc.to?.[0] || '',
270
314
  subject: doc.subject || '',
@@ -295,9 +339,13 @@ export class EmailOpsHandler {
295
339
  // Display SmartMTA's true retry schedule — doc.nextAttempt is the spool's
296
340
  // recovery lease (retry time + 30min grace), not the user-facing countdown.
297
341
  const nextAttemptMs = doc.smartMtaNextAttempt ? new Date(doc.smartMtaNextAttempt).getTime() : NaN;
342
+ const outboundAuthentication = doc.direction === 'outbound'
343
+ ? this.getLatestOutboundAuthentication(doc.smtpTransactions || [])
344
+ : undefined;
298
345
  return {
299
346
  ...base,
300
347
  smtpTransactions: structuredClone(doc.smtpTransactions || []),
348
+ ...(outboundAuthentication ? { outboundAuthentication } : {}),
301
349
  ...(base.status === 'deferred' && Number.isFinite(nextAttemptMs) && nextAttemptMs > 0
302
350
  ? {
303
351
  nextAttemptAt: nextAttemptMs,
@@ -318,7 +366,9 @@ export class EmailOpsHandler {
318
366
  authMethod: '',
319
367
  authUser: this.extractSessionUser(session),
320
368
  },
321
- authenticationResults: this.mapInboundSecurityResults(doc.inboundSecurityResults),
369
+ authenticationResults: outboundAuthentication
370
+ ? this.mapOutboundAuthenticationResults(outboundAuthentication)
371
+ : this.mapInboundSecurityResults(doc.inboundSecurityResults),
322
372
  rejectionReason: doc.status === 'failed' ? doc.lastError : undefined,
323
373
  bounceMessage: doc.status === 'failed' ? doc.lastError : undefined,
324
374
  headers,
@@ -449,6 +499,7 @@ export class EmailOpsHandler {
449
499
  const base = this.mapQueueItemToEmail(item);
450
500
  const processingResult = item.processingResult;
451
501
 
502
+ const outboundAuthentication = this.getLatestOutboundAuthentication(item.smtpTransactions || []);
452
503
  let toList: string[] = [];
453
504
  let cc: string[] = [];
454
505
  let headers: Record<string, string> = {};
@@ -471,6 +522,7 @@ export class EmailOpsHandler {
471
522
  ...base,
472
523
  smtpTransactions: structuredClone(item.smtpTransactions || []),
473
524
  toList,
525
+ ...(outboundAuthentication ? { outboundAuthentication } : {}),
474
526
  cc,
475
527
  smtpLog: this.flattenSmtpTransactions(item.smtpTransactions || []),
476
528
  connectionInfo: {
@@ -484,14 +536,7 @@ export class EmailOpsHandler {
484
536
  authMethod: '',
485
537
  authUser: '',
486
538
  },
487
- authenticationResults: {
488
- spf: 'none',
489
- spfDomain: '',
490
- dkim: 'none',
491
- dkimDomain: '',
492
- dmarc: 'none',
493
- dmarcPolicy: '',
494
- },
539
+ authenticationResults: this.mapOutboundAuthenticationResults(outboundAuthentication),
495
540
  rejectionReason: item.status === 'failed' ? item.lastError : undefined,
496
541
  bounceMessage: item.status === 'failed' ? item.lastError : undefined,
497
542
  headers,
@@ -499,6 +544,47 @@ export class EmailOpsHandler {
499
544
  };
500
545
  }
501
546
 
547
+ private getLatestOutboundAuthentication(
548
+ transactionsArg: Array<{ outboundAuthentication?: interfaces.requests.IOutboundAuthenticationCheck }>,
549
+ ): interfaces.requests.IOutboundAuthenticationCheck | undefined {
550
+ for (let index = transactionsArg.length - 1; index >= 0; index--) {
551
+ const authentication = transactionsArg[index]?.outboundAuthentication;
552
+ if (!authentication) continue;
553
+ return structuredClone(authentication);
554
+ }
555
+ return undefined;
556
+ }
557
+
558
+ private mapOutboundAuthenticationResults(
559
+ authenticationArg?: interfaces.requests.IOutboundAuthenticationCheck,
560
+ ): interfaces.requests.IEmailDetail['authenticationResults'] {
561
+ const noneResult: interfaces.requests.IEmailDetail['authenticationResults'] = {
562
+ spf: 'none',
563
+ spfDomain: '',
564
+ dkim: 'none',
565
+ dkimDomain: '',
566
+ dmarc: 'none',
567
+ dmarcPolicy: '',
568
+ };
569
+ if (authenticationArg?.state !== 'completed') return noneResult;
570
+ const dkimSignatures = (authenticationArg.dkim || [])
571
+ .filter((signature) => signature.status.toLowerCase() !== 'none');
572
+ const validDkim = dkimSignatures.find((signature) => signature.is_valid);
573
+ const spfValue = (authenticationArg.spf?.result || '').toLowerCase();
574
+ const spf = (['pass', 'fail', 'softfail', 'neutral'] as string[]).includes(spfValue)
575
+ ? spfValue as interfaces.requests.IAuthenticationResults['spf']
576
+ : 'none';
577
+ return {
578
+ spf,
579
+ spfDomain: authenticationArg.spf?.domain || '',
580
+ dkim: validDkim ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none',
581
+ dkimDomain: validDkim?.domain || dkimSignatures[0]?.domain || '',
582
+ dmarc: authenticationArg.dmarc ? (authenticationArg.dmarc.passed ? 'pass' : 'fail') : 'none',
583
+ dmarcPolicy: authenticationArg.dmarc?.policy || '',
584
+ };
585
+ }
586
+
587
+
502
588
  /**
503
589
  * Map queue status to catalog TEmailStatus
504
590
  */
@@ -525,7 +611,8 @@ export class EmailOpsHandler {
525
611
  };
526
612
  const spfResult = (parsed.spf?.result || '').toLowerCase();
527
613
  const spf = (['pass', 'fail', 'softfail', 'neutral'] as const).find((value) => value === spfResult) || 'none';
528
- const dkimSignatures = Array.isArray(parsed.dkim) ? parsed.dkim : [];
614
+ const dkimSignatures = (Array.isArray(parsed.dkim) ? parsed.dkim : [])
615
+ .filter((signature) => String(signature?.status || '').toLowerCase() !== 'none');
529
616
  const validSignature = dkimSignatures.find((signature) => signature?.is_valid);
530
617
  const dkim = validSignature ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none';
531
618
  const dmarc = parsed.dmarc ? (parsed.dmarc.passed ? 'pass' : 'fail') : 'none';
@@ -370,6 +370,20 @@ export class TunnelManager {
370
370
  return { ready: false, eligibleEdgeCount: 0, reason };
371
371
  }
372
372
 
373
+ /**
374
+ * Delegate source-bound connection evidence to the owned RemoteIngress hub.
375
+ */
376
+ public async waitForEgressConnectionEvidence(
377
+ proxyId: string,
378
+ edgeId: string,
379
+ timeoutMs?: number,
380
+ ): Promise<plugins.remoteingress.IRemoteIngressHubEgressConnectionEvent | null> {
381
+ if (this.stopped) {
382
+ return null;
383
+ }
384
+ return await this.hub.waitForEgressConnectionEvidence(proxyId, edgeId, timeoutMs);
385
+ }
386
+
373
387
  public async stopEgressTcpProxy(proxyId: string): Promise<boolean> {
374
388
  if (this.stopped) {
375
389
  return false;
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.3.0',
6
+ version: '18.4.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -8,6 +8,7 @@ export interface IEmailOpsState {
8
8
  emails: interfaces.requests.IEmail[];
9
9
  traffic: interfaces.requests.IEmailLogTraffic | null;
10
10
  searchQuery: string;
11
+ selectedRange: { from: number; to: number } | null;
11
12
  securityFindings: interfaces.requests.IEmailSecurityFinding[];
12
13
  securityFindingsLoading: boolean;
13
14
  securityFindingsError: string | null;
@@ -23,6 +24,7 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
23
24
  emails: [],
24
25
  traffic: null,
25
26
  searchQuery: '',
27
+ selectedRange: null,
26
28
  securityFindings: [],
27
29
  securityFindingsLoading: false,
28
30
  securityFindingsError: null,
@@ -38,11 +40,13 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
38
40
  // ============================================================================
39
41
 
40
42
  let activeSearchQuery = '';
43
+ let activeSelectedRange: { from: number; to: number } | null = null;
41
44
  let emailListRequestGeneration = 0;
42
45
  let securityFindingsRequestGeneration = 0;
43
46
 
44
47
  export interface IEmailOpsFetchOptions {
45
48
  searchQuery?: string;
49
+ selectedRange?: { from: number; to: number } | null;
46
50
  }
47
51
 
48
52
  export function setActiveEmailSearchQuery(searchQueryArg: string): string {
@@ -58,6 +62,27 @@ export function setActiveEmailSearchQuery(searchQueryArg: string): string {
58
62
  return activeSearchQuery;
59
63
  }
60
64
 
65
+ export function setActiveEmailSelectedRange(
66
+ rangeArg: { from: number; to: number } | null,
67
+ ): { from: number; to: number } | null {
68
+ if (rangeArg && (
69
+ !Number.isSafeInteger(rangeArg.from)
70
+ || !Number.isSafeInteger(rangeArg.to)
71
+ || rangeArg.from < 0
72
+ || rangeArg.to <= rangeArg.from
73
+ )) {
74
+ throw new Error('Invalid email time range');
75
+ }
76
+ activeSelectedRange = rangeArg ? { ...rangeArg } : null;
77
+ emailListRequestGeneration++;
78
+ const currentState = emailOpsStatePart.getState()!;
79
+ emailOpsStatePart.setState({
80
+ ...currentState,
81
+ selectedRange: activeSelectedRange,
82
+ });
83
+ return activeSelectedRange;
84
+ }
85
+
61
86
  // Fetch All Emails Action
62
87
  export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetchOptions | null>(async (
63
88
  statePartArg,
@@ -69,6 +94,9 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetc
69
94
  const searchQuery = dataArg?.searchQuery !== undefined
70
95
  ? setActiveEmailSearchQuery(dataArg.searchQuery)
71
96
  : activeSearchQuery;
97
+ const selectedRange = dataArg?.selectedRange !== undefined
98
+ ? setActiveEmailSelectedRange(dataArg.selectedRange)
99
+ : activeSelectedRange;
72
100
  const requestGeneration = ++emailListRequestGeneration;
73
101
  const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
74
102
 
@@ -80,6 +108,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetc
80
108
  const response = await request.fire({
81
109
  identity: context.identity,
82
110
  ...(searchQuery ? { search: searchQuery } : {}),
111
+ ...(selectedRange ? selectedRange : {}),
83
112
  });
84
113
  if (
85
114
  requestGeneration !== emailListRequestGeneration
@@ -92,6 +121,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetc
92
121
  traffic: response.traffic,
93
122
  searchQuery,
94
123
  isLoading: false,
124
+ selectedRange,
95
125
  error: null,
96
126
  lastUpdated: Date.now(),
97
127
  };
@@ -29,6 +29,9 @@ export class OpsViewEmails extends DeesElement {
29
29
  @state()
30
30
  accessor searchQuery = '';
31
31
 
32
+ @state()
33
+ accessor selectedRange: { from: number; to: number } | null = null;
34
+
32
35
  private stateSubscription: any;
33
36
  private lastEmailOpsUpdate = 0;
34
37
  private detailRequestGeneration = 0;
@@ -40,6 +43,7 @@ export class OpsViewEmails extends DeesElement {
40
43
  this.emails = state.emails;
41
44
  this.traffic = state.traffic;
42
45
  this.searchQuery = state.searchQuery;
46
+ this.selectedRange = state.selectedRange;
43
47
  const hasNewSnapshot = state.lastUpdated > this.lastEmailOpsUpdate;
44
48
  this.lastEmailOpsUpdate = Math.max(this.lastEmailOpsUpdate, state.lastUpdated);
45
49
  if (hasNewSnapshot && this.selectedEmail) {
@@ -75,7 +79,23 @@ export class OpsViewEmails extends DeesElement {
75
79
  height: 100%;
76
80
  }
77
81
 
78
- dees-chart-area[slot='above-table'] {
82
+ .chartStack {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 8px;
86
+ }
87
+
88
+ .rangeSummary {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ min-height: 32px;
93
+ padding: 0 4px;
94
+ color: ${cssManager.bdTheme('#374151', '#d1d5db')};
95
+ font-size: 13px;
96
+ }
97
+
98
+ .chartStack dees-chart-area {
79
99
  min-height: 360px;
80
100
  }
81
101
  `,
@@ -101,16 +121,26 @@ export class OpsViewEmails extends DeesElement {
101
121
  @email-click=${this.handleEmailClick}
102
122
  >
103
123
  ${this.traffic ? html`
104
- <dees-chart-area
105
- slot="above-table"
106
- .label=${this.traffic.label}
107
- .series=${this.getEmailTrafficSeries()}
108
- .realtimeMode=${true}
109
- .rollingWindow=${this.traffic.durationMs}
110
- .legendStats=${['latest']}
111
- .chartLines=${[]}
112
- .yAxisFormatter=${(valueArg: number) => `${valueArg}`}
113
- ></dees-chart-area>
124
+ <div slot="above-table" class="chartStack">
125
+ ${this.selectedRange ? html`
126
+ <div class="rangeSummary">
127
+ <span>${this.formatSelectedRange()}</span>
128
+ <dees-button @click=${this.clearSelectedRange}>Clear time range</dees-button>
129
+ </div>
130
+ ` : html``}
131
+ <dees-chart-area
132
+ .label=${this.traffic.label}
133
+ .series=${this.getEmailTrafficSeries()}
134
+ .realtimeMode=${true}
135
+ .rollingWindow=${this.traffic.durationMs}
136
+ .legendStats=${['latest']}
137
+ .chartLines=${[]}
138
+ .rangeSelectionEnabled=${true}
139
+ .selectedRange=${this.selectedRange}
140
+ .yAxisFormatter=${(valueArg: number) => `${valueArg}`}
141
+ @range-change=${this.handleRangeChange}
142
+ ></dees-chart-area>
143
+ </div>
114
144
  ` : html``}
115
145
  </sz-mta-list-view>
116
146
  `
@@ -155,6 +185,32 @@ export class OpsViewEmails extends DeesElement {
155
185
  }, 300);
156
186
  }
157
187
 
188
+
189
+ private handleRangeChange = (
190
+ eventArg: CustomEvent<{ from: number; to: number }>,
191
+ ): void => {
192
+ void appstate.emailOpsStatePart.dispatchAction(
193
+ appstate.fetchAllEmailsAction,
194
+ { selectedRange: eventArg.detail },
195
+ );
196
+ };
197
+
198
+ private clearSelectedRange = (): void => {
199
+ void appstate.emailOpsStatePart.dispatchAction(
200
+ appstate.fetchAllEmailsAction,
201
+ { selectedRange: null },
202
+ );
203
+ };
204
+
205
+ private formatSelectedRange(): string {
206
+ if (!this.selectedRange) return '';
207
+ const formatter = new Intl.DateTimeFormat(undefined, {
208
+ dateStyle: 'medium',
209
+ timeStyle: 'short',
210
+ });
211
+ return `Showing emails from ${formatter.format(this.selectedRange.from)} to ${formatter.format(this.selectedRange.to)}`;
212
+ }
213
+
158
214
  private getEmailTrafficSeries(): Array<{
159
215
  name: string;
160
216
  color: string;