@push.rocks/smartmta 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/changelog.md +30 -0
  2. package/dist_rust/mailer-bin_linux_amd64 +0 -0
  3. package/dist_rust/mailer-bin_linux_arm64 +0 -0
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/functions.errors.d.ts +3 -0
  6. package/dist_ts/functions.errors.js +8 -0
  7. package/dist_ts/index.d.ts +3 -0
  8. package/dist_ts/index.js +4 -1
  9. package/dist_ts/mail/core/classes.bouncemanager.d.ts +11 -0
  10. package/dist_ts/mail/core/classes.bouncemanager.js +120 -38
  11. package/dist_ts/mail/core/classes.email.js +14 -12
  12. package/dist_ts/mail/core/classes.emailvalidator.d.ts +3 -3
  13. package/dist_ts/mail/core/classes.emailvalidator.js +7 -5
  14. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +77 -2
  15. package/dist_ts/mail/delivery/classes.delivery.queue.js +551 -46
  16. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -7
  17. package/dist_ts/mail/delivery/classes.delivery.system.js +458 -145
  18. package/dist_ts/mail/delivery/classes.unified.rate.limiter.js +9 -8
  19. package/dist_ts/mail/delivery/functions.safe-observers.d.ts +10 -0
  20. package/dist_ts/mail/delivery/functions.safe-observers.js +37 -0
  21. package/dist_ts/mail/delivery/interfaces.d.ts +21 -0
  22. package/dist_ts/mail/delivery/interfaces.js +1 -1
  23. package/dist_ts/mail/routing/classes.dkim.manager.d.ts +8 -4
  24. package/dist_ts/mail/routing/classes.dkim.manager.js +46 -29
  25. package/dist_ts/mail/routing/classes.dns.manager.d.ts +5 -3
  26. package/dist_ts/mail/routing/classes.dns.manager.js +22 -11
  27. package/dist_ts/mail/routing/classes.email.action.executor.d.ts +2 -1
  28. package/dist_ts/mail/routing/classes.email.action.executor.js +45 -16
  29. package/dist_ts/mail/routing/classes.email.router.d.ts +3 -0
  30. package/dist_ts/mail/routing/classes.email.router.js +15 -9
  31. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +4 -0
  32. package/dist_ts/mail/routing/classes.unified.email.server.js +61 -40
  33. package/dist_ts/mail/security/classes.dkimcreator.d.ts +7 -0
  34. package/dist_ts/mail/security/classes.dkimcreator.js +33 -8
  35. package/dist_ts/mail/security/classes.spfverifier.js +5 -3
  36. package/dist_ts/security/classes.contentscanner.js +14 -11
  37. package/dist_ts/security/classes.ipreputationchecker.d.ts +3 -0
  38. package/dist_ts/security/classes.ipreputationchecker.js +20 -11
  39. package/dist_ts/security/classes.rustsecuritybridge.d.ts +49 -1
  40. package/dist_ts/security/classes.rustsecuritybridge.js +201 -4
  41. package/dist_ts/security/classes.securitylogger.js +7 -5
  42. package/dist_ts/security/index.d.ts +1 -1
  43. package/dist_ts/security/index.js +2 -2
  44. package/package.json +8 -8
  45. package/readme.hints.md +4 -3
  46. package/readme.md +41 -13
  47. package/readme.plan.md +6 -0
  48. package/ts/00_commitinfo_data.ts +1 -1
  49. package/ts/functions.errors.ts +8 -0
  50. package/ts/index.ts +3 -0
  51. package/ts/mail/core/classes.bouncemanager.ts +157 -45
  52. package/ts/mail/core/classes.email.ts +19 -13
  53. package/ts/mail/core/classes.emailvalidator.ts +9 -7
  54. package/ts/mail/delivery/classes.delivery.queue.ts +740 -58
  55. package/ts/mail/delivery/classes.delivery.system.ts +583 -170
  56. package/ts/mail/delivery/classes.unified.rate.limiter.ts +9 -8
  57. package/ts/mail/delivery/functions.safe-observers.ts +45 -0
  58. package/ts/mail/delivery/interfaces.ts +27 -1
  59. package/ts/mail/routing/classes.dkim.manager.ts +62 -37
  60. package/ts/mail/routing/classes.dns.manager.ts +36 -13
  61. package/ts/mail/routing/classes.email.action.executor.ts +64 -17
  62. package/ts/mail/routing/classes.email.router.ts +15 -8
  63. package/ts/mail/routing/classes.unified.email.server.ts +90 -44
  64. package/ts/mail/security/classes.dkimcreator.ts +50 -7
  65. package/ts/mail/security/classes.spfverifier.ts +4 -2
  66. package/ts/security/classes.contentscanner.ts +14 -11
  67. package/ts/security/classes.ipreputationchecker.ts +21 -10
  68. package/ts/security/classes.rustsecuritybridge.ts +269 -3
  69. package/ts/security/classes.securitylogger.ts +6 -4
  70. package/ts/security/index.ts +5 -1
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { logger } from '../../logger.js';
4
4
  import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
5
+ import { callObserverSafely, emitSafely } from './functions.safe-observers.js';
5
6
 
6
7
  /**
7
8
  * Interface for rate limit configuration
@@ -759,7 +760,7 @@ export class UnifiedRateLimiter extends EventEmitter {
759
760
 
760
761
  logger.log('warn', `IP ${ip} blocked due to excessive errors (${counter.errors}/${limit})`);
761
762
 
762
- SecurityLogger.getInstance().logEvent({
763
+ void callObserverSafely('rate-limit security log', () => SecurityLogger.getInstance().logEvent({
763
764
  level: SecurityLogLevel.WARN,
764
765
  type: SecurityEventType.RATE_LIMITING,
765
766
  message: 'IP blocked due to excessive errors',
@@ -769,7 +770,7 @@ export class UnifiedRateLimiter extends EventEmitter {
769
770
  limit
770
771
  },
771
772
  success: false
772
- });
773
+ }));
773
774
 
774
775
  return true;
775
776
  }
@@ -840,7 +841,7 @@ export class UnifiedRateLimiter extends EventEmitter {
840
841
 
841
842
  logger.log('warn', `IP ${ip} blocked due to excessive authentication failures (${counter.authFailures}/${limit})`);
842
843
 
843
- SecurityLogger.getInstance().logEvent({
844
+ void callObserverSafely('rate-limit security log', () => SecurityLogger.getInstance().logEvent({
844
845
  level: SecurityLogLevel.WARN,
845
846
  type: SecurityEventType.AUTHENTICATION,
846
847
  message: 'IP blocked due to excessive authentication failures',
@@ -850,7 +851,7 @@ export class UnifiedRateLimiter extends EventEmitter {
850
851
  limit
851
852
  },
852
853
  success: false
853
- });
854
+ }));
854
855
 
855
856
  return true;
856
857
  }
@@ -888,7 +889,7 @@ export class UnifiedRateLimiter extends EventEmitter {
888
889
  this.stats.currentlyBlocked++;
889
890
 
890
891
  // Emit event
891
- this.emit('ipBlocked', {
892
+ void emitSafely(this, 'ipBlocked', {
892
893
  ip,
893
894
  expiry,
894
895
  duration: duration || this.config.global.blockDuration
@@ -916,7 +917,7 @@ export class UnifiedRateLimiter extends EventEmitter {
916
917
  }
917
918
 
918
919
  // Emit event
919
- this.emit('ipUnblocked', { ip });
920
+ void emitSafely(this, 'ipUnblocked', { ip });
920
921
 
921
922
  logger.log('info', `IP ${ip} unblocked`);
922
923
  }
@@ -977,7 +978,7 @@ export class UnifiedRateLimiter extends EventEmitter {
977
978
  this.stats.activeCounters = this.counters.size + this.patternCounters.size + this.ipCounters.size;
978
979
 
979
980
  // Emit statistics update
980
- this.emit('statsUpdated', this.stats);
981
+ void emitSafely(this, 'statsUpdated', this.stats);
981
982
  }
982
983
 
983
984
  /**
@@ -1064,4 +1065,4 @@ export class UnifiedRateLimiter extends EventEmitter {
1064
1065
  public getDomainLimits(domain: string): IRateLimitConfig | undefined {
1065
1066
  return this.config.domains?.[domain];
1066
1067
  }
1067
- }
1068
+ }
@@ -0,0 +1,45 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ import { logger } from '../../logger.js';
4
+ import { getErrorMessage } from '../../functions.errors.js';
5
+
6
+ function logObserverFailure(message: string): void {
7
+ try {
8
+ logger.log('error', message);
9
+ } catch {
10
+ // Logging is an observer too. It must not affect the primary operation.
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Notify every EventEmitter observer without allowing observer failures to
16
+ * change the operation that produced the notification. rawListeners() keeps
17
+ * EventEmitter's once wrapper semantics intact when invoked with the emitter
18
+ * as its receiver.
19
+ */
20
+ export async function emitSafely(
21
+ emitter: EventEmitter,
22
+ eventName: string | symbol,
23
+ ...args: unknown[]
24
+ ): Promise<void> {
25
+ for (const rawListener of emitter.rawListeners(eventName)) {
26
+ try {
27
+ await Reflect.apply(rawListener, emitter, args);
28
+ } catch (error) {
29
+ logObserverFailure(`Observer for ${String(eventName)} failed: ${getErrorMessage(error)}`);
30
+ }
31
+ }
32
+ }
33
+
34
+ /** Run a callback observer with the same failure isolation as emitter events. */
35
+ export async function callObserverSafely(
36
+ observerName: string,
37
+ observer: (...args: any[]) => unknown,
38
+ ...args: unknown[]
39
+ ): Promise<void> {
40
+ try {
41
+ await observer(...args);
42
+ } catch (error) {
43
+ logObserverFailure(`${observerName} observer failed: ${getErrorMessage(error)}`);
44
+ }
45
+ }
@@ -2,6 +2,33 @@
2
2
  * SMTP and email delivery interface definitions
3
3
  */
4
4
 
5
+ import type {
6
+ ISmtpRecipientResult,
7
+ ISmtpTranscriptEntry,
8
+ TSmtpDeliveryPhase,
9
+ } from '../../security/classes.rustsecuritybridge.js';
10
+
11
+ export interface ISmtpTransactionAttempt {
12
+ id: string;
13
+ queueItemId: string;
14
+ queueAttempt: number;
15
+ targetHost: string;
16
+ targetPort: number;
17
+ recipientDomain?: string;
18
+ recipients: string[];
19
+ startedAt: string;
20
+ completedAt: string;
21
+ durationMs: number;
22
+ outcome: 'succeeded' | 'failed';
23
+ retryable: boolean;
24
+ errorType?: string;
25
+ smtpCode?: number;
26
+ recipientResults?: ISmtpRecipientResult[];
27
+ failurePhase?: TSmtpDeliveryPhase;
28
+ error?: string;
29
+ transcript: ISmtpTranscriptEntry[];
30
+ }
31
+
5
32
  /**
6
33
  * SMTP session state enumeration
7
34
  */
@@ -164,4 +191,3 @@ export interface ISmtpAuth {
164
191
  */
165
192
  password: string;
166
193
  }
167
-
@@ -1,9 +1,12 @@
1
1
  import { logger } from '../../logger.js';
2
+ import { getErrorMessage } from '../../functions.errors.js';
2
3
  import { DKIMCreator } from '../security/classes.dkimcreator.js';
3
4
  import type { IStorageManager } from '../interfaces.storage.js';
4
5
  import { DomainRegistry } from './classes.domain.registry.js';
5
- import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
6
6
  import { Email } from '../core/classes.email.js';
7
+ import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
8
+
9
+ type TDkimKeyProvisioning = 'automatic' | 'caller-managed';
7
10
 
8
11
  /** External DcRouter interface shape used by DkimManager */
9
12
  interface DcRouter {
@@ -16,13 +19,30 @@ interface DcRouter {
16
19
  */
17
20
  export class DkimManager {
18
21
  private dkimKeys: Map<string, string> = new Map();
22
+ private dkimCreator: DKIMCreator;
23
+ private domainRegistry: DomainRegistry;
24
+ private dcRouter: DcRouter;
25
+ private rustBridge?: RustSecurityBridge;
26
+ private keyProvisioning: TDkimKeyProvisioning;
19
27
 
28
+ /** v7-compatible constructor shape: the fourth argument remains the Rust bridge. */
20
29
  constructor(
21
- private dkimCreator: DKIMCreator,
22
- private domainRegistry: DomainRegistry,
23
- private dcRouter: DcRouter,
24
- private rustBridge: RustSecurityBridge,
25
- ) {}
30
+ dkimCreator: DKIMCreator,
31
+ domainRegistry: DomainRegistry,
32
+ dcRouter: DcRouter,
33
+ rustBridgeOrProvisioning?: RustSecurityBridge | TDkimKeyProvisioning,
34
+ keyProvisioning: TDkimKeyProvisioning = 'automatic',
35
+ ) {
36
+ this.dkimCreator = dkimCreator;
37
+ this.domainRegistry = domainRegistry;
38
+ this.dcRouter = dcRouter;
39
+ if (typeof rustBridgeOrProvisioning === 'string') {
40
+ this.keyProvisioning = rustBridgeOrProvisioning;
41
+ } else {
42
+ this.rustBridge = rustBridgeOrProvisioning;
43
+ this.keyProvisioning = keyProvisioning;
44
+ }
45
+ }
26
46
 
27
47
  async setupDkimForDomains(): Promise<void> {
28
48
  const domainConfigs = this.domainRegistry.getAllConfigs();
@@ -36,35 +56,43 @@ export class DkimManager {
36
56
  const domain = domainConfig.domain;
37
57
  const selector = domainConfig.dkim?.selector || 'default';
38
58
 
59
+ let keyPair: { privateKey: string; publicKey: string };
39
60
  try {
40
- let keyPair: { privateKey: string; publicKey: string };
41
-
42
61
  try {
43
- keyPair = selector === 'default'
44
- ? await this.dkimCreator.readDKIMKeys(domain)
45
- : await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
62
+ keyPair = await this.dkimCreator.readValidatedDKIMKeysForSelector(domain, selector);
46
63
  logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
47
- } catch {
48
- await this.dkimCreator.handleDKIMKeysForSelector(
64
+ } catch (error) {
65
+ if (this.keyProvisioning === 'caller-managed') {
66
+ throw new Error(
67
+ `Caller-managed DKIM readiness failed for ${domain} (selector: ${selector}): ${(error as Error).message}`,
68
+ );
69
+ }
70
+ await this.dkimCreator.createAndStoreDKIMKeysForSelector(
49
71
  domain,
50
72
  selector,
51
73
  domainConfig.dkim?.keySize || 2048,
52
74
  );
53
- keyPair = selector === 'default'
54
- ? await this.dkimCreator.readDKIMKeys(domain)
55
- : await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
75
+ keyPair = await this.dkimCreator.readValidatedDKIMKeysForSelector(domain, selector);
56
76
  logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
57
77
  }
58
-
59
- this.dkimKeys.set(domain, keyPair.privateKey);
60
- logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
61
78
  } catch (error) {
62
- logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
79
+ logger.log('error', `Failed to set up DKIM for domain ${domain}: ${getErrorMessage(error)}`);
80
+ if (this.keyProvisioning === 'caller-managed') {
81
+ throw error;
82
+ }
83
+ continue;
63
84
  }
85
+
86
+ this.dkimKeys.set(domain, keyPair.privateKey);
87
+ logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
64
88
  }
65
89
  }
66
90
 
67
91
  async checkAndRotateDkimKeys(): Promise<void> {
92
+ if (this.keyProvisioning === 'caller-managed') {
93
+ logger.log('debug', 'Skipping automatic DKIM rotation for caller-managed keys');
94
+ return;
95
+ }
68
96
  const domainConfigs = this.domainRegistry.getAllConfigs();
69
97
 
70
98
  for (const domainConfig of domainConfigs) {
@@ -131,36 +159,33 @@ export class DkimManager {
131
159
  logger.log('debug', `DKIM keys for ${domain} are up to date`);
132
160
  }
133
161
  } catch (error) {
134
- logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
162
+ logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${getErrorMessage(error)}`);
135
163
  }
136
164
  }
137
165
  }
138
166
 
139
- async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
167
+ /** v7 compatibility: explicit callers may still request eager DKIM signing. */
168
+ public async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
140
169
  try {
141
- await this.dkimCreator.handleDKIMKeysForSelector(domain, selector);
142
- const { privateKey } = selector === 'default'
143
- ? await this.dkimCreator.readDKIMKeys(domain)
144
- : await this.dkimCreator.readDKIMKeysForSelector(domain, selector);
145
- const rawEmail = email.toRFC822String();
146
-
147
- // Detect key type from PEM header
148
- const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa';
149
-
150
- const signResult = await this.rustBridge.signDkim({
151
- rawMessage: rawEmail,
170
+ if (this.keyProvisioning === 'automatic') {
171
+ await this.dkimCreator.handleDKIMKeysForSelector(domain, selector);
172
+ }
173
+ const material = await this.dkimCreator.readValidatedDKIMKeysForSelector(domain, selector);
174
+ const rustBridge = this.rustBridge || RustSecurityBridge.getInstance();
175
+ const signResult = await rustBridge.signDkim({
176
+ rawMessage: email.toRFC822String(),
152
177
  domain,
153
178
  selector,
154
- privateKey,
155
- keyType,
179
+ privateKey: material.privateKey,
180
+ keyType: material.keyType,
156
181
  });
157
-
158
182
  if (signResult.header) {
159
183
  email.addHeader('DKIM-Signature', signResult.header);
160
184
  logger.log('info', `Successfully added DKIM signature for ${domain}`);
161
185
  }
162
186
  } catch (error) {
163
- logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
187
+ logger.log('error', `Failed to sign email with DKIM: ${(error as Error).message}`);
188
+ if (this.keyProvisioning === 'caller-managed') throw error;
164
189
  }
165
190
  }
166
191
 
@@ -1,7 +1,9 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import type { IEmailDomainConfig } from './interfaces.js';
3
3
  import type { IStorageManager } from '../interfaces.storage.js';
4
+ import type { DKIMCreator } from '../security/classes.dkimcreator.js';
4
5
  import { logger } from '../../logger.js';
6
+ import { getErrorMessage } from '../../functions.errors.js';
5
7
  /** External DcRouter interface shape used by DnsManager */
6
8
  interface IDcRouterLike {
7
9
  storageManager: IStorageManager;
@@ -53,16 +55,19 @@ export class DnsManager {
53
55
  private storageManager: IStorageManager;
54
56
  private resolverFactory: () => IDnsResolver;
55
57
  private activeResolvers = new Set<IDnsResolver>();
58
+ private keyProvisioning: 'automatic' | 'caller-managed';
56
59
  private activeValidation?: IDnsValidationRun;
57
60
  private validationRunId = 0;
58
61
 
59
62
  constructor(
60
63
  dcRouter: IDcRouterLike,
61
- resolverFactory: () => IDnsResolver = () => new plugins.dns.promises.Resolver()
64
+ resolverFactory: () => IDnsResolver = () => new plugins.dns.promises.Resolver(),
65
+ keyProvisioning: 'automatic' | 'caller-managed' = 'automatic',
62
66
  ) {
63
67
  this.dcRouter = dcRouter;
64
68
  this.storageManager = dcRouter.storageManager;
65
69
  this.resolverFactory = resolverFactory;
70
+ this.keyProvisioning = keyProvisioning;
66
71
  }
67
72
 
68
73
  /**
@@ -125,9 +130,10 @@ export class DnsManager {
125
130
  logger.log('info', `DNS validation skipped for forward mode domain: ${config.domain}`);
126
131
  }
127
132
 
128
- // DKIM keys are still generated for consistency
129
133
  result.warnings.push(
130
- `Domain "${config.domain}" uses forward mode. DKIM keys will be generated but signing only happens if email is processed.`
134
+ this.keyProvisioning === 'automatic'
135
+ ? `Domain "${config.domain}" uses forward mode. DKIM keys will be generated but signing only happens if email is processed.`
136
+ : `Domain "${config.domain}" uses forward mode. Caller-managed DKIM keys are never generated by SmartMTA.`
131
137
  );
132
138
 
133
139
  return result;
@@ -233,7 +239,7 @@ export class DnsManager {
233
239
  return result;
234
240
  }
235
241
  result.warnings.push(
236
- `Could not verify NS delegation for ${config.domain}: ${error.message}`
242
+ `Could not verify NS delegation for ${config.domain}: ${getErrorMessage(error)}`
237
243
  );
238
244
  }
239
245
 
@@ -305,7 +311,7 @@ export class DnsManager {
305
311
  if (abortSignal?.aborted) {
306
312
  return result;
307
313
  }
308
- result.errors.push(`DNS validation failed: ${error.message}`);
314
+ result.errors.push(`DNS validation failed: ${getErrorMessage(error)}`);
309
315
  result.valid = false;
310
316
  }
311
317
 
@@ -404,7 +410,7 @@ export class DnsManager {
404
410
  return nsRecords;
405
411
  } catch (error) {
406
412
  if (!abortSignal?.aborted) {
407
- logger.log('warn', `Failed to resolve NS records for ${domain}: ${error.message}`);
413
+ logger.log('warn', `Failed to resolve NS records for ${domain}: ${getErrorMessage(error)}`);
408
414
  }
409
415
  return [];
410
416
  } finally {
@@ -449,7 +455,10 @@ export class DnsManager {
449
455
  }
450
456
 
451
457
  /** Provision deterministic local DNS records without performing network lookups. */
452
- async provisionLocalDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise<void> {
458
+ async provisionLocalDnsRecords(
459
+ domainConfigs: IEmailDomainConfig[],
460
+ dkimCreator?: DKIMCreator,
461
+ ): Promise<void> {
453
462
  logger.log('info', `Provisioning local DNS records for ${domainConfigs.length} domains`);
454
463
 
455
464
  const internalDnsDomains = domainConfigs.filter(config => config.dnsMode === 'internal-dns');
@@ -458,7 +467,7 @@ export class DnsManager {
458
467
 
459
468
  // Create DKIM records if DKIMCreator is provided
460
469
  if (dkimCreator) {
461
- await this.createDkimRecords(domainConfigs, dkimCreator);
470
+ await this.createDkimRecords(internalDnsDomains, dkimCreator);
462
471
  }
463
472
  }
464
473
  }
@@ -548,7 +557,10 @@ export class DnsManager {
548
557
  * Ensure DNS records and validate public DNS state.
549
558
  * Prefer the split lifecycle methods when validation must not block readiness.
550
559
  */
551
- async ensureDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise<void> {
560
+ async ensureDnsRecords(
561
+ domainConfigs: IEmailDomainConfig[],
562
+ dkimCreator?: DKIMCreator,
563
+ ): Promise<void> {
552
564
  await this.provisionLocalDnsRecords(domainConfigs, dkimCreator);
553
565
  await this.validateAndReportDnsRecords(domainConfigs);
554
566
  }
@@ -658,7 +670,7 @@ export class DnsManager {
658
670
  - DKIM: Will be created when keys are generated`);
659
671
 
660
672
  } catch (error) {
661
- logger.log('error', `Failed to create DNS records for ${domain}: ${error.message}`);
673
+ logger.log('error', `Failed to create DNS records for ${domain}: ${getErrorMessage(error)}`);
662
674
  }
663
675
  }
664
676
  }
@@ -666,13 +678,24 @@ export class DnsManager {
666
678
  /**
667
679
  * Create DKIM DNS records for all domains
668
680
  */
669
- private async createDkimRecords(domainConfigs: IEmailDomainConfig[], dkimCreator: any): Promise<void> {
681
+ private async createDkimRecords(
682
+ domainConfigs: IEmailDomainConfig[],
683
+ dkimCreator: DKIMCreator,
684
+ ): Promise<void> {
670
685
  for (const domainConfig of domainConfigs) {
671
686
  const domain = domainConfig.domain;
672
687
  const selector = domainConfig.dkim?.selector || 'default';
673
688
 
674
689
  try {
675
- // Get DKIM DNS record from DKIMCreator
690
+ if (this.keyProvisioning === 'caller-managed') {
691
+ // Ownership remains entirely with the embedding application. This is
692
+ // a read-only validity check: no handler, storage record, or selector
693
+ // mutation is permitted in caller-managed mode.
694
+ await dkimCreator.getDNSRecordForSelector(domain, selector);
695
+ logger.log('info', `Verified caller-managed DKIM record for ${selector}._domainkey.${domain}`);
696
+ continue;
697
+ }
698
+
676
699
  const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain, selector);
677
700
 
678
701
  // For internal-dns domains, register the DNS handler
@@ -711,7 +734,7 @@ export class DnsManager {
711
734
  }
712
735
 
713
736
  } catch (error) {
714
- logger.log('warn', `Could not create DKIM DNS record for ${domain}: ${error.message}`);
737
+ logger.log('warn', `Could not create DKIM DNS record for ${domain}: ${getErrorMessage(error)}`);
715
738
  }
716
739
  }
717
740
  }
@@ -1,4 +1,5 @@
1
1
  import { logger } from '../../logger.js';
2
+ import { getErrorMessage } from '../../functions.errors.js';
2
3
  import {
3
4
  SecurityLogger,
4
5
  SecurityLogLevel,
@@ -8,7 +9,12 @@ import type { IEmailAction, IEmailContext } from './interfaces.js';
8
9
  import { Email } from '../core/classes.email.js';
9
10
  import { BounceManager } from '../core/classes.bouncemanager.js';
10
11
  import { UnifiedDeliveryQueue } from '../delivery/classes.delivery.queue.js';
11
- import type { ISmtpSendResult } from '../../security/classes.rustsecuritybridge.js';
12
+ import { callObserverSafely } from '../delivery/functions.safe-observers.js';
13
+ import {
14
+ SmtpDeliveryError,
15
+ type ISmtpRecipientResult,
16
+ type ISmtpSendResult,
17
+ } from '../../security/classes.rustsecuritybridge.js';
12
18
 
13
19
  /**
14
20
  * Dependencies injected from UnifiedEmailServer to avoid circular imports
@@ -30,6 +36,31 @@ export interface IActionExecutorDeps {
30
36
  export class EmailActionExecutor {
31
37
  constructor(private deps: IActionExecutorDeps) {}
32
38
 
39
+ private async suppressPermanentRcptFailures(
40
+ email: Email,
41
+ results: ISmtpRecipientResult[],
42
+ ): Promise<void> {
43
+ for (const result of results) {
44
+ if (result.accepted || result.responseCode < 500) continue;
45
+ try {
46
+ await this.deps.bounceManager.processSmtpFailure(
47
+ result.recipient,
48
+ `SMTP recipient rejected with ${result.responseCode}`,
49
+ {
50
+ sender: email.from,
51
+ originalEmailId: email.headers['Message-ID'] as string,
52
+ statusCode: String(result.responseCode),
53
+ },
54
+ );
55
+ } catch (error) {
56
+ logger.log(
57
+ 'error',
58
+ `Failed to persist permanent RCPT failure for ${result.recipient}: ${(error as Error).message}`,
59
+ );
60
+ }
61
+ }
62
+ }
63
+
33
64
  async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
34
65
  switch (action.type) {
35
66
  case 'forward':
@@ -72,13 +103,14 @@ export class EmailActionExecutor {
72
103
 
73
104
  try {
74
105
  // Send email via Rust SMTP client
75
- await this.deps.sendOutboundEmail(host, port, email, {
106
+ const result = await this.deps.sendOutboundEmail(host, port, email, {
76
107
  auth: auth as { user: string; pass: string } | undefined,
77
108
  });
109
+ await this.suppressPermanentRcptFailures(email, result.recipientResults || []);
78
110
 
79
111
  logger.log('info', `Successfully forwarded email to ${host}:${port}`);
80
112
 
81
- SecurityLogger.getInstance().logEvent({
113
+ await callObserverSafely('forward success security log', () => SecurityLogger.getInstance().logEvent({
82
114
  level: SecurityLogLevel.INFO,
83
115
  type: SecurityEventType.EMAIL_FORWARDING,
84
116
  message: 'Email forwarded successfully',
@@ -91,11 +123,12 @@ export class EmailActionExecutor {
91
123
  recipients: email.to
92
124
  },
93
125
  success: true
94
- });
126
+ }));
95
127
  } catch (error) {
96
- logger.log('error', `Failed to forward email: ${error.message}`);
128
+ const errorMessage = getErrorMessage(error);
129
+ logger.log('error', `Failed to forward email: ${errorMessage}`);
97
130
 
98
- SecurityLogger.getInstance().logEvent({
131
+ await callObserverSafely('forward failure security log', () => SecurityLogger.getInstance().logEvent({
99
132
  level: SecurityLogLevel.ERROR,
100
133
  type: SecurityEventType.EMAIL_FORWARDING,
101
134
  message: 'Email forwarding failed',
@@ -105,17 +138,31 @@ export class EmailActionExecutor {
105
138
  routeName: context.session.matchedRoute?.name,
106
139
  targetHost: host,
107
140
  targetPort: port,
108
- error: error.message
141
+ error: errorMessage
109
142
  },
110
143
  success: false
111
- });
112
-
113
- // Handle as bounce
114
- for (const recipient of email.getAllRecipients()) {
115
- await this.deps.bounceManager.processSmtpFailure(recipient, error.message, {
116
- sender: email.from,
117
- originalEmailId: email.headers['Message-ID'] as string
118
- });
144
+ }));
145
+
146
+ const smtpError = SmtpDeliveryError.from(error);
147
+ if (
148
+ !smtpError.retryable
149
+ && smtpError.errorType === 'protocol'
150
+ && smtpError.phase === 'rcpt_to'
151
+ && (
152
+ (typeof smtpError.smtpCode === 'number' && smtpError.smtpCode >= 500)
153
+ || smtpError.recipientResults.some(
154
+ (result) => !result.accepted && result.responseCode >= 500,
155
+ )
156
+ )
157
+ ) {
158
+ const permanentResults = smtpError.recipientResults.length > 0
159
+ ? smtpError.recipientResults
160
+ : smtpError.recipients.map((recipient) => ({
161
+ recipient,
162
+ accepted: false,
163
+ responseCode: smtpError.smtpCode!,
164
+ }));
165
+ await this.suppressPermanentRcptFailures(email, permanentResults);
119
166
  }
120
167
  throw error;
121
168
  }
@@ -151,7 +198,7 @@ export class EmailActionExecutor {
151
198
 
152
199
  logger.log('info', `Rejecting email with code ${code}: ${message}`);
153
200
 
154
- SecurityLogger.getInstance().logEvent({
201
+ await callObserverSafely('routing rejection security log', () => SecurityLogger.getInstance().logEvent({
155
202
  level: SecurityLogLevel.WARN,
156
203
  type: SecurityEventType.EMAIL_PROCESSING,
157
204
  message: 'Email rejected by routing rule',
@@ -165,7 +212,7 @@ export class EmailActionExecutor {
165
212
  to: _email.to
166
213
  },
167
214
  success: false
168
- });
215
+ }));
169
216
 
170
217
  // Throw error with SMTP code and message
171
218
  const error = new Error(message);
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
  import type { IStorageManager } from '../interfaces.storage.js';
4
4
  import type { IEmailRoute, IEmailMatch, IEmailAction, IEmailContext } from './interfaces.js';
5
5
  import type { Email } from '../core/classes.email.js';
6
+ import { getErrorMessage } from '../../functions.errors.js';
6
7
 
7
8
  /**
8
9
  * Email router that evaluates routes and determines actions
@@ -12,6 +13,7 @@ export class EmailRouter extends EventEmitter {
12
13
  private patternCache: Map<string, boolean> = new Map();
13
14
  private storageManager?: IStorageManager;
14
15
  private persistChanges: boolean;
16
+ private initializationPromise: Promise<void>;
15
17
 
16
18
  /**
17
19
  * Create a new email router
@@ -27,12 +29,17 @@ export class EmailRouter extends EventEmitter {
27
29
  this.storageManager = options?.storageManager;
28
30
  this.persistChanges = options?.persistChanges ?? !!this.storageManager;
29
31
 
30
- // If storage manager is provided, try to load persisted routes
31
- if (this.storageManager) {
32
- this.loadRoutes({ merge: true }).catch(error => {
33
- console.error(`Failed to load persisted routes: ${error.message}`);
34
- });
35
- }
32
+ // Capture hydration so callers can establish a real readiness barrier before routing.
33
+ const hydrationPromise = this.storageManager
34
+ ? this.loadRoutes({ merge: true }).then(() => undefined)
35
+ : Promise.resolve();
36
+ hydrationPromise.catch(() => undefined);
37
+ this.initializationPromise = hydrationPromise;
38
+ }
39
+
40
+ /** Wait until managed routes have been hydrated. */
41
+ public async initialize(): Promise<void> {
42
+ await this.initializationPromise;
36
43
  }
37
44
 
38
45
  /**
@@ -413,7 +420,7 @@ export class EmailRouter extends EventEmitter {
413
420
 
414
421
  this.emit('routesPersisted', this.routes.length);
415
422
  } catch (error) {
416
- console.error(`Failed to save routes: ${error.message}`);
423
+ console.error(`Failed to save routes: ${getErrorMessage(error)}`);
417
424
  throw error;
418
425
  }
419
426
  }
@@ -473,7 +480,7 @@ export class EmailRouter extends EventEmitter {
473
480
 
474
481
  return loadedRoutes;
475
482
  } catch (error) {
476
- console.error(`Failed to load routes: ${error.message}`);
483
+ console.error(`Failed to load routes: ${getErrorMessage(error)}`);
477
484
  throw error;
478
485
  }
479
486
  }