@push.rocks/smartmta 9.2.0 → 9.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/changelog.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-07-26 - 9.3.0
4
+
5
+ ### Fixes
6
+
7
+ - a submission-permission route no longer claims a recipient this server terminates (routing)
8
+ - Found empirically in a dcrouter deployment: the same message from the same sender to `admin@central.eu`, a domain that server hosts, was accepted and stored when submitted unauthenticated but permanently lost when submitted over authenticated SMTP minutes later. Log chain: `MTA: skipped MX mx.lossless.directory for central.eu: ... points back at this server because it exactly matches a configured listener identity` → `all MX hosts failed` → `Terminal delivery failure persisted for admin@central.eu`, all after the client had already been given a `250`.
9
+ - Root cause: `resolveRecipientPolicy` let ANY matched route win over the domain inbound policy. An authenticated submission route (`match: { authenticated: true, senders }`, `action: { type: 'process', allowRelay: true }`, no recipient scope) therefore claimed locally hosted recipients too, and `process` transports via DNS MX — which for such a recipient resolves straight back to this listener, where the self-MX loop guard terminates it permanently. The domain-policy `store` fallback was only reachable when no route matched at all, which is why the unauthenticated path worked.
10
+ - Resolution now falls through to the domain's inbound policy when the recipient domain is registered AND stores inbound mail locally (`inboundPolicy.defaultAction: 'store'`), the action is `process`, and the route names no recipient scope (`match.recipients` absent, empty, or only match-all patterns `*` / `*@*`).
11
+ - Deliberately narrow, so nothing that works today starts bouncing: recipient-scoped `process` routes still win (consumers deliver specific local addresses through their own acceptance pipeline that way), `store`/`deliver`/`forward`/`reject` always win, non-local recipients are untouched, and a domain registered for outbound identity only — no `inboundPolicy`, or the send-only `defaultAction: 'reject'` — keeps relaying because its real MX legitimately lives elsewhere. The requirement that the fall-through target be a `store` policy means the change can never turn a working relay into a `550`.
12
+ - The self-MX loop guard is unchanged; local recipients simply stop reaching it. Authenticated relay to remote domains is unchanged. Mixed envelopes now resolve per recipient: local recipients get `store` and remote recipients keep the submission route, so an envelope addressed to both is handled through the durable `hooks.onAcceptEnvelope` path with only the remote recipients dispatched to the queue.
13
+ - Encoding recipient intent in `match.headers` is explicitly not recognised for this purpose.
14
+
15
+ ### Security
16
+
17
+ - AUTH is no longer advertised or accepted on an unencrypted session (smtp)
18
+ - Found in the same deployment: ports 25 and 587 advertised `AUTH PLAIN LOGIN SCRAM-SHA-256` while offering no `STARTTLS`, so credentials could be solicited over a cleartext channel. `build_capabilities` pushed the AUTH capability whenever auth was configured, ignoring `already_secure`, and `handle_auth`/`handle_auth_scram` only checked `config.auth_enabled`, never `session.secure`.
19
+ - `AUTH` is now advertised only on an encrypted transport, and an `AUTH` command on a cleartext session is refused with `538 5.7.11 Encryption required for requested authentication mechanism` — so a client that ignores the absent capability cannot send credentials in the clear either. Applies to `PLAIN`, `LOGIN` and `SCRAM-SHA-256`.
20
+ - BREAKING for deployments that relied on cleartext AUTH: a plain port with no TLS material now offers neither `STARTTLS` nor `AUTH`. Verify that submission ports actually receive `tls.certPem`/`tls.keyPem`.
21
+
22
+ ### Features
23
+
24
+ - `smtp.tlsTerminatedPorts` for edge-terminated submission ports (smtp)
25
+ - Declares listener ports whose inbound transport a trusted upstream TLS terminator already encrypted, which is how a proxy-fronted deployment keeps implicit-TLS submission working under the new AUTH gate: the backend leg is plaintext, but the client's channel is encrypted. Sessions on such a port count as encrypted, report `secure: true` to hooks, and do not advertise `STARTTLS`.
26
+ - Because it is a trust assertion about the peer, it is only honoured together with `smtp.proxyProtocol.required` and a non-empty `trustedIps` — the exact-peer PROXY check is what proves the connection came from the terminator. `start()` throws when a listed port is not among the configured `ports`, is the implicit-TLS `securePort`, or PROXY protocol is not required. Validated on both the TypeScript and the Rust side.
27
+ - `updateOptions` now restarts the Rust SMTP listener when TLS material or `smtp.tlsTerminatedPorts` change, alongside the existing AUTH-presence transition (routing)
28
+ - Both are listener-level Rust configuration. Previously they were merged into the options and silently never took effect on the running listener, so a renewed certificate could not be applied without a full process restart.
29
+
30
+ ### Known limitations (unchanged, documented)
31
+
32
+ - `tls.caPem`, `tls.minVersion` and `tls.ciphers` are accepted but not consumed by the Rust listener; only `certPem`/`keyPem` take effect.
33
+ - `auth.required` only turns AUTH availability on — it does not require authentication. A global require-auth flag is incoherent for port 25, which must accept anonymous inbound mail; relay permission is expressed per route via `allowRelay` plus local-recipient resolution. The option name remains misleading.
34
+
3
35
  ## 2026-07-26 - 9.2.0
4
36
 
5
37
  ### Features
Binary file
Binary file
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '9.2.0',
6
+ version: '9.3.0',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
@@ -231,6 +231,19 @@ export interface IUnifiedEmailServerOptions {
231
231
  recipientValidation?: boolean;
232
232
  /** Listener port that should use implicit TLS. Defaults to 465 when present. */
233
233
  securePort?: number;
234
+ /**
235
+ * Listener ports whose inbound transport is already encrypted by a trusted
236
+ * upstream TLS terminator (an edge proxy that terminates TLS and forwards
237
+ * the decrypted stream here). Sessions on these ports count as encrypted:
238
+ * AUTH is offered even though this process performed no handshake, and
239
+ * STARTTLS is not advertised.
240
+ *
241
+ * Only honoured together with `proxyProtocol.required` — the exact-peer
242
+ * PROXY check is what proves the connection came from the terminator.
243
+ * Startup fails when a listed port is not bound, is the implicit-TLS
244
+ * `securePort`, or PROXY protocol is not required.
245
+ */
246
+ tlsTerminatedPorts?: number[];
234
247
  /** Timeout for RCPT policy callbacks. Defaults to 5000 ms. */
235
248
  recipientValidationTimeoutMs?: number;
236
249
  /** Timeout for message acceptance callbacks. Defaults to 30000 ms. */
@@ -405,6 +418,13 @@ export declare class UnifiedEmailServer extends EventEmitter {
405
418
  private unregisterBridgeStateHandler;
406
419
  private unregisterBridgeEventHandlers;
407
420
  private startSmtpServer;
421
+ /**
422
+ * Validate and resolve the ports whose transport an upstream proxy already
423
+ * encrypted. Misconfiguration here would let a peer claim an encrypted
424
+ * channel and be offered AUTH in cleartext, so every case fails startup
425
+ * rather than degrading silently.
426
+ */
427
+ private resolveTlsTerminatedPorts;
408
428
  /** Whether the current options require SMTP AUTH support on the listener. */
409
429
  private isAuthConfigured;
410
430
  /**
@@ -429,6 +449,29 @@ export declare class UnifiedEmailServer extends EventEmitter {
429
449
  private handleRustRcptToRequest;
430
450
  private evaluateDefaultRcptToPolicy;
431
451
  private resolveRecipientPolicy;
452
+ /**
453
+ * Whether a matched route is a pure submission-permission route that must
454
+ * not claim a recipient whose inbound mail this server terminates.
455
+ *
456
+ * A route qualifies only when ALL of the following hold:
457
+ * - the recipient domain is registered here AND its inbound policy stores
458
+ * mail locally, i.e. this server really is the destination. A domain
459
+ * registered for outbound purposes only (`inboundPolicy` absent, or
460
+ * `defaultAction: 'reject'` for send-only domains) keeps relaying, because
461
+ * its real MX legitimately lives elsewhere;
462
+ * - the action is `process`, the only action whose transport is DNS-MX
463
+ * relay. `store`, `deliver`, `forward` and `reject` all express a
464
+ * deliberate disposition for the recipient and always win;
465
+ * - the route names no recipient scope. `match.recipients` is the only way a
466
+ * route states which recipients it is for, and a pattern that matches every
467
+ * address states nothing. Recipient-scoped `process` routes keep winning —
468
+ * consumers use them for local addresses handled by their own acceptance
469
+ * pipeline. Encoding recipient intent in `match.headers` is not supported
470
+ * for this purpose.
471
+ */
472
+ private isSubmissionPermissionRouteForLocalRecipient;
473
+ /** Whether a recipient pattern list narrows the route to specific recipients. */
474
+ private routeNamesRecipientScope;
432
475
  private getRejectionReasonForResolution;
433
476
  private notifyRecipientRejected;
434
477
  private buildPolicySession;