@push.rocks/smartmta 14.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "14.2.0",
3
+ "version": "14.3.0",
4
4
  "description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
5
5
  "keywords": [
6
6
  "mta",
package/readme.md CHANGED
@@ -518,7 +518,9 @@ email.getEnvelopeTo(); // ['alice@example.com', 'archive@example.org']
518
518
  email.getAllRecipients(); // the same list: the RCPT TO of every delivery
519
519
  ```
520
520
 
521
- The `Email` of a received message (hook contexts, `processEmailByMode()`, `dispatchAcceptedEnvelope()`) always carries the SMTP envelope it was accepted with. `processEmailByMode(emailOrBuffer, session)` sets it from `session.envelope.rcptTo` on every call and throws when that list is empty. `sendEmail()` checks every recipient against the suppression list and takes suppressed addresses out of the envelope.
521
+ `new Email({ ..., envelopeTo })` sets the envelope at construction. With an envelope, `to`, `cc` and `bcc` are the message's header fields: they take no part in delivery, and every copy of the message (one per MX domain or per recipient) shows the same To and Cc. Bcc is never written. No field entry is refused: CR and LF are removed, the entry is trimmed, and an entry that is then empty is dropped.
522
+
523
+ The `Email` of a received message (hook contexts, `processEmailByMode()`, `dispatchAcceptedEnvelope()`) always carries the SMTP envelope it was accepted with, and its `to` and `cc` are the addresses of the message's To and Cc fields, in order; a relayed copy carries them, never addresses taken from the envelope, and a message without a To field is relayed without one. Display names, groups and comments are not carried: a relayed copy's To and Cc list the addresses only (a group's member addresses among them). A To or Cc entry that is not a valid mailbox address never refuses the message: relayed copies carry it, and `deliver` and `store` routes hand on the received bytes unchanged. `X-Forwarded-To` is added only to a forwarded copy with exactly one envelope recipient and names that recipient. `processEmailByMode(emailOrBuffer, session)` sets the envelope from `session.envelope.rcptTo` on every call and throws when that list is empty. `sendEmail()` and `enqueue()` never change the caller's `Email`: the queue item holds its own copy, whose envelope is the recipients at that call minus any address on the suppression list.
522
524
 
523
525
  ### 📮 Delivery Modes
524
526
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '14.2.0',
6
+ version: '14.3.0',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  }
@@ -24,6 +24,13 @@ export interface IEmailOptions {
24
24
  priority?: 'high' | 'normal' | 'low'; // Optional email priority
25
25
  skipAdvancedValidation?: boolean; // Skip advanced validation for special cases
26
26
  variables?: Record<string, any>; // Template variables for placeholder replacement
27
+ /**
28
+ * The SMTP envelope recipients (RCPT TO), as setEnvelopeTo sets them. With
29
+ * an envelope, `to`, `cc` and `bcc` are the message's header fields: they
30
+ * take no part in delivery, every copy of the email shows the same entries,
31
+ * and no entry is refused (see headerFieldRecipients).
32
+ */
33
+ envelopeTo?: string[];
27
34
  }
28
35
 
29
36
  /**
@@ -70,13 +77,21 @@ export class Email {
70
77
  }
71
78
  this.from = options.from;
72
79
 
73
- // Handle to addresses (single or multiple)
74
- this.to = options.to ? this.parseRecipients(options.to) : [];
75
-
76
- // Handle optional cc and bcc
77
- this.cc = options.cc ? this.parseRecipients(options.cc) : [];
78
- this.bcc = options.bcc ? this.parseRecipients(options.bcc) : [];
79
-
80
+ if (options.envelopeTo !== undefined) {
81
+ // The envelope alone receives the email; to, cc and bcc are header fields.
82
+ this.setEnvelopeTo(options.envelopeTo);
83
+ this.to = this.headerFieldRecipients(options.to);
84
+ this.cc = this.headerFieldRecipients(options.cc);
85
+ this.bcc = this.headerFieldRecipients(options.bcc);
86
+ } else {
87
+ // Handle to addresses (single or multiple)
88
+ this.to = options.to ? this.parseRecipients(options.to) : [];
89
+
90
+ // Handle optional cc and bcc
91
+ this.cc = options.cc ? this.parseRecipients(options.cc) : [];
92
+ this.bcc = options.bcc ? this.parseRecipients(options.bcc) : [];
93
+ }
94
+
80
95
  // Note: Templates may be created without recipients
81
96
  // Recipients will be added when the email is actually sent
82
97
 
@@ -216,6 +231,24 @@ export class Email {
216
231
  return result;
217
232
  }
218
233
 
234
+ /**
235
+ * The entries of a To, Cc or Bcc field of an email that has an envelope, for
236
+ * received and constructed emails alike. The envelope alone receives such an
237
+ * email, so an entry is kept whether or not it is a valid mailbox address:
238
+ * CR and LF are removed (an entry never adds a header line), the entry is
239
+ * trimmed, and an entry that is then empty is dropped. It never throws.
240
+ * @param recipients A string or array of field entries
241
+ * @returns The field entries, in order
242
+ */
243
+ private headerFieldRecipients(recipients: string | string[] | undefined): string[] {
244
+ if (recipients === undefined) {
245
+ return [];
246
+ }
247
+ return (Array.isArray(recipients) ? recipients : [recipients])
248
+ .map((entry) => entry.replace(/[\r\n]/g, '').trim())
249
+ .filter((entry) => entry.length > 0);
250
+ }
251
+
219
252
  /**
220
253
  * Basic sanitization for strings to prevent header injection
221
254
  * @param input The string to sanitize
@@ -563,8 +596,8 @@ export class Email {
563
596
  }
564
597
 
565
598
  // Validate recipients
566
- const recipientsToCheck = options.checkFirstRecipientOnly ?
567
- [this.to[0]] : this.getAllRecipients();
599
+ const recipientsToCheck = options.checkFirstRecipientOnly ?
600
+ this.getAllRecipients().slice(0, 1) : this.getAllRecipients();
568
601
 
569
602
  for (const recipient of recipientsToCheck) {
570
603
  const recipientResult = await Email.emailValidator.validate(recipient, {
@@ -461,11 +461,9 @@ export class UnifiedDeliveryQueue extends EventEmitter {
461
461
 
462
462
  private cloneEmail(emailArg: Email): Email {
463
463
  const options = deserialize(serialize(emailArg.toEmailOptions())) as IEmailOptions;
464
- const email = new Email(options)
464
+ return new Email({ ...options, envelopeTo: emailArg.getEnvelopeTo() })
465
465
  .setMessageId(emailArg.getMessageId())
466
466
  .setEnvelopeFrom(emailArg.getEnvelopeFrom());
467
- const envelopeTo = emailArg.getEnvelopeTo();
468
- return envelopeTo ? email.setEnvelopeTo(envelopeTo) : email;
469
467
  }
470
468
 
471
469
  public [kQueueCreateItemSnapshot](itemArg: IQueueItem): IQueueItem {
@@ -1116,10 +1114,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
1116
1114
  },
1117
1115
  ): Promise<string> {
1118
1116
  if (!raw) {
1119
- // The item's recipients become its envelope, the list serializeItem
1120
- // stores and deserializeItem restores. setEnvelopeTo refuses an empty
1121
- // list or a recipient that is not a mailbox, so the store never holds an
1122
- // item its reader refuses.
1117
+ // The item owns a copy of the caller's email, so a caller that edits and
1118
+ // sends its email again never changes or reuses this item. Its
1119
+ // recipients become its envelope, the list serializeItem stores and
1120
+ // deserializeItem restores. setEnvelopeTo refuses an empty list or a
1121
+ // recipient that is not a mailbox, so the store never holds an item its
1122
+ // reader refuses.
1123
+ processingResult = this.cloneEmail(processingResult);
1123
1124
  try {
1124
1125
  processingResult.setEnvelopeTo(processingResult.getAllRecipients());
1125
1126
  } catch (error) {
@@ -2310,12 +2311,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
2310
2311
 
2311
2312
  let processingResult: Email;
2312
2313
  try {
2313
- processingResult = new Email(storedOptions)
2314
+ // With its stored envelope, an item's to, cc and bcc are header fields.
2315
+ processingResult = new Email({ ...storedOptions, envelopeTo: parsed.processingResult.envelopeTo })
2314
2316
  .setMessageId(parsed.processingResult.messageId)
2315
2317
  .setEnvelopeFrom(parsed.processingResult.envelopeFrom);
2316
- if (parsed.processingResult.envelopeTo !== undefined) {
2317
- processingResult.setEnvelopeTo(parsed.processingResult.envelopeTo);
2318
- }
2319
2318
  } catch (error) {
2320
2319
  throw new Error(`Invalid managed queue item at ${key}: ${getErrorMessage(error)}`);
2321
2320
  }
@@ -284,18 +284,15 @@ export class MultiModeDeliverySystem extends EventEmitter {
284
284
  }
285
285
  }
286
286
 
287
- /** The copy of an email for one SMTP transaction: its envelope is exactly `recipients`. */
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
+ */
288
292
  private createEmailForRecipients(email: Email, recipients: string[]): Email {
289
- const recipientSet = new Set(recipients.map((recipient) => recipient.toLowerCase()));
290
- return new Email({
291
- ...email.toEmailOptions(),
292
- to: email.to.filter((recipient) => recipientSet.has(recipient.toLowerCase())),
293
- cc: (email.cc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
294
- bcc: (email.bcc || []).filter((recipient) => recipientSet.has(recipient.toLowerCase())),
295
- })
293
+ return new Email({ ...email.toEmailOptions(), envelopeTo: recipients })
296
294
  .setMessageId(email.getMessageId())
297
- .setEnvelopeFrom(email.getEnvelopeFrom())
298
- .setEnvelopeTo(recipients);
295
+ .setEnvelopeFrom(email.getEnvelopeFrom());
299
296
  }
300
297
 
301
298
  private contextualizeSmtpError(
@@ -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) {
@@ -2597,13 +2597,17 @@ export class UnifiedEmailServer extends EventEmitter {
2597
2597
  }
2598
2598
  }
2599
2599
 
2600
- /** A copy of the email whose envelope is only the given 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
+ */
2601
2604
  private cloneEmailForRoutingRecipient(email: Email, recipient: string): Email {
2602
2605
  return new Email({
2603
2606
  from: email.from,
2604
- to: [recipient],
2605
- cc: [],
2606
- bcc: [],
2607
+ to: [...email.to],
2608
+ cc: [...email.cc],
2609
+ bcc: [...email.bcc],
2610
+ envelopeTo: [recipient],
2607
2611
  subject: email.subject,
2608
2612
  text: email.text,
2609
2613
  html: email.html,
@@ -2617,8 +2621,7 @@ export class UnifiedEmailServer extends EventEmitter {
2617
2621
  variables: structuredClone(email.variables),
2618
2622
  })
2619
2623
  .setMessageId(email.getMessageId())
2620
- .setEnvelopeFrom(email.getEnvelopeFrom())
2621
- .setEnvelopeTo([recipient]);
2624
+ .setEnvelopeFrom(email.getEnvelopeFrom());
2622
2625
  }
2623
2626
 
2624
2627
  private scopeSessionForRoutingRecipient(
@@ -3566,9 +3569,12 @@ export class UnifiedEmailServer extends EventEmitter {
3566
3569
  return new Email({
3567
3570
  // The first mailbox of the From fields in message order, groups flattened
3568
3571
  from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
3569
- to: envelopeRecipients,
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),
3570
3575
  cc: parsed.cc.map(recipient => recipient.email),
3571
3576
  bcc: [],
3577
+ envelopeTo: envelopeRecipients,
3572
3578
  subject: parsed.subject || '',
3573
3579
  text: bodies.text,
3574
3580
  html: bodies.html,
@@ -3579,7 +3585,7 @@ export class UnifiedEmailServer extends EventEmitter {
3579
3585
  contentType: attachment.contentType,
3580
3586
  ...(attachment.contentId ? { contentId: attachment.contentId } : {}),
3581
3587
  })),
3582
- }).setEnvelopeTo(envelopeRecipients);
3588
+ });
3583
3589
  }
3584
3590
 
3585
3591
  private isStandardParsedHeader(headerName: string): boolean {
@@ -4298,6 +4304,8 @@ export class UnifiedEmailServer extends EventEmitter {
4298
4304
  ): Promise<string> {
4299
4305
  this.assertAcceptingOutboundWork();
4300
4306
  const recipients = email.getAllRecipients();
4307
+ // What goes to the queue; the caller's email is never changed.
4308
+ let outgoing = email;
4301
4309
  logger.log('info', `Sending email: ${email.subject} to ${recipients.join(', ')}`);
4302
4310
 
4303
4311
  try {
@@ -4331,12 +4339,17 @@ export class UnifiedEmailServer extends EventEmitter {
4331
4339
  }
4332
4340
 
4333
4341
  // The message's fields stay as written; the suppressed recipients
4334
- // only leave the envelope.
4335
- email.setEnvelopeTo(recipients.filter(recipient => !this.isEmailSuppressed(recipient)));
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());
4336
4349
  }
4337
4350
  }
4338
4351
 
4339
- const id = await this.deliveryQueue.enqueue(email, mode, route);
4352
+ const id = await this.deliveryQueue.enqueue(outgoing, mode, route);
4340
4353
 
4341
4354
  logger.log('info', `Email queued with ID: ${id}`);
4342
4355
  return id;