@serve.zone/dcrouter 18.2.1 → 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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +2254 -2315
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/db/documents/classes.cached.email.d.ts +17 -4
- package/dist_ts/db/documents/classes.cached.email.js +105 -8
- package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -8
- package/dist_ts/email/classes.accepted-email-spool.js +3 -40
- package/dist_ts/email/helpers.email-log-traffic.d.ts +19 -0
- package/dist_ts/email/helpers.email-log-traffic.js +79 -0
- package/dist_ts/email/helpers.inbound-security.d.ts +8 -0
- package/dist_ts/email/helpers.inbound-security.js +36 -0
- package/dist_ts/email/index.d.ts +2 -0
- package/dist_ts/email/index.js +3 -1
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +3 -0
- package/dist_ts/opsserver/handlers/email-ops.handler.js +84 -6
- package/dist_ts_interfaces/requests/email-ops.d.ts +41 -0
- package/dist_ts_migrations/index.js +47 -14
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/email-ops.d.ts +12 -1
- package/dist_ts_web/appstate/email-ops.js +77 -4
- package/dist_ts_web/appstate/runtime.js +11 -3
- package/dist_ts_web/elements/email/ops-view-email-security.d.ts +2 -0
- package/dist_ts_web/elements/email/ops-view-email-security.js +58 -16
- package/dist_ts_web/elements/email/ops-view-emails.d.ts +7 -2
- package/dist_ts_web/elements/email/ops-view-emails.js +78 -4
- package/package.json +3 -3
- package/readme.plan.md +22 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/db/documents/classes.cached.email.ts +141 -7
- package/ts/email/classes.accepted-email-spool.ts +5 -52
- package/ts/email/helpers.email-log-traffic.ts +117 -0
- package/ts/email/helpers.inbound-security.ts +46 -0
- package/ts/email/index.ts +2 -0
- package/ts/opsserver/handlers/email-ops.handler.ts +116 -4
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/email-ops.ts +99 -3
- package/ts_web/appstate/runtime.ts +13 -2
- package/ts_web/elements/email/ops-view-email-security.ts +56 -16
- package/ts_web/elements/email/ops-view-emails.ts +74 -2
- 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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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).
|
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|