@serve.zone/dcrouter 18.2.1 → 18.4.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 (48) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2986 -2954
  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 +61 -4
  6. package/dist_ts/db/documents/classes.cached.email.js +126 -9
  7. package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -8
  8. package/dist_ts/email/classes.accepted-email-spool.js +18 -52
  9. package/dist_ts/email/classes.mail-egress-coordinator.js +10 -1
  10. package/dist_ts/email/helpers.email-log-traffic.d.ts +19 -0
  11. package/dist_ts/email/helpers.email-log-traffic.js +79 -0
  12. package/dist_ts/email/helpers.inbound-security.d.ts +8 -0
  13. package/dist_ts/email/helpers.inbound-security.js +35 -0
  14. package/dist_ts/email/index.d.ts +2 -0
  15. package/dist_ts/email/index.js +3 -1
  16. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +6 -0
  17. package/dist_ts/opsserver/handlers/email-ops.handler.js +162 -17
  18. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +4 -0
  19. package/dist_ts/remoteingress/classes.tunnel-manager.js +10 -1
  20. package/dist_ts_interfaces/requests/email-ops.d.ts +85 -0
  21. package/dist_ts_migrations/index.js +147 -14
  22. package/dist_ts_web/00_commitinfo_data.js +1 -1
  23. package/dist_ts_web/appstate/email-ops.d.ts +27 -1
  24. package/dist_ts_web/appstate/email-ops.js +100 -4
  25. package/dist_ts_web/appstate/runtime.js +11 -3
  26. package/dist_ts_web/elements/email/ops-view-email-security.d.ts +2 -0
  27. package/dist_ts_web/elements/email/ops-view-email-security.js +58 -16
  28. package/dist_ts_web/elements/email/ops-view-emails.d.ts +14 -2
  29. package/dist_ts_web/elements/email/ops-view-emails.js +128 -4
  30. package/package.json +6 -6
  31. package/readme.md +19 -0
  32. package/readme.plan.md +22 -0
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/classes.dcrouter.ts +7 -0
  35. package/ts/db/documents/classes.cached.email.ts +199 -7
  36. package/ts/email/classes.accepted-email-spool.ts +25 -66
  37. package/ts/email/classes.mail-egress-coordinator.ts +8 -0
  38. package/ts/email/helpers.email-log-traffic.ts +117 -0
  39. package/ts/email/helpers.inbound-security.ts +45 -0
  40. package/ts/email/index.ts +2 -0
  41. package/ts/opsserver/handlers/email-ops.handler.ts +214 -15
  42. package/ts/remoteingress/classes.tunnel-manager.ts +14 -0
  43. package/ts_web/00_commitinfo_data.ts +1 -1
  44. package/ts_web/appstate/email-ops.ts +129 -3
  45. package/ts_web/appstate/runtime.ts +13 -2
  46. package/ts_web/elements/email/ops-view-email-security.ts +56 -16
  47. package/ts_web/elements/email/ops-view-emails.ts +130 -2
  48. package/ts_web/readme.md +7 -0
@@ -6,18 +6,32 @@ import { captureRealtimeTicket, isRealtimeTicketCurrent } from './realtime.js';
6
6
 
7
7
  export interface IEmailOpsState {
8
8
  emails: interfaces.requests.IEmail[];
9
+ traffic: interfaces.requests.IEmailLogTraffic | null;
10
+ searchQuery: string;
11
+ selectedRange: { from: number; to: number } | null;
12
+ securityFindings: interfaces.requests.IEmailSecurityFinding[];
13
+ securityFindingsLoading: boolean;
14
+ securityFindingsError: string | null;
9
15
  isLoading: boolean;
10
16
  error: string | null;
11
17
  lastUpdated: number;
18
+ lastSecurityUpdated: number;
12
19
  }
13
20
 
14
21
  export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
15
22
  'emailOps',
16
23
  {
17
24
  emails: [],
25
+ traffic: null,
26
+ searchQuery: '',
27
+ selectedRange: null,
28
+ securityFindings: [],
29
+ securityFindingsLoading: false,
30
+ securityFindingsError: null,
18
31
  isLoading: false,
19
32
  error: null,
20
33
  lastUpdated: 0,
34
+ lastSecurityUpdated: 0,
21
35
  },
22
36
  'soft'
23
37
  );
@@ -25,11 +39,65 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
25
39
  // Email Operations Actions
26
40
  // ============================================================================
27
41
 
42
+ let activeSearchQuery = '';
43
+ let activeSelectedRange: { from: number; to: number } | null = null;
44
+ let emailListRequestGeneration = 0;
45
+ let securityFindingsRequestGeneration = 0;
46
+
47
+ export interface IEmailOpsFetchOptions {
48
+ searchQuery?: string;
49
+ selectedRange?: { from: number; to: number } | null;
50
+ }
51
+
52
+ export function setActiveEmailSearchQuery(searchQueryArg: string): string {
53
+ const nextSearchQuery = searchQueryArg.trim().slice(0, 200);
54
+ if (nextSearchQuery === activeSearchQuery) return activeSearchQuery;
55
+ activeSearchQuery = nextSearchQuery;
56
+ emailListRequestGeneration++;
57
+ const currentState = emailOpsStatePart.getState()!;
58
+ emailOpsStatePart.setState({
59
+ ...currentState,
60
+ searchQuery: activeSearchQuery,
61
+ });
62
+ return activeSearchQuery;
63
+ }
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
+
28
86
  // Fetch All Emails Action
29
- export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg): Promise<IEmailOpsState> => {
87
+ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetchOptions | null>(async (
88
+ statePartArg,
89
+ dataArg,
90
+ ): Promise<IEmailOpsState> => {
30
91
  const context = getActionContext();
31
92
  const currentState = statePartArg.getState()!;
32
93
  if (!context.identity) return currentState;
94
+ const searchQuery = dataArg?.searchQuery !== undefined
95
+ ? setActiveEmailSearchQuery(dataArg.searchQuery)
96
+ : activeSearchQuery;
97
+ const selectedRange = dataArg?.selectedRange !== undefined
98
+ ? setActiveEmailSelectedRange(dataArg.selectedRange)
99
+ : activeSelectedRange;
100
+ const requestGeneration = ++emailListRequestGeneration;
33
101
  const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
34
102
 
35
103
  try {
@@ -39,18 +107,29 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
39
107
 
40
108
  const response = await request.fire({
41
109
  identity: context.identity,
110
+ ...(searchQuery ? { search: searchQuery } : {}),
111
+ ...(selectedRange ? selectedRange : {}),
42
112
  });
43
- if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
113
+ if (
114
+ requestGeneration !== emailListRequestGeneration
115
+ || !isRealtimeTicketCurrent(ticket)
116
+ ) return statePartArg.getState()!;
44
117
 
45
118
  return {
46
119
  ...statePartArg.getState()!,
47
120
  emails: response.emails,
121
+ traffic: response.traffic,
122
+ searchQuery,
48
123
  isLoading: false,
124
+ selectedRange,
49
125
  error: null,
50
126
  lastUpdated: Date.now(),
51
127
  };
52
128
  } catch (error) {
53
- if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
129
+ if (
130
+ requestGeneration !== emailListRequestGeneration
131
+ || !isRealtimeTicketCurrent(ticket)
132
+ ) return statePartArg.getState()!;
54
133
  return {
55
134
  ...statePartArg.getState()!,
56
135
  isLoading: false,
@@ -59,6 +138,53 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
59
138
  }
60
139
  });
61
140
 
141
+ export const fetchEmailSecurityFindingsAction = emailOpsStatePart.createAction<void>(async (
142
+ statePartArg,
143
+ ): Promise<IEmailOpsState> => {
144
+ const context = getActionContext();
145
+ const currentState = statePartArg.getState()!;
146
+ if (!context.identity) return currentState;
147
+ const requestGeneration = ++securityFindingsRequestGeneration;
148
+ const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
149
+ statePartArg.setState({
150
+ ...currentState,
151
+ securityFindingsLoading: true,
152
+ securityFindingsError: null,
153
+ });
154
+ try {
155
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
156
+ interfaces.requests.IReq_GetEmailSecurityFindings
157
+ >('/typedrequest', 'getEmailSecurityFindings');
158
+ const response = await request.fire({
159
+ identity: context.identity,
160
+ limit: 200,
161
+ });
162
+ if (
163
+ requestGeneration !== securityFindingsRequestGeneration
164
+ || !isRealtimeTicketCurrent(ticket)
165
+ ) return statePartArg.getState()!;
166
+ return {
167
+ ...statePartArg.getState()!,
168
+ securityFindings: response.findings,
169
+ securityFindingsLoading: false,
170
+ securityFindingsError: null,
171
+ lastSecurityUpdated: Date.now(),
172
+ };
173
+ } catch (error) {
174
+ if (
175
+ requestGeneration !== securityFindingsRequestGeneration
176
+ || !isRealtimeTicketCurrent(ticket)
177
+ ) return statePartArg.getState()!;
178
+ return {
179
+ ...statePartArg.getState()!,
180
+ securityFindingsLoading: false,
181
+ securityFindingsError: error instanceof Error
182
+ ? error.message
183
+ : 'Failed to fetch email security findings',
184
+ };
185
+ }
186
+ });
187
+
62
188
  // ============================================================================
63
189
  export async function fetchEmailDetailSnapshot(
64
190
  emailIdArg: string,
@@ -11,7 +11,11 @@ import { certificateStatePart, fetchCertificateOverviewAction } from './certific
11
11
  import { remoteIngressStatePart, fetchRemoteIngressAction } from './remoteingress.js';
12
12
  import { vpnStatePart, fetchVpnAction } from './vpn.js';
13
13
  import { emailDomainsStatePart, fetchEmailDomainsAction } from './email-domains.js';
14
- import { emailOpsStatePart, fetchAllEmailsAction } from './email-ops.js';
14
+ import {
15
+ emailOpsStatePart,
16
+ fetchAllEmailsAction,
17
+ fetchEmailSecurityFindingsAction,
18
+ } from './email-ops.js';
15
19
  import {
16
20
  domainsStatePart,
17
21
  fetchDnsRecordsForDomainAction,
@@ -65,7 +69,14 @@ const refreshRealtimeGroup = async (groupArg: TRealtimeRefreshGroup): Promise<vo
65
69
  await emailDomainsStatePart.dispatchAction(fetchEmailDomainsAction, null);
66
70
  break;
67
71
  case 'emailOps':
68
- await emailOpsStatePart.dispatchAction(fetchAllEmailsAction, null);
72
+ if (uiStatePart.getState()!.activeView !== 'email') {
73
+ break;
74
+ }
75
+ if (uiStatePart.getState()!.activeSubview === 'security') {
76
+ await emailOpsStatePart.dispatchAction(fetchEmailSecurityFindingsAction, undefined);
77
+ } else if (uiStatePart.getState()!.activeSubview === 'log') {
78
+ await emailOpsStatePart.dispatchAction(fetchAllEmailsAction, null);
79
+ }
69
80
  break;
70
81
  case 'dns': {
71
82
  const selectedDomainId = domainsStatePart.getState()!.selectedDomainId;
@@ -23,14 +23,32 @@ export class OpsViewEmailSecurity extends DeesElement {
23
23
  @state()
24
24
  accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
25
25
 
26
+ @state()
27
+ accessor emailOpsState: appstate.IEmailOpsState = appstate.emailOpsStatePart.getState()!;
28
+
26
29
  constructor() {
27
30
  super();
28
31
  const sub = appstate.statsStatePart
29
- .select((s) => s)
32
+ .select()
30
33
  .subscribe((s) => {
31
34
  this.statsState = s;
32
35
  });
33
36
  this.rxSubscriptions.push(sub);
37
+
38
+ const emailOpsSub = appstate.emailOpsStatePart
39
+ .select()
40
+ .subscribe((s) => {
41
+ this.emailOpsState = s;
42
+ });
43
+ this.rxSubscriptions.push(emailOpsSub);
44
+ }
45
+
46
+ public async connectedCallback(): Promise<void> {
47
+ await super.connectedCallback();
48
+ await appstate.emailOpsStatePart.dispatchAction(
49
+ appstate.fetchEmailSecurityFindingsAction,
50
+ undefined,
51
+ );
34
52
  }
35
53
 
36
54
  public static styles = [
@@ -42,21 +60,16 @@ export class OpsViewEmailSecurity extends DeesElement {
42
60
  flex-direction: column;
43
61
  gap: 24px;
44
62
  }
63
+
64
+ .findingsError {
65
+ color: ${cssManager.bdTheme('#b91c1c', '#fca5a5')};
66
+ }
45
67
  `,
46
68
  ];
47
69
 
48
70
  public render(): TemplateResult {
49
71
  const metrics = this.statsState.securityMetrics;
50
-
51
- if (!metrics) {
52
- return html`
53
- <div class="loadingMessage">
54
- <p>Loading security metrics...</p>
55
- </div>
56
- `;
57
- }
58
-
59
- const tiles: IStatsTile[] = [
72
+ const tiles: IStatsTile[] = metrics ? [
60
73
  {
61
74
  id: 'malware',
62
75
  title: 'Malware Detection',
@@ -93,16 +106,20 @@ export class OpsViewEmailSecurity extends DeesElement {
93
106
  color: '#f59e0b',
94
107
  description: 'Spam emails blocked',
95
108
  },
96
- ];
109
+ ] : [];
97
110
 
98
111
  return html`
99
112
  <dees-heading level="3">Email Security</dees-heading>
100
113
 
101
114
  <div class="securityContainer">
102
- <dees-statsgrid
103
- .tiles=${tiles}
104
- .minTileWidth=${200}
105
- ></dees-statsgrid>
115
+ ${metrics ? html`
116
+ <dees-statsgrid
117
+ .tiles=${tiles}
118
+ .minTileWidth=${200}
119
+ ></dees-statsgrid>
120
+ ` : html`
121
+ <p class="loadingMessage">Loading security metrics...</p>
122
+ `}
106
123
 
107
124
  <dees-settings
108
125
  .heading=${'Security Configuration'}
@@ -114,6 +131,29 @@ export class OpsViewEmailSecurity extends DeesElement {
114
131
  ]}
115
132
  .actions=${[{ name: 'Edit', action: () => this.showEditSecurityDialog() }]}
116
133
  ></dees-settings>
134
+
135
+ <dees-table
136
+ .heading1=${'Email Security Findings'}
137
+ .heading2=${this.emailOpsState.securityFindingsLoading
138
+ ? 'Loading retained findings...'
139
+ : this.emailOpsState.securityFindingsError
140
+ ? 'Could not load retained findings'
141
+ : `Last 30 days · ${this.emailOpsState.securityFindings.length} findings`}
142
+ .data=${this.emailOpsState.securityFindings}
143
+ .searchable=${false}
144
+ .rowKey=${'id'}
145
+ .displayFunction=${(finding) => ({
146
+ 'Time': new Date(finding.timestamp).toLocaleString(),
147
+ 'From': finding.from,
148
+ 'To': finding.to,
149
+ 'Title': finding.subject || '(no title)',
150
+ 'Security issue': `${finding.disposition === 'rejected' ? 'Rejected' : 'Flagged'} — ${finding.failureSummary}`,
151
+ })}
152
+ ></dees-table>
153
+
154
+ ${this.emailOpsState.securityFindingsError ? html`
155
+ <p class="findingsError">${this.emailOpsState.securityFindingsError}</p>
156
+ ` : html``}
117
157
  </div>
118
158
  `;
119
159
  }
@@ -1,4 +1,4 @@
1
- import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
1
+ import { DeesElement, html, customElement, css, state, cssManager } from '@design.estate/dees-element';
2
2
  import * as appstate from '../../appstate.js';
3
3
  import * as shared from '../shared/index.js';
4
4
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
@@ -23,14 +23,27 @@ export class OpsViewEmails extends DeesElement {
23
23
  @state()
24
24
  accessor isLoading = false;
25
25
 
26
+ @state()
27
+ accessor traffic: interfaces.requests.IEmailLogTraffic | null = null;
28
+
29
+ @state()
30
+ accessor searchQuery = '';
31
+
32
+ @state()
33
+ accessor selectedRange: { from: number; to: number } | null = null;
34
+
26
35
  private stateSubscription: any;
27
36
  private lastEmailOpsUpdate = 0;
28
37
  private detailRequestGeneration = 0;
38
+ private searchDebounceTimer: number | undefined;
29
39
 
30
40
  async connectedCallback() {
31
41
  await super.connectedCallback();
32
42
  this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
33
43
  this.emails = state.emails;
44
+ this.traffic = state.traffic;
45
+ this.searchQuery = state.searchQuery;
46
+ this.selectedRange = state.selectedRange;
34
47
  const hasNewSnapshot = state.lastUpdated > this.lastEmailOpsUpdate;
35
48
  this.lastEmailOpsUpdate = Math.max(this.lastEmailOpsUpdate, state.lastUpdated);
36
49
  if (hasNewSnapshot && this.selectedEmail) {
@@ -47,6 +60,10 @@ export class OpsViewEmails extends DeesElement {
47
60
  if (this.stateSubscription) {
48
61
  this.stateSubscription.unsubscribe();
49
62
  }
63
+ if (this.searchDebounceTimer !== undefined) {
64
+ window.clearTimeout(this.searchDebounceTimer);
65
+ this.searchDebounceTimer = undefined;
66
+ }
50
67
  }
51
68
 
52
69
  public static styles = [
@@ -61,6 +78,26 @@ export class OpsViewEmails extends DeesElement {
61
78
  .viewContainer {
62
79
  height: 100%;
63
80
  }
81
+
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 {
99
+ min-height: 360px;
100
+ }
64
101
  `,
65
102
  ];
66
103
 
@@ -78,8 +115,34 @@ export class OpsViewEmails extends DeesElement {
78
115
  : html`
79
116
  <sz-mta-list-view
80
117
  .emails=${this.emails}
118
+ .searchQuery=${this.searchQuery}
119
+ .stickyFilterBar=${true}
120
+ @search-change=${this.handleSearchChange}
81
121
  @email-click=${this.handleEmailClick}
82
- ></sz-mta-list-view>
122
+ >
123
+ ${this.traffic ? html`
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>
144
+ ` : html``}
145
+ </sz-mta-list-view>
83
146
  `
84
147
  }
85
148
  </div>
@@ -108,6 +171,71 @@ export class OpsViewEmails extends DeesElement {
108
171
  await this.refreshEmailDetail(e.detail.id, true);
109
172
  }
110
173
 
174
+ private handleSearchChange(eventArg: CustomEvent<{ searchQuery: string }>): void {
175
+ const searchQuery = appstate.setActiveEmailSearchQuery(eventArg.detail.searchQuery);
176
+ if (this.searchDebounceTimer !== undefined) {
177
+ window.clearTimeout(this.searchDebounceTimer);
178
+ }
179
+ this.searchDebounceTimer = window.setTimeout(() => {
180
+ this.searchDebounceTimer = undefined;
181
+ void appstate.emailOpsStatePart.dispatchAction(
182
+ appstate.fetchAllEmailsAction,
183
+ { searchQuery },
184
+ );
185
+ }, 300);
186
+ }
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
+
214
+ private getEmailTrafficSeries(): Array<{
215
+ name: string;
216
+ color: string;
217
+ data: Array<{ x: number; y: number }>;
218
+ }> {
219
+ if (!this.traffic) return [];
220
+ return [
221
+ {
222
+ name: 'Sent',
223
+ color: '#22c55e',
224
+ data: this.traffic.sent.map((point) => ({ x: point.timestamp, y: point.value })),
225
+ },
226
+ {
227
+ name: 'Received',
228
+ color: '#3b82f6',
229
+ data: this.traffic.received.map((point) => ({ x: point.timestamp, y: point.value })),
230
+ },
231
+ {
232
+ name: 'Failed',
233
+ color: '#ef4444',
234
+ data: this.traffic.failed.map((point) => ({ x: point.timestamp, y: point.value })),
235
+ },
236
+ ];
237
+ }
238
+
111
239
  private handleBack() {
112
240
  this.detailRequestGeneration++;
113
241
  this.selectedEmail = null;
package/ts_web/readme.md CHANGED
@@ -27,6 +27,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
27
27
  | `security` | `overview`, `blocked`, `authentication` |
28
28
  | `domains` | `providers`, `domains`, `dns`, `certificates` |
29
29
 
30
+ The Email Log keeps its search controls visible while scrolling and places the
31
+ traffic chart above the retained email table. With no search the chart shows the
32
+ last 24 hours. A search filters both the table and chart, expanding the chart to
33
+ 7, 14, or 30 days when older matching mail exists. Email Security lists retained
34
+ SPF, DKIM, and enforcing DMARC findings with sender, recipient, subject, and a
35
+ concise reason.
36
+
30
37
  ## Routes Navigation
31
38
 
32
39
  The `/network/routes` view uses two adjacent, independent multitoggles: