@push.rocks/smartmta 13.1.0 → 14.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.
Files changed (26) hide show
  1. package/changelog.md +23 -0
  2. package/dist_rust/mailer-bin_linux_amd64_musl +0 -0
  3. package/dist_rust/mailer-bin_linux_amd64_musl.third-party-notices.json +0 -51
  4. package/dist_rust/mailer-bin_linux_amd64_musl.third-party-notices.txt +1 -291
  5. package/dist_rust/mailer-bin_linux_amd64_musl.tsrust-build.json +5 -5
  6. package/dist_rust/mailer-bin_linux_arm64_musl +0 -0
  7. package/dist_rust/mailer-bin_linux_arm64_musl.third-party-notices.json +0 -51
  8. package/dist_rust/mailer-bin_linux_arm64_musl.third-party-notices.txt +1 -291
  9. package/dist_rust/mailer-bin_linux_arm64_musl.tsrust-build.json +5 -5
  10. package/dist_ts/00_commitinfo_data.js +1 -1
  11. package/dist_ts/mail/routing/classes.unified.email.server.d.ts +17 -14
  12. package/dist_ts/mail/routing/classes.unified.email.server.js +80 -71
  13. package/dist_ts/security/classes.rustsecuritybridge.d.ts +108 -1
  14. package/dist_ts/security/classes.rustsecuritybridge.js +187 -50
  15. package/native-notices/manifest.json +1 -64
  16. package/native-notices/readme.md +3 -3
  17. package/package.json +9 -10
  18. package/readme.md +20 -12
  19. package/ts/00_commitinfo_data.ts +1 -1
  20. package/ts/mail/routing/classes.unified.email.server.ts +82 -75
  21. package/ts/security/classes.rustsecuritybridge.ts +264 -49
  22. package/native-notices/crates/charset-0.1.5/copyright +0 -12
  23. package/native-notices/crates/charset-0.1.5/license-apache +0 -202
  24. package/native-notices/crates/charset-0.1.5/license-mit +0 -25
  25. package/native-notices/crates/mailparse-0.16.1/license +0 -13
  26. package/native-notices/crates/quoted_printable-0.5.2/license +0 -13
@@ -27,7 +27,6 @@
27
27
  "bzip2@0.6.1",
28
28
  "cc@1.3.0",
29
29
  "cfg-if@1.0.4",
30
- "charset@0.1.5",
31
30
  "cipher@0.4.4",
32
31
  "clap@4.6.3",
33
32
  "clap_builder@4.6.2",
@@ -102,7 +101,6 @@
102
101
  "mail-auth@0.7.5",
103
102
  "mail-builder@0.4.4",
104
103
  "mail-parser@0.11.5",
105
- "mailparse@0.16.1",
106
104
  "memchr@2.8.3",
107
105
  "miniz_oxide@0.8.9",
108
106
  "mio@1.2.2",
@@ -126,7 +124,6 @@
126
124
  "quick-xml@0.38.4",
127
125
  "quick_cache@0.6.24",
128
126
  "quote@1.0.47",
129
- "quoted_printable@0.5.2",
130
127
  "rand@0.9.5",
131
128
  "rand_chacha@0.9.0",
132
129
  "rand_core@0.9.5",
@@ -232,7 +229,6 @@
232
229
  "bzip2@0.6.1",
233
230
  "cc@1.3.0",
234
231
  "cfg-if@1.0.4",
235
- "charset@0.1.5",
236
232
  "cipher@0.4.4",
237
233
  "clap@4.6.3",
238
234
  "clap_builder@4.6.2",
@@ -307,7 +303,6 @@
307
303
  "mail-auth@0.7.5",
308
304
  "mail-builder@0.4.4",
309
305
  "mail-parser@0.11.5",
310
- "mailparse@0.16.1",
311
306
  "memchr@2.8.3",
312
307
  "miniz_oxide@0.8.9",
313
308
  "mio@1.2.2",
@@ -331,7 +326,6 @@
331
326
  "quick-xml@0.38.4",
332
327
  "quick_cache@0.6.24",
333
328
  "quote@1.0.47",
334
- "quoted_printable@0.5.2",
335
329
  "rand@0.9.5",
336
330
  "rand_chacha@0.9.0",
337
331
  "rand_core@0.9.5",
@@ -761,30 +755,6 @@
761
755
  }
762
756
  ]
763
757
  },
764
- {
765
- "name": "charset",
766
- "version": "0.1.5",
767
- "source": "registry+https://github.com/rust-lang/crates.io-index",
768
- "license": "Apache-2.0 OR MIT",
769
- "files": [
770
- {
771
- "path": "crates/charset-0.1.5/copyright",
772
- "licenses": []
773
- },
774
- {
775
- "path": "crates/charset-0.1.5/license-apache",
776
- "licenses": [
777
- "Apache-2.0"
778
- ]
779
- },
780
- {
781
- "path": "crates/charset-0.1.5/license-mit",
782
- "licenses": [
783
- "MIT"
784
- ]
785
- }
786
- ]
787
- },
788
758
  {
789
759
  "name": "cipher",
790
760
  "version": "0.4.4",
@@ -2223,20 +2193,6 @@
2223
2193
  }
2224
2194
  ]
2225
2195
  },
2226
- {
2227
- "name": "mailparse",
2228
- "version": "0.16.1",
2229
- "source": "registry+https://github.com/rust-lang/crates.io-index",
2230
- "license": "0BSD",
2231
- "files": [
2232
- {
2233
- "path": "crates/mailparse-0.16.1/license",
2234
- "licenses": [
2235
- "0BSD"
2236
- ]
2237
- }
2238
- ]
2239
- },
2240
2196
  {
2241
2197
  "name": "memchr",
2242
2198
  "version": "2.8.3",
@@ -2688,20 +2644,6 @@
2688
2644
  }
2689
2645
  ]
2690
2646
  },
2691
- {
2692
- "name": "quoted_printable",
2693
- "version": "0.5.2",
2694
- "source": "registry+https://github.com/rust-lang/crates.io-index",
2695
- "license": "0BSD",
2696
- "files": [
2697
- {
2698
- "path": "crates/quoted_printable-0.5.2/license",
2699
- "licenses": [
2700
- "0BSD"
2701
- ]
2702
- }
2703
- ]
2704
- },
2705
2647
  {
2706
2648
  "name": "rand",
2707
2649
  "version": "0.9.5",
@@ -4451,9 +4393,6 @@
4451
4393
  "crates/cc-1.3.0/license-mit": "378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397",
4452
4394
  "crates/cfg-if-1.0.4/license-apache": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2",
4453
4395
  "crates/cfg-if-1.0.4/license-mit": "378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397",
4454
- "crates/charset-0.1.5/copyright": "5918196cdc0b35bca82b9b21b775b4e344dafe95ca9b6b716906c36b4a1161da",
4455
- "crates/charset-0.1.5/license-apache": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
4456
- "crates/charset-0.1.5/license-mit": "3fa4ca83dcc9237839b1bdeb2e6d16bdfb5ec0c5ce42b24694d8bbf0dcbef72c",
4457
4396
  "crates/cipher-0.4.4/license-apache": "a9040321c3712d8fd0b09cf52b17445de04a23a10165049ae187cd39e5c86be5",
4458
4397
  "crates/cipher-0.4.4/license-mit": "5c7bd92d1f096f12203dc1b601e3cb17484fa1b023e30b6b8edcc80e416237ec",
4459
4398
  "crates/clap-4.6.3/license-apache": "c6596eb7be8581c18be736c846fb9173b69eccf6ef94c5135893ec56bd92ba08",
@@ -4594,7 +4533,6 @@
4594
4533
  "crates/mail-builder-0.4.4/mit.txt": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3",
4595
4534
  "crates/mail-parser-0.11.5/apache-2.0.txt": "a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2",
4596
4535
  "crates/mail-parser-0.11.5/mit.txt": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3",
4597
- "crates/mailparse-0.16.1/license": "b3d6ddb122348e7ce92973bdafd8fb0b643beb3ecdfc32c15d73fb44c7f5ba62",
4598
4536
  "crates/memchr-2.8.3/copying": "01c266bced4a434da0051174d6bee16a4c82cf634e2679b6155d40d75012390f",
4599
4537
  "crates/memchr-2.8.3/license-mit": "0f96a83840e146e43c0ec96a22ec1f392e0680e6c1226e6f3ba87e0740af850f",
4600
4538
  "crates/memchr-2.8.3/unlicense": "7e12e5df4bae12cb21581ba157ced20e1986a0508dd10d0e8a4ab9a4cf94e85c",
@@ -4640,7 +4578,6 @@
4640
4578
  "crates/quick_cache-0.6.24/licence.md": "c845c85aa74d7eb1fb6848d2d96360a5edfe51dfbacbf96d7bb5193951e79b1a",
4641
4579
  "crates/quote-1.0.47/license-apache": "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a",
4642
4580
  "crates/quote-1.0.47/license-mit": "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3",
4643
- "crates/quoted_printable-0.5.2/license": "b3d6ddb122348e7ce92973bdafd8fb0b643beb3ecdfc32c15d73fb44c7f5ba62",
4644
4581
  "crates/rand-0.9.5/copyright": "90eb64f0279b0d9432accfa6023ff803bc4965212383697eee27a0f426d5f8d5",
4645
4582
  "crates/rand-0.9.5/license-apache": "35242e7a83f69875e6edeff02291e688c97caafe2f8902e4e19b49d3e78b4cab",
4646
4583
  "crates/rand-0.9.5/license-mit": "209fbbe0ad52d9235e37badf9cadfe4dbdc87203179c0899e738b39ade42177b",
@@ -4792,7 +4729,7 @@
4792
4729
  "native/tikv-jemalloc-sys-0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8/jemalloc-5.3.1/jemalloc-copying": "94aa2caa98c25d942f58b956c71dba6a99ff98fc3a31cbc669fe2a4cd0268b53",
4793
4730
  "native/zstd-sys-2.0.16+zstd.1.5.7/zstd-1.5.7/zstd-copying": "f9c375a1be4a41f7b70301dd83c91cb89e41567478859b77eef375a52d782505",
4794
4731
  "native/zstd-sys-2.0.16+zstd.1.5.7/zstd-1.5.7/zstd-license": "7055266497633c9025b777c78eb7235af13922117480ed5c674677adc381c9d8",
4795
- "readme.md": "39afaa90fa9c385bc949af82714d064f2cd856cd0f4f294b2c5e74057b32f17f",
4732
+ "readme.md": "c13dc71596cdeebe53a22c7c9c8446347ee56d0abf6bd3ec9bf2a2acb0444da3",
4796
4733
  "runtime/gcc/gpl-3.0.txt": "8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903",
4797
4734
  "runtime/gcc/runtime-library-exception-3.1.txt": "9d6b43ce4d8de0c878bf16b54d8e7a10d9bd42b75178153e3af6a815bdc90f74",
4798
4735
  "runtime/libunwind-22.1.2/license.txt": "b5efebcaca80879234098e52d1725e6d9eb8fb96a19fce625d39184b705f7b6d",
@@ -12,12 +12,12 @@ package's own code is covered by its own license.
12
12
 
13
13
  | Target | Rust target | Linkage | Cargo packages |
14
14
  | --- | --- | --- | --- |
15
- | linux_amd64_musl | x86_64-unknown-linux-musl | static musl | 193 |
16
- | linux_arm64_musl | aarch64-unknown-linux-musl | static musl | 193 |
15
+ | linux_amd64_musl | x86_64-unknown-linux-musl | static musl | 190 |
16
+ | linux_arm64_musl | aarch64-unknown-linux-musl | static musl | 190 |
17
17
 
18
18
  ## Contents
19
19
 
20
- - `crates/`: the license and notice files of the 193 third-party Cargo packages compiled
20
+ - `crates/`: the license and notice files of the 190 third-party Cargo packages compiled
21
21
  for these targets (normal and build dependencies, including procedural macros;
22
22
  development dependencies are excluded), copied verbatim. `manifest.json` records each
23
23
  package's license expression, the texts that cover it, and the targets it is built for.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "13.1.0",
3
+ "version": "14.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,16 +31,15 @@
31
31
  ".": "./dist_ts/index.js"
32
32
  },
33
33
  "devDependencies": {
34
- "@git.zone/cli": "^10.0.0",
35
- "@git.zone/tsbuild": "^6.0.2",
36
- "@git.zone/tsrust": "^5.0.0",
37
- "@git.zone/tstest": "^8.0.0",
34
+ "@git.zone/cli": "^11.0.1",
35
+ "@git.zone/rsrepo": "^1.1.1",
36
+ "@git.zone/tsrepo": "^1.1.1",
38
37
  "@types/node": "^26.6.5",
39
38
  "tsx": "^4.23.15"
40
39
  },
41
40
  "dependencies": {
42
41
  "@push.rocks/smartlog": "^5.0.1",
43
- "@push.rocks/smartmail": "^3.1.2",
42
+ "@push.rocks/smartmail": "^3.2.0",
44
43
  "@push.rocks/smartpath": "^6.0.0",
45
44
  "@push.rocks/smartrust": "^2.6.0",
46
45
  "@tsclass/tsclass": "^9.5.0",
@@ -69,10 +68,10 @@
69
68
  "access": "public"
70
69
  },
71
70
  "scripts": {
72
- "test": "tstest test/ --logfile --verbose --timeout 60",
71
+ "test": "tsrepo test test/ --logfile --verbose --timeout 60",
73
72
  "test:rust": "cd rust && cargo test",
74
- "build": "tsbuild tsfolders && tsrust",
75
- "build:release": "(tsbuild tsfolders) && (tsrust --configured-targets) && (tsrust verify)",
76
- "check": "tsbuild check"
73
+ "build": "tsrepo build tsfolders && rsrepo",
74
+ "build:release": "(tsrepo build tsfolders) && (rsrepo --configured-targets) && (rsrepo verify)",
75
+ "check": "tsrepo check"
77
76
  }
78
77
  }
package/readme.md CHANGED
@@ -355,7 +355,7 @@ Without this, an authenticated submission addressed to a domain this server host
355
355
 
356
356
  `hooks.onMessageData` runs after `DATA` and before the final SMTP response — but only for envelopes with **no** resolved `store` or `deliver` action; terminal local deliveries go through `hooks.onAcceptEnvelope` instead and never invoke `onMessageData`. Its `rawMessage` holds the same exact received bytes as in `hooks.onAcceptEnvelope`, final CRLF included. Return `{ accepted: true }` only after your application has accepted responsibility for the message, such as after durable persistence. Set `continueProcessing: false` when the consumer owns all downstream processing.
357
357
 
358
- `hooks.onAcceptEnvelope` is the terminal durable owner for any envelope containing a resolved `store` or `deliver` action. It receives the exact raw RFC822 bytes (`rawMessage`): the message as the client sent it, with only the DATA dot-stuffing removed and the CRLF that ends the last line kept (RFC 5321 §4.1.1.4: the first CRLF of `<CRLF>.<CRLF>` belongs to the message), so the bytes can be relayed unchanged through `enqueueRawEmail()`, `sendRawOutboundEmail()` or an unsigned raw relay. The listener refuses a message that contains a bare CR or bare LF (RFC 5321 §2.3.8) with `550 5.6.0` after reading it to its end-of-data marker, without calling a hook, so every handed-off message uses canonical CRLF line endings. It also receives the complete per-recipient route resolution (`resolvedRecipientRoutes`), and the Rust security verdicts (`securityResults`). It must durably persist the message before resolving — the SMTP `250` is only sent after the hook resolves. Throwing an `AcceptEnvelopeRejectionError` with a `5xx` code rejects the envelope permanently with that status; any other error or a `smtp.messageAcceptanceTimeoutMs` timeout refuses the envelope with a temporary `451`. Use `createAcceptedEnvelopeDispatchPlan()` inside the hook to snapshot the resolved plan for later replay-safe `dispatchAcceptedEnvelope()` calls. Both `AcceptEnvelopeRejectionError` and `IAcceptEnvelopeContext` are exported.
358
+ `hooks.onAcceptEnvelope` is the terminal durable owner for any envelope containing a resolved `store` or `deliver` action. It receives the exact raw RFC822 bytes (`rawMessage`): the message as the client sent it, with only the DATA dot-stuffing removed and the CRLF that ends the last line kept (RFC 5321 §4.1.1.4: the first CRLF of `<CRLF>.<CRLF>` belongs to the message), so the bytes can be relayed unchanged through `enqueueRawEmail()`, `sendRawOutboundEmail()` or an unsigned raw relay. The listener refuses a message that contains a bare CR or bare LF (RFC 5321 §2.3.8) with `550 5.6.0` after reading it to its end-of-data marker, without calling a hook, so every handed-off message uses canonical CRLF line endings. It also receives the complete per-recipient route resolution (`resolvedRecipientRoutes`), and the Rust security verdicts (`securityResults`). It must durably persist the message before resolving — the SMTP `250` is only sent after the hook resolves. Throwing an `AcceptEnvelopeRejectionError` with a `5xx` code rejects the envelope permanently with that status; any other error or a `smtp.messageAcceptanceTimeoutMs` timeout refuses the envelope with a temporary `451`. Use `createAcceptedEnvelopeDispatchPlan()` inside the hook to snapshot the resolved plan for later replay-safe `dispatchAcceptedEnvelope()` calls on a started server: a replay that builds an `Email` parses the stored bytes with the Rust parser of the DATA path (`parseMessage`). Both `AcceptEnvelopeRejectionError` and `IAcceptEnvelopeContext` are exported.
359
359
 
360
360
  Use `smtp.securePort` when the implicit TLS listener uses a remapped backend port instead of public `465`; the configured secure port must also be present in `ports`, otherwise `start()` throws.
361
361
 
@@ -646,6 +646,8 @@ Embedders can check `smartMtaCapabilities.selectorCorrectDkimSigning`, `callerMa
646
646
 
647
647
  All verification is powered by the Rust binary. For inbound mail, `UnifiedEmailServer` runs the full security pipeline **automatically** — DKIM, SPF, DMARC, content scanning, and IP reputation in a single Rust pass. Results are attached as headers (`Received-SPF`, `X-DKIM-Result`, `X-DMARC-Result`).
648
648
 
649
+ A From that names more than one domain (one field with several domains, or several From fields) cannot be evaluated for DMARC (RFC 9989 §5.3.1); such a message gets `X-DMARC-Result: quarantine` and is marked as potential spam (§11.5). When no DMARC policy was evaluated (no From domain, no DMARC record, or a DNS error), the action is `none`, never `pass`.
650
+
649
651
  For direct TypeScript usage, `SpfVerifier` is exported as a lightweight SPF record parser. DKIM signing/key management is handled through `DKIMCreator` and the Rust bridge; SPF, DKIM, and DMARC verification results are produced by the automatic inbound security pipeline.
650
652
 
651
653
  ```typescript
@@ -901,18 +903,23 @@ The Rust `mailer-bin` process is supervised by `RustSecurityBridge`:
901
903
 
902
904
  Tune with `RustSecurityBridge.configure({ maxRestartAttempts, healthCheckIntervalMs, healthCheckTimeoutMs, restartBackoffBaseMs, restartBackoffMaxMs })`.
903
905
 
906
+ ### 📏 IPC Line Cap
907
+
908
+ Every JSON line between TypeScript and `mailer-bin` is limited by one cap, `4 × maxMessageSize` bytes (41,943,040 at the 10 MiB default). `UnifiedEmailServer` sets it through `RustSecurityBridge.setMaxMessageSize(maxMessageSize)` before the bridge starts; the bridge passes the same value to smartrust (`maxPayloadSize`) and to `mailer-bin --max-line-bytes`, so both ends of the transport agree, and a changed `maxMessageSize` (also through `updateOptions()`, which restarts the listener) restarts the process with the new cap. Rust never writes a line over the cap: a received message whose `emailReceived` event would exceed it is refused at DATA with `552 5.3.4 Parsed message size exceeds maximum of <cap> bytes`. The outbound requests `sendEmail`, `sendRawEmail`, `signDkim` and `verifyDkim` are limited by the same cap: a request line above it is refused by smartrust before anything is written (`Outbound message exceeds maxPayloadSize`), which is about 3 × `maxMessageSize` of message.
909
+
904
910
  ## 🦀 Rust Acceleration Layer
905
911
 
906
- Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with four crates:
912
+ Performance-critical operations are implemented in Rust and communicate with the TypeScript runtime via `@push.rocks/smartrust` (JSON-over-stdin/stdout IPC). The Rust workspace lives at `rust/` with five crates:
907
913
 
908
914
  | Crate | Status | Purpose |
909
915
  |---|---|---|
916
+ | `mailer-mime` | Complete | The inbound RFC 5322 / MIME parser: one parse per message at DATA, for the content scan, DMARC alignment and the `emailReceived` event's `parsed` result, from which TypeScript builds the `Email` (stored bytes through `parseMessage`); a clean-room implementation of `docs/cleanroom/mailer-mime.md` |
910
917
  | `mailer-core` | Complete | Email types, validation, MIME building, bounce detection |
911
918
  | `mailer-security` | Complete | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
912
919
  | `mailer-smtp` | Complete | Full SMTP protocol engine — TCP/TLS server + client, STARTTLS, AUTH, pipelining, connection pooling, in-process security pipeline |
913
920
  | `mailer-bin` | Complete | CLI + smartrust IPC bridge — wires everything together |
914
921
 
915
- `RustSecurityBridge` finds `mailer-bin` in the package's `dist_rust/` (or a local `rust/target` build). Inside a Node.js single executable application (built with `@git.zone/tssea`), smartrust runs the sidecar the executable embeds under the name `mailer-bin` instead and searches nothing else; a sidecar the executable cannot provide makes `start()` resolve `false`, and background restarts look it up again.
922
+ `RustSecurityBridge` finds `mailer-bin` in the package's `dist_rust/` (or a local `rust/target` build). Inside a Node.js single executable application (built with `tsrepo sea` from `@git.zone/tsrepo`), smartrust runs the sidecar the executable embeds under the name `mailer-bin` instead and searches nothing else; a sidecar the executable cannot provide makes `start()` resolve `false`, and background restarts look it up again.
916
923
 
917
924
  The shipped Linux binaries are static musl executables. The SMTP client connects with `TcpStream::connect("host:port")` (`rust/crates/mailer-smtp/src/client/connection.rs`), so a host name it connects to (an MX or smarthost name, or a `connect_host`) is looked up through the C library, which is musl: only `/etc/hosts` and `/etc/resolv.conf` are consulted, and no NSS modules (`nsswitch.conf`, systemd-resolved's NSS module, LDAP and the like) take part. The SPF, DKIM, DMARC and DNSBL lookups use the hickory resolver instead.
918
925
 
@@ -929,6 +936,7 @@ The shipped Linux binaries are static musl executables. The SMTP client connects
929
936
  | Content scanning (text patterns) | 🦀 Rust | Regex engine performance |
930
937
  | Bounce detection (pattern matching) | 🦀 Rust | Regex engine performance |
931
938
  | Email validation & MIME building | 🦀 Rust | Parsing performance |
939
+ | Inbound MIME parsing (`mailer-mime`, at DATA and through `parseMessage`) | 🦀 Rust | One linear-time parse with explicit limits for hostile input |
932
940
  | Email routing & orchestration | 🟦 TypeScript | Business logic, flexibility |
933
941
  | Delivery queue & retry | 🟦 TypeScript | State management, persistence |
934
942
  | Template rendering | 🟦 TypeScript | String interpolation |
@@ -968,10 +976,10 @@ pnpm run build
968
976
  pnpm test
969
977
 
970
978
  # Run specific test files
971
- tstest test/test.e2e.server-lifecycle.node.ts --verbose --timeout 60
972
- tstest test/test.e2e.inbound-smtp.node.ts --verbose --timeout 60
973
- tstest test/test.e2e.routing-actions.node.ts --verbose --timeout 60
974
- tstest test/test.e2e.outbound-delivery.node.ts --verbose --timeout 60
979
+ pnpm exec tsrepo test test/test.e2e.server-lifecycle.node.ts --verbose --timeout 60
980
+ pnpm exec tsrepo test test/test.e2e.inbound-smtp.node.ts --verbose --timeout 60
981
+ pnpm exec tsrepo test test/test.e2e.routing-actions.node.ts --verbose --timeout 60
982
+ pnpm exec tsrepo test test/test.e2e.outbound-delivery.node.ts --verbose --timeout 60
975
983
  ```
976
984
 
977
985
  **E2E tests** exercise the full pipeline — starting `UnifiedEmailServer`, connecting via raw TCP sockets, sending SMTP transactions, verifying routing actions, and testing outbound delivery through a mock SMTP receiver.
@@ -1009,15 +1017,15 @@ third-party works too.
1009
1017
 
1010
1018
  Every binary carries its notices beside it in `dist_rust/`:
1011
1019
  `mailer-bin_<target>.third-party-notices.json` and `.txt`, in the format that
1012
- [`@git.zone/tssea`](https://code.foss.global/git.zone/tssea) merges into a
1020
+ [`@git.zone/tsrepo`](https://code.foss.global/git.zone/tsrepo)'s `sea` build merges into a
1013
1021
  single executable application. Keep that pair with any separately distributed
1014
- copy of a `mailer-bin` executable. `@git.zone/tsrust` projects them from the
1022
+ copy of a `mailer-bin` executable. [`@git.zone/rsrepo`](https://code.foss.global/git.zone/rsrepo) projects them from the
1015
1023
  committed `native-notices/` directory, which ships in the npm package as well.
1016
- `tsrust notices` generates that directory from `rust/Cargo.lock`, the toolchain
1024
+ `rsrepo notices` generates that directory from `rust/Cargo.lock`, the toolchain
1017
1025
  pinned in `rust-toolchain.toml` and the `notices` overrides in `.smartconfig.json`
1018
1026
  (reviewed project texts live in `legal/`); every build checks it and
1019
- `tsrust verify` checks the release binaries' notices. Regenerate it with
1020
- `pnpm exec tsrust notices` after any change to the Cargo graph, the toolchain or
1027
+ `rsrepo verify` checks the release binaries' notices. Regenerate it with
1028
+ `pnpm exec rsrepo notices` after any change to the Cargo graph, the toolchain or
1021
1029
  the targets, and review the diff.
1022
1030
 
1023
1031
  ## License and Legal Information
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '13.1.0',
6
+ version: '14.0.0',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  }
@@ -37,7 +37,7 @@ import { Email } from '../core/classes.email.js';
37
37
  import { DomainRegistry } from './classes.domain.registry.js';
38
38
  import { DnsManager } from './classes.dns.manager.js';
39
39
  import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
40
- import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
40
+ import type { ISmtpSendResult, IOutboundEmail, IBridgeParsedMessage } from '../../security/classes.rustsecuritybridge.js';
41
41
  import { MultiModeDeliverySystem, type IDeliveryStats, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
42
42
  import {
43
43
  ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
@@ -1383,6 +1383,9 @@ export class UnifiedEmailServer extends EventEmitter {
1383
1383
  try {
1384
1384
  await this.startDeliveryPipeline();
1385
1385
  this.startAcceptedEnvelopeDispatchCleanup();
1386
+ // The IPC line cap derives from the SMTP size limit and is fixed per mailer-bin process,
1387
+ // so it is set before the bridge starts (and before any parseMessage call).
1388
+ await this.rustBridge.setMaxMessageSize(this.getMaxMessageSize());
1386
1389
  await this.startRustBridge();
1387
1390
  await this.initializeDkimAndDns();
1388
1391
  await this.startDeliveryProcessing();
@@ -1419,6 +1422,11 @@ export class UnifiedEmailServer extends EventEmitter {
1419
1422
  logger.log('info', 'Email delivery system started');
1420
1423
  }
1421
1424
 
1425
+ /** The SMTP size limit, which the bridge's IPC line cap derives from. */
1426
+ private getMaxMessageSize(): number {
1427
+ return this.options.maxMessageSize || 10 * 1024 * 1024;
1428
+ }
1429
+
1422
1430
  private async startRustBridge(): Promise<void> {
1423
1431
  const bridgeOk = await this.rustBridge.start();
1424
1432
  if (!bridgeOk) {
@@ -1618,7 +1626,7 @@ export class UnifiedEmailServer extends EventEmitter {
1618
1626
  tlsCertPem,
1619
1627
  tlsKeyPem,
1620
1628
  ...(tlsTerminatedPorts.length > 0 ? { tlsTerminatedPorts } : {}),
1621
- maxMessageSize: this.options.maxMessageSize || 10 * 1024 * 1024,
1629
+ maxMessageSize: this.getMaxMessageSize(),
1622
1630
  maxConnections: this.options.maxConnections || this.options.maxClients || 100,
1623
1631
  maxRecipients: 100,
1624
1632
  connectionTimeoutSecs: this.options.connectionTimeout ? Math.floor(this.options.connectionTimeout / 1000) : 30,
@@ -1727,6 +1735,9 @@ export class UnifiedEmailServer extends EventEmitter {
1727
1735
  this.smtpListenerActive = false;
1728
1736
  await this.rustBridge.stopSmtpServer();
1729
1737
  if (this.smtpListenerShutdown) return;
1738
+ // With the listener stopped, a changed maximum message size restarts the bridge with
1739
+ // the new line cap before the listener comes back with the new size.
1740
+ await this.rustBridge.setMaxMessageSize(this.getMaxMessageSize());
1730
1741
  await this.startSmtpServer();
1731
1742
  })
1732
1743
  .catch(async (error) => {
@@ -2479,36 +2490,15 @@ export class UnifiedEmailServer extends EventEmitter {
2479
2490
 
2480
2491
  /**
2481
2492
  * The mailbox addresses of every From header field, one list per field in
2482
- * message order. Groups are flattened into their member mailboxes, and a
2483
- * repeated From field is kept rather than collapsed to one occurrence,
2484
- * because scope enforcement and Email.from must see every mailbox a
2485
- * recipient's client might display. A field that yields no mailbox, such
2486
- * as an empty group or an encoded word in place of an address, gives an
2487
- * empty list.
2488
- */
2489
- private parseFromHeaderFields(
2490
- headers: ReadonlyArray<{ key: string; value: string }>,
2491
- ): string[][] {
2492
- return headers
2493
- .filter((header) => header.key === 'from')
2494
- .map((header) =>
2495
- plugins.smartmail.parseAddressList(header.value).map((mailbox) => mailbox.email),
2496
- );
2497
- }
2498
-
2499
- /**
2500
- * The From header fields of the header section of a raw message, see
2501
- * parseFromHeaderFields. Parsing stops at the first CRLFCRLF header
2502
- * boundary when present (the Rust receiver normalizes line endings to
2503
- * CRLF); a bodyless message without one is parsed whole.
2493
+ * message order, as the Rust parser read them (`parsed.fromFields`). Groups
2494
+ * are flattened into their member mailboxes, and a repeated From field is
2495
+ * kept rather than collapsed to one occurrence, because scope enforcement and
2496
+ * Email.from must see every mailbox a recipient's client might display. A
2497
+ * field that yields no mailbox, such as an empty group or an encoded word in
2498
+ * place of an address, gives an empty list.
2504
2499
  */
2505
- private async parseHeaderFromFields(rawMessage: Buffer): Promise<string[][]> {
2506
- const headerBoundary = rawMessage.indexOf('\r\n\r\n');
2507
- const headerSection = headerBoundary === -1
2508
- ? rawMessage
2509
- : rawMessage.subarray(0, headerBoundary + 4);
2510
- const parsed = await plugins.smartmail.parseMailMessage(headerSection);
2511
- return this.parseFromHeaderFields(parsed.headers);
2500
+ private fromFieldAddresses(parsed: IBridgeParsedMessage): string[][] {
2501
+ return parsed.fromFields.map((field) => field.map((mailbox) => mailbox.email));
2512
2502
  }
2513
2503
 
2514
2504
  /**
@@ -2520,7 +2510,7 @@ export class UnifiedEmailServer extends EventEmitter {
2520
2510
  */
2521
2511
  private async checkAuthenticatedHeaderFromScope(
2522
2512
  email: Email,
2523
- rawMessage: Buffer,
2513
+ fromFields: string[][],
2524
2514
  session: IExtendedSmtpSession,
2525
2515
  ): Promise<ISmtpDecision | null> {
2526
2516
  const username = session.user?.username;
@@ -2539,13 +2529,8 @@ export class UnifiedEmailServer extends EventEmitter {
2539
2529
  smtpCode: 553,
2540
2530
  smtpMessage: `5.7.1 Header From address not permitted for authenticated user ${username}`,
2541
2531
  };
2542
- let fromFields: string[][];
2543
- try {
2544
- fromFields = await this.parseHeaderFromFields(rawMessage);
2545
- } catch {
2546
- // A header section that cannot be read cannot be shown to be in scope
2547
- return notPermitted;
2548
- }
2532
+ // A message the parser rejects never reaches this check: Rust refuses it at
2533
+ // DATA, as it refuses a header section that ends before its empty line.
2549
2534
  let fromMailboxes: string[];
2550
2535
  if (fromFields.length === 0) {
2551
2536
  // A message without a From field is accepted deliberately: Email.from
@@ -3348,14 +3333,18 @@ export class UnifiedEmailServer extends EventEmitter {
3348
3333
  }
3349
3334
 
3350
3335
 
3351
- const email = await this.createEmailFromBuffer(rawMessageBuffer, session);
3336
+ // The one parse Rust made at DATA; this path never parses again.
3337
+ if (!data.parsed) {
3338
+ throw new Error('emailReceived event without its parsed message');
3339
+ }
3340
+ const email = this.createEmailFromParsed(data.parsed, session);
3352
3341
 
3353
3342
  // Header-From alignment for scoped authenticated accounts must be
3354
3343
  // enforced before any durable acceptance or routing: the RCPT phase
3355
3344
  // only ever saw the envelope MAIL FROM.
3356
3345
  const headerFromViolation = await this.checkAuthenticatedHeaderFromScope(
3357
3346
  email,
3358
- rawMessageBuffer,
3347
+ this.fromFieldAddresses(data.parsed),
3359
3348
  session,
3360
3349
  );
3361
3350
  if (headerFromViolation) {
@@ -3523,45 +3512,58 @@ export class UnifiedEmailServer extends EventEmitter {
3523
3512
  }
3524
3513
  }
3525
3514
 
3515
+ /**
3516
+ * Builds the Email of stored message bytes (the replay paths
3517
+ * dispatchAcceptedEnvelope and processEmailByMode(Buffer)) with the parser
3518
+ * and setting of the DATA path, through mailer-bin's parseMessage, so a
3519
+ * replay rebuilds the Email the live path built for the same bytes.
3520
+ */
3526
3521
  private async createEmailFromBuffer(emailData: Buffer, session: IExtendedSmtpSession): Promise<Email> {
3522
+ let parsed: IBridgeParsedMessage;
3527
3523
  try {
3528
- // Forwarded messages stay attachments of their bytes, so a relay hands them on unchanged
3529
- const parsed = await plugins.smartmail.parseMailMessage(emailData, { rfc822Attachments: true });
3530
- const headerFromMailboxes = this.parseFromHeaderFields(parsed.headers).flat();
3531
- const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
3532
- const parsedHeaders = new Map<string, string>();
3533
- for (const header of parsed.headers) {
3534
- if (this.isStandardParsedHeader(header.key)) {
3535
- continue;
3536
- }
3537
- // Raw values as written; a repeated field joins its values with ', '
3538
- const previous = parsedHeaders.get(header.key);
3539
- parsedHeaders.set(header.key, previous === undefined ? header.value : `${previous}, ${header.value}`);
3540
- }
3541
- return new Email({
3542
- // The first mailbox of the From fields in message order, groups flattened
3543
- from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
3544
- to: envelopeRecipients.length > 0
3545
- ? envelopeRecipients
3546
- : parsed.to.map(recipient => recipient.email),
3547
- cc: parsed.cc.map(recipient => recipient.email),
3548
- bcc: [],
3549
- subject: parsed.subject || '',
3550
- // A message that carries only HTML is read and scanned through its text
3551
- text: parsed.text ?? (parsed.html ? plugins.smartmail.htmlToText(parsed.html) : ''),
3552
- html: parsed.html || undefined,
3553
- headers: Object.fromEntries(parsedHeaders),
3554
- attachments: parsed.attachments.map(attachment => ({
3555
- filename: attachment.filename || '',
3556
- content: Buffer.from(attachment.content),
3557
- contentType: attachment.contentType,
3558
- ...(attachment.contentId ? { contentId: attachment.contentId } : {}),
3559
- })),
3560
- });
3524
+ parsed = await this.rustBridge.parseMessage(emailData);
3561
3525
  } catch (error) {
3562
3526
  logger.log('error', `Error parsing email data: ${(error as Error).message}`);
3563
3527
  throw new Error(`Error parsing email data: ${(error as Error).message}`);
3564
3528
  }
3529
+ return this.createEmailFromParsed(parsed, session);
3530
+ }
3531
+
3532
+ /** The Email of a message the Rust parser read (docs/cleanroom/mailer-mime.md section 2.2). */
3533
+ private createEmailFromParsed(parsed: IBridgeParsedMessage, session: IExtendedSmtpSession): Email {
3534
+ const headerFromMailboxes = this.fromFieldAddresses(parsed).flat();
3535
+ const envelopeRecipients = session.envelope.rcptTo.map(recipient => recipient.address);
3536
+ const parsedHeaders = new Map<string, string>();
3537
+ for (const header of parsed.headers) {
3538
+ if (this.isStandardParsedHeader(header.key)) {
3539
+ continue;
3540
+ }
3541
+ // Raw values as written; a repeated field joins its values with ', '
3542
+ const previous = parsedHeaders.get(header.key);
3543
+ parsedHeaders.set(header.key, previous === undefined ? header.value : `${previous}, ${header.value}`);
3544
+ }
3545
+ // Each body travels once, as body groups; an HTML-only group is read through
3546
+ // smartmail's HTML-to-text, the only HTML converter.
3547
+ const bodies = plugins.smartmail.joinMailBodyGroups(parsed.bodyGroups);
3548
+ return new Email({
3549
+ // The first mailbox of the From fields in message order, groups flattened
3550
+ from: headerFromMailboxes[0] || session.envelope.mailFrom.address,
3551
+ to: envelopeRecipients.length > 0
3552
+ ? envelopeRecipients
3553
+ : parsed.to.map(recipient => recipient.email),
3554
+ cc: parsed.cc.map(recipient => recipient.email),
3555
+ bcc: [],
3556
+ subject: parsed.subject || '',
3557
+ text: bodies.text,
3558
+ html: bodies.html,
3559
+ headers: Object.fromEntries(parsedHeaders),
3560
+ attachments: parsed.attachments.map(attachment => ({
3561
+ filename: attachment.filename || '',
3562
+ content: Buffer.from(attachment.content, 'base64'),
3563
+ contentType: attachment.contentType,
3564
+ ...(attachment.contentId ? { contentId: attachment.contentId } : {}),
3565
+ })),
3566
+ });
3565
3567
  }
3566
3568
 
3567
3569
  private isStandardParsedHeader(headerName: string): boolean {
@@ -4141,6 +4143,9 @@ export class UnifiedEmailServer extends EventEmitter {
4141
4143
  const tlsTerminatedPortsChanged = options.smtp !== undefined
4142
4144
  && JSON.stringify(options.smtp.tlsTerminatedPorts ?? [])
4143
4145
  !== JSON.stringify(this.options.smtp?.tlsTerminatedPorts ?? []);
4146
+ // The size limit is listener configuration and fixes the bridge's IPC line cap.
4147
+ const maxMessageSizeChanged = options.maxMessageSize !== undefined
4148
+ && options.maxMessageSize !== this.options.maxMessageSize;
4144
4149
 
4145
4150
  if (portsChanged) {
4146
4151
  this.stop().then(() => {
@@ -4188,7 +4193,9 @@ export class UnifiedEmailServer extends EventEmitter {
4188
4193
  ? 'SMTP TLS material replaced by configuration update'
4189
4194
  : tlsTerminatedPortsChanged
4190
4195
  ? 'SMTP upstream-terminated port list changed by configuration update'
4191
- : undefined;
4196
+ : maxMessageSizeChanged
4197
+ ? 'SMTP maximum message size changed by configuration update'
4198
+ : undefined;
4192
4199
  if (listenerRestartReason && this.smtpListenerActive && !this.smtpListenerShutdown) {
4193
4200
  this.scheduleSmtpListenerRestart(listenerRestartReason);
4194
4201
  }