@push.rocks/smartmta 9.4.1 → 9.5.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 (35) hide show
  1. package/changelog.md +14 -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 +1 -0
  6. package/dist_ts/index.js +2 -1
  7. package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +63 -2
  8. package/dist_ts/mail/delivery/classes.delivery.queue.js +749 -193
  9. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +6 -0
  10. package/dist_ts/mail/delivery/classes.delivery.system.js +146 -62
  11. package/dist_ts/mail/delivery/functions.raw-email.d.ts +9 -0
  12. package/dist_ts/mail/delivery/functions.raw-email.js +85 -0
  13. package/dist_ts/mail/delivery/symbols.delivery.queue.d.ts +6 -0
  14. package/dist_ts/mail/delivery/symbols.delivery.queue.js +7 -0
  15. package/dist_ts/mail/index.d.ts +1 -0
  16. package/dist_ts/mail/index.js +1 -1
  17. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +40 -0
  18. package/dist_ts/mail/routing/classes.unified.email.server.js +222 -76
  19. package/dist_ts/mail/routing/symbols.unified.email.server.d.ts +2 -0
  20. package/dist_ts/mail/routing/symbols.unified.email.server.js +3 -0
  21. package/dist_ts/security/classes.rustsecuritybridge.d.ts +11 -0
  22. package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
  23. package/package.json +2 -2
  24. package/readme.hints.md +8 -0
  25. package/readme.md +34 -3
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/index.ts +1 -0
  28. package/ts/mail/delivery/classes.delivery.queue.ts +923 -226
  29. package/ts/mail/delivery/classes.delivery.system.ts +192 -80
  30. package/ts/mail/delivery/functions.raw-email.ts +111 -0
  31. package/ts/mail/delivery/symbols.delivery.queue.ts +6 -0
  32. package/ts/mail/index.ts +1 -0
  33. package/ts/mail/routing/classes.unified.email.server.ts +329 -85
  34. package/ts/mail/routing/symbols.unified.email.server.ts +6 -0
  35. package/ts/security/classes.rustsecuritybridge.ts +6 -0
@@ -23,6 +23,15 @@ import type {
23
23
  } from '../../security/classes.rustsecuritybridge.js';
24
24
  import type { ISmtpTransactionAttempt } from './interfaces.js';
25
25
  import { callObserverSafely, emitSafely } from './functions.safe-observers.js';
26
+ import {
27
+ kQueueCreateItemSnapshot,
28
+ kQueueSetReadyItemsHandler,
29
+ kQueueWithRawMessage,
30
+ } from './symbols.delivery.queue.js';
31
+ import {
32
+ kSendOutboundEmailInternal,
33
+ kSendRawOutboundEmailInternal,
34
+ } from '../routing/symbols.unified.email.server.js';
26
35
 
27
36
  const dns = plugins.dns;
28
37
 
@@ -148,8 +157,20 @@ export class MultiModeDeliverySystem extends EventEmitter {
148
157
  private emailServer?: UnifiedEmailServer;
149
158
  private bounceDrainTimer?: NodeJS.Timeout;
150
159
  private bounceDrainPromise?: Promise<void>;
151
- private readonly queueItemsReadyHandler = (items: IQueueItem[]): Promise<void> =>
152
- this.processItems(items);
160
+ private readonly dispatchFlights = new Set<Promise<void>>();
161
+ private readonly deliveryFlights = new Set<Promise<void>>();
162
+ private readonly authenticationUpdateFlights = new Set<Promise<void>>();
163
+ private stopFlight?: Promise<void>;
164
+ private readonly queueItemsReadyHandler = (itemIds: string[]): Promise<void> => {
165
+ let flight: Promise<void>;
166
+ flight = Promise.resolve()
167
+ .then(() => this.processItems(itemIds))
168
+ .finally(() => {
169
+ this.dispatchFlights.delete(flight);
170
+ });
171
+ this.dispatchFlights.add(flight);
172
+ return flight;
173
+ };
153
174
  private queueListenerRegistered = false;
154
175
  private lifecycleGeneration = 0;
155
176
  private pendingSmtpAuthenticationUpdates = new Map<string, {
@@ -233,16 +254,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
233
254
 
234
255
  private registerQueueListener(): void {
235
256
  if (this.queueListenerRegistered) return;
236
- this.queue.on('itemsReady', this.queueItemsReadyHandler);
257
+ this.queue[kQueueSetReadyItemsHandler](this.queueItemsReadyHandler);
237
258
  this.queueListenerRegistered = true;
238
259
  }
239
260
 
240
261
  private unregisterQueueListener(): void {
241
262
  if (!this.queueListenerRegistered) return;
242
- this.queue.off('itemsReady', this.queueItemsReadyHandler);
263
+ this.queue[kQueueSetReadyItemsHandler](undefined);
243
264
  this.queueListenerRegistered = false;
244
265
  }
245
266
 
267
+ private async emitItemEventSafely(
268
+ eventNameArg: string,
269
+ itemArg: IQueueItem,
270
+ ...additionalArgsArg: unknown[]
271
+ ): Promise<void> {
272
+ for (const rawListener of this.rawListeners(eventNameArg)) {
273
+ try {
274
+ await Reflect.apply(rawListener, this, [
275
+ this.queue[kQueueCreateItemSnapshot](itemArg),
276
+ ...additionalArgsArg,
277
+ ]);
278
+ } catch (error) {
279
+ logger.log(
280
+ 'error',
281
+ `Observer for ${eventNameArg} failed: ${getErrorMessage(error)}`,
282
+ );
283
+ }
284
+ }
285
+ }
286
+
246
287
  private createEmailForRecipients(email: Email, recipients: string[]): Email {
247
288
  const recipientSet = new Set(recipients.map((recipient) => recipient.toLowerCase()));
248
289
  return new Email({
@@ -368,20 +409,27 @@ export class MultiModeDeliverySystem extends EventEmitter {
368
409
  }
369
410
  await this.persistSmtpAuthenticationUpdate(update);
370
411
  };
371
- void authenticationHandle.result.then(
372
- persistAuthentication,
373
- (failureArg) => persistAuthentication({
374
- state: 'indeterminate',
375
- overall: 'indeterminate',
376
- checkedAt: new Date().toISOString(),
377
- error: String(failureArg instanceof Error ? failureArg.message : failureArg).slice(0, 512),
378
- }),
379
- ).catch((error) => {
380
- logger.log(
381
- 'warn',
382
- `Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
383
- );
384
- });
412
+ let authenticationUpdateFlight: Promise<void>;
413
+ authenticationUpdateFlight = authenticationHandle.result
414
+ .then(
415
+ persistAuthentication,
416
+ (failureArg) => persistAuthentication({
417
+ state: 'indeterminate',
418
+ overall: 'indeterminate',
419
+ checkedAt: new Date().toISOString(),
420
+ error: String(failureArg instanceof Error ? failureArg.message : failureArg).slice(0, 512),
421
+ }),
422
+ )
423
+ .catch((error) => {
424
+ logger.log(
425
+ 'warn',
426
+ `Failed to persist outbound authentication self-check: ${getErrorMessage(error)}`,
427
+ );
428
+ })
429
+ .finally(() => {
430
+ this.authenticationUpdateFlights.delete(authenticationUpdateFlight);
431
+ });
432
+ this.authenticationUpdateFlights.add(authenticationUpdateFlight);
385
433
  }
386
434
  return attempt;
387
435
  }
@@ -556,7 +604,19 @@ export class MultiModeDeliverySystem extends EventEmitter {
556
604
  /**
557
605
  * Stop the delivery system
558
606
  */
559
- public async stop(): Promise<void> {
607
+ public stop(): Promise<void> {
608
+ if (this.stopFlight) return this.stopFlight;
609
+ let stopFlight: Promise<void>;
610
+ stopFlight = this.stopOnce().finally(() => {
611
+ if (this.stopFlight === stopFlight) {
612
+ this.stopFlight = undefined;
613
+ }
614
+ });
615
+ this.stopFlight = stopFlight;
616
+ return stopFlight;
617
+ }
618
+
619
+ private async stopOnce(): Promise<void> {
560
620
  logger.log('info', 'Stopping MultiModeDeliverySystem');
561
621
 
562
622
  if (!this.running) {
@@ -584,26 +644,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
584
644
  this.rateLimitLastCheck = Date.now();
585
645
  this.stats.rateLimiting.currentRate = 0;
586
646
 
587
- // Wait for active deliveries to complete
588
- if (this.activeDeliveries.size > 0) {
589
- logger.log('info', `Waiting for ${this.activeDeliveries.size} active deliveries to complete`);
590
-
591
- // Wait for a maximum of 30 seconds
592
- await new Promise<void>(resolve => {
593
- const checkInterval = setInterval(() => {
594
- if (this.activeDeliveries.size === 0) {
595
- clearInterval(checkInterval);
596
- clearTimeout(forceTimeout);
597
- resolve();
598
- }
599
- }, 1000);
600
-
601
- // Force resolve after 30 seconds
602
- const forceTimeout = setTimeout(() => {
603
- clearInterval(checkInterval);
604
- resolve();
605
- }, 30000);
606
- });
647
+ while (this.dispatchFlights.size > 0) {
648
+ await Promise.allSettled([...this.dispatchFlights]);
649
+ }
650
+ if (this.deliveryFlights.size > 0) {
651
+ logger.log('info', `Waiting for ${this.deliveryFlights.size} active deliveries to complete`);
652
+ while (this.deliveryFlights.size > 0) {
653
+ await Promise.allSettled([...this.deliveryFlights]);
654
+ }
655
+ }
656
+ while (this.authenticationUpdateFlights.size > 0) {
657
+ await Promise.allSettled([...this.authenticationUpdateFlights]);
607
658
  }
608
659
 
609
660
  await this.bounceDrainPromise;
@@ -617,7 +668,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
617
668
  * Process ready items from the queue
618
669
  * @param items Queue items ready for processing
619
670
  */
620
- private async processItems(items: IQueueItem[]): Promise<void> {
671
+ private async processItems(itemIds: string[]): Promise<void> {
621
672
  if (!this.running) {
622
673
  return;
623
674
  }
@@ -630,9 +681,9 @@ export class MultiModeDeliverySystem extends EventEmitter {
630
681
 
631
682
  let startedDeliveries = 0;
632
683
 
633
- for (const item of items) {
684
+ for (const itemId of itemIds) {
634
685
  if (this.activeDeliveries.size >= this.options.concurrentDeliveries) break;
635
- if (this.activeDeliveries.has(item.id)) continue;
686
+ if (this.activeDeliveries.has(itemId)) continue;
636
687
  if (this.checkRateLimit()) {
637
688
  logger.log('debug', 'Rate limit exceeded, throttling deliveries');
638
689
  break;
@@ -640,30 +691,43 @@ export class MultiModeDeliverySystem extends EventEmitter {
640
691
 
641
692
  // Reserve synchronously before the first await so overlapping queue events
642
693
  // cannot dispatch the same item while its durable claim is in flight.
643
- this.activeDeliveries.add(item.id);
694
+ this.activeDeliveries.add(itemId);
644
695
  this.stats.activeDeliveries = this.activeDeliveries.size;
645
696
 
646
697
  let claimed = false;
647
698
  try {
648
- claimed = await this.queue.markProcessing(item.id);
699
+ claimed = await this.queue.markProcessing(itemId);
649
700
  } catch (error) {
650
- this.activeDeliveries.delete(item.id);
701
+ this.activeDeliveries.delete(itemId);
651
702
  this.stats.activeDeliveries = this.activeDeliveries.size;
652
- logger.log('error', `Failed to claim queue item ${item.id}: ${(error as Error).message}`);
703
+ logger.log('error', `Failed to claim queue item ${itemId}: ${(error as Error).message}`);
653
704
  continue;
654
705
  }
655
706
 
656
707
  if (!claimed) {
657
- this.activeDeliveries.delete(item.id);
708
+ this.activeDeliveries.delete(itemId);
709
+ this.stats.activeDeliveries = this.activeDeliveries.size;
710
+ continue;
711
+ }
712
+
713
+ const item = this.queue.getItem(itemId);
714
+ if (!item) {
715
+ this.activeDeliveries.delete(itemId);
658
716
  this.stats.activeDeliveries = this.activeDeliveries.size;
659
717
  continue;
660
718
  }
661
719
 
662
720
  startedDeliveries++;
663
- // Deliver asynchronously
664
- this.deliverItem(item).catch(err => {
665
- logger.log('error', `Unhandled error in delivery: ${err.message}`);
666
- });
721
+ let deliveryFlight: Promise<void>;
722
+ deliveryFlight = Promise.resolve()
723
+ .then(() => this.deliverItem(item))
724
+ .catch((error) => {
725
+ logger.log('error', `Unhandled error in delivery: ${getErrorMessage(error)}`);
726
+ })
727
+ .finally(() => {
728
+ this.deliveryFlights.delete(deliveryFlight);
729
+ });
730
+ this.deliveryFlights.add(deliveryFlight);
667
731
  }
668
732
 
669
733
  if (startedDeliveries > 0) {
@@ -683,10 +747,14 @@ export class MultiModeDeliverySystem extends EventEmitter {
683
747
 
684
748
  try {
685
749
  // Call delivery start hook
686
- await callObserverSafely('delivery start hook', this.options.onDeliveryStart, item);
750
+ await callObserverSafely(
751
+ 'delivery start hook',
752
+ this.options.onDeliveryStart,
753
+ this.queue[kQueueCreateItemSnapshot](item),
754
+ );
687
755
 
688
756
  // Emit delivery start event
689
- await emitSafely(this, 'deliveryStart', item);
757
+ await this.emitItemEventSafely('deliveryStart', item);
690
758
  logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
691
759
 
692
760
  // Choose the appropriate handler based on mode
@@ -694,15 +762,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
694
762
 
695
763
  switch (item.processingMode) {
696
764
  case 'forward':
697
- result = await this.options.forwardHandler.deliver(item);
765
+ result = await this.options.forwardHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
698
766
  break;
699
767
 
700
768
  case 'mta':
701
- result = await this.options.deliveryHandler.deliver(item);
769
+ result = item.messageFormat === 'raw-rfc822'
770
+ ? await this.handleMtaDelivery(this.queue[kQueueCreateItemSnapshot](item))
771
+ : await this.options.deliveryHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
702
772
  break;
703
773
 
704
774
  case 'process':
705
- result = await this.options.processHandler.deliver(item);
775
+ result = await this.options.processHandler.deliver(this.queue[kQueueCreateItemSnapshot](item));
706
776
  break;
707
777
 
708
778
  default:
@@ -752,11 +822,11 @@ export class MultiModeDeliverySystem extends EventEmitter {
752
822
  await callObserverSafely(
753
823
  'delivery success hook',
754
824
  this.options.onDeliverySuccess,
755
- item,
825
+ this.queue[kQueueCreateItemSnapshot](item),
756
826
  result,
757
827
  );
758
828
 
759
- await emitSafely(this, 'deliverySuccess', item, result);
829
+ await this.emitItemEventSafely('deliverySuccess', item, result);
760
830
  logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
761
831
 
762
832
  await callObserverSafely('delivery security log', () => SecurityLogger.getInstance().logEvent({
@@ -816,17 +886,17 @@ export class MultiModeDeliverySystem extends EventEmitter {
816
886
  await callObserverSafely(
817
887
  'delivery failure hook',
818
888
  this.options.onDeliveryFailed,
819
- item,
889
+ this.queue[kQueueCreateItemSnapshot](item),
820
890
  deliveryError.message,
821
891
  );
822
892
  }
823
893
 
824
894
  // Emit delivery failed event
825
895
  if (transition.terminal) {
826
- await emitSafely(this, 'deliveryFailed', item, deliveryError);
827
- await emitSafely(this, 'deliveryTerminalFailure', item, deliveryError, transition);
896
+ await this.emitItemEventSafely('deliveryFailed', item, deliveryError);
897
+ await this.emitItemEventSafely('deliveryTerminalFailure', item, deliveryError, transition);
828
898
  } else {
829
- await emitSafely(this, 'deliveryDeferred', item, deliveryError, transition);
899
+ await this.emitItemEventSafely('deliveryDeferred', item, deliveryError, transition);
830
900
  }
831
901
  logger.log(
832
902
  transition.terminal ? 'error' : 'warn',
@@ -908,7 +978,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
908
978
  const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
909
979
 
910
980
  // Send via Rust SMTP client
911
- const result = await this.emailServer.sendOutboundEmail(
981
+ const result = await this.emailServer[kSendOutboundEmailInternal](
912
982
  targetServer,
913
983
  targetPort,
914
984
  this.createEmailForRecipients(email, recipients),
@@ -1066,6 +1136,18 @@ export class MultiModeDeliverySystem extends EventEmitter {
1066
1136
  logger.log('info', `MTA delivery for item ${item.id}`);
1067
1137
 
1068
1138
  const email = item.processingResult as Email;
1139
+ const rawEnvelope = item.messageFormat === 'raw-rfc822' ? item.smtpEnvelope : undefined;
1140
+ if (item.messageFormat === 'raw-rfc822' && !rawEnvelope) {
1141
+ throw new SmtpDeliveryError({
1142
+ message: `Raw queue item ${item.id} is missing its SMTP envelope metadata`,
1143
+ errorType: 'raw_queue_integrity',
1144
+ retryable: false,
1145
+ phase: 'message_body',
1146
+ transactionLog: [],
1147
+ recipients: [],
1148
+ });
1149
+ }
1150
+ const allRecipients = rawEnvelope?.to || email.getAllRecipients();
1069
1151
 
1070
1152
  // Structural invariant: `deliver` actions are terminal local deliveries
1071
1153
  // and must never reach outbound MX relay (and thus never the self-MX
@@ -1079,7 +1161,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
1079
1161
  retryable: false,
1080
1162
  phase: 'connect',
1081
1163
  transactionLog: [],
1082
- recipients: email.getAllRecipients(),
1164
+ recipients: allRecipients,
1083
1165
  });
1084
1166
  }
1085
1167
 
@@ -1087,16 +1169,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
1087
1169
  throw new Error('No email server available for MTA delivery');
1088
1170
  }
1089
1171
 
1090
- // Build DKIM options from route config
1091
- const effectiveDkimSigning = Boolean(
1092
- item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim,
1093
- );
1094
- const dkimDomain = effectiveDkimSigning
1095
- ? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
1096
- : undefined;
1097
- const dkimSelector = this.resolveDkimSelector(item, dkimDomain);
1172
+ // Raw queue delivery always resolves the current sender-domain selector and
1173
+ // key at final transmit. The unsigned queue bytes never change.
1174
+ let dkimDomain: string | undefined;
1175
+ let dkimSelector: string;
1176
+ if (rawEnvelope) {
1177
+ dkimDomain = rawEnvelope.from.split('@')[1]?.toLowerCase();
1178
+ const domainConfig = dkimDomain
1179
+ ? this.emailServer.getDomainRegistry().getDomainConfig(dkimDomain)
1180
+ : undefined;
1181
+ if (!dkimDomain || !domainConfig?.dkim?.selector) {
1182
+ throw new SmtpDeliveryError({
1183
+ message: `Raw outbound DKIM is unavailable for sender domain ${dkimDomain || 'unknown'}`,
1184
+ errorType: 'dkim',
1185
+ retryable: false,
1186
+ phase: 'message_body',
1187
+ transactionLog: [],
1188
+ recipients: allRecipients,
1189
+ });
1190
+ }
1191
+ dkimSelector = domainConfig.dkim.selector;
1192
+ } else {
1193
+ const effectiveDkimSigning = Boolean(
1194
+ item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim,
1195
+ );
1196
+ dkimDomain = effectiveDkimSigning
1197
+ ? (item.route?.action.options?.mtaOptions?.dkimOptions?.domainName || email.getFromDomain() || undefined)
1198
+ : undefined;
1199
+ dkimSelector = this.resolveDkimSelector(item, dkimDomain);
1200
+ }
1098
1201
 
1099
- const allRecipients = email.getAllRecipients();
1100
1202
  if (allRecipients.length === 0) {
1101
1203
  throw new Error('No recipients specified for MTA delivery');
1102
1204
  }
@@ -1193,13 +1295,23 @@ export class MultiModeDeliverySystem extends EventEmitter {
1193
1295
  try {
1194
1296
  logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
1195
1297
 
1196
- // Create a temporary Email scoped to this domain's recipients
1197
- const domainEmail = this.createEmailForRecipients(email, recipientsForMx);
1198
-
1199
- const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
1200
- dkimDomain,
1201
- dkimSelector,
1202
- });
1298
+ const result = rawEnvelope
1299
+ ? await this.queue[kQueueWithRawMessage](
1300
+ item.id,
1301
+ (rawMessage) => this.emailServer![kSendRawOutboundEmailInternal](
1302
+ mx.exchange,
1303
+ 25,
1304
+ rawMessage,
1305
+ { from: rawEnvelope.from, to: recipientsForMx },
1306
+ { dkimDomain: dkimDomain!, dkimSelector },
1307
+ ),
1308
+ )
1309
+ : await this.emailServer[kSendOutboundEmailInternal](
1310
+ mx.exchange,
1311
+ 25,
1312
+ this.createEmailForRecipients(email, recipientsForMx),
1313
+ { dkimDomain, dkimSelector },
1314
+ );
1203
1315
 
1204
1316
  const disposition = this.splitRecipientResults(
1205
1317
  result.recipientResults,
@@ -0,0 +1,111 @@
1
+ import { createHash, type Hash } from 'node:crypto';
2
+ import { domainToASCII } from 'node:url';
3
+
4
+ import { Email } from '../core/classes.email.js';
5
+
6
+ export interface IRawSmtpEnvelope {
7
+ from: string;
8
+ to: string[];
9
+ }
10
+
11
+ export const MAX_RAW_SMTP_RECIPIENTS = 100;
12
+
13
+ export const assertCanonicalRawRfc822Message = (rawMessageArg: Buffer): void => {
14
+ if (!Buffer.isBuffer(rawMessageArg) || rawMessageArg.length === 0) {
15
+ throw new Error('Raw outbound message must be a nonempty Buffer');
16
+ }
17
+ for (let index = 0; index < rawMessageArg.length; index++) {
18
+ const byte = rawMessageArg[index];
19
+ if (byte === 0x0d && rawMessageArg[index + 1] !== 0x0a) {
20
+ throw new Error('Raw outbound message must use canonical CRLF line endings');
21
+ }
22
+ if (byte === 0x0a && (index === 0 || rawMessageArg[index - 1] !== 0x0d)) {
23
+ throw new Error('Raw outbound message must use canonical CRLF line endings');
24
+ }
25
+ }
26
+ if (
27
+ rawMessageArg.length < 2
28
+ || rawMessageArg[rawMessageArg.length - 2] !== 0x0d
29
+ || rawMessageArg[rawMessageArg.length - 1] !== 0x0a
30
+ ) {
31
+ throw new Error('Raw outbound message must end with CRLF');
32
+ }
33
+ };
34
+
35
+ const writeLengthFramed = (hashArg: Hash, labelArg: string, valueArg: Buffer): void => {
36
+ const label = Buffer.from(labelArg, 'utf8');
37
+ const labelLength = Buffer.allocUnsafe(4);
38
+ labelLength.writeUInt32BE(label.length);
39
+ const valueLength = Buffer.allocUnsafe(8);
40
+ valueLength.writeBigUInt64BE(BigInt(valueArg.length));
41
+ hashArg.update(labelLength).update(label).update(valueLength).update(valueArg);
42
+ };
43
+
44
+ const normalizeRawMailbox = (valueArg: string, fieldArg: string): string => {
45
+ if (typeof valueArg !== 'string' || /[\r\n<>]/.test(valueArg)) {
46
+ throw new Error(`Raw SMTP envelope ${fieldArg} must be a plain mailbox address`);
47
+ }
48
+ const value = valueArg.trim();
49
+ const atIndex = value.indexOf('@');
50
+ if (atIndex < 1 || atIndex !== value.lastIndexOf('@') || atIndex === value.length - 1) {
51
+ throw new Error(`Raw SMTP envelope ${fieldArg} must be a plain mailbox address`);
52
+ }
53
+ const asciiDomain = domainToASCII(value.slice(atIndex + 1));
54
+ if (!asciiDomain) {
55
+ throw new Error(`Raw SMTP envelope ${fieldArg} has an invalid domain`);
56
+ }
57
+ const normalized = `${value.slice(0, atIndex).toLowerCase()}@${asciiDomain.toLowerCase()}`;
58
+ try {
59
+ new Email({ from: normalized, to: normalized, subject: '', text: '' });
60
+ } catch {
61
+ throw new Error(`Raw SMTP envelope ${fieldArg} must be a valid mailbox address`);
62
+ }
63
+ return normalized;
64
+ };
65
+
66
+ export const normalizeRawSmtpEnvelope = (envelopeArg: IRawSmtpEnvelope): IRawSmtpEnvelope => {
67
+ if (!envelopeArg || typeof envelopeArg !== 'object') {
68
+ throw new Error('Raw SMTP envelope is required');
69
+ }
70
+ if (
71
+ !Array.isArray(envelopeArg.to)
72
+ || envelopeArg.to.length === 0
73
+ || envelopeArg.to.length > MAX_RAW_SMTP_RECIPIENTS
74
+ ) {
75
+ throw new Error(
76
+ `Raw SMTP envelope requires 1 to ${MAX_RAW_SMTP_RECIPIENTS} recipients`,
77
+ );
78
+ }
79
+ const from = normalizeRawMailbox(envelopeArg.from, 'sender');
80
+ const to = envelopeArg.to.map((recipient, index) => (
81
+ normalizeRawMailbox(recipient, `recipient ${index + 1}`)
82
+ ));
83
+ if (new Set(to).size !== to.length) {
84
+ throw new Error('Raw SMTP envelope recipients must be unique after canonicalization');
85
+ }
86
+ return { from, to };
87
+ };
88
+
89
+ export const cloneRawSmtpEnvelope = (envelopeArg: IRawSmtpEnvelope): IRawSmtpEnvelope => ({
90
+ from: envelopeArg.from,
91
+ to: [...envelopeArg.to],
92
+ });
93
+
94
+ export const createRawQueueFingerprint = (
95
+ rawMessageArg: Buffer,
96
+ envelopeArg: IRawSmtpEnvelope,
97
+ ): string => {
98
+ const hash = createHash('sha256');
99
+ hash.update('smartmta:durable-raw-outbound-queue:fingerprint:v1\0');
100
+ writeLengthFramed(hash, 'raw-rfc822-bytes', rawMessageArg);
101
+ writeLengthFramed(hash, 'smtp-envelope-from', Buffer.from(envelopeArg.from, 'utf8'));
102
+ for (const recipient of envelopeArg.to) {
103
+ writeLengthFramed(hash, 'smtp-envelope-recipient', Buffer.from(recipient, 'utf8'));
104
+ }
105
+ writeLengthFramed(
106
+ hash,
107
+ 'processing-semantics',
108
+ Buffer.from('raw-mta;dkim=required-current-sender-domain-key;transform=none', 'utf8'),
109
+ );
110
+ return hash.digest('hex');
111
+ };
@@ -0,0 +1,6 @@
1
+ export const kQueueCloseAdmission: unique symbol = Symbol('smartmta.queue.closeAdmission');
2
+ export const kQueueCreateItemSnapshot: unique symbol = Symbol('smartmta.queue.createItemSnapshot');
3
+ export const kQueueEnqueueRaw: unique symbol = Symbol('smartmta.queue.enqueueRaw');
4
+ export const kQueueReserveRawAccess: unique symbol = Symbol('smartmta.queue.reserveRawAccess');
5
+ export const kQueueSetReadyItemsHandler: unique symbol = Symbol('smartmta.queue.setReadyItemsHandler');
6
+ export const kQueueWithRawMessage: unique symbol = Symbol('smartmta.queue.withRawMessage');
package/ts/mail/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './interfaces.storage.js';
3
3
  export * from './routing/index.js';
4
4
  export * from './security/index.js';
5
5
  export type { ISmtpTransactionAttempt } from './delivery/interfaces.js';
6
+ export type { IRawSmtpEnvelope } from './delivery/functions.raw-email.js';
6
7
 
7
8
  // Make the core and delivery modules accessible
8
9
  import * as Core from './core/index.js';