@push.rocks/smartmta 11.1.0 → 12.0.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.
@@ -17,5 +17,3 @@ export { smartlog, smartmail, smartmailValidation, smartpath, smartrust };
17
17
  export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
18
18
  import * as tsclass from '@tsclass/tsclass';
19
19
  export { tsclass, };
20
- import mailparser from 'mailparser';
21
- export { mailparser, };
@@ -19,7 +19,4 @@ export { smartlog, smartmail, smartmailValidation, smartpath, smartrust };
19
19
  // tsclass scope
20
20
  import * as tsclass from '@tsclass/tsclass';
21
21
  export { tsclass, };
22
- // third party
23
- import mailparser from 'mailparser';
24
- export { mailparser, };
25
- //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsTUFBTSxFQUNOLElBQUksRUFDSixHQUFHLEVBQ0gsRUFBRSxFQUNGLElBQUksRUFDSixHQUFHLEVBQ0gsR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxRQUFRLE1BQU0sc0JBQXNCLENBQUM7QUFDakQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssbUJBQW1CLE1BQU0sa0NBQWtDLENBQUM7QUFDeEUsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBRW5ELE9BQU8sRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLG1CQUFtQixFQUFFLFNBQVMsRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUsxRSxnQkFBZ0I7QUFDaEIsT0FBTyxLQUFLLE9BQU8sTUFBTSxrQkFBa0IsQ0FBQztBQUU1QyxPQUFPLEVBQ0wsT0FBTyxHQUNSLENBQUE7QUFFRCxjQUFjO0FBQ2QsT0FBTyxVQUFVLE1BQU0sWUFBWSxDQUFDO0FBRXBDLE9BQU8sRUFDTCxVQUFVLEdBQ1gsQ0FBQSJ9
22
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsTUFBTSxFQUNOLElBQUksRUFDSixHQUFHLEVBQ0gsRUFBRSxFQUNGLElBQUksRUFDSixHQUFHLEVBQ0gsR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxRQUFRLE1BQU0sc0JBQXNCLENBQUM7QUFDakQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssbUJBQW1CLE1BQU0sa0NBQWtDLENBQUM7QUFDeEUsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBRW5ELE9BQU8sRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLG1CQUFtQixFQUFFLFNBQVMsRUFBRSxTQUFTLEVBQUUsQ0FBQztBQUsxRSxnQkFBZ0I7QUFDaEIsT0FBTyxLQUFLLE9BQU8sTUFBTSxrQkFBa0IsQ0FBQztBQUU1QyxPQUFPLEVBQ0wsT0FBTyxHQUNSLENBQUEifQ==
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "11.1.0",
3
+ "version": "12.0.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",
@@ -31,22 +31,20 @@
31
31
  ".": "./dist_ts/index.js"
32
32
  },
33
33
  "devDependencies": {
34
- "@git.zone/cli": "^8.11.0",
34
+ "@git.zone/cli": "^9.0.1",
35
35
  "@git.zone/tsbuild": "^6.0.2",
36
- "@git.zone/tsrust": "^4.5.1",
36
+ "@git.zone/tsrust": "^4.6.0",
37
37
  "@git.zone/tstest": "^8.0.0",
38
- "@types/mailparser": "^3.4.6",
39
38
  "@types/node": "^26.6.4",
40
39
  "tsx": "^4.23.15"
41
40
  },
42
41
  "dependencies": {
43
42
  "@push.rocks/smartlog": "^4.0.4",
44
- "@push.rocks/smartmail": "^3.0.1",
43
+ "@push.rocks/smartmail": "^3.1.0",
45
44
  "@push.rocks/smartpath": "^6.0.0",
46
45
  "@push.rocks/smartrust": "^2.6.0",
47
46
  "@tsclass/tsclass": "^9.5.0",
48
- "lru-cache": "^11.5.2",
49
- "mailparser": "^3.9.14"
47
+ "lru-cache": "^11.5.2"
50
48
  },
51
49
  "files": [
52
50
  "ts/**/*",
package/readme.md CHANGED
@@ -430,7 +430,7 @@ server.updateOptions({ auth: { accounts } });
430
430
  Enforcement semantics:
431
431
 
432
432
  - **Credentials** — `PLAIN`/`LOGIN` passwords are verified against the stored verifier by deriving `StoredKey` from the presented password (PBKDF2-HMAC-SHA256 on the async crypto threadpool) and comparing in constant time. `SCRAM-SHA-256` clients are served the stored verifier fields directly, so the plaintext never needs to exist server-side. Accounts with `enabled: false` always fail authentication on both mechanisms. Verification is fully in-memory — no I/O inside the Rust auth callback window.
433
- - **Sender scope ("send AS who")** — enforced twice. At the first `RCPT TO`, an out-of-scope `MAIL FROM` is refused with `553` before any recipient is accepted (with `smtp.recipientValidation: false` this check still runs, but only at `DATA`). At `DATA`, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and **every** parsed header `From` mailbox must independently be in scope — header-From spoofing behind an in-scope envelope, including multi-mailbox `From:` headers, is refused with `553`.
433
+ - **Sender scope ("send AS who")** — enforced twice. At the first `RCPT TO`, an out-of-scope `MAIL FROM` is refused with `553` before any recipient is accepted (with `smtp.recipientValidation: false` this check still runs, but only at `DATA`). At `DATA`, the envelope sender (taken from session envelope truth, never the parsed message alone) is re-checked per recipient, and **every** header `From` mailbox must independently be in scope — header-From spoofing behind an in-scope envelope is refused with `553`, including multi-mailbox `From:` fields, group syntax (members are checked), repeated `From:` fields (all are checked) and `From:` fields that yield no mailbox. A message with no `From:` field at all is accepted, because its sender is the already-checked envelope `MAIL FROM`.
434
434
  - **Recipient scope ("send TO who")** — with `recipients.mode: 'patterns'`, every `RCPT TO` matching no pattern is refused with `550`. `mode: 'any'` or an absent `recipients` block leaves recipients unrestricted.
435
435
  - **Patterns** are exact addresses (`a@b.com`) or glob patterns (`*@b.com`), matched case-insensitively with identical semantics to route matching. A scoped account sending with an empty `MAIL FROM <>` (null sender) is refused unless a pattern explicitly matches — `senders: ['*']` is the documented escape hatch.
436
436
  - **Route binding** — a route with `match.authenticatedUser: 'newsletter-service'` (or an array of usernames) matches only sessions authenticated as that account (exact, case-sensitive username comparison), so dedicated processing or relay routes can be attached per account. Unauthenticated sessions never match such routes.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '11.1.0',
6
+ version: '12.0.0',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  }
@@ -2475,22 +2475,37 @@ export class UnifiedEmailServer extends EventEmitter {
2475
2475
  }
2476
2476
 
2477
2477
  /**
2478
- * Extract every mailbox address from the parsed From header. The Email
2479
- * abstraction keeps only the first From mailbox, which is not sufficient
2480
- * for scope enforcement: `From: allowed@a.com, victim@b.com` must not
2481
- * pass. Parsing stops at the first CRLFCRLF header boundary when present
2482
- * (the Rust receiver normalizes line endings to CRLF); a bodyless message
2483
- * without one is parsed whole.
2478
+ * The mailbox addresses of every From header field, one list per field in
2479
+ * message order. Groups are flattened into their member mailboxes, and a
2480
+ * repeated From field is kept rather than collapsed to one occurrence,
2481
+ * because scope enforcement and Email.from must see every mailbox a
2482
+ * recipient's client might display. A field that yields no mailbox, such
2483
+ * as an empty group or an encoded word in place of an address, gives an
2484
+ * empty list.
2484
2485
  */
2485
- private async parseHeaderFromMailboxes(rawMessage: Buffer): Promise<string[]> {
2486
+ private parseFromHeaderFields(
2487
+ headers: ReadonlyArray<{ key: string; value: string }>,
2488
+ ): string[][] {
2489
+ return headers
2490
+ .filter((header) => header.key === 'from')
2491
+ .map((header) =>
2492
+ plugins.smartmail.parseAddressList(header.value).map((mailbox) => mailbox.email),
2493
+ );
2494
+ }
2495
+
2496
+ /**
2497
+ * The From header fields of the header section of a raw message, see
2498
+ * parseFromHeaderFields. Parsing stops at the first CRLFCRLF header
2499
+ * boundary when present (the Rust receiver normalizes line endings to
2500
+ * CRLF); a bodyless message without one is parsed whole.
2501
+ */
2502
+ private async parseHeaderFromFields(rawMessage: Buffer): Promise<string[][]> {
2486
2503
  const headerBoundary = rawMessage.indexOf('\r\n\r\n');
2487
2504
  const headerSection = headerBoundary === -1
2488
2505
  ? rawMessage
2489
2506
  : rawMessage.subarray(0, headerBoundary + 4);
2490
- const parsed = await plugins.mailparser.simpleParser(headerSection);
2491
- return ((parsed.from as any)?.value || [])
2492
- .map((mailbox: any) => mailbox?.address)
2493
- .filter(Boolean) as string[];
2507
+ const parsed = await plugins.smartmail.parseMailMessage(headerSection);
2508
+ return this.parseFromHeaderFields(parsed.headers);
2494
2509
  }
2495
2510
 
2496
2511
  /**
@@ -2516,28 +2531,36 @@ export class UnifiedEmailServer extends EventEmitter {
2516
2531
  if (resolved.kind === 'unrestricted') return null;
2517
2532
  if (resolved.kind === 'refused') return UnifiedEmailServer.authScopeUnavailableDecision();
2518
2533
  const scope = resolved.scope;
2519
- let fromMailboxes: string[];
2534
+ const notPermitted: ISmtpDecision = {
2535
+ accepted: false,
2536
+ smtpCode: 553,
2537
+ smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
2538
+ };
2539
+ let fromFields: string[][];
2520
2540
  try {
2521
- fromMailboxes = await this.parseHeaderFromMailboxes(rawMessage);
2541
+ fromFields = await this.parseHeaderFromFields(rawMessage);
2522
2542
  } catch {
2523
- fromMailboxes = [];
2543
+ // A header section that cannot be read cannot be shown to be in scope
2544
+ return notPermitted;
2524
2545
  }
2525
- if (fromMailboxes.length === 0) {
2526
- // No parsable From header: Email.from falls back to the envelope
2527
- // MAIL FROM, which resolveRecipientPolicy has already scope-checked.
2546
+ let fromMailboxes: string[];
2547
+ if (fromFields.length === 0) {
2548
+ // A message without a From field is accepted deliberately: Email.from
2549
+ // falls back to the envelope MAIL FROM, which resolveRecipientPolicy has
2550
+ // already scope-checked, and no other sender identity is displayed.
2528
2551
  fromMailboxes = [email.getFromAddress()];
2552
+ } else if (fromFields.some((field) => field.length === 0)) {
2553
+ // A From field without a mailbox, such as an empty group or an encoded
2554
+ // word in place of the address, still displays an identity this check
2555
+ // cannot verify.
2556
+ return notPermitted;
2557
+ } else {
2558
+ fromMailboxes = fromFields.flat();
2529
2559
  }
2530
2560
  const outOfScope = fromMailboxes.find(
2531
2561
  (mailbox) => !this.matchesScopePatterns(mailbox, scope.senders),
2532
2562
  );
2533
- if (outOfScope === undefined) {
2534
- return null;
2535
- }
2536
- return {
2537
- accepted: false,
2538
- smtpCode: 553,
2539
- smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
2540
- };
2563
+ return outOfScope === undefined ? null : notPermitted;
2541
2564
  }
2542
2565
 
2543
2566
  private getRecipientValidationTimeoutMs(): number {
@@ -3499,37 +3522,38 @@ export class UnifiedEmailServer extends EventEmitter {
3499
3522
 
3500
3523
  private async createEmailFromBuffer(emailData: Buffer, session: IExtendedSmtpSession): Promise<Email> {
3501
3524
  try {
3502
- const parsed = await plugins.mailparser.simpleParser(emailData);
3525
+ // Forwarded messages stay attachments of their bytes, so a relay hands them on unchanged
3526
+ const parsed = await plugins.smartmail.parseMailMessage(emailData, { rfc822Attachments: true });
3527
+ const headerFromMailboxes = this.parseFromHeaderFields(parsed.headers).flat();
3503
3528
  const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
3504
- const parsedTo = ((parsed.to as any)?.value || [])
3505
- .map((recipient: any) => recipient.address)
3506
- .filter(Boolean) as string[];
3507
- const parsedCc = ((parsed.cc as any)?.value || [])
3508
- .map((recipient: any) => recipient.address)
3509
- .filter(Boolean) as string[];
3510
- const parsedHeaders: Record<string, string> = {};
3511
- for (const [headerName, headerValue] of parsed.headers.entries()) {
3512
- if (this.isStandardParsedHeader(headerName)) {
3529
+ const parsedHeaders = new Map<string, string>();
3530
+ for (const header of parsed.headers) {
3531
+ if (this.isStandardParsedHeader(header.key)) {
3513
3532
  continue;
3514
3533
  }
3515
- parsedHeaders[headerName] = this.stringifyParsedHeaderValue(headerValue);
3534
+ // Raw values as written; a repeated field joins its values with ', '
3535
+ const previous = parsedHeaders.get(header.key);
3536
+ parsedHeaders.set(header.key, previous === undefined ? header.value : `${previous}, ${header.value}`);
3516
3537
  }
3517
3538
  return new Email({
3518
- from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
3539
+ // The first mailbox of the From fields in message order, groups flattened
3540
+ from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
3519
3541
  to: envelopeRecipients.length > 0
3520
3542
  ? envelopeRecipients
3521
- : parsedTo,
3522
- cc: parsedCc,
3543
+ : parsed.to.map(recipient => recipient.email),
3544
+ cc: parsed.cc.map(recipient => recipient.email),
3523
3545
  bcc: [],
3524
3546
  subject: parsed.subject || '',
3525
- text: parsed.text || '',
3547
+ // A message that carries only HTML is read and scanned through its text
3548
+ text: parsed.text ?? (parsed.html ? plugins.smartmail.htmlToText(parsed.html) : ''),
3526
3549
  html: parsed.html || undefined,
3527
- headers: parsedHeaders,
3528
- attachments: parsed.attachments?.map(att => ({
3529
- filename: att.filename || '',
3530
- content: att.content,
3531
- contentType: att.contentType
3532
- })) || []
3550
+ headers: Object.fromEntries(parsedHeaders),
3551
+ attachments: parsed.attachments.map(attachment => ({
3552
+ filename: attachment.filename || '',
3553
+ content: Buffer.from(attachment.content),
3554
+ contentType: attachment.contentType,
3555
+ ...(attachment.contentId ? { contentId: attachment.contentId } : {}),
3556
+ })),
3533
3557
  });
3534
3558
  } catch (error) {
3535
3559
  logger.log('error', `Error parsing email data: ${(error as Error).message}`);
@@ -3555,22 +3579,6 @@ export class UnifiedEmailServer extends EventEmitter {
3555
3579
  ]).has(headerName.toLowerCase());
3556
3580
  }
3557
3581
 
3558
- private stringifyParsedHeaderValue(headerValue: unknown): string {
3559
- if (Array.isArray(headerValue)) {
3560
- return headerValue.map(value => this.stringifyParsedHeaderValue(value)).join(', ');
3561
- }
3562
- if (headerValue instanceof Date) {
3563
- return headerValue.toUTCString();
3564
- }
3565
- if (typeof headerValue === 'string') {
3566
- return headerValue;
3567
- }
3568
- if (headerValue && typeof headerValue === 'object' && 'text' in headerValue) {
3569
- return String((headerValue as { text?: unknown }).text || '');
3570
- }
3571
- return headerValue === undefined || headerValue === null ? '' : String(headerValue);
3572
- }
3573
-
3574
3582
  /**
3575
3583
  * Compute SCRAM-SHA-256 credential fields from a plaintext password with a
3576
3584
  * fresh random salt (used for legacy plaintext credentials only; verifier
package/ts/plugins.ts CHANGED
@@ -39,10 +39,3 @@ import * as tsclass from '@tsclass/tsclass';
39
39
  export {
40
40
  tsclass,
41
41
  }
42
-
43
- // third party
44
- import mailparser from 'mailparser';
45
-
46
- export {
47
- mailparser,
48
- }