@push.rocks/smartmta 14.0.1 → 14.2.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 (32) hide show
  1. package/changelog.md +30 -2
  2. package/dist_rust/mailer-bin_linux_amd64_musl +0 -0
  3. package/dist_rust/mailer-bin_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/mailer-bin_linux_arm64_musl +0 -0
  5. package/dist_rust/mailer-bin_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/mail/core/classes.email.d.ts +24 -3
  8. package/dist_ts/mail/core/classes.email.js +55 -6
  9. package/dist_ts/mail/core/functions.mailbox.d.ts +8 -0
  10. package/dist_ts/mail/core/functions.mailbox.js +21 -0
  11. package/dist_ts/mail/delivery/classes.delivery.queue.js +41 -4
  12. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +1 -0
  13. package/dist_ts/mail/delivery/classes.delivery.system.js +4 -2
  14. package/dist_ts/mail/delivery/functions.raw-email.js +4 -5
  15. package/dist_ts/mail/routing/classes.email.action.executor.js +3 -3
  16. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +9 -2
  17. package/dist_ts/mail/routing/classes.unified.email.server.js +47 -27
  18. package/dist_ts/security/classes.contentscanner.js +3 -3
  19. package/dist_ts/security/classes.rustsecuritybridge.d.ts +3 -0
  20. package/dist_ts/security/classes.rustsecuritybridge.js +1 -1
  21. package/package.json +5 -5
  22. package/readme.md +14 -1
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/mail/core/classes.email.ts +57 -6
  25. package/ts/mail/core/functions.mailbox.ts +21 -0
  26. package/ts/mail/delivery/classes.delivery.queue.ts +48 -2
  27. package/ts/mail/delivery/classes.delivery.system.ts +3 -1
  28. package/ts/mail/delivery/functions.raw-email.ts +3 -4
  29. package/ts/mail/routing/classes.email.action.executor.ts +2 -2
  30. package/ts/mail/routing/classes.unified.email.server.ts +46 -25
  31. package/ts/security/classes.contentscanner.ts +2 -2
  32. package/ts/security/classes.rustsecuritybridge.ts +3 -0
@@ -174,7 +174,7 @@ export class EmailActionExecutor {
174
174
  routeName: context.session.matchedRoute?.name,
175
175
  targetHost: host,
176
176
  targetPort: port,
177
- recipients: email.to
177
+ recipients: email.getAllRecipients()
178
178
  },
179
179
  success: true
180
180
  }));
@@ -272,7 +272,7 @@ export class EmailActionExecutor {
272
272
  rejectCode: code,
273
273
  rejectMessage: message,
274
274
  from: _email.from,
275
- to: _email.to
275
+ to: _email.getAllRecipients()
276
276
  },
277
277
  success: false
278
278
  }));
@@ -1057,12 +1057,14 @@ export class UnifiedEmailServer extends EventEmitter {
1057
1057
  ? await this.resolveOutboundDkim(options.dkimDomain, options.dkimSelector || 'default')
1058
1058
  : undefined;
1059
1059
 
1060
- // Serialize the Email to the outbound format
1060
+ // Serialize the Email to the outbound format. RCPT TO is the one accessor's
1061
+ // list, which mailer-bin sends verbatim; to and cc are only written.
1061
1062
  const outboundEmail: IOutboundEmail = {
1062
1063
  from: email.from,
1063
1064
  to: email.to,
1064
1065
  cc: email.cc || [],
1065
1066
  bcc: email.bcc || [],
1067
+ envelopeTo: email.getAllRecipients(),
1066
1068
  subject: email.subject || '',
1067
1069
  text: email.text || '',
1068
1070
  html: email.html || undefined,
@@ -2506,10 +2508,10 @@ export class UnifiedEmailServer extends EventEmitter {
2506
2508
  * envelope MAIL FROM is enforced per recipient via resolveRecipientPolicy
2507
2509
  * from session.envelope truth; every parsed header From mailbox must
2508
2510
  * independently stay in scope because route matching and downstream
2509
- * consumers see the header identity.
2511
+ * consumers see the header identity. A scoped submission without a From
2512
+ * field is refused with 550 5.6.0.
2510
2513
  */
2511
2514
  private async checkAuthenticatedHeaderFromScope(
2512
- email: Email,
2513
2515
  fromFields: string[][],
2514
2516
  session: IExtendedSmtpSession,
2515
2517
  ): Promise<ISmtpDecision | null> {
@@ -2531,21 +2533,24 @@ export class UnifiedEmailServer extends EventEmitter {
2531
2533
  };
2532
2534
  // A message the parser rejects never reaches this check: Rust refuses it at
2533
2535
  // DATA, as it refuses a header section that ends before its empty line.
2534
- let fromMailboxes: string[];
2535
2536
  if (fromFields.length === 0) {
2536
- // A message without a From field is accepted deliberately: Email.from
2537
- // falls back to the envelope MAIL FROM, which resolveRecipientPolicy has
2538
- // already scope-checked, and no other sender identity is displayed.
2539
- fromMailboxes = [email.getFromAddress()];
2540
- } else if (fromFields.some((field) => field.length === 0)) {
2537
+ // A scoped submission must carry the From field this check verifies
2538
+ // (RFC 5322 3.6). Passing it on the envelope sender would rest on the
2539
+ // assumption that no recipient's reader finds a From where this parser
2540
+ // found none; unscoped and unauthenticated mail are not checked here.
2541
+ return {
2542
+ accepted: false,
2543
+ smtpCode: 550,
2544
+ smtpMessage: '5.6.0 Message has no From header field (RFC 5322 3.6)',
2545
+ };
2546
+ }
2547
+ if (fromFields.some((field) => field.length === 0)) {
2541
2548
  // A From field without a mailbox, such as an empty group or an encoded
2542
2549
  // word in place of the address, still displays an identity this check
2543
2550
  // cannot verify.
2544
2551
  return notPermitted;
2545
- } else {
2546
- fromMailboxes = fromFields.flat();
2547
2552
  }
2548
- const outOfScope = fromMailboxes.find(
2553
+ const outOfScope = fromFields.flat().find(
2549
2554
  (mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
2550
2555
  );
2551
2556
  return outOfScope === undefined ? null : notPermitted;
@@ -2592,7 +2597,7 @@ export class UnifiedEmailServer extends EventEmitter {
2592
2597
  }
2593
2598
  }
2594
2599
 
2595
- /** Build a route-evaluation view containing only the current SMTP envelope recipient. */
2600
+ /** A copy of the email whose envelope is only the given SMTP envelope recipient. */
2596
2601
  private cloneEmailForRoutingRecipient(email: Email, recipient: string): Email {
2597
2602
  return new Email({
2598
2603
  from: email.from,
@@ -2612,7 +2617,8 @@ export class UnifiedEmailServer extends EventEmitter {
2612
2617
  variables: structuredClone(email.variables),
2613
2618
  })
2614
2619
  .setMessageId(email.getMessageId())
2615
- .setEnvelopeFrom(email.getEnvelopeFrom());
2620
+ .setEnvelopeFrom(email.getEnvelopeFrom())
2621
+ .setEnvelopeTo([recipient]);
2616
2622
  }
2617
2623
 
2618
2624
  private scopeSessionForRoutingRecipient(
@@ -3343,7 +3349,6 @@ export class UnifiedEmailServer extends EventEmitter {
3343
3349
  // enforced before any durable acceptance or routing: the RCPT phase
3344
3350
  // only ever saw the envelope MAIL FROM.
3345
3351
  const headerFromViolation = await this.checkAuthenticatedHeaderFromScope(
3346
- email,
3347
3352
  this.fromFieldAddresses(data.parsed),
3348
3353
  session,
3349
3354
  );
@@ -3512,6 +3517,19 @@ export class UnifiedEmailServer extends EventEmitter {
3512
3517
  }
3513
3518
  }
3514
3519
 
3520
+ /**
3521
+ * The envelope recipients of a received message's session. A received
3522
+ * message is delivered to its envelope alone, so an empty one is refused;
3523
+ * its To and Cc fields never stand in for it.
3524
+ */
3525
+ private envelopeRecipientsOf(session: IExtendedSmtpSession): string[] {
3526
+ const recipients = (session.envelope?.rcptTo || []).map((recipient) => recipient.address);
3527
+ if (recipients.length === 0) {
3528
+ throw new Error('A received message needs at least one envelope recipient');
3529
+ }
3530
+ return recipients;
3531
+ }
3532
+
3515
3533
  /**
3516
3534
  * Builds the Email of stored message bytes (the replay paths
3517
3535
  * dispatchAcceptedEnvelope and processEmailByMode(Buffer)) with the parser
@@ -3532,7 +3550,7 @@ export class UnifiedEmailServer extends EventEmitter {
3532
3550
  /** The Email of a message the Rust parser read (docs/cleanroom/mailer-mime.md section 2.2). */
3533
3551
  private createEmailFromParsed(parsed: IBridgeParsedMessage, session: IExtendedSmtpSession): Email {
3534
3552
  const headerFromMailboxes = this.fromFieldAddresses(parsed).flat();
3535
- const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
3553
+ const envelopeRecipients = this.envelopeRecipientsOf(session);
3536
3554
  const parsedHeaders = new Map<string, string>();
3537
3555
  for (const header of parsed.headers) {
3538
3556
  if (this.isStandardParsedHeader(header.key)) {
@@ -3548,9 +3566,7 @@ export class UnifiedEmailServer extends EventEmitter {
3548
3566
  return new Email({
3549
3567
  // The first mailbox of the From fields in message order, groups flattened
3550
3568
  from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
3551
- to: envelopeRecipients.length > 0
3552
- ? envelopeRecipients
3553
- : parsed.to.map(recipient => recipient.email),
3569
+ to: envelopeRecipients,
3554
3570
  cc: parsed.cc.map(recipient => recipient.email),
3555
3571
  bcc: [],
3556
3572
  subject: parsed.subject || '',
@@ -3563,7 +3579,7 @@ export class UnifiedEmailServer extends EventEmitter {
3563
3579
  contentType: attachment.contentType,
3564
3580
  ...(attachment.contentId ? { contentId: attachment.contentId } : {}),
3565
3581
  })),
3566
- });
3582
+ }).setEnvelopeTo(envelopeRecipients);
3567
3583
  }
3568
3584
 
3569
3585
  private isStandardParsedHeader(headerName: string): boolean {
@@ -3969,6 +3985,8 @@ export class UnifiedEmailServer extends EventEmitter {
3969
3985
  } else {
3970
3986
  email = emailData;
3971
3987
  }
3988
+ // The session's envelope alone decides who receives the message.
3989
+ email.setEnvelopeTo(this.envelopeRecipientsOf(session));
3972
3990
 
3973
3991
  // Run inbound security verification (DKIM/SPF/DMARC) via Rust bridge
3974
3992
  if (session.remoteAddress && session.remoteAddress !== '127.0.0.1') {
@@ -4279,23 +4297,24 @@ export class UnifiedEmailServer extends EventEmitter {
4279
4297
  }
4280
4298
  ): Promise<string> {
4281
4299
  this.assertAcceptingOutboundWork();
4282
- logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
4300
+ const recipients = email.getAllRecipients();
4301
+ logger.log('info', `Sending email: ${email.subject} to ${recipients.join(', ')}`);
4283
4302
 
4284
4303
  try {
4285
4304
  if (!email.from) {
4286
4305
  throw new Error('Email must have a sender address');
4287
4306
  }
4288
4307
 
4289
- if (!email.to || email.to.length === 0) {
4308
+ if (recipients.length === 0) {
4290
4309
  throw new Error('Email must have at least one recipient');
4291
4310
  }
4292
4311
 
4293
4312
  // Check if any recipients are on the suppression list
4294
4313
  if (!options?.skipSuppressionCheck) {
4295
- const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
4314
+ const suppressedRecipients = recipients.filter(recipient => this.isEmailSuppressed(recipient));
4296
4315
 
4297
4316
  if (suppressedRecipients.length > 0) {
4298
- const originalCount = email.to.length;
4317
+ const originalCount = recipients.length;
4299
4318
  const suppressed = suppressedRecipients.map(recipient => {
4300
4319
  const info = this.getSuppressionInfo(recipient);
4301
4320
  return {
@@ -4311,7 +4330,9 @@ export class UnifiedEmailServer extends EventEmitter {
4311
4330
  throw new Error('All recipients are on the suppression list');
4312
4331
  }
4313
4332
 
4314
- email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
4333
+ // The message's fields stay as written; the suppressed recipients
4334
+ // only leave the envelope.
4335
+ email.setEnvelopeTo(recipients.filter(recipient => !this.isEmailSuppressed(recipient)));
4315
4336
  }
4316
4337
  }
4317
4338
 
@@ -346,7 +346,7 @@ export class ContentScanner {
346
346
  void callObserverSafely('content scan security log', () => SecurityLogger.getInstance().logEvent({
347
347
  level: SecurityLogLevel.ERROR,
348
348
  type: SecurityEventType.MALWARE,
349
- message: `High threat content detected in email from ${email.from} to ${email.to.join(', ')}`,
349
+ message: `High threat content detected in email from ${email.from} to ${email.getAllRecipients().join(', ')}`,
350
350
  details: {
351
351
  messageId: email.getMessageId(),
352
352
  threatType: result.threatType,
@@ -369,7 +369,7 @@ export class ContentScanner {
369
369
  void callObserverSafely('content scan security log', () => SecurityLogger.getInstance().logEvent({
370
370
  level: SecurityLogLevel.WARN,
371
371
  type: SecurityEventType.SPAM,
372
- message: `Suspicious content detected in email from ${email.from} to ${email.to.join(', ')}`,
372
+ message: `Suspicious content detected in email from ${email.from} to ${email.getAllRecipients().join(', ')}`,
373
373
  details: {
374
374
  messageId: email.getMessageId(),
375
375
  threatType: result.threatType,
@@ -358,9 +358,12 @@ const isSmtpDeliveryPhase = (valueArg: unknown): valueArg is TSmtpDeliveryPhase
358
358
 
359
359
  interface IOutboundEmail {
360
360
  from: string;
361
+ /** The message's To field; written as To, never used for RCPT TO when `envelopeTo` is set. */
361
362
  to: string[];
362
363
  cc?: string[];
363
364
  bcc?: string[];
365
+ /** The SMTP envelope recipients; when present, RCPT TO goes to them alone. */
366
+ envelopeTo?: string[];
364
367
  subject?: string;
365
368
  text?: string;
366
369
  html?: string;