@serve.zone/dcrouter 18.0.2 → 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.
Files changed (103) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1529 -1382
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +7 -2
  5. package/dist_ts/classes.dcrouter.js +61 -9
  6. package/dist_ts/db/documents/classes.authentication-event.doc.d.ts +25 -0
  7. package/dist_ts/db/documents/classes.authentication-event.doc.js +273 -0
  8. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.d.ts +25 -0
  9. package/dist_ts/db/documents/classes.email-traffic-bucket.doc.js +200 -0
  10. package/dist_ts/db/documents/classes.smtp-account.doc.d.ts +31 -0
  11. package/dist_ts/db/documents/classes.smtp-account.doc.js +176 -0
  12. package/dist_ts/db/documents/index.d.ts +3 -0
  13. package/dist_ts/db/documents/index.js +4 -1
  14. package/dist_ts/email/classes.accepted-email-spool.d.ts +1 -0
  15. package/dist_ts/email/classes.accepted-email-spool.js +9 -1
  16. package/dist_ts/email/classes.email-settings.manager.d.ts +7 -0
  17. package/dist_ts/email/classes.email-settings.manager.js +30 -1
  18. package/dist_ts/email/classes.smtp-account.manager.d.ts +110 -0
  19. package/dist_ts/email/classes.smtp-account.manager.js +507 -0
  20. package/dist_ts/email/classes.workapp-mail-manager.d.ts +23 -4
  21. package/dist_ts/email/classes.workapp-mail-manager.js +39 -46
  22. package/dist_ts/email/index.d.ts +2 -0
  23. package/dist_ts/email/index.js +3 -1
  24. package/dist_ts/email/smtp-scram.d.ts +13 -0
  25. package/dist_ts/email/smtp-scram.js +19 -0
  26. package/dist_ts/monitoring/classes.metricsmanager.d.ts +19 -0
  27. package/dist_ts/monitoring/classes.metricsmanager.js +240 -44
  28. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  29. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  30. package/dist_ts/opsserver/handlers/admin.handler.d.ts +1 -0
  31. package/dist_ts/opsserver/handlers/admin.handler.js +61 -14
  32. package/dist_ts/opsserver/handlers/email-settings.handler.js +14 -2
  33. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  34. package/dist_ts/opsserver/handlers/index.js +2 -1
  35. package/dist_ts/opsserver/handlers/security.handler.js +21 -3
  36. package/dist_ts/opsserver/handlers/smtp-account.handler.d.ts +20 -0
  37. package/dist_ts/opsserver/handlers/smtp-account.handler.js +155 -0
  38. package/dist_ts/opsserver/handlers/stats.handler.js +27 -10
  39. package/dist_ts/security/classes.authentication-event-manager.d.ts +35 -0
  40. package/dist_ts/security/classes.authentication-event-manager.js +185 -0
  41. package/dist_ts/security/index.d.ts +1 -0
  42. package/dist_ts/security/index.js +2 -1
  43. package/dist_ts_interfaces/data/email-settings.d.ts +10 -0
  44. package/dist_ts_interfaces/data/index.d.ts +1 -0
  45. package/dist_ts_interfaces/data/index.js +2 -1
  46. package/dist_ts_interfaces/data/route-management.d.ts +1 -1
  47. package/dist_ts_interfaces/data/route-management.js +3 -1
  48. package/dist_ts_interfaces/data/smtp-account.d.ts +50 -0
  49. package/dist_ts_interfaces/data/smtp-account.js +9 -0
  50. package/dist_ts_interfaces/data/stats.d.ts +16 -0
  51. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  52. package/dist_ts_interfaces/requests/index.js +2 -1
  53. package/dist_ts_interfaces/requests/smtp-accounts.d.ts +125 -0
  54. package/dist_ts_interfaces/requests/smtp-accounts.js +2 -0
  55. package/dist_ts_migrations/index.js +186 -13
  56. package/dist_ts_web/00_commitinfo_data.js +1 -1
  57. package/dist_ts_web/appstate/smtp-accounts.d.ts +38 -0
  58. package/dist_ts_web/appstate/smtp-accounts.js +94 -0
  59. package/dist_ts_web/appstate.d.ts +1 -0
  60. package/dist_ts_web/appstate.js +2 -1
  61. package/dist_ts_web/elements/email/index.d.ts +1 -0
  62. package/dist_ts_web/elements/email/index.js +2 -1
  63. package/dist_ts_web/elements/email/ops-view-smtp-accounts.d.ts +25 -0
  64. package/dist_ts_web/elements/email/ops-view-smtp-accounts.js +598 -0
  65. package/dist_ts_web/elements/ops-dashboard.js +3 -1
  66. package/dist_ts_web/elements/overview/ops-view-overview.js +3 -2
  67. package/dist_ts_web/elements/security/ops-view-security-authentication.js +20 -13
  68. package/dist_ts_web/elements/security/ops-view-security-overview.js +8 -11
  69. package/dist_ts_web/router.js +2 -2
  70. package/package.json +2 -2
  71. package/readme.md +20 -0
  72. package/ts/00_commitinfo_data.ts +1 -1
  73. package/ts/classes.dcrouter.ts +66 -8
  74. package/ts/db/documents/classes.authentication-event.doc.ts +248 -0
  75. package/ts/db/documents/classes.email-traffic-bucket.doc.ts +161 -0
  76. package/ts/db/documents/classes.smtp-account.doc.ts +103 -0
  77. package/ts/db/documents/index.ts +3 -0
  78. package/ts/email/classes.accepted-email-spool.ts +8 -0
  79. package/ts/email/classes.email-settings.manager.ts +34 -0
  80. package/ts/email/classes.smtp-account.manager.ts +601 -0
  81. package/ts/email/classes.workapp-mail-manager.ts +53 -55
  82. package/ts/email/index.ts +2 -0
  83. package/ts/email/smtp-scram.ts +24 -0
  84. package/ts/monitoring/classes.metricsmanager.ts +287 -50
  85. package/ts/opsserver/classes.opsserver.ts +2 -0
  86. package/ts/opsserver/handlers/admin.handler.ts +74 -15
  87. package/ts/opsserver/handlers/email-settings.handler.ts +13 -1
  88. package/ts/opsserver/handlers/index.ts +1 -0
  89. package/ts/opsserver/handlers/security.handler.ts +23 -2
  90. package/ts/opsserver/handlers/smtp-account.handler.ts +199 -0
  91. package/ts/opsserver/handlers/stats.handler.ts +29 -9
  92. package/ts/security/classes.authentication-event-manager.ts +221 -0
  93. package/ts/security/index.ts +1 -0
  94. package/ts_web/00_commitinfo_data.ts +1 -1
  95. package/ts_web/appstate/smtp-accounts.ts +143 -0
  96. package/ts_web/appstate.ts +1 -0
  97. package/ts_web/elements/email/index.ts +1 -0
  98. package/ts_web/elements/email/ops-view-smtp-accounts.ts +586 -0
  99. package/ts_web/elements/ops-dashboard.ts +2 -0
  100. package/ts_web/elements/overview/ops-view-overview.ts +2 -1
  101. package/ts_web/elements/security/ops-view-security-authentication.ts +20 -13
  102. package/ts_web/elements/security/ops-view-security-overview.ts +7 -12
  103. package/ts_web/router.ts +1 -1
@@ -93,6 +93,26 @@ interface IStoredWorkAppMailState {
93
93
  identities: IStoredWorkAppMailIdentity[];
94
94
  }
95
95
 
96
+ /**
97
+ * WorkApp-generated routes and SMTP users contributed to the composed email
98
+ * runtime config. Consumed by SmtpAccountManager, the single owner of the
99
+ * final `auth` block and route-list composition.
100
+ */
101
+ export interface IWorkAppMailRuntimeContribution {
102
+ routes: IEmailRoute[];
103
+ users: Array<{ username: string; password: string }>;
104
+ }
105
+
106
+ /** Route names generated (and owned wholesale) by WorkAppMailManager. */
107
+ export function isWorkAppManagedMailRouteName(routeName: string): boolean {
108
+ return routeName.startsWith('workapp-mail-');
109
+ }
110
+
111
+ /** SMTP usernames generated (and owned wholesale) by WorkAppMailManager. */
112
+ export function isWorkAppManagedSmtpUsername(username: string): boolean {
113
+ return username.startsWith('workapp-');
114
+ }
115
+
96
116
  export class WorkAppMailManager {
97
117
  private readonly storageKey = '/workhosters/mail-identities.json';
98
118
  private identityMutationPromise: Promise<unknown> = Promise.resolve();
@@ -659,12 +679,36 @@ export class WorkAppMailManager {
659
679
  };
660
680
  }
661
681
 
662
- public async applyStoredIdentitiesToEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
663
- emailConfig: TConfig,
664
- ): Promise<TConfig> {
665
- const identities = await this.readStoredIdentities();
666
- const readyDomains = await this.getReadyOutboundDomains(identities);
667
- return this.mergeIdentitiesIntoEmailConfig(emailConfig, identities, readyDomains);
682
+ /**
683
+ * Compute the workapp-generated routes and SMTP users for the given
684
+ * candidate email config. Pure contribution — the final composition of
685
+ * routes and the `auth` block is owned exclusively by SmtpAccountManager.
686
+ */
687
+ public async getStoredIdentityContribution(
688
+ emailConfig: IUnifiedEmailServerOptions,
689
+ identities = undefined as IStoredWorkAppMailIdentity[] | undefined,
690
+ ): Promise<IWorkAppMailRuntimeContribution> {
691
+ const nextIdentities = identities || await this.readStoredIdentities();
692
+ const readyDomains = await this.getReadyOutboundDomains(nextIdentities);
693
+ const generatedRoutes = nextIdentities
694
+ .filter((identity) => {
695
+ const inboundTarget = this.getCanonicalInboundTarget(identity);
696
+ return identity.enabled && ['smtpForward', 'typedEndpoint'].includes(inboundTarget?.type || '');
697
+ })
698
+ .map((identity) => this.buildInboundRoute(identity));
699
+ const generatedOutboundRoutes = nextIdentities
700
+ .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
701
+ .map((identity) => this.buildOutboundRoute(identity, emailConfig));
702
+ const generatedUsers = nextIdentities
703
+ .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
704
+ .map((identity) => ({
705
+ username: identity.smtp.username,
706
+ password: identity.smtpPassword,
707
+ }));
708
+ return {
709
+ routes: [...generatedRoutes, ...generatedOutboundRoutes],
710
+ users: generatedUsers,
711
+ };
668
712
  }
669
713
 
670
714
  public async applyStoredIdentitiesToRuntime(
@@ -673,15 +717,8 @@ export class WorkAppMailManager {
673
717
  const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
674
718
  if (!emailConfig) return;
675
719
 
676
- const nextIdentities = identities || await this.readStoredIdentities();
677
- const readyDomains = await this.getReadyOutboundDomains(nextIdentities);
678
- const nextConfig = this.mergeIdentitiesIntoEmailConfig(emailConfig, nextIdentities, readyDomains);
679
-
680
- this.dcRouterRef.options.emailConfig = nextConfig;
681
- if (this.dcRouterRef.emailServer) {
682
- this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
683
- await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
684
- }
720
+ const contribution = await this.getStoredIdentityContribution(emailConfig, identities);
721
+ await this.dcRouterRef.smtpAccountManager.applyToRuntime(contribution);
685
722
  }
686
723
 
687
724
  private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
@@ -717,41 +754,6 @@ export class WorkAppMailManager {
717
754
  >;
718
755
  }
719
756
 
720
- private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
721
- emailConfig: TConfig,
722
- identities: IStoredWorkAppMailIdentity[],
723
- readyDomains: Set<string>,
724
- ): TConfig {
725
- const generatedRoutes = identities
726
- .filter((identity) => {
727
- const inboundTarget = this.getCanonicalInboundTarget(identity);
728
- return identity.enabled && ['smtpForward', 'typedEndpoint'].includes(inboundTarget?.type || '');
729
- })
730
- .map((identity) => this.buildInboundRoute(identity));
731
- const generatedOutboundRoutes = identities
732
- .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
733
- .map((identity) => this.buildOutboundRoute(identity, emailConfig));
734
- const configuredRoutes = (emailConfig.routes || [])
735
- .filter((route) => !this.isManagedMailRouteName(route.name));
736
- const generatedUsers = identities
737
- .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
738
- .map((identity) => ({
739
- username: identity.smtp.username,
740
- password: identity.smtpPassword,
741
- }));
742
- const configuredUsers = (emailConfig.auth?.users || [])
743
- .filter((user) => !this.isManagedSmtpUsername(user.username));
744
-
745
- return {
746
- ...emailConfig,
747
- routes: [...configuredRoutes, ...generatedRoutes, ...generatedOutboundRoutes],
748
- auth: {
749
- ...(emailConfig.auth || {}),
750
- users: [...configuredUsers, ...generatedUsers],
751
- },
752
- };
753
- }
754
-
755
757
  private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
756
758
  const inboundTarget = this.getCanonicalInboundTarget(identity);
757
759
  if (inboundTarget?.type === 'typedEndpoint') {
@@ -1594,12 +1596,8 @@ export class WorkAppMailManager {
1594
1596
  return plugins.crypto.randomBytes(24).toString('base64url');
1595
1597
  }
1596
1598
 
1597
- private isManagedMailRouteName(routeName: string): boolean {
1598
- return routeName.startsWith('workapp-mail-');
1599
- }
1600
-
1601
1599
  private isManagedSmtpUsername(username: string): boolean {
1602
- return username.startsWith('workapp-');
1600
+ return isWorkAppManagedSmtpUsername(username);
1603
1601
  }
1604
1602
 
1605
1603
  private buildSmtpCredentials(
package/ts/email/index.ts CHANGED
@@ -11,5 +11,7 @@ export * from './classes.email-settings.manager.js';
11
11
  export * from './classes.smartmta-blob-storage-manager.js';
12
12
  export * from './classes.smartmta-storage-manager.js';
13
13
  export * from './smartmta-storage-policy.js';
14
+ export * from './classes.smtp-account.manager.js';
15
+ export * from './smtp-scram.js';
14
16
  export * from './classes.workapp-mail-manager.js';
15
17
  export * from './email-dns-records.js';
@@ -0,0 +1,24 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { deriveScramVerifier } from '@push.rocks/smartmta';
3
+ import type { ISmtpScramVerifier } from '@push.rocks/smartmta';
4
+
5
+ /**
6
+ * Derive an RFC 5802 SCRAM-SHA-256 verifier from a plaintext password using
7
+ * smartmta's canonical exported derivation (fresh random salt, OWASP-aligned
8
+ * default iteration count) — the exact algorithm the server verifies at AUTH
9
+ * time. Only the verifier is ever persisted; the plaintext is shown once at
10
+ * create/rotate time and then discarded.
11
+ */
12
+ export function deriveSmtpScramVerifier(
13
+ password: string,
14
+ iterations?: number,
15
+ ): ISmtpScramVerifier {
16
+ return deriveScramVerifier(password, iterations);
17
+ }
18
+
19
+ /**
20
+ * Machine-generated SMTP account password (~192 bits of entropy). Shown once.
21
+ */
22
+ export function generateSmtpAccountPassword(): string {
23
+ return plugins.crypto.randomBytes(24).toString('base64url');
24
+ }
@@ -4,7 +4,15 @@ import { MetricsCache } from './classes.metricscache.js';
4
4
  import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
5
5
  import { logger } from '../logger.js';
6
6
  import type { IAsnActivity, IProtocolConnectionHistoryPoint } from '../../ts_interfaces/data/stats.js';
7
-
7
+ import {
8
+ EmailTrafficBucketDoc,
9
+ type IEmailTrafficBucketSnapshot,
10
+ } from '../db/documents/classes.email-traffic-bucket.doc.js';
11
+
12
+ const EMAIL_TRAFFIC_WINDOW_MINUTES = 24 * 60;
13
+ const EMAIL_TRAFFIC_FLUSH_INTERVAL_MS = 5_000;
14
+ const EMAIL_TRAFFIC_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
15
+ const AUTHENTICATION_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
8
16
  const NETWORK_STATS_CACHE_TTL_MS = 3_000;
9
17
  const ASN_INTELLIGENCE_CACHE_TTL_MS = 60_000;
10
18
  const ACTIVE_CONNECTION_SNAPSHOT_CACHE_TTL_MS = 3_000;
@@ -20,6 +28,12 @@ export class MetricsManager {
20
28
  private dcRouter: DcRouter;
21
29
  private resetInterval?: NodeJS.Timeout;
22
30
  private observedIpSamplerInterval?: NodeJS.Timeout;
31
+ private emailTrafficFlushInterval?: NodeJS.Timeout;
32
+ private persistedMetricsPruneInterval?: NodeJS.Timeout;
33
+ private emailTrafficFlushChain: Promise<void> = Promise.resolve();
34
+ private persistedMetricsFlushInFlight?: Promise<void>;
35
+ private persistedMetricsPruneInFlight?: Promise<void>;
36
+ private emailDirtyBuckets = new Map<number, number>();
23
37
  private metricsCache: MetricsCache;
24
38
 
25
39
  // Constants
@@ -32,7 +46,7 @@ export class MetricsManager {
32
46
  failedToday: 0,
33
47
  bouncedToday: 0,
34
48
  queueSize: 0,
35
- lastResetDate: new Date().toDateString(),
49
+ lastResetDate: new Date().toISOString().slice(0, 10),
36
50
  deliveryTimes: [] as number[], // Track delivery times in ms
37
51
  recipients: new Map<string, number>(), // Track email count by recipient
38
52
  recentActivity: [] as Array<{ timestamp: number; type: string; details: string }>,
@@ -54,7 +68,9 @@ export class MetricsManager {
54
68
  };
55
69
 
56
70
  // Per-minute time-series buckets for charts
57
- private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
71
+ private emailMinuteBuckets = new Map<number, {
72
+ sent: number; received: number; failed: number; revision: number;
73
+ }>();
58
74
  private dnsMinuteBuckets = new Map<number, { queries: number }>();
59
75
 
60
76
  // Track security-specific metrics
@@ -84,23 +100,18 @@ export class MetricsManager {
84
100
  }
85
101
 
86
102
  public async start(): Promise<void> {
103
+ if (this.canPersistEmailTraffic()) {
104
+ await this.loadEmailTrafficBuckets();
105
+ await this.prunePersistedMetrics();
106
+ }
107
+
87
108
  // Start SmartMetrics collection
88
109
  this.smartMetrics.start();
89
110
 
90
- // Reset daily counters at midnight
111
+ // Maintain UTC daily ancillary counters and bounded in-memory windows.
91
112
  this.resetInterval = setInterval(() => {
92
113
  const currentDate = new Date().toDateString();
93
-
94
- if (currentDate !== this.emailMetrics.lastResetDate) {
95
- this.emailMetrics.sentToday = 0;
96
- this.emailMetrics.receivedToday = 0;
97
- this.emailMetrics.failedToday = 0;
98
- this.emailMetrics.bouncedToday = 0;
99
- this.emailMetrics.deliveryTimes = [];
100
- this.emailMetrics.recipients.clear();
101
- this.emailMetrics.recentActivity = [];
102
- this.emailMetrics.lastResetDate = currentDate;
103
- }
114
+ this.ensureEmailDailyState();
104
115
 
105
116
  if (currentDate !== this.dnsMetrics.lastResetDate) {
106
117
  this.dnsMetrics.totalQueries = 0;
@@ -128,17 +139,33 @@ export class MetricsManager {
128
139
  // Prune old time-series buckets every minute (don't wait for lazy query)
129
140
  this.pruneOldBuckets();
130
141
  }, 60000); // Check every minute
142
+ this.resetInterval.unref();
131
143
 
132
144
  this.observedIpSamplerInterval = setInterval(() => {
133
145
  this.sampleObservedIps();
134
146
  }, OBSERVED_IP_SAMPLE_INTERVAL_MS);
135
147
  this.observedIpSamplerInterval.unref();
148
+
149
+ if (this.canPersistEmailTraffic()) {
150
+ this.emailTrafficFlushInterval = setInterval(() => {
151
+ void this.flushPersistedMetrics().catch((error) => {
152
+ logger.log('warn', `Metrics persistence flush failed: ${(error as Error).message}`);
153
+ });
154
+ }, EMAIL_TRAFFIC_FLUSH_INTERVAL_MS);
155
+ this.emailTrafficFlushInterval.unref();
156
+
157
+ this.persistedMetricsPruneInterval = setInterval(() => {
158
+ void this.prunePersistedMetrics().catch((error) => {
159
+ logger.log('warn', `Metrics persistence pruning failed: ${(error as Error).message}`);
160
+ });
161
+ }, EMAIL_TRAFFIC_PRUNE_INTERVAL_MS);
162
+ this.persistedMetricsPruneInterval.unref();
163
+ }
136
164
 
137
165
  logger.log('info', 'MetricsManager started');
138
166
  }
139
167
 
140
168
  public async stop(): Promise<void> {
141
- // Clear the reset interval
142
169
  if (this.resetInterval) {
143
170
  clearInterval(this.resetInterval);
144
171
  this.resetInterval = undefined;
@@ -147,12 +174,60 @@ export class MetricsManager {
147
174
  clearInterval(this.observedIpSamplerInterval);
148
175
  this.observedIpSamplerInterval = undefined;
149
176
  }
177
+ if (this.emailTrafficFlushInterval) {
178
+ clearInterval(this.emailTrafficFlushInterval);
179
+ this.emailTrafficFlushInterval = undefined;
180
+ }
181
+ if (this.persistedMetricsPruneInterval) {
182
+ clearInterval(this.persistedMetricsPruneInterval);
183
+ this.persistedMetricsPruneInterval = undefined;
184
+ }
185
+
186
+ if (this.persistedMetricsPruneInFlight) {
187
+ try {
188
+ await this.persistedMetricsPruneInFlight;
189
+ } catch (error) {
190
+ logger.log('warn', `Final metrics pruning wait failed: ${(error as Error).message}`);
191
+ }
192
+ }
193
+
194
+ let finalFlushError: Error | undefined;
195
+ if (this.canPersistEmailTraffic()) {
196
+ const activeFlush = this.persistedMetricsFlushInFlight;
197
+ if (activeFlush) {
198
+ try {
199
+ await activeFlush;
200
+ } catch (error) {
201
+ logger.log(
202
+ 'warn',
203
+ `Active metrics persistence flush failed before final drain: ${(error as Error).message}`,
204
+ );
205
+ }
206
+ }
207
+
208
+ // OpsServer and EmailServer have stopped by this point. Run a fresh,
209
+ // non-coalesced pass so events added after an earlier flush snapshot are
210
+ // included in the final durable drain.
211
+ for (let attempt = 0; attempt < 3; attempt++) {
212
+ try {
213
+ await this.flushPersistedMetricsUnlocked();
214
+ finalFlushError = undefined;
215
+ break;
216
+ } catch (error) {
217
+ finalFlushError = error as Error;
218
+ }
219
+ }
220
+ }
150
221
 
151
222
  this.smartMetrics.stop();
152
223
 
153
- // Clear caches and time-series buckets on shutdown
224
+ if (finalFlushError) {
225
+ throw new Error(`Final metrics persistence flush failed: ${finalFlushError.message}`);
226
+ }
227
+
154
228
  this.metricsCache.clear();
155
229
  this.emailMinuteBuckets.clear();
230
+ this.emailDirtyBuckets.clear();
156
231
  this.dnsMinuteBuckets.clear();
157
232
 
158
233
  logger.log('info', 'MetricsManager stopped');
@@ -199,30 +274,31 @@ export class MetricsManager {
199
274
  // Get email metrics
200
275
  public async getEmailStats() {
201
276
  return this.metricsCache.get('emailStats', () => {
202
- // Calculate average delivery time
277
+ this.ensureEmailDailyState();
278
+ const totals = this.getUtcTodayEmailTotals();
279
+ const deliveryAttempts = totals.sent + totals.failed;
203
280
  const avgDeliveryTime = this.emailMetrics.deliveryTimes.length > 0
204
281
  ? this.emailMetrics.deliveryTimes.reduce((a, b) => a + b, 0) / this.emailMetrics.deliveryTimes.length
205
282
  : 0;
206
-
207
- // Get top recipients
283
+
208
284
  const topRecipients = Array.from(this.emailMetrics.recipients.entries())
209
285
  .sort((a, b) => b[1] - a[1])
210
286
  .slice(0, 10)
211
287
  .map(([email, count]) => ({ email, count }));
212
-
213
- // Get recent activity (last 50 entries)
288
+
214
289
  const recentActivity = this.emailMetrics.recentActivity.slice(-50);
215
-
290
+
216
291
  return {
217
- sentToday: this.emailMetrics.sentToday,
218
- receivedToday: this.emailMetrics.receivedToday,
219
- failedToday: this.emailMetrics.failedToday,
220
- bounceRate: this.emailMetrics.bouncedToday > 0
221
- ? (this.emailMetrics.bouncedToday / this.emailMetrics.sentToday) * 100
292
+ sentToday: totals.sent,
293
+ receivedToday: totals.received,
294
+ failedToday: totals.failed,
295
+ bouncedToday: this.emailMetrics.bouncedToday,
296
+ bounceRate: deliveryAttempts > 0
297
+ ? Math.min(1, Math.max(0, this.emailMetrics.bouncedToday / deliveryAttempts))
222
298
  : 0,
223
- deliveryRate: this.emailMetrics.sentToday > 0
224
- ? ((this.emailMetrics.sentToday - this.emailMetrics.failedToday) / this.emailMetrics.sentToday) * 100
225
- : 100,
299
+ deliveryRate: deliveryAttempts > 0
300
+ ? Math.min(1, Math.max(0, totals.sent / deliveryAttempts))
301
+ : 1,
226
302
  queueSize: this.emailMetrics.queueSize,
227
303
  averageDeliveryTime: Math.round(avgDeliveryTime),
228
304
  topRecipients,
@@ -278,8 +354,13 @@ export class MetricsManager {
278
354
  this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
279
355
  this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
280
356
  this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
281
- this.securityMetrics.authFailures =
282
- summary.byType[SecurityEventType.AUTHENTICATION] || 0;
357
+ this.securityMetrics.authFailures = securityLogger
358
+ .getRecentEvents(10_000, {
359
+ type: SecurityEventType.AUTHENTICATION,
360
+ fromTimestamp: Date.now() - 86_400_000,
361
+ })
362
+ .filter((event) => event.success === false)
363
+ .length;
283
364
  this.securityMetrics.blockedIPs =
284
365
  (summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
285
366
  (summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
@@ -377,7 +458,7 @@ export class MetricsManager {
377
458
 
378
459
  // Email event tracking methods
379
460
  public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
380
- this.emailMetrics.sentToday++;
461
+ this.ensureEmailDailyState();
381
462
  this.incrementEmailBucket('sent');
382
463
 
383
464
  if (recipient) {
@@ -414,7 +495,7 @@ export class MetricsManager {
414
495
  }
415
496
 
416
497
  public trackEmailReceived(sender?: string): void {
417
- this.emailMetrics.receivedToday++;
498
+ this.ensureEmailDailyState();
418
499
  this.incrementEmailBucket('received');
419
500
 
420
501
  this.emailMetrics.recentActivity.push({
@@ -430,7 +511,7 @@ export class MetricsManager {
430
511
  }
431
512
 
432
513
  public trackEmailFailed(recipient?: string, reason?: string): void {
433
- this.emailMetrics.failedToday++;
514
+ this.ensureEmailDailyState();
434
515
  this.incrementEmailBucket('failed');
435
516
 
436
517
  this.emailMetrics.recentActivity.push({
@@ -446,6 +527,7 @@ export class MetricsManager {
446
527
  }
447
528
 
448
529
  public trackEmailBounced(recipient?: string): void {
530
+ this.ensureEmailDailyState();
449
531
  this.emailMetrics.bouncedToday++;
450
532
 
451
533
  this.emailMetrics.recentActivity.push({
@@ -1087,14 +1169,164 @@ export class MetricsManager {
1087
1169
  return Math.floor(ts / 60000) * 60000;
1088
1170
  }
1089
1171
 
1172
+ private static firstMinuteForHours(hoursArg: number, nowArg = Date.now()): number {
1173
+ const pointCount = Math.max(1, Math.floor(hoursArg * 60));
1174
+ return MetricsManager.minuteKey(nowArg) - (pointCount - 1) * 60_000;
1175
+ }
1176
+
1177
+ private isEmailTrafficPersistenceConfigured(): boolean {
1178
+ return this.dcRouter.options.dbConfig?.enabled !== false;
1179
+ }
1180
+
1181
+ private canPersistEmailTraffic(): boolean {
1182
+ return this.isEmailTrafficPersistenceConfigured()
1183
+ && Boolean(this.dcRouter.dcRouterDb?.isReady());
1184
+ }
1185
+
1186
+ private ensureEmailDailyState(nowArg = Date.now()): void {
1187
+ const currentUtcDate = new Date(nowArg).toISOString().slice(0, 10);
1188
+ if (currentUtcDate === this.emailMetrics.lastResetDate) return;
1189
+
1190
+ this.emailMetrics.sentToday = 0;
1191
+ this.emailMetrics.receivedToday = 0;
1192
+ this.emailMetrics.failedToday = 0;
1193
+ this.emailMetrics.bouncedToday = 0;
1194
+ this.emailMetrics.deliveryTimes = [];
1195
+ this.emailMetrics.recipients.clear();
1196
+ this.emailMetrics.recentActivity = [];
1197
+ this.emailMetrics.lastResetDate = currentUtcDate;
1198
+ }
1199
+
1200
+ private getUtcTodayEmailTotals(nowArg = Date.now()): {
1201
+ sent: number;
1202
+ received: number;
1203
+ failed: number;
1204
+ } {
1205
+ const now = new Date(nowArg);
1206
+ const utcMidnight = Date.UTC(
1207
+ now.getUTCFullYear(),
1208
+ now.getUTCMonth(),
1209
+ now.getUTCDate(),
1210
+ );
1211
+ const totals = { sent: 0, received: 0, failed: 0 };
1212
+ for (const [bucketStart, bucket] of this.emailMinuteBuckets) {
1213
+ if (bucketStart < utcMidnight || bucketStart > nowArg) continue;
1214
+ totals.sent += bucket.sent;
1215
+ totals.received += bucket.received;
1216
+ totals.failed += bucket.failed;
1217
+ }
1218
+ return totals;
1219
+ }
1220
+
1221
+ private async loadEmailTrafficBuckets(): Promise<void> {
1222
+ const snapshots = await EmailTrafficBucketDoc.loadSince(
1223
+ MetricsManager.firstMinuteForHours(24),
1224
+ );
1225
+ for (const snapshot of snapshots) {
1226
+ this.emailMinuteBuckets.set(snapshot.bucketStart, {
1227
+ sent: snapshot.sent,
1228
+ received: snapshot.received,
1229
+ failed: snapshot.failed,
1230
+ revision: 0,
1231
+ });
1232
+ }
1233
+ }
1234
+
1235
+ private flushPersistedMetrics(): Promise<void> {
1236
+ if (this.persistedMetricsFlushInFlight) return this.persistedMetricsFlushInFlight;
1237
+ const run = this.flushPersistedMetricsUnlocked();
1238
+ this.persistedMetricsFlushInFlight = run;
1239
+ run.then(
1240
+ () => { if (this.persistedMetricsFlushInFlight === run) this.persistedMetricsFlushInFlight = undefined; },
1241
+ () => { if (this.persistedMetricsFlushInFlight === run) this.persistedMetricsFlushInFlight = undefined; },
1242
+ );
1243
+ return run;
1244
+ }
1245
+
1246
+ private async flushPersistedMetricsUnlocked(): Promise<void> {
1247
+ const results = await Promise.allSettled([
1248
+ this.flushEmailTrafficBuckets(),
1249
+ this.dcRouter.authenticationEventManager.flushBuffered(),
1250
+ ]);
1251
+ const failure = results.find((result) => result.status === 'rejected');
1252
+ if (failure?.status === 'rejected') {
1253
+ throw failure.reason;
1254
+ }
1255
+ }
1256
+
1257
+ private flushEmailTrafficBuckets(): Promise<void> {
1258
+ const run = this.emailTrafficFlushChain.then(
1259
+ () => this.flushEmailTrafficBucketsUnlocked(),
1260
+ () => this.flushEmailTrafficBucketsUnlocked(),
1261
+ );
1262
+ this.emailTrafficFlushChain = run.then(() => undefined, () => undefined);
1263
+ return run;
1264
+ }
1265
+
1266
+ private async flushEmailTrafficBucketsUnlocked(): Promise<void> {
1267
+ if (!this.canPersistEmailTraffic() || this.emailDirtyBuckets.size === 0) return;
1268
+
1269
+ const snapshots: Array<IEmailTrafficBucketSnapshot & { revision: number }> = [];
1270
+ for (const [bucketStart, revision] of this.emailDirtyBuckets) {
1271
+ const bucket = this.emailMinuteBuckets.get(bucketStart);
1272
+ if (!bucket) continue;
1273
+ snapshots.push({
1274
+ bucketStart,
1275
+ sent: bucket.sent,
1276
+ received: bucket.received,
1277
+ failed: bucket.failed,
1278
+ revision,
1279
+ });
1280
+ }
1281
+
1282
+ for (let offset = 0; offset < snapshots.length; offset += 500) {
1283
+ const batch = snapshots.slice(offset, offset + 500);
1284
+ await EmailTrafficBucketDoc.persistAbsolute(batch);
1285
+ for (const snapshot of batch) {
1286
+ const current = this.emailMinuteBuckets.get(snapshot.bucketStart);
1287
+ if (
1288
+ current?.revision === snapshot.revision
1289
+ && this.emailDirtyBuckets.get(snapshot.bucketStart) === snapshot.revision
1290
+ ) {
1291
+ this.emailDirtyBuckets.delete(snapshot.bucketStart);
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ private prunePersistedMetrics(): Promise<void> {
1298
+ if (this.persistedMetricsPruneInFlight) return this.persistedMetricsPruneInFlight;
1299
+ const run = this.prunePersistedMetricsUnlocked();
1300
+ this.persistedMetricsPruneInFlight = run;
1301
+ run.then(
1302
+ () => { if (this.persistedMetricsPruneInFlight === run) this.persistedMetricsPruneInFlight = undefined; },
1303
+ () => { if (this.persistedMetricsPruneInFlight === run) this.persistedMetricsPruneInFlight = undefined; },
1304
+ );
1305
+ return run;
1306
+ }
1307
+
1308
+ private async prunePersistedMetricsUnlocked(): Promise<void> {
1309
+ if (!this.canPersistEmailTraffic()) return;
1310
+ await Promise.all([
1311
+ EmailTrafficBucketDoc.pruneBefore(MetricsManager.firstMinuteForHours(24)),
1312
+ this.dcRouter.authenticationEventManager.pruneBefore(
1313
+ Date.now() - AUTHENTICATION_EVENT_RETENTION_MS,
1314
+ ),
1315
+ ]);
1316
+ }
1317
+
1090
1318
  private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
1091
1319
  const key = MetricsManager.minuteKey();
1092
1320
  let bucket = this.emailMinuteBuckets.get(key);
1093
1321
  if (!bucket) {
1094
- bucket = { sent: 0, received: 0, failed: 0 };
1322
+ bucket = { sent: 0, received: 0, failed: 0, revision: 0 };
1095
1323
  this.emailMinuteBuckets.set(key, bucket);
1096
1324
  }
1097
1325
  bucket[field]++;
1326
+ bucket.revision++;
1327
+ if (this.isEmailTrafficPersistenceConfigured()) {
1328
+ this.emailDirtyBuckets.set(key, bucket.revision);
1329
+ }
1098
1330
  }
1099
1331
 
1100
1332
  private incrementDnsBucket(): void {
@@ -1165,12 +1397,16 @@ export class MetricsManager {
1165
1397
  }
1166
1398
 
1167
1399
  private pruneOldBuckets(): void {
1168
- const cutoff = Date.now() - 86400000; // 24h
1400
+ const emailCutoff = MetricsManager.firstMinuteForHours(24);
1169
1401
  for (const key of this.emailMinuteBuckets.keys()) {
1170
- if (key < cutoff) this.emailMinuteBuckets.delete(key);
1402
+ if (key < emailCutoff) {
1403
+ this.emailMinuteBuckets.delete(key);
1404
+ this.emailDirtyBuckets.delete(key);
1405
+ }
1171
1406
  }
1407
+ const dnsCutoff = Date.now() - 86400000;
1172
1408
  for (const key of this.dnsMinuteBuckets.keys()) {
1173
- if (key < cutoff) this.dnsMinuteBuckets.delete(key);
1409
+ if (key < dnsCutoff) this.dnsMinuteBuckets.delete(key);
1174
1410
  }
1175
1411
  }
1176
1412
 
@@ -1183,20 +1419,21 @@ export class MetricsManager {
1183
1419
  failed: Array<{ timestamp: number; value: number }>;
1184
1420
  } {
1185
1421
  this.pruneOldBuckets();
1186
- const cutoff = Date.now() - hours * 3600000;
1422
+ const pointCount = Math.max(
1423
+ 1,
1424
+ Math.min(EMAIL_TRAFFIC_WINDOW_MINUTES, Math.floor(hours * 60)),
1425
+ );
1426
+ const end = MetricsManager.minuteKey();
1427
+ const start = end - (pointCount - 1) * 60_000;
1187
1428
  const sent: Array<{ timestamp: number; value: number }> = [];
1188
1429
  const received: Array<{ timestamp: number; value: number }> = [];
1189
1430
  const failed: Array<{ timestamp: number; value: number }> = [];
1190
1431
 
1191
- const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
1192
- .filter((k) => k >= cutoff)
1193
- .sort((a, b) => a - b);
1194
-
1195
- for (const key of sortedKeys) {
1196
- const bucket = this.emailMinuteBuckets.get(key)!;
1197
- sent.push({ timestamp: key, value: bucket.sent });
1198
- received.push({ timestamp: key, value: bucket.received });
1199
- failed.push({ timestamp: key, value: bucket.failed });
1432
+ for (let timestamp = start; timestamp <= end; timestamp += 60_000) {
1433
+ const bucket = this.emailMinuteBuckets.get(timestamp);
1434
+ sent.push({ timestamp, value: bucket?.sent || 0 });
1435
+ received.push({ timestamp, value: bucket?.received || 0 });
1436
+ failed.push({ timestamp, value: bucket?.failed || 0 });
1200
1437
  }
1201
1438
 
1202
1439
  return { sent, received, failed };
@@ -41,6 +41,7 @@ export class OpsServer {
41
41
  private dnsRecordHandler!: handlers.DnsRecordHandler;
42
42
  private acmeConfigHandler!: handlers.AcmeConfigHandler;
43
43
  private emailDomainHandler!: handlers.EmailDomainHandler;
44
+ private smtpAccountHandler!: handlers.SmtpAccountHandler;
44
45
  private gatewayClientHandler!: handlers.GatewayClientHandler;
45
46
  private webPushControlHandler!: handlers.WebPushControlHandler;
46
47
  private webPushAppHandler!: handlers.WebPushAppHandler;
@@ -109,6 +110,7 @@ export class OpsServer {
109
110
  this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
110
111
  this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
111
112
  this.emailDomainHandler = new handlers.EmailDomainHandler(this);
113
+ this.smtpAccountHandler = new handlers.SmtpAccountHandler(this);
112
114
  this.gatewayClientHandler = new handlers.GatewayClientHandler(this);
113
115
  this.webPushControlHandler = new handlers.WebPushControlHandler(this);
114
116
  this.webPushAppHandler = new handlers.WebPushAppHandler(this);