@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
|
@@ -32,7 +32,7 @@ var __runInitializers = (this && this.__runInitializers) || function (thisArg, i
|
|
|
32
32
|
}
|
|
33
33
|
return useValue ? value : void 0;
|
|
34
34
|
};
|
|
35
|
-
import { DeesElement,
|
|
35
|
+
import { DeesElement, html, customElement, css, state, cssManager } from '@design.estate/dees-element';
|
|
36
36
|
import * as appstate from '../../appstate.js';
|
|
37
37
|
import * as shared from '../shared/index.js';
|
|
38
38
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
|
@@ -54,6 +54,12 @@ let OpsViewEmails = (() => {
|
|
|
54
54
|
let _isLoading_decorators;
|
|
55
55
|
let _isLoading_initializers = [];
|
|
56
56
|
let _isLoading_extraInitializers = [];
|
|
57
|
+
let _traffic_decorators;
|
|
58
|
+
let _traffic_initializers = [];
|
|
59
|
+
let _traffic_extraInitializers = [];
|
|
60
|
+
let _searchQuery_decorators;
|
|
61
|
+
let _searchQuery_initializers = [];
|
|
62
|
+
let _searchQuery_extraInitializers = [];
|
|
57
63
|
var OpsViewEmails = class extends _classSuper {
|
|
58
64
|
static { _classThis = this; }
|
|
59
65
|
static {
|
|
@@ -62,10 +68,14 @@ let OpsViewEmails = (() => {
|
|
|
62
68
|
_selectedEmail_decorators = [state()];
|
|
63
69
|
_currentView_decorators = [state()];
|
|
64
70
|
_isLoading_decorators = [state()];
|
|
71
|
+
_traffic_decorators = [state()];
|
|
72
|
+
_searchQuery_decorators = [state()];
|
|
65
73
|
__esDecorate(this, null, _emails_decorators, { kind: "accessor", name: "emails", static: false, private: false, access: { has: obj => "emails" in obj, get: obj => obj.emails, set: (obj, value) => { obj.emails = value; } }, metadata: _metadata }, _emails_initializers, _emails_extraInitializers);
|
|
66
74
|
__esDecorate(this, null, _selectedEmail_decorators, { kind: "accessor", name: "selectedEmail", static: false, private: false, access: { has: obj => "selectedEmail" in obj, get: obj => obj.selectedEmail, set: (obj, value) => { obj.selectedEmail = value; } }, metadata: _metadata }, _selectedEmail_initializers, _selectedEmail_extraInitializers);
|
|
67
75
|
__esDecorate(this, null, _currentView_decorators, { kind: "accessor", name: "currentView", static: false, private: false, access: { has: obj => "currentView" in obj, get: obj => obj.currentView, set: (obj, value) => { obj.currentView = value; } }, metadata: _metadata }, _currentView_initializers, _currentView_extraInitializers);
|
|
68
76
|
__esDecorate(this, null, _isLoading_decorators, { kind: "accessor", name: "isLoading", static: false, private: false, access: { has: obj => "isLoading" in obj, get: obj => obj.isLoading, set: (obj, value) => { obj.isLoading = value; } }, metadata: _metadata }, _isLoading_initializers, _isLoading_extraInitializers);
|
|
77
|
+
__esDecorate(this, null, _traffic_decorators, { kind: "accessor", name: "traffic", static: false, private: false, access: { has: obj => "traffic" in obj, get: obj => obj.traffic, set: (obj, value) => { obj.traffic = value; } }, metadata: _metadata }, _traffic_initializers, _traffic_extraInitializers);
|
|
78
|
+
__esDecorate(this, null, _searchQuery_decorators, { kind: "accessor", name: "searchQuery", static: false, private: false, access: { has: obj => "searchQuery" in obj, get: obj => obj.searchQuery, set: (obj, value) => { obj.searchQuery = value; } }, metadata: _metadata }, _searchQuery_initializers, _searchQuery_extraInitializers);
|
|
69
79
|
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
|
70
80
|
OpsViewEmails = _classThis = _classDescriptor.value;
|
|
71
81
|
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
@@ -82,13 +92,22 @@ let OpsViewEmails = (() => {
|
|
|
82
92
|
#isLoading_accessor_storage = (__runInitializers(this, _currentView_extraInitializers), __runInitializers(this, _isLoading_initializers, false));
|
|
83
93
|
get isLoading() { return this.#isLoading_accessor_storage; }
|
|
84
94
|
set isLoading(value) { this.#isLoading_accessor_storage = value; }
|
|
85
|
-
|
|
95
|
+
#traffic_accessor_storage = (__runInitializers(this, _isLoading_extraInitializers), __runInitializers(this, _traffic_initializers, null));
|
|
96
|
+
get traffic() { return this.#traffic_accessor_storage; }
|
|
97
|
+
set traffic(value) { this.#traffic_accessor_storage = value; }
|
|
98
|
+
#searchQuery_accessor_storage = (__runInitializers(this, _traffic_extraInitializers), __runInitializers(this, _searchQuery_initializers, ''));
|
|
99
|
+
get searchQuery() { return this.#searchQuery_accessor_storage; }
|
|
100
|
+
set searchQuery(value) { this.#searchQuery_accessor_storage = value; }
|
|
101
|
+
stateSubscription = __runInitializers(this, _searchQuery_extraInitializers);
|
|
86
102
|
lastEmailOpsUpdate = 0;
|
|
87
103
|
detailRequestGeneration = 0;
|
|
104
|
+
searchDebounceTimer;
|
|
88
105
|
async connectedCallback() {
|
|
89
106
|
await super.connectedCallback();
|
|
90
107
|
this.stateSubscription = appstate.emailOpsStatePart.select().subscribe((state) => {
|
|
91
108
|
this.emails = state.emails;
|
|
109
|
+
this.traffic = state.traffic;
|
|
110
|
+
this.searchQuery = state.searchQuery;
|
|
92
111
|
const hasNewSnapshot = state.lastUpdated > this.lastEmailOpsUpdate;
|
|
93
112
|
this.lastEmailOpsUpdate = Math.max(this.lastEmailOpsUpdate, state.lastUpdated);
|
|
94
113
|
if (hasNewSnapshot && this.selectedEmail) {
|
|
@@ -104,6 +123,10 @@ let OpsViewEmails = (() => {
|
|
|
104
123
|
if (this.stateSubscription) {
|
|
105
124
|
this.stateSubscription.unsubscribe();
|
|
106
125
|
}
|
|
126
|
+
if (this.searchDebounceTimer !== undefined) {
|
|
127
|
+
window.clearTimeout(this.searchDebounceTimer);
|
|
128
|
+
this.searchDebounceTimer = undefined;
|
|
129
|
+
}
|
|
107
130
|
}
|
|
108
131
|
static styles = [
|
|
109
132
|
cssManager.defaultStyles,
|
|
@@ -117,6 +140,10 @@ let OpsViewEmails = (() => {
|
|
|
117
140
|
.viewContainer {
|
|
118
141
|
height: 100%;
|
|
119
142
|
}
|
|
143
|
+
|
|
144
|
+
dees-chart-area[slot='above-table'] {
|
|
145
|
+
min-height: 360px;
|
|
146
|
+
}
|
|
120
147
|
`,
|
|
121
148
|
];
|
|
122
149
|
render() {
|
|
@@ -133,8 +160,24 @@ let OpsViewEmails = (() => {
|
|
|
133
160
|
: html `
|
|
134
161
|
<sz-mta-list-view
|
|
135
162
|
.emails=${this.emails}
|
|
163
|
+
.searchQuery=${this.searchQuery}
|
|
164
|
+
.stickyFilterBar=${true}
|
|
165
|
+
@search-change=${this.handleSearchChange}
|
|
136
166
|
@email-click=${this.handleEmailClick}
|
|
137
|
-
|
|
167
|
+
>
|
|
168
|
+
${this.traffic ? html `
|
|
169
|
+
<dees-chart-area
|
|
170
|
+
slot="above-table"
|
|
171
|
+
.label=${this.traffic.label}
|
|
172
|
+
.series=${this.getEmailTrafficSeries()}
|
|
173
|
+
.realtimeMode=${true}
|
|
174
|
+
.rollingWindow=${this.traffic.durationMs}
|
|
175
|
+
.legendStats=${['latest']}
|
|
176
|
+
.chartLines=${[]}
|
|
177
|
+
.yAxisFormatter=${(valueArg) => `${valueArg}`}
|
|
178
|
+
></dees-chart-area>
|
|
179
|
+
` : html ``}
|
|
180
|
+
</sz-mta-list-view>
|
|
138
181
|
`}
|
|
139
182
|
</div>
|
|
140
183
|
`;
|
|
@@ -162,6 +205,37 @@ let OpsViewEmails = (() => {
|
|
|
162
205
|
async handleEmailClick(e) {
|
|
163
206
|
await this.refreshEmailDetail(e.detail.id, true);
|
|
164
207
|
}
|
|
208
|
+
handleSearchChange(eventArg) {
|
|
209
|
+
const searchQuery = appstate.setActiveEmailSearchQuery(eventArg.detail.searchQuery);
|
|
210
|
+
if (this.searchDebounceTimer !== undefined) {
|
|
211
|
+
window.clearTimeout(this.searchDebounceTimer);
|
|
212
|
+
}
|
|
213
|
+
this.searchDebounceTimer = window.setTimeout(() => {
|
|
214
|
+
this.searchDebounceTimer = undefined;
|
|
215
|
+
void appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, { searchQuery });
|
|
216
|
+
}, 300);
|
|
217
|
+
}
|
|
218
|
+
getEmailTrafficSeries() {
|
|
219
|
+
if (!this.traffic)
|
|
220
|
+
return [];
|
|
221
|
+
return [
|
|
222
|
+
{
|
|
223
|
+
name: 'Sent',
|
|
224
|
+
color: '#22c55e',
|
|
225
|
+
data: this.traffic.sent.map((point) => ({ x: point.timestamp, y: point.value })),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'Received',
|
|
229
|
+
color: '#3b82f6',
|
|
230
|
+
data: this.traffic.received.map((point) => ({ x: point.timestamp, y: point.value })),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'Failed',
|
|
234
|
+
color: '#ef4444',
|
|
235
|
+
data: this.traffic.failed.map((point) => ({ x: point.timestamp, y: point.value })),
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
}
|
|
165
239
|
handleBack() {
|
|
166
240
|
this.detailRequestGeneration++;
|
|
167
241
|
this.selectedEmail = null;
|
|
@@ -174,4 +248,4 @@ let OpsViewEmails = (() => {
|
|
|
174
248
|
return OpsViewEmails = _classThis;
|
|
175
249
|
})();
|
|
176
250
|
export { OpsViewEmails };
|
|
177
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
251
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3BzLXZpZXctZW1haWxzLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vdHNfd2ViL2VsZW1lbnRzL2VtYWlsL29wcy12aWV3LWVtYWlscy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7O0FBQUEsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsYUFBYSxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsVUFBVSxFQUFFLE1BQU0sNkJBQTZCLENBQUM7QUFDdkcsT0FBTyxLQUFLLFFBQVEsTUFBTSxtQkFBbUIsQ0FBQztBQUM5QyxPQUFPLEtBQUssTUFBTSxNQUFNLG9CQUFvQixDQUFDO0FBQzdDLE9BQU8sS0FBSyxVQUFVLE1BQU0sc0NBQXNDLENBQUM7SUFTdEQsYUFBYTs0QkFEekIsYUFBYSxDQUFDLGlCQUFpQixDQUFDOzs7O3NCQUNFLFdBQVc7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7NkJBQW5CLFNBQVEsV0FBVzs7OztrQ0FDM0MsS0FBSyxFQUFFO3lDQUdQLEtBQUssRUFBRTt1Q0FHUCxLQUFLLEVBQUU7cUNBR1AsS0FBSyxFQUFFO21DQUdQLEtBQUssRUFBRTt1Q0FHUCxLQUFLLEVBQUU7WUFkUix1S0FBUyxNQUFNLDZCQUFOLE1BQU0sdUZBQW9DO1lBR25ELDRMQUFTLGFBQWEsNkJBQWIsYUFBYSxxR0FBaUQ7WUFHdkUsc0xBQVMsV0FBVyw2QkFBWCxXQUFXLGlHQUE2QjtZQUdqRCxnTEFBUyxTQUFTLDZCQUFULFNBQVMsNkZBQVM7WUFHM0IsMEtBQVMsT0FBTyw2QkFBUCxPQUFPLHlGQUFxRDtZQUdyRSxzTEFBUyxXQUFXLDZCQUFYLFdBQVcsaUdBQU07WUFqQjVCLDZLQStLQzs7OztRQTdLQyx5RUFBZ0QsRUFBRSxFQUFDO1FBQW5ELElBQVMsTUFBTSw0Q0FBb0M7UUFBbkQsSUFBUyxNQUFNLGtEQUFvQztRQUduRCw0SUFBa0UsSUFBSSxHQUFDO1FBQXZFLElB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
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/dcrouter",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.3.0",
|
|
5
5
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"@git.zone/tsdeno": "^1.7.0",
|
|
21
21
|
"@git.zone/tsdocker": "^3.1.0",
|
|
22
22
|
"@git.zone/tsrun": "^2.0.5",
|
|
23
|
-
"@git.zone/tstest": "^3.6.
|
|
23
|
+
"@git.zone/tstest": "^3.6.7",
|
|
24
24
|
"@git.zone/tswatch": "^3.3.5",
|
|
25
25
|
"@types/node": "26.1.1",
|
|
26
26
|
"@types/web-push": "3.6.4"
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"@push.rocks/smartunique": "^3.0.10",
|
|
64
64
|
"@push.rocks/smartvpn": "1.22.0",
|
|
65
65
|
"@push.rocks/taskbuffer": "^8.0.2",
|
|
66
|
-
"@serve.zone/catalog": "^2.
|
|
66
|
+
"@serve.zone/catalog": "^2.20.0",
|
|
67
67
|
"@serve.zone/interfaces": "^19.2.0",
|
|
68
68
|
"@serve.zone/remoteingress": "^5.0.3",
|
|
69
69
|
"@tsclass/tsclass": "^9.5.1",
|
package/readme.plan.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Platform refactoring program
|
|
2
|
+
|
|
3
|
+
Status: approved 2026-07-21, derived from that day's incident ledger (deploy-verification crashes, unbounded scans, DNS/IP logic duplication, version-skew scares). Upstream foundations are RELEASED; consumer adoption and the dcrouter-internal refactors are sequenced below — do not start them mid-wave, fold them into the next natural release of each surface.
|
|
4
|
+
|
|
5
|
+
## Shipped upstream (adopt on next dependency bump)
|
|
6
|
+
|
|
7
|
+
1. **Typed-request error boundary — already existed.** @api.global/typedrequest ≥ 3.3.2 sanitizes every handler/middleware throw into a structured error envelope (no socket destruction). Action: ensure every deployed consumer (cloudly, dcrouter, coreflow, onebox) resolves ≥ 3.3.2 and remove any local try/catch layers that only re-implement it.
|
|
8
|
+
2. **Canonical IP helpers — @push.rocks/smartnetwork 4.9.0.** `isPublicIpAddress`, `toReverseDnsName` (non-throwing), `expandIpv6`, alongside the existing `getIpVersion`/`isValidIpv4`/`isValidIpv6`. Adoption: dcrouter ts/email/classes.mail-dns-sync.ts drops its private `toReverseDnsName`/`expandIpv6` (and the `Invalid IP address:` throw); cloudly's `isPublicDeploymentAddress` (classes.deploymentoperationmanager.ts) delegates to `isPublicIpAddress` (IPv4 semantics are byte-identical; IPv6 is 2000::/3 minus 2001:db8::/32).
|
|
9
|
+
3. **Cursor pagination as the org default — @push.rocks/smartdata 7.4.0.** `SmartDataDbDoc.getPagedInstances({ filter, sortField, sortDirection, uniqueField, limit ≤ 1000, cursor, withTotal })` — seek pagination with a unique tiebreaker, never materializes the full set. Adoption: OpsConfigEventDoc.findFiltered becomes a thin wrapper; every future list surface uses it. Review rule: a list API without a cursor is a defect.
|
|
10
|
+
4. **Binary provenance — @git.zone/tsrust 1.7.0.** Every built binary gets a JSON trailer (project, version, git commit, target, builtAt, tsrust version); `tsrust inspect <binary>` prints it. Adoption: bump tsrust across the Rust-bridge repos (smartdb, smartproxy, smartmta, smartnetwork, coretraffic, remoteingress) on their next releases; deployment verification can then assert binary identity instead of guessing (see the strings-on-x86 pitfall in smartdb readme.hints.md).
|
|
11
|
+
|
|
12
|
+
## Sequenced (do NOT start mid-wave)
|
|
13
|
+
|
|
14
|
+
5. **Ops-handler base class (dcrouter).** 25 handler files carry 11 copies of the requireAuth wrapper and 49 hand-written "manager not initialized" guards. Introduce a base with `registerMethod(name, scope, handlerFn)` doing auth, manager guard, error boundary, and realtime invalidation; migrate handlers opportunistically (new handlers must use it; old ones migrate when touched).
|
|
15
|
+
6. **Composition-root service table (dcrouter).** classes.dcrouter.ts (2,326 lines) has 20 near-identical addService blocks with drift (withRetry presence, error logging). Replace with a declarative registration table; behavior-preserving.
|
|
16
|
+
7. **Mail-detail contract dedup (interfaces + catalog + dcrouter).** IEmailDetail and friends exist twice by convention ("matches @serve.zone/catalog"). Move the contract into @serve.zone/interfaces; catalog and dcrouter consume it. Sequence AFTER the in-flight mail-detail wave (countdown, verdicts, transfer stats) fully lands — one migration wave, no compatibility layer.
|
|
17
|
+
8. **Version-skew hardening (conventions).** (a) Tolerant-reader parsing for persisted operation/evidence/state documents that cross release boundaries; (b) version handshake wherever a live process or socket is reused across upgrades (LocalSmartDb engine reuse — smartdb plan Track 6; gateway-client policyGeneration is the precedent).
|
|
18
|
+
|
|
19
|
+
## Non-goals
|
|
20
|
+
|
|
21
|
+
- No big-bang rewrites: every item lands either additive-upstream or inside a surface's next natural release.
|
|
22
|
+
- No compatibility layers (org rule): consumers move to the canonical helpers outright when their wave touches the code.
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
|
|
2
2
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
3
3
|
|
|
4
4
|
const TTL = plugins.smartdata.smartdataTtlValues;
|
|
5
|
+
const DB_OPERATION_TIMEOUT_MS = 5_000;
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Email status in the cache
|
|
@@ -14,6 +15,19 @@ export type TCachedEmailStatus = 'pending' | 'processing' | 'queued' | 'delivere
|
|
|
14
15
|
*/
|
|
15
16
|
export type TCachedEmailDirection = 'inbound' | 'outbound';
|
|
16
17
|
|
|
18
|
+
export interface ICachedEmailListFilter {
|
|
19
|
+
direction?: TCachedEmailDirection;
|
|
20
|
+
recipientDomain?: string;
|
|
21
|
+
search?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ICachedEmailTrafficBucket {
|
|
25
|
+
bucketStart: number;
|
|
26
|
+
sent: number;
|
|
27
|
+
received: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export type TCachedEmailSmtpDeliveryPhase =
|
|
18
32
|
| 'connect'
|
|
19
33
|
| 'greeting'
|
|
@@ -222,6 +236,7 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
222
236
|
/**
|
|
223
237
|
* Epoch ms when the message was accepted (for newest-first listings)
|
|
224
238
|
*/
|
|
239
|
+
@plugins.smartdata.index()
|
|
225
240
|
@plugins.smartdata.svDb()
|
|
226
241
|
public acceptedAt: number = 0;
|
|
227
242
|
|
|
@@ -366,21 +381,140 @@ export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<Cache
|
|
|
366
381
|
}
|
|
367
382
|
}
|
|
368
383
|
|
|
384
|
+
private static getNativeCollection() {
|
|
385
|
+
return getDb().mongoDb.collection('CachedEmail');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private static buildListQuery(filterArg: ICachedEmailListFilter): Record<string, any> {
|
|
389
|
+
const query: Record<string, any> = {};
|
|
390
|
+
if (filterArg.direction) {
|
|
391
|
+
query.direction = filterArg.direction;
|
|
392
|
+
}
|
|
393
|
+
if (filterArg.recipientDomain) {
|
|
394
|
+
query.recipientDomains = filterArg.recipientDomain.toLowerCase();
|
|
395
|
+
}
|
|
396
|
+
if (filterArg.search) {
|
|
397
|
+
if (typeof filterArg.search !== 'string' || filterArg.search.length > 200) {
|
|
398
|
+
throw new Error('Invalid CachedEmail search');
|
|
399
|
+
}
|
|
400
|
+
const escapedSearch = filterArg.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
401
|
+
query.$or = [
|
|
402
|
+
{ from: { $regex: escapedSearch, $options: 'i' } },
|
|
403
|
+
{ to: { $regex: escapedSearch, $options: 'i' } },
|
|
404
|
+
{ subject: { $regex: escapedSearch, $options: 'i' } },
|
|
405
|
+
{ messageId: { $regex: escapedSearch, $options: 'i' } },
|
|
406
|
+
];
|
|
407
|
+
}
|
|
408
|
+
return query;
|
|
409
|
+
}
|
|
410
|
+
|
|
369
411
|
/**
|
|
370
412
|
* Find the most recently accepted emails, newest first.
|
|
371
413
|
*/
|
|
372
414
|
public static async findRecent(
|
|
373
415
|
limit = 200,
|
|
374
|
-
filter:
|
|
416
|
+
filter: ICachedEmailListFilter = {},
|
|
375
417
|
): Promise<CachedEmail[]> {
|
|
376
|
-
const query
|
|
377
|
-
|
|
378
|
-
|
|
418
|
+
const query = CachedEmail.buildListQuery(filter);
|
|
419
|
+
return await CachedEmail.findLimited(query, limit, { acceptedAt: -1 });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
public static async findOldestAcceptedAt(
|
|
423
|
+
filterArg: ICachedEmailListFilter,
|
|
424
|
+
cutoffArg: number,
|
|
425
|
+
): Promise<number | null> {
|
|
426
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
427
|
+
throw new Error('CachedEmail.findOldestAcceptedAt requires a valid cutoff');
|
|
379
428
|
}
|
|
380
|
-
|
|
381
|
-
|
|
429
|
+
const query = CachedEmail.buildListQuery(filterArg);
|
|
430
|
+
query.acceptedAt = { $gte: cutoffArg };
|
|
431
|
+
const collection = await CachedEmail.getNativeCollection();
|
|
432
|
+
const row = await collection.findOne(
|
|
433
|
+
query,
|
|
434
|
+
{
|
|
435
|
+
projection: { acceptedAt: 1 },
|
|
436
|
+
sort: { acceptedAt: 1 },
|
|
437
|
+
timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
const acceptedAt = Number(row?.acceptedAt);
|
|
441
|
+
return Number.isSafeInteger(acceptedAt) && acceptedAt >= 0 ? acceptedAt : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
public static async aggregateEmailLogTraffic(
|
|
445
|
+
filterArg: ICachedEmailListFilter,
|
|
446
|
+
windowStartArg: number,
|
|
447
|
+
windowEndArg: number,
|
|
448
|
+
bucketSizeMsArg: number,
|
|
449
|
+
): Promise<ICachedEmailTrafficBucket[]> {
|
|
450
|
+
if (
|
|
451
|
+
!Number.isSafeInteger(windowStartArg)
|
|
452
|
+
|| !Number.isSafeInteger(windowEndArg)
|
|
453
|
+
|| !Number.isSafeInteger(bucketSizeMsArg)
|
|
454
|
+
|| windowStartArg < 0
|
|
455
|
+
|| windowEndArg < windowStartArg
|
|
456
|
+
|| bucketSizeMsArg <= 0
|
|
457
|
+
) {
|
|
458
|
+
throw new Error('CachedEmail.aggregateEmailLogTraffic received an invalid window');
|
|
382
459
|
}
|
|
383
|
-
|
|
460
|
+
const query = CachedEmail.buildListQuery(filterArg);
|
|
461
|
+
query.acceptedAt = { $gte: windowStartArg, $lte: windowEndArg };
|
|
462
|
+
const collection = await CachedEmail.getNativeCollection();
|
|
463
|
+
const cursor = collection.find(
|
|
464
|
+
query,
|
|
465
|
+
{
|
|
466
|
+
projection: { acceptedAt: 1, direction: 1, status: 1 },
|
|
467
|
+
batchSize: 1000,
|
|
468
|
+
timeoutMS: DB_OPERATION_TIMEOUT_MS,
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
try {
|
|
472
|
+
const buckets = new Map<number, ICachedEmailTrafficBucket>();
|
|
473
|
+
while (await cursor.hasNext()) {
|
|
474
|
+
const row = await cursor.next();
|
|
475
|
+
if (!row) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const acceptedAt = Number(row.acceptedAt);
|
|
479
|
+
if (!Number.isSafeInteger(acceptedAt)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const bucketStart = Math.floor(acceptedAt / bucketSizeMsArg) * bucketSizeMsArg;
|
|
483
|
+
let bucket = buckets.get(bucketStart);
|
|
484
|
+
if (!bucket) {
|
|
485
|
+
bucket = { bucketStart, sent: 0, received: 0, failed: 0 };
|
|
486
|
+
buckets.set(bucketStart, bucket);
|
|
487
|
+
}
|
|
488
|
+
if (row.status === 'failed' || row.status === 'rejected') {
|
|
489
|
+
bucket.failed++;
|
|
490
|
+
} else if (row.direction === 'outbound') {
|
|
491
|
+
bucket.sent++;
|
|
492
|
+
} else if (row.direction === 'inbound') {
|
|
493
|
+
bucket.received++;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return [...buckets.values()].sort((firstArg, secondArg) => (
|
|
497
|
+
firstArg.bucketStart - secondArg.bucketStart
|
|
498
|
+
));
|
|
499
|
+
} finally {
|
|
500
|
+
await cursor.close({ timeoutMS: DB_OPERATION_TIMEOUT_MS });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
public static async findRecentSecurityCandidates(
|
|
505
|
+
limitArg: number,
|
|
506
|
+
cutoffArg: number,
|
|
507
|
+
): Promise<CachedEmail[]> {
|
|
508
|
+
const safeLimit = Math.min(Math.max(Math.floor(limitArg), 1), 1000);
|
|
509
|
+
if (!Number.isSafeInteger(cutoffArg) || cutoffArg < 0) {
|
|
510
|
+
throw new Error('CachedEmail.findRecentSecurityCandidates requires a valid cutoff');
|
|
511
|
+
}
|
|
512
|
+
return await CachedEmail.findLimited({
|
|
513
|
+
direction: 'inbound',
|
|
514
|
+
status: { $in: ['flagged', 'rejected', 'deferred'] },
|
|
515
|
+
inboundSecurityResults: { $exists: true, $ne: '' },
|
|
516
|
+
acceptedAt: { $gte: cutoffArg },
|
|
517
|
+
}, safeLimit, { acceptedAt: -1 });
|
|
384
518
|
}
|
|
385
519
|
|
|
386
520
|
/**
|
|
@@ -18,58 +18,11 @@ import type {
|
|
|
18
18
|
UnifiedEmailServer,
|
|
19
19
|
} from '@push.rocks/smartmta';
|
|
20
20
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
doubts: string[];
|
|
27
|
-
dmarcReject: boolean;
|
|
28
|
-
dmarcDomain?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function evaluateInboundAcceptance(securityResults: any): IInboundAcceptanceEvaluation {
|
|
32
|
-
const doubts: string[] = [];
|
|
33
|
-
let dmarcReject = false;
|
|
34
|
-
let dmarcDomain: string | undefined;
|
|
35
|
-
|
|
36
|
-
// Infrastructure failures (temperror, resolver problems) are OUR problem,
|
|
37
|
-
// not evidence against the sender — they never produce doubts.
|
|
38
|
-
const spfResult = String(securityResults?.spf?.result || '').toLowerCase();
|
|
39
|
-
if (spfResult === 'fail' || spfResult === 'softfail') {
|
|
40
|
-
doubts.push(`SPF ${spfResult} for ${securityResults?.spf?.domain || 'sender domain'}`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// The verdict bridge emits a placeholder entry (status 'none') when a
|
|
44
|
-
// message carries no DKIM signature at all; only real failed signatures
|
|
45
|
-
// count as doubts.
|
|
46
|
-
const dkimSignatures = (Array.isArray(securityResults?.dkim) ? securityResults.dkim : [])
|
|
47
|
-
.filter((signature: any) => String(signature?.status || '').toLowerCase() !== 'none');
|
|
48
|
-
if (dkimSignatures.length > 0 && !dkimSignatures.some((signature: any) => signature?.is_valid)) {
|
|
49
|
-
doubts.push('DKIM signature(s) present but none verified');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// DMARC only matters when the sender domain publishes an enforcing policy;
|
|
53
|
-
// absent records surface as policy 'none' and are not doubts.
|
|
54
|
-
const dmarc = securityResults?.dmarc;
|
|
55
|
-
if (dmarc && dmarc.passed === false) {
|
|
56
|
-
const policy = String(dmarc.policy || '').toLowerCase();
|
|
57
|
-
const action = String(dmarc.action || '').toLowerCase();
|
|
58
|
-
if (policy === 'reject' || policy === 'quarantine' || action === 'reject' || action === 'quarantine') {
|
|
59
|
-
dmarcDomain = dmarc.domain || undefined;
|
|
60
|
-
if (action === 'reject' || policy === 'reject') {
|
|
61
|
-
dmarcReject = true;
|
|
62
|
-
}
|
|
63
|
-
doubts.push(`DMARC failed for ${dmarc.domain || 'sender domain'} (policy ${dmarc.policy || 'unknown'})`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
status: doubts.length > 0 ? 'flagged' : 'accepted',
|
|
68
|
-
doubts,
|
|
69
|
-
dmarcReject,
|
|
70
|
-
dmarcDomain,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
21
|
+
import { evaluateInboundAcceptance } from './helpers.inbound-security.js';
|
|
22
|
+
export {
|
|
23
|
+
evaluateInboundAcceptance,
|
|
24
|
+
type IInboundAcceptanceEvaluation,
|
|
25
|
+
} from './helpers.inbound-security.js';
|
|
73
26
|
|
|
74
27
|
export const DCROUTER_CACHE_ID_HEADER = 'X-Dcrouter-Cached-Email-Id';
|
|
75
28
|
const ACCEPTED_EMAIL_SPOOL_INTERVAL_MS = 60_000;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IEmail,
|
|
3
|
+
IEmailLogTraffic,
|
|
4
|
+
TEmailLogTrafficWindow,
|
|
5
|
+
} from '../../ts_interfaces/requests/email-ops.js';
|
|
6
|
+
|
|
7
|
+
const MINUTE_MS = 60_000;
|
|
8
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
9
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
10
|
+
export const EMAIL_LOG_RETENTION_MS = 30 * DAY_MS;
|
|
11
|
+
export const EMAIL_LOG_SEARCH_MAX_LENGTH = 200;
|
|
12
|
+
|
|
13
|
+
export interface IEmailLogTrafficWindowDescriptor {
|
|
14
|
+
window: TEmailLogTrafficWindow;
|
|
15
|
+
label: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
bucketSizeMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IEmailLogSparseTrafficBucket {
|
|
21
|
+
bucketStart: number;
|
|
22
|
+
sent: number;
|
|
23
|
+
received: number;
|
|
24
|
+
failed: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const trafficWindows: IEmailLogTrafficWindowDescriptor[] = [
|
|
28
|
+
{ window: '24h', label: 'Email Traffic — Last 24 Hours', durationMs: DAY_MS, bucketSizeMs: MINUTE_MS },
|
|
29
|
+
{ window: '7d', label: 'Email Traffic — Last 7 Days', durationMs: 7 * DAY_MS, bucketSizeMs: 15 * MINUTE_MS },
|
|
30
|
+
{ window: '14d', label: 'Email Traffic — Last 14 Days', durationMs: 14 * DAY_MS, bucketSizeMs: 30 * MINUTE_MS },
|
|
31
|
+
{ window: '30d', label: 'Email Traffic — Last 30 Days', durationMs: 30 * DAY_MS, bucketSizeMs: HOUR_MS },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function normalizeEmailLogSearch(searchArg: unknown): string {
|
|
35
|
+
if (searchArg === undefined || searchArg === null) return '';
|
|
36
|
+
if (typeof searchArg !== 'string') {
|
|
37
|
+
throw new Error('Email log search must be a string');
|
|
38
|
+
}
|
|
39
|
+
const search = searchArg.trim();
|
|
40
|
+
if (search.length > EMAIL_LOG_SEARCH_MAX_LENGTH) {
|
|
41
|
+
throw new Error(`Email log search accepts at most ${EMAIL_LOG_SEARCH_MAX_LENGTH} characters`);
|
|
42
|
+
}
|
|
43
|
+
return search;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function selectEmailLogTrafficWindow(
|
|
47
|
+
searchArg: string,
|
|
48
|
+
oldestMatchAtArg: number | null,
|
|
49
|
+
nowArg = Date.now(),
|
|
50
|
+
): IEmailLogTrafficWindowDescriptor {
|
|
51
|
+
if (!searchArg || oldestMatchAtArg === null || !Number.isFinite(oldestMatchAtArg)) {
|
|
52
|
+
return trafficWindows[0];
|
|
53
|
+
}
|
|
54
|
+
return trafficWindows.find((window) => {
|
|
55
|
+
const lastBucketStart = Math.floor(nowArg / window.bucketSizeMs) * window.bucketSizeMs;
|
|
56
|
+
const windowStart = lastBucketStart - window.durationMs + window.bucketSizeMs;
|
|
57
|
+
return oldestMatchAtArg >= windowStart;
|
|
58
|
+
}) || trafficWindows.at(-1)!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildEmailLogTraffic(
|
|
62
|
+
descriptorArg: IEmailLogTrafficWindowDescriptor,
|
|
63
|
+
sparseBucketsArg: IEmailLogSparseTrafficBucket[],
|
|
64
|
+
nowArg = Date.now(),
|
|
65
|
+
): IEmailLogTraffic {
|
|
66
|
+
const lastBucketStart = Math.floor(nowArg / descriptorArg.bucketSizeMs) * descriptorArg.bucketSizeMs;
|
|
67
|
+
const windowStart = lastBucketStart - descriptorArg.durationMs + descriptorArg.bucketSizeMs;
|
|
68
|
+
const bucketsByStart = new Map(
|
|
69
|
+
sparseBucketsArg.map((bucket) => [bucket.bucketStart, bucket]),
|
|
70
|
+
);
|
|
71
|
+
const sent: IEmailLogTraffic['sent'] = [];
|
|
72
|
+
const received: IEmailLogTraffic['received'] = [];
|
|
73
|
+
const failed: IEmailLogTraffic['failed'] = [];
|
|
74
|
+
for (
|
|
75
|
+
let timestamp = windowStart;
|
|
76
|
+
timestamp <= lastBucketStart;
|
|
77
|
+
timestamp += descriptorArg.bucketSizeMs
|
|
78
|
+
) {
|
|
79
|
+
const bucket = bucketsByStart.get(timestamp);
|
|
80
|
+
sent.push({ timestamp, value: Math.max(0, Number(bucket?.sent || 0)) });
|
|
81
|
+
received.push({ timestamp, value: Math.max(0, Number(bucket?.received || 0)) });
|
|
82
|
+
failed.push({ timestamp, value: Math.max(0, Number(bucket?.failed || 0)) });
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
...descriptorArg,
|
|
86
|
+
windowStart,
|
|
87
|
+
windowEnd: nowArg,
|
|
88
|
+
sent,
|
|
89
|
+
received,
|
|
90
|
+
failed,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function aggregateEmailLogTrafficInMemory(
|
|
95
|
+
emailsArg: IEmail[],
|
|
96
|
+
descriptorArg: IEmailLogTrafficWindowDescriptor,
|
|
97
|
+
nowArg = Date.now(),
|
|
98
|
+
): IEmailLogSparseTrafficBucket[] {
|
|
99
|
+
const lastBucketStart = Math.floor(nowArg / descriptorArg.bucketSizeMs) * descriptorArg.bucketSizeMs;
|
|
100
|
+
const windowStart = lastBucketStart - descriptorArg.durationMs + descriptorArg.bucketSizeMs;
|
|
101
|
+
const buckets = new Map<number, IEmailLogSparseTrafficBucket>();
|
|
102
|
+
for (const email of emailsArg) {
|
|
103
|
+
const timestamp = new Date(email.timestamp).getTime();
|
|
104
|
+
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > nowArg) continue;
|
|
105
|
+
const bucketStart = Math.floor(timestamp / descriptorArg.bucketSizeMs) * descriptorArg.bucketSizeMs;
|
|
106
|
+
const bucket = buckets.get(bucketStart) || { bucketStart, sent: 0, received: 0, failed: 0 };
|
|
107
|
+
if (email.status === 'bounced' || email.status === 'rejected') {
|
|
108
|
+
bucket.failed++;
|
|
109
|
+
} else if (email.direction === 'outbound') {
|
|
110
|
+
bucket.sent++;
|
|
111
|
+
} else {
|
|
112
|
+
bucket.received++;
|
|
113
|
+
}
|
|
114
|
+
buckets.set(bucketStart, bucket);
|
|
115
|
+
}
|
|
116
|
+
return [...buckets.values()].sort((a, b) => a.bucketStart - b.bucketStart);
|
|
117
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Inbound authentication verdict evaluation shared by SMTP acceptance and ops reporting. */
|
|
2
|
+
export interface IInboundAcceptanceEvaluation {
|
|
3
|
+
status: 'accepted' | 'flagged';
|
|
4
|
+
doubts: string[];
|
|
5
|
+
dmarcReject: boolean;
|
|
6
|
+
dmarcDomain?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function evaluateInboundAcceptance(securityResults: any): IInboundAcceptanceEvaluation {
|
|
10
|
+
const doubts: string[] = [];
|
|
11
|
+
let dmarcReject = false;
|
|
12
|
+
let dmarcDomain: string | undefined;
|
|
13
|
+
|
|
14
|
+
// Infrastructure failures are our problem, not evidence against the sender.
|
|
15
|
+
const spfResult = String(securityResults?.spf?.result || '').toLowerCase();
|
|
16
|
+
if (spfResult === 'fail' || spfResult === 'softfail') {
|
|
17
|
+
doubts.push(`SPF ${spfResult} for ${securityResults?.spf?.domain || 'sender domain'}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Ignore the placeholder emitted when no DKIM signature exists.
|
|
21
|
+
const dkimSignatures = (Array.isArray(securityResults?.dkim) ? securityResults.dkim : [])
|
|
22
|
+
.filter((signature: any) => String(signature?.status || '').toLowerCase() !== 'none');
|
|
23
|
+
if (dkimSignatures.length > 0 && !dkimSignatures.some((signature: any) => signature?.is_valid)) {
|
|
24
|
+
doubts.push('DKIM signature(s) present but none verified');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Only enforcing DMARC policies produce a security finding.
|
|
28
|
+
const dmarc = securityResults?.dmarc;
|
|
29
|
+
if (dmarc && dmarc.passed === false) {
|
|
30
|
+
const policy = String(dmarc.policy || '').toLowerCase();
|
|
31
|
+
const action = String(dmarc.action || '').toLowerCase();
|
|
32
|
+
if (policy === 'reject' || policy === 'quarantine' || action === 'reject' || action === 'quarantine') {
|
|
33
|
+
dmarcDomain = dmarc.domain || undefined;
|
|
34
|
+
if (action === 'reject' || policy === 'reject') {
|
|
35
|
+
dmarcReject = true;
|
|
36
|
+
}
|
|
37
|
+
doubts.push(`DMARC failed for ${dmarc.domain || 'sender domain'} (policy ${dmarc.policy || 'unknown'})`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
status: doubts.length > 0 ? 'flagged' : 'accepted',
|
|
42
|
+
doubts,
|
|
43
|
+
dmarcReject,
|
|
44
|
+
dmarcDomain,
|
|
45
|
+
};
|
|
46
|
+
}
|
package/ts/email/index.ts
CHANGED
|
@@ -15,3 +15,5 @@ export * from './classes.smtp-account.manager.js';
|
|
|
15
15
|
export * from './smtp-scram.js';
|
|
16
16
|
export * from './classes.workapp-mail-manager.js';
|
|
17
17
|
export * from './email-dns-records.js';
|
|
18
|
+
export * from './helpers.email-log-traffic.js';
|
|
19
|
+
export * from './helpers.inbound-security.js';
|