@serve.zone/dcrouter 18.2.0 → 18.3.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 (42) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +2254 -2315
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +5 -3
  5. package/dist_ts/classes.dcrouter.js +1 -1
  6. package/dist_ts/db/documents/classes.cached.email.d.ts +17 -4
  7. package/dist_ts/db/documents/classes.cached.email.js +105 -8
  8. package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -8
  9. package/dist_ts/email/classes.accepted-email-spool.js +3 -40
  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 +36 -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 +3 -0
  17. package/dist_ts/opsserver/handlers/email-ops.handler.js +84 -6
  18. package/dist_ts_interfaces/requests/email-ops.d.ts +41 -0
  19. package/dist_ts_migrations/index.js +47 -14
  20. package/dist_ts_web/00_commitinfo_data.js +1 -1
  21. package/dist_ts_web/appstate/email-ops.d.ts +12 -1
  22. package/dist_ts_web/appstate/email-ops.js +77 -4
  23. package/dist_ts_web/appstate/runtime.js +11 -3
  24. package/dist_ts_web/elements/email/ops-view-email-security.d.ts +2 -0
  25. package/dist_ts_web/elements/email/ops-view-email-security.js +58 -16
  26. package/dist_ts_web/elements/email/ops-view-emails.d.ts +7 -2
  27. package/dist_ts_web/elements/email/ops-view-emails.js +78 -4
  28. package/package.json +3 -3
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/classes.dcrouter.ts +6 -3
  31. package/ts/db/documents/classes.cached.email.ts +141 -7
  32. package/ts/email/classes.accepted-email-spool.ts +5 -52
  33. package/ts/email/helpers.email-log-traffic.ts +117 -0
  34. package/ts/email/helpers.inbound-security.ts +46 -0
  35. package/ts/email/index.ts +2 -0
  36. package/ts/opsserver/handlers/email-ops.handler.ts +116 -4
  37. package/ts_web/00_commitinfo_data.ts +1 -1
  38. package/ts_web/appstate/email-ops.ts +99 -3
  39. package/ts_web/appstate/runtime.ts +13 -2
  40. package/ts_web/elements/email/ops-view-email-security.ts +56 -16
  41. package/ts_web/elements/email/ops-view-emails.ts +74 -2
  42. package/ts_web/readme.md +7 -0
@@ -3,6 +3,14 @@ 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
  import { CachedEmail } from '../../db/index.js';
6
+ import {
7
+ aggregateEmailLogTrafficInMemory,
8
+ buildEmailLogTraffic,
9
+ EMAIL_LOG_RETENTION_MS,
10
+ normalizeEmailLogSearch,
11
+ selectEmailLogTrafficWindow,
12
+ } from '../../email/helpers.email-log-traffic.js';
13
+ import { evaluateInboundAcceptance } from '../../email/helpers.inbound-security.js';
6
14
 
7
15
  export class EmailOpsHandler {
8
16
  constructor(private opsServerRef: OpsServer) {
@@ -21,12 +29,27 @@ export class EmailOpsHandler {
21
29
  'getAllEmails',
22
30
  async (dataArg) => {
23
31
  await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
24
- const emails = await this.getAllEmails(dataArg.direction, dataArg.limit);
25
- return { emails };
32
+ return await this.getEmailLogSnapshot(
33
+ dataArg.direction,
34
+ dataArg.limit,
35
+ dataArg.search,
36
+ );
26
37
  }
27
38
  )
28
39
  );
29
40
 
41
+ viewRouter.addTypedHandler(
42
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailSecurityFindings>(
43
+ 'getEmailSecurityFindings',
44
+ async (dataArg) => {
45
+ await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
46
+ return {
47
+ findings: await this.getEmailSecurityFindings(dataArg.limit),
48
+ };
49
+ },
50
+ ),
51
+ );
52
+
30
53
  // Get Email Detail Handler
31
54
  viewRouter.addTypedHandler(
32
55
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
@@ -92,10 +115,12 @@ export class EmailOpsHandler {
92
115
  private async getAllEmails(
93
116
  direction?: interfaces.requests.TEmailDirection,
94
117
  limitArg?: number,
118
+ searchArg?: string,
95
119
  ): Promise<interfaces.requests.IEmail[]> {
96
120
  const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 1000);
121
+ const search = normalizeEmailLogSearch(searchArg);
97
122
  const cachedDocs = this.isCachedEmailStoreAvailable()
98
- ? await CachedEmail.findRecent(limit, { direction })
123
+ ? await CachedEmail.findRecent(limit, { direction, search: search || undefined })
99
124
  : [];
100
125
  const emails = cachedDocs.map((doc) => this.mapCachedEmailToEmail(doc));
101
126
  const representedIds = new Set(cachedDocs.map((doc) => doc.id));
@@ -110,7 +135,10 @@ export class EmailOpsHandler {
110
135
  if (cachedId && representedIds.has(cachedId)) {
111
136
  continue;
112
137
  }
113
- emails.push(this.mapQueueItemToEmail(item));
138
+ const email = this.mapQueueItemToEmail(item);
139
+ if (this.matchesEmailSearch(email, search)) {
140
+ emails.push(email);
141
+ }
114
142
  }
115
143
  }
116
144
 
@@ -120,6 +148,90 @@ export class EmailOpsHandler {
120
148
  return emails.slice(0, limit);
121
149
  }
122
150
 
151
+ private async getEmailLogSnapshot(
152
+ direction?: interfaces.requests.TEmailDirection,
153
+ limitArg?: number,
154
+ searchArg?: string,
155
+ ): Promise<interfaces.requests.IReq_GetAllEmails['response']> {
156
+ const search = normalizeEmailLogSearch(searchArg);
157
+ const now = Date.now();
158
+ const filter = { direction, search: search || undefined };
159
+ const emails = await this.getAllEmails(direction, limitArg, search);
160
+
161
+ let oldestMatchAt: number | null = null;
162
+ if (search) {
163
+ oldestMatchAt = this.isCachedEmailStoreAvailable()
164
+ ? await CachedEmail.findOldestAcceptedAt(filter, now - EMAIL_LOG_RETENTION_MS)
165
+ : emails.reduce<number | null>((oldest, email) => {
166
+ const timestamp = new Date(email.timestamp).getTime();
167
+ if (!Number.isFinite(timestamp)) return oldest;
168
+ return oldest === null ? timestamp : Math.min(oldest, timestamp);
169
+ }, null);
170
+ }
171
+
172
+ const descriptor = selectEmailLogTrafficWindow(search, oldestMatchAt, now);
173
+ const emptyTraffic = buildEmailLogTraffic(descriptor, [], now);
174
+ const sparseTraffic = this.isCachedEmailStoreAvailable()
175
+ ? await CachedEmail.aggregateEmailLogTraffic(
176
+ filter,
177
+ emptyTraffic.windowStart,
178
+ emptyTraffic.windowEnd,
179
+ descriptor.bucketSizeMs,
180
+ )
181
+ : aggregateEmailLogTrafficInMemory(emails, descriptor, now);
182
+
183
+ return {
184
+ emails,
185
+ traffic: buildEmailLogTraffic(descriptor, sparseTraffic, now),
186
+ };
187
+ }
188
+
189
+ private async getEmailSecurityFindings(
190
+ limitArg?: number,
191
+ ): Promise<interfaces.requests.IEmailSecurityFinding[]> {
192
+ if (!this.isCachedEmailStoreAvailable()) return [];
193
+ const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 200);
194
+ const candidates = await CachedEmail.findRecentSecurityCandidates(
195
+ Math.min(limit * 5, 1000),
196
+ Date.now() - EMAIL_LOG_RETENTION_MS,
197
+ );
198
+ const findings: interfaces.requests.IEmailSecurityFinding[] = [];
199
+ for (const candidate of candidates) {
200
+ let securityResults: unknown;
201
+ try {
202
+ securityResults = JSON.parse(candidate.inboundSecurityResults || '');
203
+ } catch {
204
+ continue;
205
+ }
206
+ const evaluation = evaluateInboundAcceptance(securityResults);
207
+ if (evaluation.doubts.length === 0) continue;
208
+ findings.push({
209
+ id: candidate.id,
210
+ timestamp: new Date(candidate.acceptedAt || 0).toISOString(),
211
+ from: candidate.from || '',
212
+ to: (candidate.to || []).join(', '),
213
+ subject: candidate.subject || '',
214
+ disposition: candidate.status === 'rejected' || evaluation.dmarcReject
215
+ ? 'rejected'
216
+ : 'flagged',
217
+ failures: evaluation.doubts,
218
+ failureSummary: evaluation.doubts.join('; '),
219
+ });
220
+ if (findings.length >= limit) break;
221
+ }
222
+ return findings;
223
+ }
224
+
225
+ private matchesEmailSearch(
226
+ emailArg: interfaces.requests.IEmail,
227
+ searchArg: string,
228
+ ): boolean {
229
+ if (!searchArg) return true;
230
+ const search = searchArg.toLowerCase();
231
+ return [emailArg.from, emailArg.to, emailArg.subject, emailArg.messageId]
232
+ .some((value) => value.toLowerCase().includes(search));
233
+ }
234
+
123
235
  /**
124
236
  * Get a single email detail by ID — live queue item first (freshest state),
125
237
  * else the durable CachedEmail row (covers inbound + historical mail).
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.2.0',
6
+ version: '18.3.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -6,18 +6,30 @@ 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
+ securityFindings: interfaces.requests.IEmailSecurityFinding[];
12
+ securityFindingsLoading: boolean;
13
+ securityFindingsError: string | null;
9
14
  isLoading: boolean;
10
15
  error: string | null;
11
16
  lastUpdated: number;
17
+ lastSecurityUpdated: number;
12
18
  }
13
19
 
14
20
  export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
15
21
  'emailOps',
16
22
  {
17
23
  emails: [],
24
+ traffic: null,
25
+ searchQuery: '',
26
+ securityFindings: [],
27
+ securityFindingsLoading: false,
28
+ securityFindingsError: null,
18
29
  isLoading: false,
19
30
  error: null,
20
31
  lastUpdated: 0,
32
+ lastSecurityUpdated: 0,
21
33
  },
22
34
  'soft'
23
35
  );
@@ -25,11 +37,39 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
25
37
  // Email Operations Actions
26
38
  // ============================================================================
27
39
 
40
+ let activeSearchQuery = '';
41
+ let emailListRequestGeneration = 0;
42
+ let securityFindingsRequestGeneration = 0;
43
+
44
+ export interface IEmailOpsFetchOptions {
45
+ searchQuery?: string;
46
+ }
47
+
48
+ export function setActiveEmailSearchQuery(searchQueryArg: string): string {
49
+ const nextSearchQuery = searchQueryArg.trim().slice(0, 200);
50
+ if (nextSearchQuery === activeSearchQuery) return activeSearchQuery;
51
+ activeSearchQuery = nextSearchQuery;
52
+ emailListRequestGeneration++;
53
+ const currentState = emailOpsStatePart.getState()!;
54
+ emailOpsStatePart.setState({
55
+ ...currentState,
56
+ searchQuery: activeSearchQuery,
57
+ });
58
+ return activeSearchQuery;
59
+ }
60
+
28
61
  // Fetch All Emails Action
29
- export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg): Promise<IEmailOpsState> => {
62
+ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetchOptions | null>(async (
63
+ statePartArg,
64
+ dataArg,
65
+ ): Promise<IEmailOpsState> => {
30
66
  const context = getActionContext();
31
67
  const currentState = statePartArg.getState()!;
32
68
  if (!context.identity) return currentState;
69
+ const searchQuery = dataArg?.searchQuery !== undefined
70
+ ? setActiveEmailSearchQuery(dataArg.searchQuery)
71
+ : activeSearchQuery;
72
+ const requestGeneration = ++emailListRequestGeneration;
33
73
  const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
34
74
 
35
75
  try {
@@ -39,18 +79,27 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
39
79
 
40
80
  const response = await request.fire({
41
81
  identity: context.identity,
82
+ ...(searchQuery ? { search: searchQuery } : {}),
42
83
  });
43
- if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
84
+ if (
85
+ requestGeneration !== emailListRequestGeneration
86
+ || !isRealtimeTicketCurrent(ticket)
87
+ ) return statePartArg.getState()!;
44
88
 
45
89
  return {
46
90
  ...statePartArg.getState()!,
47
91
  emails: response.emails,
92
+ traffic: response.traffic,
93
+ searchQuery,
48
94
  isLoading: false,
49
95
  error: null,
50
96
  lastUpdated: Date.now(),
51
97
  };
52
98
  } catch (error) {
53
- if (!isRealtimeTicketCurrent(ticket)) return statePartArg.getState()!;
99
+ if (
100
+ requestGeneration !== emailListRequestGeneration
101
+ || !isRealtimeTicketCurrent(ticket)
102
+ ) return statePartArg.getState()!;
54
103
  return {
55
104
  ...statePartArg.getState()!,
56
105
  isLoading: false,
@@ -59,6 +108,53 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
59
108
  }
60
109
  });
61
110
 
111
+ export const fetchEmailSecurityFindingsAction = emailOpsStatePart.createAction<void>(async (
112
+ statePartArg,
113
+ ): Promise<IEmailOpsState> => {
114
+ const context = getActionContext();
115
+ const currentState = statePartArg.getState()!;
116
+ if (!context.identity) return currentState;
117
+ const requestGeneration = ++securityFindingsRequestGeneration;
118
+ const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
119
+ statePartArg.setState({
120
+ ...currentState,
121
+ securityFindingsLoading: true,
122
+ securityFindingsError: null,
123
+ });
124
+ try {
125
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
126
+ interfaces.requests.IReq_GetEmailSecurityFindings
127
+ >('/typedrequest', 'getEmailSecurityFindings');
128
+ const response = await request.fire({
129
+ identity: context.identity,
130
+ limit: 200,
131
+ });
132
+ if (
133
+ requestGeneration !== securityFindingsRequestGeneration
134
+ || !isRealtimeTicketCurrent(ticket)
135
+ ) return statePartArg.getState()!;
136
+ return {
137
+ ...statePartArg.getState()!,
138
+ securityFindings: response.findings,
139
+ securityFindingsLoading: false,
140
+ securityFindingsError: null,
141
+ lastSecurityUpdated: Date.now(),
142
+ };
143
+ } catch (error) {
144
+ if (
145
+ requestGeneration !== securityFindingsRequestGeneration
146
+ || !isRealtimeTicketCurrent(ticket)
147
+ ) return statePartArg.getState()!;
148
+ return {
149
+ ...statePartArg.getState()!,
150
+ securityFindingsLoading: false,
151
+ securityFindingsError: error instanceof Error
152
+ ? error.message
153
+ : 'Failed to fetch email security findings',
154
+ };
155
+ }
156
+ });
157
+
62
158
  // ============================================================================
63
159
  export async function fetchEmailDetailSnapshot(
64
160
  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,23 @@ 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
+
26
32
  private stateSubscription: any;
27
33
  private lastEmailOpsUpdate = 0;
28
34
  private detailRequestGeneration = 0;
35
+ private searchDebounceTimer: number | undefined;
29
36
 
30
37
  async connectedCallback() {
31
38
  await super.connectedCallback();
32
39
  this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
33
40
  this.emails = state.emails;
41
+ this.traffic = state.traffic;
42
+ this.searchQuery = state.searchQuery;
34
43
  const hasNewSnapshot = state.lastUpdated > this.lastEmailOpsUpdate;
35
44
  this.lastEmailOpsUpdate = Math.max(this.lastEmailOpsUpdate, state.lastUpdated);
36
45
  if (hasNewSnapshot && this.selectedEmail) {
@@ -47,6 +56,10 @@ export class OpsViewEmails extends DeesElement {
47
56
  if (this.stateSubscription) {
48
57
  this.stateSubscription.unsubscribe();
49
58
  }
59
+ if (this.searchDebounceTimer !== undefined) {
60
+ window.clearTimeout(this.searchDebounceTimer);
61
+ this.searchDebounceTimer = undefined;
62
+ }
50
63
  }
51
64
 
52
65
  public static styles = [
@@ -61,6 +74,10 @@ export class OpsViewEmails extends DeesElement {
61
74
  .viewContainer {
62
75
  height: 100%;
63
76
  }
77
+
78
+ dees-chart-area[slot='above-table'] {
79
+ min-height: 360px;
80
+ }
64
81
  `,
65
82
  ];
66
83
 
@@ -78,8 +95,24 @@ export class OpsViewEmails extends DeesElement {
78
95
  : html`
79
96
  <sz-mta-list-view
80
97
  .emails=${this.emails}
98
+ .searchQuery=${this.searchQuery}
99
+ .stickyFilterBar=${true}
100
+ @search-change=${this.handleSearchChange}
81
101
  @email-click=${this.handleEmailClick}
82
- ></sz-mta-list-view>
102
+ >
103
+ ${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>
114
+ ` : html``}
115
+ </sz-mta-list-view>
83
116
  `
84
117
  }
85
118
  </div>
@@ -108,6 +141,45 @@ export class OpsViewEmails extends DeesElement {
108
141
  await this.refreshEmailDetail(e.detail.id, true);
109
142
  }
110
143
 
144
+ private handleSearchChange(eventArg: CustomEvent<{ searchQuery: string }>): void {
145
+ const searchQuery = appstate.setActiveEmailSearchQuery(eventArg.detail.searchQuery);
146
+ if (this.searchDebounceTimer !== undefined) {
147
+ window.clearTimeout(this.searchDebounceTimer);
148
+ }
149
+ this.searchDebounceTimer = window.setTimeout(() => {
150
+ this.searchDebounceTimer = undefined;
151
+ void appstate.emailOpsStatePart.dispatchAction(
152
+ appstate.fetchAllEmailsAction,
153
+ { searchQuery },
154
+ );
155
+ }, 300);
156
+ }
157
+
158
+ private getEmailTrafficSeries(): Array<{
159
+ name: string;
160
+ color: string;
161
+ data: Array<{ x: number; y: number }>;
162
+ }> {
163
+ if (!this.traffic) return [];
164
+ return [
165
+ {
166
+ name: 'Sent',
167
+ color: '#22c55e',
168
+ data: this.traffic.sent.map((point) => ({ x: point.timestamp, y: point.value })),
169
+ },
170
+ {
171
+ name: 'Received',
172
+ color: '#3b82f6',
173
+ data: this.traffic.received.map((point) => ({ x: point.timestamp, y: point.value })),
174
+ },
175
+ {
176
+ name: 'Failed',
177
+ color: '#ef4444',
178
+ data: this.traffic.failed.map((point) => ({ x: point.timestamp, y: point.value })),
179
+ },
180
+ ];
181
+ }
182
+
111
183
  private handleBack() {
112
184
  this.detailRequestGeneration++;
113
185
  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: