@push.rocks/smartmta 14.1.0 → 14.3.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 +35 -2
  2. package/dist_rust/mailer-bin_linux_amd64_musl +0 -0
  3. package/dist_rust/mailer-bin_linux_amd64_musl.tsrust-build.json +4 -4
  4. package/dist_rust/mailer-bin_linux_arm64_musl +0 -0
  5. package/dist_rust/mailer-bin_linux_arm64_musl.tsrust-build.json +4 -4
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/mail/core/classes.email.d.ts +41 -3
  8. package/dist_ts/mail/core/classes.email.js +87 -12
  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 -5
  12. package/dist_ts/mail/delivery/classes.delivery.system.d.ts +5 -0
  13. package/dist_ts/mail/delivery/classes.delivery.system.js +7 -8
  14. package/dist_ts/mail/delivery/functions.raw-email.js +4 -5
  15. package/dist_ts/mail/routing/classes.email.action.executor.js +10 -5
  16. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +10 -1
  17. package/dist_ts/mail/routing/classes.unified.email.server.js +47 -16
  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 +1 -1
  22. package/readme.md +15 -0
  23. package/ts/00_commitinfo_data.ts +1 -1
  24. package/ts/mail/core/classes.email.ts +99 -15
  25. package/ts/mail/core/functions.mailbox.ts +21 -0
  26. package/ts/mail/delivery/classes.delivery.queue.ts +48 -3
  27. package/ts/mail/delivery/classes.delivery.system.ts +6 -7
  28. package/ts/mail/delivery/functions.raw-email.ts +3 -4
  29. package/ts/mail/routing/classes.email.action.executor.ts +9 -4
  30. package/ts/mail/routing/classes.unified.email.server.ts +47 -15
  31. package/ts/security/classes.contentscanner.ts +2 -2
  32. package/ts/security/classes.rustsecuritybridge.ts +3 -0
@@ -284,14 +284,13 @@ export class MultiModeDeliverySystem extends EventEmitter {
284
284
  }
285
285
  }
286
286
 
287
+ /**
288
+ * The copy of an email for one SMTP transaction: its envelope is exactly
289
+ * `recipients`, and its To, Cc and Bcc fields stay as they are, so every
290
+ * copy shows the same To and Cc.
291
+ */
287
292
  private createEmailForRecipients(email: Email, recipients: string[]): Email {
288
- const recipientSet = new Set(recipients.map((recipient) => recipient.toLowerCase()));
289
- return new Email({
290
- ...email.toEmailOptions(),
291
- to: email.to.filter((recipient) => recipientSet.has(recipient.toLowerCase())),
292
- cc: (email.cc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
293
- bcc: (email.bcc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
294
- })
293
+ return new Email({ ...email.toEmailOptions(), envelopeTo: recipients })
295
294
  .setMessageId(email.getMessageId())
296
295
  .setEnvelopeFrom(email.getEnvelopeFrom());
297
296
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash, type Hash } from 'node:crypto';
2
- import { domainToASCII } from 'node:url';
3
2
 
4
3
  import { Email } from '../core/classes.email.js';
4
+ import { canonicalMailboxAddress } from '../core/functions.mailbox.js';
5
5
 
6
6
  export interface IRawSmtpEnvelope {
7
7
  from: string;
@@ -50,11 +50,10 @@ const normalizeRawMailbox = (valueArg: string, fieldArg: string): string => {
50
50
  if (atIndex < 1 || atIndex !== value.lastIndexOf('@') || atIndex === value.length - 1) {
51
51
  throw new Error(`Raw SMTP envelope ${fieldArg} must be a plain mailbox address`);
52
52
  }
53
- const asciiDomain = domainToASCII(value.slice(atIndex + 1));
54
- if (!asciiDomain) {
53
+ const normalized = canonicalMailboxAddress(value);
54
+ if (!normalized) {
55
55
  throw new Error(`Raw SMTP envelope ${fieldArg} has an invalid domain`);
56
56
  }
57
- const normalized = `${value.slice(0, atIndex).toLowerCase()}@${asciiDomain.toLowerCase()}`;
58
57
  try {
59
58
  new Email({ from: normalized, to: normalized, subject: '', text: '' });
60
59
  } catch {
@@ -136,9 +136,14 @@ export class EmailActionExecutor {
136
136
  }
137
137
  }
138
138
 
139
- // Add standard forwarding headers
139
+ // Add standard forwarding headers. X-Forwarded-To names the one envelope
140
+ // recipient of a single-recipient copy and is never a list: the next hop
141
+ // has RCPT TO, and a list would show each recipient to every other.
140
142
  email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
141
- email.headers['X-Forwarded-To'] = email.to.join(', ');
143
+ const forwardedRecipients = email.getAllRecipients();
144
+ if (forwardedRecipients.length === 1) {
145
+ email.headers['X-Forwarded-To'] = forwardedRecipients[0];
146
+ }
142
147
  email.headers['X-Forwarded-Date'] = new Date().toISOString();
143
148
 
144
149
  if (options.durableQueue) {
@@ -174,7 +179,7 @@ export class EmailActionExecutor {
174
179
  routeName: context.session.matchedRoute?.name,
175
180
  targetHost: host,
176
181
  targetPort: port,
177
- recipients: email.to
182
+ recipients: email.getAllRecipients()
178
183
  },
179
184
  success: true
180
185
  }));
@@ -272,7 +277,7 @@ export class EmailActionExecutor {
272
277
  rejectCode: code,
273
278
  rejectMessage: message,
274
279
  from: _email.from,
275
- to: _email.to
280
+ to: _email.getAllRecipients()
276
281
  },
277
282
  success: false
278
283
  }));
@@ -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,
@@ -2595,13 +2597,17 @@ export class UnifiedEmailServer extends EventEmitter {
2595
2597
  }
2596
2598
  }
2597
2599
 
2598
- /** Build a route-evaluation view containing only the current SMTP envelope recipient. */
2600
+ /**
2601
+ * A copy of the email whose envelope is only the given SMTP envelope
2602
+ * recipient; its To, Cc and Bcc fields stay as they are.
2603
+ */
2599
2604
  private cloneEmailForRoutingRecipient(email: Email, recipient: string): Email {
2600
2605
  return new Email({
2601
2606
  from: email.from,
2602
- to: [recipient],
2603
- cc: [],
2604
- bcc: [],
2607
+ to: [...email.to],
2608
+ cc: [...email.cc],
2609
+ bcc: [...email.bcc],
2610
+ envelopeTo: [recipient],
2605
2611
  subject: email.subject,
2606
2612
  text: email.text,
2607
2613
  html: email.html,
@@ -3514,6 +3520,19 @@ export class UnifiedEmailServer extends EventEmitter {
3514
3520
  }
3515
3521
  }
3516
3522
 
3523
+ /**
3524
+ * The envelope recipients of a received message's session. A received
3525
+ * message is delivered to its envelope alone, so an empty one is refused;
3526
+ * its To and Cc fields never stand in for it.
3527
+ */
3528
+ private envelopeRecipientsOf(session: IExtendedSmtpSession): string[] {
3529
+ const recipients = (session.envelope?.rcptTo || []).map((recipient) => recipient.address);
3530
+ if (recipients.length === 0) {
3531
+ throw new Error('A received message needs at least one envelope recipient');
3532
+ }
3533
+ return recipients;
3534
+ }
3535
+
3517
3536
  /**
3518
3537
  * Builds the Email of stored message bytes (the replay paths
3519
3538
  * dispatchAcceptedEnvelope and processEmailByMode(Buffer)) with the parser
@@ -3534,7 +3553,7 @@ export class UnifiedEmailServer extends EventEmitter {
3534
3553
  /** The Email of a message the Rust parser read (docs/cleanroom/mailer-mime.md section 2.2). */
3535
3554
  private createEmailFromParsed(parsed: IBridgeParsedMessage, session: IExtendedSmtpSession): Email {
3536
3555
  const headerFromMailboxes = this.fromFieldAddresses(parsed).flat();
3537
- const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
3556
+ const envelopeRecipients = this.envelopeRecipientsOf(session);
3538
3557
  const parsedHeaders = new Map<string, string>();
3539
3558
  for (const header of parsed.headers) {
3540
3559
  if (this.isStandardParsedHeader(header.key)) {
@@ -3550,11 +3569,12 @@ export class UnifiedEmailServer extends EventEmitter {
3550
3569
  return new Email({
3551
3570
  // The first mailbox of the From fields in message order, groups flattened
3552
3571
  from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
3553
- to: envelopeRecipients.length > 0
3554
- ? envelopeRecipients
3555
- : parsed.to.map(recipient => recipient.email),
3572
+ // The message's To and Cc fields; the envelope alone receives it, and a
3573
+ // Bcc field never travels on.
3574
+ to: parsed.to.map(recipient => recipient.email),
3556
3575
  cc: parsed.cc.map(recipient => recipient.email),
3557
3576
  bcc: [],
3577
+ envelopeTo: envelopeRecipients,
3558
3578
  subject: parsed.subject || '',
3559
3579
  text: bodies.text,
3560
3580
  html: bodies.html,
@@ -3971,6 +3991,8 @@ export class UnifiedEmailServer extends EventEmitter {
3971
3991
  } else {
3972
3992
  email = emailData;
3973
3993
  }
3994
+ // The session's envelope alone decides who receives the message.
3995
+ email.setEnvelopeTo(this.envelopeRecipientsOf(session));
3974
3996
 
3975
3997
  // Run inbound security verification (DKIM/SPF/DMARC) via Rust bridge
3976
3998
  if (session.remoteAddress && session.remoteAddress !== '127.0.0.1') {
@@ -4281,23 +4303,26 @@ export class UnifiedEmailServer extends EventEmitter {
4281
4303
  }
4282
4304
  ): Promise<string> {
4283
4305
  this.assertAcceptingOutboundWork();
4284
- logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
4306
+ const recipients = email.getAllRecipients();
4307
+ // What goes to the queue; the caller's email is never changed.
4308
+ let outgoing = email;
4309
+ logger.log('info', `Sending email: ${email.subject} to ${recipients.join(', ')}`);
4285
4310
 
4286
4311
  try {
4287
4312
  if (!email.from) {
4288
4313
  throw new Error('Email must have a sender address');
4289
4314
  }
4290
4315
 
4291
- if (!email.to || email.to.length === 0) {
4316
+ if (recipients.length === 0) {
4292
4317
  throw new Error('Email must have at least one recipient');
4293
4318
  }
4294
4319
 
4295
4320
  // Check if any recipients are on the suppression list
4296
4321
  if (!options?.skipSuppressionCheck) {
4297
- const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
4322
+ const suppressedRecipients = recipients.filter(recipient => this.isEmailSuppressed(recipient));
4298
4323
 
4299
4324
  if (suppressedRecipients.length > 0) {
4300
- const originalCount = email.to.length;
4325
+ const originalCount = recipients.length;
4301
4326
  const suppressed = suppressedRecipients.map(recipient => {
4302
4327
  const info = this.getSuppressionInfo(recipient);
4303
4328
  return {
@@ -4313,11 +4338,18 @@ export class UnifiedEmailServer extends EventEmitter {
4313
4338
  throw new Error('All recipients are on the suppression list');
4314
4339
  }
4315
4340
 
4316
- email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
4341
+ // The message's fields stay as written; the suppressed recipients
4342
+ // only leave the envelope of the copy that is sent.
4343
+ outgoing = new Email({
4344
+ ...email.toEmailOptions(),
4345
+ envelopeTo: recipients.filter(recipient => !this.isEmailSuppressed(recipient)),
4346
+ })
4347
+ .setMessageId(email.getMessageId())
4348
+ .setEnvelopeFrom(email.getEnvelopeFrom());
4317
4349
  }
4318
4350
  }
4319
4351
 
4320
- const id = await this.deliveryQueue.enqueue(email, mode, route);
4352
+ const id = await this.deliveryQueue.enqueue(outgoing, mode, route);
4321
4353
 
4322
4354
  logger.log('info', `Email queued with ID: ${id}`);
4323
4355
  return id;
@@ -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;