@push.rocks/smartmta 8.0.0 → 8.1.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 (45) hide show
  1. package/changelog.md +41 -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/index.d.ts +5 -0
  6. package/dist_ts/index.js +6 -1
  7. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +15 -2
  8. package/dist_ts/mail/delivery/classes.delivery.queue.js +91 -8
  9. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +13 -2
  10. package/dist_ts/mail/delivery/classes.delivery.system.js +106 -36
  11. package/dist_ts/mail/delivery/index.d.ts +1 -0
  12. package/dist_ts/mail/delivery/index.js +2 -1
  13. package/dist_ts/mail/index.d.ts +1 -0
  14. package/dist_ts/mail/index.js +1 -1
  15. package/dist_ts/mail/routing/classes.domain.registry.js +5 -1
  16. package/dist_ts/mail/routing/classes.email.action.executor.js +3 -1
  17. package/dist_ts/mail/routing/classes.email.router.d.ts +2 -0
  18. package/dist_ts/mail/routing/classes.email.router.js +28 -21
  19. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +85 -3
  20. package/dist_ts/mail/routing/classes.unified.email.server.js +517 -60
  21. package/dist_ts/mail/routing/functions.routing.validation.d.ts +8 -0
  22. package/dist_ts/mail/routing/functions.routing.validation.js +18 -0
  23. package/dist_ts/mail/routing/interfaces.d.ts +11 -1
  24. package/dist_ts/plugins.d.ts +2 -1
  25. package/dist_ts/plugins.js +3 -2
  26. package/dist_ts/security/classes.rustsecuritybridge.d.ts +32 -1
  27. package/dist_ts/security/classes.rustsecuritybridge.js +13 -1
  28. package/dist_ts/security/index.d.ts +1 -1
  29. package/dist_ts/security/index.js +1 -1
  30. package/package.json +1 -1
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/index.ts +5 -0
  33. package/ts/mail/delivery/classes.delivery.queue.ts +126 -6
  34. package/ts/mail/delivery/classes.delivery.system.ts +137 -41
  35. package/ts/mail/delivery/index.ts +1 -0
  36. package/ts/mail/index.ts +1 -0
  37. package/ts/mail/routing/classes.domain.registry.ts +4 -0
  38. package/ts/mail/routing/classes.email.action.executor.ts +4 -0
  39. package/ts/mail/routing/classes.email.router.ts +33 -20
  40. package/ts/mail/routing/classes.unified.email.server.ts +703 -72
  41. package/ts/mail/routing/functions.routing.validation.ts +36 -0
  42. package/ts/mail/routing/interfaces.ts +13 -1
  43. package/ts/plugins.ts +2 -0
  44. package/ts/security/classes.rustsecuritybridge.ts +46 -1
  45. package/ts/security/index.ts +3 -0
@@ -10,6 +10,7 @@ import {
10
10
  UnifiedDeliveryQueue,
11
11
  type IQueueItem,
12
12
  type IRecipientPermanentFailure,
13
+ type IBounceOperation,
13
14
  } from './classes.delivery.queue.js';
14
15
  import { Email } from '../core/classes.email.js';
15
16
  import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
@@ -41,6 +42,17 @@ export interface IDeliveryHandler {
41
42
  deliver(item: IQueueItem): Promise<any>;
42
43
  }
43
44
 
45
+ export interface IDeliveryFailureNotice {
46
+ itemId: string;
47
+ operation: Readonly<IBounceOperation>;
48
+ sender: string;
49
+ headers: Readonly<Record<string, string>>;
50
+ }
51
+
52
+ export interface IDeliveryFailureHandler {
53
+ processDeliveryFailure(notice: IDeliveryFailureNotice): Promise<void>;
54
+ }
55
+
44
56
  /**
45
57
  * Delivery options
46
58
  */
@@ -71,7 +83,9 @@ export interface IMultiModeDeliveryOptions {
71
83
  bounceHandler?: {
72
84
  processSmtpFailure: (recipient: string, smtpResponse: string, options: any) => Promise<any>;
73
85
  };
74
-
86
+ /** Durable, idempotent terminal-delivery notice consumer. */
87
+ deliveryFailureHandler?: IDeliveryFailureHandler;
88
+
75
89
  // Event hooks
76
90
  onDeliveryStart?: (item: IQueueItem) => Promise<void>;
77
91
  onDeliverySuccess?: (item: IQueueItem, result: any) => Promise<void>;
@@ -112,9 +126,10 @@ export interface IDeliveryStats {
112
126
  */
113
127
  type TResolvedMultiModeDeliveryOptions = Omit<
114
128
  Required<IMultiModeDeliveryOptions>,
115
- 'bounceHandler'
129
+ 'bounceHandler' | 'deliveryFailureHandler'
116
130
  > & {
117
131
  bounceHandler?: NonNullable<IMultiModeDeliveryOptions['bounceHandler']>;
132
+ deliveryFailureHandler: IDeliveryFailureHandler;
118
133
  };
119
134
 
120
135
  export class MultiModeDeliverySystem extends EventEmitter {
@@ -165,6 +180,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
165
180
  perPatternRateLimit: options.perPatternRateLimit || {},
166
181
  processBounces: options.processBounces !== false, // Default to true
167
182
  bounceHandler: options.bounceHandler,
183
+ deliveryFailureHandler: options.deliveryFailureHandler || {
184
+ processDeliveryFailure: async (notice) => {
185
+ logger.log('warn', `Terminal delivery failure persisted for ${notice.operation.recipient}`);
186
+ await emitSafely(this, 'deliveryFailureNotice', notice);
187
+ },
188
+ },
168
189
  onDeliveryStart: options.onDeliveryStart || (async () => {}),
169
190
  onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
170
191
  onDeliveryFailed: options.onDeliveryFailed || (async () => {})
@@ -313,34 +334,68 @@ export class MultiModeDeliverySystem extends EventEmitter {
313
334
  }
314
335
 
315
336
  /**
316
- * Drain durable bounce work with one in-process flight. Custom handlers
337
+ * Drain durable failure work with one in-process flight. Custom handlers
317
338
  * receive operationId and must use it for their own idempotency.
318
339
  */
319
340
  public async drainBounceOutbox(): Promise<void> {
320
341
  if (this.bounceDrainPromise) return this.bounceDrainPromise;
321
342
  const drain = (async () => {
322
- if (!this.options.processBounces || !this.options.bounceHandler) return;
323
- for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
343
+ if (this.options.processBounces && this.options.bounceHandler) {
344
+ for (const { itemId, operation } of this.queue.listPendingBounceOperations()) {
345
+ const item = this.queue.getItem(itemId);
346
+ if (!item) continue;
347
+ const email = item.processingResult as Email;
348
+ try {
349
+ await this.options.bounceHandler.processSmtpFailure(
350
+ operation.recipient,
351
+ operation.smtpResponse,
352
+ {
353
+ operationId: operation.operationId,
354
+ sender: email.from,
355
+ originalEmailId: item.id,
356
+ headers: email.headers,
357
+ statusCode: String(operation.smtpCode),
358
+ },
359
+ );
360
+ await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
361
+ } catch (error) {
362
+ logger.log(
363
+ 'error',
364
+ `Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
365
+ );
366
+ try {
367
+ await this.queue.markBounceOperationFailed(
368
+ itemId,
369
+ operation.operationId,
370
+ (error as Error).message,
371
+ );
372
+ } catch (checkpointError) {
373
+ logger.log(
374
+ 'error',
375
+ `Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
376
+ );
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ for (const { itemId, operation } of this.queue.listPendingDeliveryFailureOperations()) {
324
383
  const item = this.queue.getItem(itemId);
325
384
  if (!item) continue;
326
385
  const email = item.processingResult as Email;
386
+ const notice: IDeliveryFailureNotice = {
387
+ itemId,
388
+ operation,
389
+ sender: email.from,
390
+ headers: { ...email.headers },
391
+ };
327
392
  try {
328
- await this.options.bounceHandler.processSmtpFailure(
329
- operation.recipient,
330
- operation.smtpResponse,
331
- {
332
- operationId: operation.operationId,
333
- sender: email.from,
334
- originalEmailId: item.id,
335
- headers: email.headers,
336
- statusCode: String(operation.smtpCode),
337
- },
338
- );
393
+ await this.options.deliveryFailureHandler.processDeliveryFailure(notice);
339
394
  await this.queue.markBounceOperationCompleted(itemId, operation.operationId);
340
395
  } catch (error) {
341
396
  logger.log(
342
397
  'error',
343
- `Bounce operation ${operation.operationId} failed: ${(error as Error).message}`,
398
+ `Delivery-failure operation ${operation.operationId} failed: ${(error as Error).message}`,
344
399
  );
345
400
  try {
346
401
  await this.queue.markBounceOperationFailed(
@@ -351,7 +406,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
351
406
  } catch (checkpointError) {
352
407
  logger.log(
353
408
  'error',
354
- `Could not checkpoint failed bounce operation ${operation.operationId}: ${(checkpointError as Error).message}`,
409
+ `Could not checkpoint failed delivery-failure operation ${operation.operationId}: ${(checkpointError as Error).message}`,
355
410
  );
356
411
  }
357
412
  }
@@ -619,6 +674,12 @@ export class MultiModeDeliverySystem extends EventEmitter {
619
674
 
620
675
  const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
621
676
  retryable: deliveryError.retryable,
677
+ terminalFailure: {
678
+ failureClass: deliveryError.errorType.startsWith('self_mx_')
679
+ ? 'localTopology'
680
+ : 'transport',
681
+ ...(deliveryError.smtpCode !== undefined ? { smtpCode: deliveryError.smtpCode } : {}),
682
+ },
622
683
  });
623
684
  if (this.running) void this.drainBounceOutbox();
624
685
  if (!transition.updated) return;
@@ -832,6 +893,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
832
893
  && typeof failure.smtpCode === 'number'
833
894
  && failure.smtpCode >= 500
834
895
  ));
896
+ const allLocalTopologyFailures = normalized.every((failure) => (
897
+ failure.errorType.startsWith('self_mx_')
898
+ ));
835
899
  const selected = normalized.find((failure) => failure.retryable)
836
900
  || normalized[normalized.length - 1];
837
901
  const smtpCode = allPermanentRecipientRejections
@@ -839,7 +903,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
839
903
  : selected.smtpCode;
840
904
  return new SmtpDeliveryError({
841
905
  message: normalized.map((failure) => failure.message).join('; '),
842
- errorType: allPermanentRecipientRejections ? 'protocol' : 'smtp_aggregate',
906
+ errorType: allPermanentRecipientRejections
907
+ ? 'protocol'
908
+ : allLocalTopologyFailures
909
+ ? selected.errorType
910
+ : 'smtp_aggregate',
843
911
  retryable,
844
912
  ...(smtpCode !== undefined ? { smtpCode } : {}),
845
913
  phase: allPermanentRecipientRejections ? 'rcpt_to' : selected.phase,
@@ -903,37 +971,65 @@ export class MultiModeDeliverySystem extends EventEmitter {
903
971
 
904
972
  for (const [domain, recipients] of domainGroups) {
905
973
  const allMxHosts = await this.resolveMxForDomain(domain);
974
+ let recipientsForMx = [...recipients];
975
+ const domainFailures: SmtpDeliveryError[] = [];
976
+ const acceptedForDomain: string[] = [];
977
+ const mxHosts: typeof allMxHosts = [];
978
+
979
+ // RFC 5321 §5.3.6: skip local MX identities but continue to non-local
980
+ // backup MX hosts. Proxy connection targets are intentionally absent
981
+ // from this identity check.
982
+ for (const mx of allMxHosts) {
983
+ const identity = await this.emailServer.resolveSelfMxIdentity(mx.exchange);
984
+ if (!identity.isSelf && !identity.indeterminate) {
985
+ mxHosts.push(mx);
986
+ continue;
987
+ }
906
988
 
907
- // RFC 5321 §5.3.6: never relay to an MX that is this server itself —
908
- // that is a mail loop (observed live: a managed domain whose MX record
909
- // points at our own mail hostname re-enters the inbound pipeline).
910
- const mxHosts = allMxHosts.filter(mx => !this.emailServer!.isSelfHostname(mx.exchange));
911
- if (mxHosts.length === 0) {
912
- const selfMx = allMxHosts.map(mx => mx.exchange).join(', ');
989
+ const startedAt = new Date();
990
+ const retryable = identity.source !== 'explicit';
991
+ const errorType = identity.indeterminate
992
+ ? 'self_mx_dns_indeterminate'
993
+ : identity.source === 'explicit'
994
+ ? 'self_mx_config'
995
+ : 'self_mx_loop';
996
+ const reason = identity.indeterminate
997
+ ? 'could not be safely classified against current DNS and listener topology'
998
+ : identity.source === 'explicit'
999
+ ? 'exactly matches a configured listener identity'
1000
+ : 'resolves to the current listener topology';
1001
+ const evidence = identity.evidence.length > 0
1002
+ ? ` (${identity.evidence.join('; ')})`
1003
+ : '';
913
1004
  const loopError = new SmtpDeliveryError({
914
- message: `MX for ${domain} (${selfMx || 'none'}) points back at this server — refusing self-delivery to avoid a mail loop`,
915
- errorType: 'config',
916
- retryable: false,
1005
+ message: identity.indeterminate
1006
+ ? `MX ${mx.exchange} for ${domain} ${reason}${evidence}`
1007
+ : `MX ${mx.exchange} for ${domain} points back at this server because it ${reason}${evidence}`,
1008
+ errorType,
1009
+ retryable,
917
1010
  phase: 'connect',
918
- transactionLog: [],
1011
+ transactionLog: [{
1012
+ timestampMs: Date.now(),
1013
+ phase: 'connect',
1014
+ direction: 'system',
1015
+ text: `Skipped ${mx.exchange}: ${reason}`,
1016
+ }],
919
1017
  recipients,
920
1018
  recipientDomain: domain,
921
1019
  });
922
- unresolvedFailures.push(loopError);
923
- unresolvedRecipients.push(...recipients);
924
- results.push({
925
- domain,
926
- success: false,
927
- error: loopError.message,
1020
+ await this.persistSmtpAttempt({
1021
+ item,
1022
+ targetHost: mx.exchange,
1023
+ targetPort: 25,
1024
+ recipientDomain: domain,
1025
+ recipients,
1026
+ startedAt,
1027
+ error: loopError,
928
1028
  });
929
- logger.log('error', `MTA: refusing self-delivery for ${domain} — MX (${selfMx}) is this server's own hostname (mail loop)`);
930
- continue;
1029
+ domainFailures.push(loopError);
1030
+ logger.log(retryable ? 'warn' : 'error', `MTA: skipped MX ${mx.exchange} for ${domain}: ${loopError.message}`);
931
1031
  }
932
1032
 
933
- let recipientsForMx = [...recipients];
934
- const domainFailures: SmtpDeliveryError[] = [];
935
- const acceptedForDomain: string[] = [];
936
-
937
1033
  for (const mx of mxHosts) {
938
1034
  if (recipientsForMx.length === 0) break;
939
1035
  const startedAt = new Date();
@@ -1,4 +1,5 @@
1
1
  // Email delivery components
2
+ export * from './interfaces.js';
2
3
  export * from './classes.delivery.queue.js';
3
4
  export * from './classes.delivery.system.js';
4
5
  export { DeliveryStatus } from './classes.delivery.system.js';
package/ts/mail/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from './interfaces.storage.js';
3
3
  export * from './routing/index.js';
4
4
  export * from './security/index.js';
5
+ export type { ISmtpTransactionAttempt } from './delivery/interfaces.js';
5
6
 
6
7
  // Make the core and delivery modules accessible
7
8
  import * as Core from './core/index.js';
@@ -1,5 +1,6 @@
1
1
  import type { IEmailDomainConfig } from './interfaces.js';
2
2
  import { logger } from '../../logger.js';
3
+ import { assertValidInboundPolicy } from './functions.routing.validation.js';
3
4
 
4
5
  /**
5
6
  * Registry for email domain configurations
@@ -30,6 +31,9 @@ export class DomainRegistry {
30
31
 
31
32
  // Process and store domain configurations
32
33
  for (const config of domainConfigs) {
34
+ if (config.inboundPolicy) {
35
+ assertValidInboundPolicy(config.inboundPolicy, config.domain);
36
+ }
33
37
  const processedConfig = this.applyDefaults(config);
34
38
  this.domains.set(config.domain.toLowerCase(), processedConfig);
35
39
  logger.log('info', `Registered domain: ${config.domain} with DNS mode: ${processedConfig.dnsMode}`);
@@ -72,6 +72,10 @@ export class EmailActionExecutor {
72
72
  case 'deliver':
73
73
  await this.handleDeliverAction(action, email, context);
74
74
  break;
75
+ case 'store':
76
+ throw new Error(
77
+ 'Store actions require raw SMTP context and an awaited hooks.onAcceptEnvelope handler',
78
+ );
75
79
  case 'reject':
76
80
  await this.handleRejectAction(action, email, context);
77
81
  break;
@@ -4,6 +4,7 @@ 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
6
  import { getErrorMessage } from '../../functions.errors.js';
7
+ import { assertValidRetentionDays } from './functions.routing.validation.js';
7
8
 
8
9
  /**
9
10
  * Email router that evaluates routes and determines actions
@@ -25,6 +26,7 @@ export class EmailRouter extends EventEmitter {
25
26
  persistChanges?: boolean;
26
27
  }) {
27
28
  super();
29
+ this.validateRoutes(routes);
28
30
  this.routes = this.sortRoutesByPriority(routes);
29
31
  this.storageManager = options?.storageManager;
30
32
  this.persistChanges = options?.persistChanges ?? !!this.storageManager;
@@ -41,6 +43,32 @@ export class EmailRouter extends EventEmitter {
41
43
  public async initialize(): Promise<void> {
42
44
  await this.initializationPromise;
43
45
  }
46
+
47
+ private validateRoute(route: IEmailRoute): void {
48
+ if (!route?.name || !route.match || !route.action) {
49
+ throw new Error('Invalid route: missing required fields');
50
+ }
51
+
52
+ if (route.action.type !== 'store') {
53
+ return;
54
+ }
55
+ if (!route.action.store) {
56
+ throw new Error(`Store route ${route.name} requires store configuration`);
57
+ }
58
+ assertValidRetentionDays(
59
+ route.action.store.retentionDays,
60
+ `Store route ${route.name}`,
61
+ );
62
+ }
63
+
64
+ private validateRoutes(routes: IEmailRoute[]): void {
65
+ if (!Array.isArray(routes)) {
66
+ throw new Error('Invalid routes: expected an array');
67
+ }
68
+ for (const route of routes) {
69
+ this.validateRoute(route);
70
+ }
71
+ }
44
72
 
45
73
  /**
46
74
  * Sort routes by priority (higher priority first)
@@ -69,6 +97,7 @@ export class EmailRouter extends EventEmitter {
69
97
  * @param persist Whether to persist changes (defaults to persistChanges setting)
70
98
  */
71
99
  public async updateRoutes(routes: IEmailRoute[], persist?: boolean): Promise<void> {
100
+ this.validateRoutes(routes);
72
101
  this.routes = this.sortRoutesByPriority(routes);
73
102
  this.clearCache();
74
103
  this.emit('routesUpdated', this.routes);
@@ -409,11 +438,7 @@ export class EmailRouter extends EventEmitter {
409
438
 
410
439
  try {
411
440
  // Validate all routes before saving
412
- for (const route of this.routes) {
413
- if (!route.name || !route.match || !route.action) {
414
- throw new Error(`Invalid route: ${JSON.stringify(route)}`);
415
- }
416
- }
441
+ this.validateRoutes(this.routes);
417
442
 
418
443
  const routesData = JSON.stringify(this.routes, null, 2);
419
444
  await this.storageManager.set('/email/routes/config.json', routesData);
@@ -447,13 +472,7 @@ export class EmailRouter extends EventEmitter {
447
472
 
448
473
  const loadedRoutes = JSON.parse(routesData) as IEmailRoute[];
449
474
 
450
- // Validate loaded routes
451
- for (const route of loadedRoutes) {
452
- if (!route.name || !route.match || !route.action) {
453
- console.warn(`Skipping invalid route: ${JSON.stringify(route)}`);
454
- continue;
455
- }
456
- }
475
+ this.validateRoutes(loadedRoutes);
457
476
 
458
477
  if (options?.replace) {
459
478
  // Replace all routes
@@ -491,10 +510,7 @@ export class EmailRouter extends EventEmitter {
491
510
  * @param persist Whether to persist changes
492
511
  */
493
512
  public async addRoute(route: IEmailRoute, persist?: boolean): Promise<void> {
494
- // Validate route
495
- if (!route.name || !route.match || !route.action) {
496
- throw new Error('Invalid route: missing required fields');
497
- }
513
+ this.validateRoute(route);
498
514
 
499
515
  // Check if route already exists
500
516
  const existingIndex = this.routes.findIndex(r => r.name === route.name);
@@ -547,10 +563,7 @@ export class EmailRouter extends EventEmitter {
547
563
  * @param persist Whether to persist changes
548
564
  */
549
565
  public async updateRoute(name: string, route: IEmailRoute, persist?: boolean): Promise<void> {
550
- // Validate route
551
- if (!route.name || !route.match || !route.action) {
552
- throw new Error('Invalid route: missing required fields');
553
- }
566
+ this.validateRoute(route);
554
567
 
555
568
  const index = this.routes.findIndex(r => r.name === name);
556
569