@serve.zone/dcrouter 18.1.0 → 18.1.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.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +8 -8
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +4 -1
- package/dist_ts/classes.dcrouter.js +30 -6
- package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +25 -0
- package/dist_ts/db/documents/classes.authentication-event.doc.js +273 -0
- package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +25 -0
- package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +200 -0
- package/dist_ts/db/documents/index.d.ts +2 -0
- package/dist_ts/db/documents/index.js +3 -1
- package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -0
- package/dist_ts/email/classes.accepted-email-spool.js +9 -1
- package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
- package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
- package/dist_ts/opsserver/handlers/admin.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/admin.handler.js +61 -14
- package/dist_ts/opsserver/handlers/security.handler.js +21 -3
- package/dist_ts/opsserver/handlers/stats.handler.js +27 -10
- package/dist_ts/security/classes.authentication-event-manager.d.ts +35 -0
- package/dist_ts/security/classes.authentication-event-manager.js +185 -0
- package/dist_ts/security/index.d.ts +1 -0
- package/dist_ts/security/index.js +2 -1
- package/dist_ts_interfaces/data/stats.d.ts +16 -0
- package/dist_ts_migrations/index.js +74 -15
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/overview/ops-view-overview.js +3 -2
- package/dist_ts_web/elements/security/ops-view-security-authentication.js +20 -13
- package/dist_ts_web/elements/security/ops-view-security-overview.js +8 -11
- package/package.json +1 -1
- package/readme.md +8 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +31 -5
- package/ts/db/documents/classes.authentication-event.doc.ts +248 -0
- package/ts/db/documents/classes.email-traffic-bucket.doc.ts +161 -0
- package/ts/db/documents/index.ts +2 -0
- package/ts/email/classes.accepted-email-spool.ts +8 -0
- package/ts/monitoring/classes.metricsmanager.ts +287 -50
- package/ts/opsserver/handlers/admin.handler.ts +74 -15
- package/ts/opsserver/handlers/security.handler.ts +23 -2
- package/ts/opsserver/handlers/stats.handler.ts +29 -9
- package/ts/security/classes.authentication-event-manager.ts +221 -0
- package/ts/security/index.ts +1 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/overview/ops-view-overview.ts +2 -1
- package/ts_web/elements/security/ops-view-security-authentication.ts +20 -13
- package/ts_web/elements/security/ops-view-security-overview.ts +7 -12
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
|
+
import type {
|
|
4
|
+
IAuthenticationEvent,
|
|
5
|
+
TAuthenticationFailureReason,
|
|
6
|
+
TAuthenticationSource,
|
|
7
|
+
TResolvedAuthenticationSource,
|
|
8
|
+
} from '../../../ts_interfaces/data/stats.js';
|
|
9
|
+
|
|
10
|
+
const DB_OPERATION_TIMEOUT_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
13
|
+
|
|
14
|
+
@plugins.smartdata.Collection(() => getDb())
|
|
15
|
+
export class AuthenticationEventDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
16
|
+
AuthenticationEventDoc,
|
|
17
|
+
AuthenticationEventDoc
|
|
18
|
+
> implements IAuthenticationEvent {
|
|
19
|
+
@plugins.smartdata.unI()
|
|
20
|
+
@plugins.smartdata.svDb()
|
|
21
|
+
public id!: string;
|
|
22
|
+
|
|
23
|
+
@plugins.smartdata.svDb()
|
|
24
|
+
public timestamp!: number;
|
|
25
|
+
|
|
26
|
+
@plugins.smartdata.svDb()
|
|
27
|
+
public username!: string;
|
|
28
|
+
|
|
29
|
+
@plugins.smartdata.svDb()
|
|
30
|
+
public userId?: string;
|
|
31
|
+
|
|
32
|
+
@plugins.smartdata.svDb()
|
|
33
|
+
public success!: boolean;
|
|
34
|
+
|
|
35
|
+
@plugins.smartdata.svDb()
|
|
36
|
+
public requestedAuthSource!: TAuthenticationSource;
|
|
37
|
+
|
|
38
|
+
@plugins.smartdata.svDb()
|
|
39
|
+
public resolvedAuthSource?: TResolvedAuthenticationSource;
|
|
40
|
+
|
|
41
|
+
@plugins.smartdata.svDb()
|
|
42
|
+
public failureReason?: TAuthenticationFailureReason;
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public toApiObject(): IAuthenticationEvent {
|
|
49
|
+
return {
|
|
50
|
+
id: this.id,
|
|
51
|
+
timestamp: this.timestamp,
|
|
52
|
+
username: this.username,
|
|
53
|
+
success: this.success,
|
|
54
|
+
requestedAuthSource: this.requestedAuthSource,
|
|
55
|
+
...(this.userId ? { userId: this.userId } : {}),
|
|
56
|
+
...(this.resolvedAuthSource ? { resolvedAuthSource: this.resolvedAuthSource } : {}),
|
|
57
|
+
...(this.failureReason ? { failureReason: this.failureReason } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static async getNativeCollection() {
|
|
62
|
+
const smartdataCollection = (AuthenticationEventDoc as typeof AuthenticationEventDoc & {
|
|
63
|
+
collection: plugins.smartdata.SmartdataCollection<AuthenticationEventDoc>;
|
|
64
|
+
}).collection;
|
|
65
|
+
await smartdataCollection.init();
|
|
66
|
+
const probe = new AuthenticationEventDoc();
|
|
67
|
+
await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
|
|
68
|
+
await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
|
|
69
|
+
return smartdataCollection.mongoDbCollection;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static toPersistedFields(eventArg: IAuthenticationEvent): IAuthenticationEvent {
|
|
73
|
+
return {
|
|
74
|
+
id: eventArg.id,
|
|
75
|
+
timestamp: eventArg.timestamp,
|
|
76
|
+
username: eventArg.username,
|
|
77
|
+
success: eventArg.success,
|
|
78
|
+
requestedAuthSource: eventArg.requestedAuthSource,
|
|
79
|
+
...(eventArg.userId !== undefined ? { userId: eventArg.userId } : {}),
|
|
80
|
+
...(eventArg.resolvedAuthSource !== undefined
|
|
81
|
+
? { resolvedAuthSource: eventArg.resolvedAuthSource }
|
|
82
|
+
: {}),
|
|
83
|
+
...(eventArg.failureReason !== undefined ? { failureReason: eventArg.failureReason } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private static validateEvent(eventArg: IAuthenticationEvent): void {
|
|
88
|
+
if (
|
|
89
|
+
typeof eventArg.id !== 'string'
|
|
90
|
+
|| !eventArg.id.trim()
|
|
91
|
+
|| !Number.isSafeInteger(eventArg.timestamp)
|
|
92
|
+
|| eventArg.timestamp < 0
|
|
93
|
+
|| typeof eventArg.username !== 'string'
|
|
94
|
+
|| !eventArg.username.trim()
|
|
95
|
+
|| typeof eventArg.success !== 'boolean'
|
|
96
|
+
|| !['auto', 'local', 'idp.global'].includes(eventArg.requestedAuthSource)
|
|
97
|
+
) {
|
|
98
|
+
throw new Error('Invalid authentication event');
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
eventArg.userId !== undefined
|
|
102
|
+
&& (typeof eventArg.userId !== 'string' || !eventArg.userId.trim())
|
|
103
|
+
) {
|
|
104
|
+
throw new Error('Invalid authentication event user ID');
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
eventArg.resolvedAuthSource !== undefined
|
|
108
|
+
&& eventArg.resolvedAuthSource !== 'local'
|
|
109
|
+
&& eventArg.resolvedAuthSource !== 'idp.global'
|
|
110
|
+
) {
|
|
111
|
+
throw new Error('Invalid resolved authentication source');
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
eventArg.failureReason !== undefined
|
|
115
|
+
&& ![
|
|
116
|
+
'invalidCredentials',
|
|
117
|
+
'serviceUnavailable',
|
|
118
|
+
'identityIssuanceFailed',
|
|
119
|
+
'internalError',
|
|
120
|
+
].includes(eventArg.failureReason)
|
|
121
|
+
) {
|
|
122
|
+
throw new Error('Invalid authentication failure reason');
|
|
123
|
+
}
|
|
124
|
+
if (eventArg.success && eventArg.failureReason) {
|
|
125
|
+
throw new Error('Successful authentication events cannot carry a failure reason');
|
|
126
|
+
}
|
|
127
|
+
if (!eventArg.success && !eventArg.failureReason) {
|
|
128
|
+
throw new Error('Failed authentication events require a failure reason');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
public static async upsertMany(eventsArg: IAuthenticationEvent[]): Promise<void> {
|
|
133
|
+
if (eventsArg.length === 0) return;
|
|
134
|
+
if (eventsArg.length > 500) {
|
|
135
|
+
throw new Error('AuthenticationEventDoc.upsertMany accepts at most 500 events');
|
|
136
|
+
}
|
|
137
|
+
for (const event of eventsArg) {
|
|
138
|
+
AuthenticationEventDoc.validateEvent(event);
|
|
139
|
+
}
|
|
140
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
141
|
+
await collection.bulkWrite(
|
|
142
|
+
eventsArg.map((event) => {
|
|
143
|
+
const timestamp = new Date(event.timestamp).toISOString();
|
|
144
|
+
const persistedEvent = AuthenticationEventDoc.toPersistedFields(event);
|
|
145
|
+
return {
|
|
146
|
+
updateOne: {
|
|
147
|
+
filter: { id: event.id },
|
|
148
|
+
update: {
|
|
149
|
+
$setOnInsert: {
|
|
150
|
+
...persistedEvent,
|
|
151
|
+
_createdAt: timestamp,
|
|
152
|
+
_updatedAt: timestamp,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
upsert: true,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}),
|
|
159
|
+
{ ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public static async getWindowSummary(
|
|
164
|
+
cutoffArg: number,
|
|
165
|
+
limitArg = 100,
|
|
166
|
+
): Promise<{
|
|
167
|
+
successes: number;
|
|
168
|
+
failures: number;
|
|
169
|
+
events: IAuthenticationEvent[];
|
|
170
|
+
}> {
|
|
171
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
172
|
+
throw new Error('AuthenticationEventDoc.getWindowSummary requires a valid cutoff');
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isSafeInteger(limitArg) || limitArg <= 0 || limitArg > 500) {
|
|
175
|
+
throw new Error('AuthenticationEventDoc.getWindowSummary requires a limit from 1 to 500');
|
|
176
|
+
}
|
|
177
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
178
|
+
const selector = { timestamp: { $gte: cutoffArg } };
|
|
179
|
+
const cursor = collection
|
|
180
|
+
.find(selector, { timeoutMS: DB_OPERATION_TIMEOUT_MS })
|
|
181
|
+
.sort({ timestamp: -1, id: -1 })
|
|
182
|
+
.limit(limitArg);
|
|
183
|
+
try {
|
|
184
|
+
const [successes, failures, rows] = await Promise.all([
|
|
185
|
+
collection.countDocuments(
|
|
186
|
+
{ ...selector, success: true },
|
|
187
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
188
|
+
),
|
|
189
|
+
collection.countDocuments(
|
|
190
|
+
{ ...selector, success: false },
|
|
191
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
192
|
+
),
|
|
193
|
+
cursor.toArray(),
|
|
194
|
+
]);
|
|
195
|
+
return {
|
|
196
|
+
successes,
|
|
197
|
+
failures,
|
|
198
|
+
events: rows.map((row) => AuthenticationEventDoc
|
|
199
|
+
.createInstanceFromMongoDbNativeDoc(row as any)
|
|
200
|
+
.toApiObject()),
|
|
201
|
+
};
|
|
202
|
+
} finally {
|
|
203
|
+
await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public static async findExistingIds(idsArg: string[]): Promise<Set<string>> {
|
|
208
|
+
const ids = [...new Set(idsArg)];
|
|
209
|
+
if (ids.length === 0) return new Set();
|
|
210
|
+
if (ids.length > 500) {
|
|
211
|
+
throw new Error('AuthenticationEventDoc.findExistingIds accepts at most 500 ids');
|
|
212
|
+
}
|
|
213
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
214
|
+
const rows = await collection
|
|
215
|
+
.find(
|
|
216
|
+
{ id: { $in: ids } },
|
|
217
|
+
{ projection: { id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
218
|
+
)
|
|
219
|
+
.toArray();
|
|
220
|
+
return new Set(rows.map((row) => String(row.id)));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
public static async pruneBefore(cutoffArg: number): Promise<number> {
|
|
224
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
225
|
+
throw new Error('AuthenticationEventDoc.pruneBefore requires a valid cutoff');
|
|
226
|
+
}
|
|
227
|
+
const collection = await AuthenticationEventDoc.getNativeCollection();
|
|
228
|
+
let deletedCount = 0;
|
|
229
|
+
while (true) {
|
|
230
|
+
const rows = await collection
|
|
231
|
+
.find(
|
|
232
|
+
{ timestamp: { $lt: cutoffArg } },
|
|
233
|
+
{ projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
234
|
+
)
|
|
235
|
+
.sort({ timestamp: 1, _id: 1 })
|
|
236
|
+
.limit(500)
|
|
237
|
+
.toArray();
|
|
238
|
+
if (rows.length === 0) break;
|
|
239
|
+
const result = await collection.deleteMany(
|
|
240
|
+
{ _id: { $in: rows.map((row) => row._id) } },
|
|
241
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
242
|
+
);
|
|
243
|
+
deletedCount += result.deletedCount;
|
|
244
|
+
if (rows.length < 500) break;
|
|
245
|
+
}
|
|
246
|
+
return deletedCount;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
|
+
|
|
4
|
+
const DB_OPERATION_TIMEOUT_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
7
|
+
|
|
8
|
+
export interface IEmailTrafficBucketCounts {
|
|
9
|
+
sent: number;
|
|
10
|
+
received: number;
|
|
11
|
+
failed: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IEmailTrafficBucketSnapshot extends IEmailTrafficBucketCounts {
|
|
15
|
+
bucketStart: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@plugins.smartdata.Collection(() => getDb())
|
|
19
|
+
export class EmailTrafficBucketDoc extends plugins.smartdata.SmartDataDbDoc<
|
|
20
|
+
EmailTrafficBucketDoc,
|
|
21
|
+
EmailTrafficBucketDoc
|
|
22
|
+
> implements IEmailTrafficBucketSnapshot {
|
|
23
|
+
@plugins.smartdata.unI()
|
|
24
|
+
@plugins.smartdata.svDb()
|
|
25
|
+
public id!: string;
|
|
26
|
+
|
|
27
|
+
@plugins.smartdata.svDb()
|
|
28
|
+
public bucketStart!: number;
|
|
29
|
+
|
|
30
|
+
@plugins.smartdata.svDb()
|
|
31
|
+
public sent: number = 0;
|
|
32
|
+
|
|
33
|
+
@plugins.smartdata.svDb()
|
|
34
|
+
public received: number = 0;
|
|
35
|
+
|
|
36
|
+
@plugins.smartdata.svDb()
|
|
37
|
+
public failed: number = 0;
|
|
38
|
+
|
|
39
|
+
@plugins.smartdata.svDb()
|
|
40
|
+
public createdAt!: number;
|
|
41
|
+
|
|
42
|
+
@plugins.smartdata.svDb()
|
|
43
|
+
public updatedAt!: number;
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
super();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static async getNativeCollection() {
|
|
50
|
+
const smartdataCollection = (EmailTrafficBucketDoc as typeof EmailTrafficBucketDoc & {
|
|
51
|
+
collection: plugins.smartdata.SmartdataCollection<EmailTrafficBucketDoc>;
|
|
52
|
+
}).collection;
|
|
53
|
+
await smartdataCollection.init();
|
|
54
|
+
const probe = new EmailTrafficBucketDoc();
|
|
55
|
+
await smartdataCollection.markUniqueIndexes(probe.uniqueIndexes || []);
|
|
56
|
+
await smartdataCollection.createRegularIndexes(probe.regularIndexes || []);
|
|
57
|
+
return smartdataCollection.mongoDbCollection;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static validateSnapshot(snapshotArg: IEmailTrafficBucketSnapshot): void {
|
|
61
|
+
const counts = [snapshotArg.sent, snapshotArg.received, snapshotArg.failed];
|
|
62
|
+
if (
|
|
63
|
+
!Number.isSafeInteger(snapshotArg.bucketStart)
|
|
64
|
+
|| snapshotArg.bucketStart < 0
|
|
65
|
+
|| snapshotArg.bucketStart % 60_000 !== 0
|
|
66
|
+
|| counts.some((count) => !Number.isSafeInteger(count) || count < 0)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error('Invalid email traffic bucket snapshot');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Persist retry-safe absolute monotonic counters. */
|
|
73
|
+
public static async persistAbsolute(snapshotsArg: IEmailTrafficBucketSnapshot[]): Promise<void> {
|
|
74
|
+
if (snapshotsArg.length === 0) return;
|
|
75
|
+
if (snapshotsArg.length > 500) {
|
|
76
|
+
throw new Error('EmailTrafficBucketDoc.persistAbsolute accepts at most 500 buckets');
|
|
77
|
+
}
|
|
78
|
+
for (const snapshot of snapshotsArg) {
|
|
79
|
+
EmailTrafficBucketDoc.validateSnapshot(snapshot);
|
|
80
|
+
}
|
|
81
|
+
const collection = await EmailTrafficBucketDoc.getNativeCollection();
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const updatedAt = new Date(now).toISOString();
|
|
84
|
+
await collection.bulkWrite(
|
|
85
|
+
snapshotsArg.map((snapshot) => ({
|
|
86
|
+
updateOne: {
|
|
87
|
+
filter: { bucketStart: snapshot.bucketStart },
|
|
88
|
+
update: {
|
|
89
|
+
$max: {
|
|
90
|
+
sent: snapshot.sent,
|
|
91
|
+
received: snapshot.received,
|
|
92
|
+
failed: snapshot.failed,
|
|
93
|
+
},
|
|
94
|
+
$set: {
|
|
95
|
+
updatedAt: now,
|
|
96
|
+
_updatedAt: updatedAt,
|
|
97
|
+
},
|
|
98
|
+
$setOnInsert: {
|
|
99
|
+
id: `email-traffic-${snapshot.bucketStart}`,
|
|
100
|
+
bucketStart: snapshot.bucketStart,
|
|
101
|
+
createdAt: now,
|
|
102
|
+
_createdAt: updatedAt,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
upsert: true,
|
|
106
|
+
},
|
|
107
|
+
})),
|
|
108
|
+
{ ordered: false, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public static async loadSince(cutoffArg: number): Promise<IEmailTrafficBucketSnapshot[]> {
|
|
113
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
114
|
+
throw new Error('EmailTrafficBucketDoc.loadSince requires a valid cutoff');
|
|
115
|
+
}
|
|
116
|
+
const collection = await EmailTrafficBucketDoc.getNativeCollection();
|
|
117
|
+
const cursor = collection
|
|
118
|
+
.find(
|
|
119
|
+
{ bucketStart: { $gte: cutoffArg } },
|
|
120
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
121
|
+
)
|
|
122
|
+
.sort({ bucketStart: 1 });
|
|
123
|
+
try {
|
|
124
|
+
const rows = await cursor.toArray();
|
|
125
|
+
return rows.map((row) => ({
|
|
126
|
+
bucketStart: Number(row.bucketStart),
|
|
127
|
+
sent: Number(row.sent || 0),
|
|
128
|
+
received: Number(row.received || 0),
|
|
129
|
+
failed: Number(row.failed || 0),
|
|
130
|
+
}));
|
|
131
|
+
} finally {
|
|
132
|
+
await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public static async pruneBefore(cutoffArg: number): Promise<number> {
|
|
137
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
138
|
+
throw new Error('EmailTrafficBucketDoc.pruneBefore requires a valid cutoff');
|
|
139
|
+
}
|
|
140
|
+
const collection = await EmailTrafficBucketDoc.getNativeCollection();
|
|
141
|
+
let deletedCount = 0;
|
|
142
|
+
while (true) {
|
|
143
|
+
const rows = await collection
|
|
144
|
+
.find(
|
|
145
|
+
{ bucketStart: { $lt: cutoffArg } },
|
|
146
|
+
{ projection: { _id: 1 }, timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
147
|
+
)
|
|
148
|
+
.sort({ bucketStart: 1, _id: 1 })
|
|
149
|
+
.limit(500)
|
|
150
|
+
.toArray();
|
|
151
|
+
if (rows.length === 0) break;
|
|
152
|
+
const result = await collection.deleteMany(
|
|
153
|
+
{ _id: { $in: rows.map((row) => row._id) } },
|
|
154
|
+
{ timeoutMS: DB_OPERATION_TIMEOUT_MS },
|
|
155
|
+
);
|
|
156
|
+
deletedCount += result.deletedCount;
|
|
157
|
+
if (rows.length < 500) break;
|
|
158
|
+
}
|
|
159
|
+
return deletedCount;
|
|
160
|
+
}
|
|
161
|
+
}
|
package/ts/db/documents/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ export * from './classes.cached.ip.reputation.js';
|
|
|
4
4
|
export * from './classes.ip-intelligence.doc.js';
|
|
5
5
|
export * from './classes.security-block-rule.doc.js';
|
|
6
6
|
export * from './classes.security-policy-audit.doc.js';
|
|
7
|
+
export * from './classes.authentication-event.doc.js';
|
|
8
|
+
export * from './classes.email-traffic-bucket.doc.js';
|
|
7
9
|
|
|
8
10
|
// Config document classes
|
|
9
11
|
export * from './classes.route.doc.js';
|
|
@@ -256,6 +256,7 @@ export class AcceptedEmailSpool {
|
|
|
256
256
|
await this.notifyEmailQueuePersisted(cachedEmail, 'email-accepted-without-queue');
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
this.trackAcceptedInboundEmail(cachedEmail);
|
|
259
260
|
return {
|
|
260
261
|
accepted: true,
|
|
261
262
|
smtpCode: 250,
|
|
@@ -319,6 +320,7 @@ export class AcceptedEmailSpool {
|
|
|
319
320
|
await this.notifyEmailQueuePersisted(cachedEmail, 'raw-email-accepted-without-queue');
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
this.trackAcceptedInboundEmail(cachedEmail);
|
|
322
324
|
return {
|
|
323
325
|
accepted: true,
|
|
324
326
|
spoolItemId: cachedEmail.id,
|
|
@@ -480,6 +482,12 @@ export class AcceptedEmailSpool {
|
|
|
480
482
|
logger.log('warn', `Durable envelope ${cachedEmail.id}: ${failed.length}/${dispatchResult.results.length} non-store recipients failed dispatch: ${failed.map((result) => `${result.recipient}=${result.message || result.status}`).join('; ')}`);
|
|
481
483
|
}
|
|
482
484
|
}
|
|
485
|
+
this.trackAcceptedInboundEmail(cachedEmail);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private trackAcceptedInboundEmail(cachedEmailArg: CachedEmail): void {
|
|
489
|
+
if (cachedEmailArg.direction !== 'inbound') return;
|
|
490
|
+
this.dcRouterRef.metricsManager?.trackEmailReceived(cachedEmailArg.from);
|
|
483
491
|
}
|
|
484
492
|
|
|
485
493
|
/**
|