@push.rocks/smartmta 8.2.1 → 9.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.
@@ -41,9 +41,21 @@ export interface IEmailMatch {
41
41
  * Action to take when route matches
42
42
  */
43
43
  export interface IEmailAction {
44
- /** Type of action to perform */
44
+ /**
45
+ * Type of action to perform.
46
+ * - `forward`: relay to a configured SMTP host.
47
+ * - `process`: queue for scanning and outbound MX delivery.
48
+ * - `deliver`: terminal local delivery through the durable
49
+ * `hooks.onAcceptEnvelope` acceptance path. Never relays.
50
+ * - `store`: terminal local delivery like `deliver`, with explicit
51
+ * retention configuration.
52
+ * - `reject`: refuse with a configurable SMTP error.
53
+ */
45
54
  type: 'forward' | 'deliver' | 'reject' | 'process' | 'store';
46
- /** Explicitly permits this route to relay to non-local recipients. */
55
+ /**
56
+ * Explicitly permits this route to relay to non-local recipients.
57
+ * Ignored for `deliver` actions, which are local-only and never relay.
58
+ */
47
59
  allowRelay?: boolean;
48
60
  /** Forward action configuration */
49
61
  forward?: {
@@ -116,7 +128,7 @@ export interface IEmailAction {
116
128
  [key: string]: any;
117
129
  }>;
118
130
  };
119
- /** Delivery options (applies to forward/process/deliver) */
131
+ /** Delivery options (applies to the queue-backed forward/process actions) */
120
132
  delivery?: {
121
133
  /** Rate limit (messages per minute) */
122
134
  rateLimit?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartmta",
3
- "version": "8.2.1",
3
+ "version": "9.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,9 +31,9 @@
31
31
  ".": "./dist_ts/index.js"
32
32
  },
33
33
  "devDependencies": {
34
- "@git.zone/cli": "^2.23.0",
34
+ "@git.zone/cli": "^3.0.0",
35
35
  "@git.zone/tsbuild": "4.4.2",
36
- "@git.zone/tsrust": "^1.6.0",
36
+ "@git.zone/tsrust": "^1.7.0",
37
37
  "@git.zone/tstest": "^3.6.6",
38
38
  "@types/mailparser": "^3.4.6",
39
39
  "@types/node": "^26.1.1",
package/readme.md CHANGED
@@ -29,7 +29,7 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
29
29
  | **DKIM** | Key generation, signing, and verification — per domain, with automatic rotation |
30
30
  | **SPF** | Full SPF record validation via Rust |
31
31
  | **DMARC** | Policy enforcement and verification |
32
- | **Email Router** | Pattern-based routing with priority, forward/deliver/reject/process actions |
32
+ | **Email Router** | Pattern-based routing with priority, forward/process/reject actions plus terminal local `deliver`/`store` actions |
33
33
  | **Bounce Manager** | Automatic bounce detection via Rust, classification (hard/soft), and suppression tracking |
34
34
  | **Content Scanner** | Spam, phishing, malware, XSS, and suspicious link detection — powered by Rust |
35
35
  | **IP Reputation** | DNSBL checks, proxy/TOR/VPN detection, risk scoring via Rust |
@@ -171,7 +171,8 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
171
171
  securePort: 465,
172
172
  recipientValidationTimeoutMs: 5000,
173
173
 
174
- // Timeout for hooks.onMessageData before returning a temporary SMTP failure.
174
+ // Timeout for hooks.onMessageData and hooks.onAcceptEnvelope before
175
+ // returning a temporary SMTP failure.
175
176
  messageAcceptanceTimeoutMs: 30000,
176
177
 
177
178
  // Set required to true only on backend listeners reached through trusted proxies.
@@ -197,10 +198,19 @@ const emailServer = new UnifiedEmailServer(dcRouterRef, {
197
198
  return { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' };
198
199
  },
199
200
  async onMessageData(context) {
201
+ // Runs for envelopes WITHOUT a resolved store/deliver action.
200
202
  // Return accepted only after the consumer has taken responsibility for the message.
201
203
  // await durableStore.save(context.email, context.rawMessage, { signal: context.abortSignal });
202
204
  return { accepted: true, continueProcessing: false };
203
205
  },
206
+ async onAcceptEnvelope(context) {
207
+ // Required terminal durable owner for envelopes with a resolved
208
+ // `store` or `deliver` action. Durably persist the EXACT raw bytes and
209
+ // the per-recipient plan before resolving — the SMTP 250 is only sent
210
+ // after this hook resolves.
211
+ // const plan = emailServer.createAcceptedEnvelopeDispatchPlan(context, idempotencyKeys);
212
+ // await durableStore.saveEnvelope(context.rawMessage, plan, { signal: context.abortSignal });
213
+ },
204
214
  },
205
215
 
206
216
  // Authentication settings for the SMTP server
@@ -241,7 +251,7 @@ await emailServer.start();
241
251
 
242
252
  > 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
243
253
  >
244
- > SmartMTA 8 requires Node.js 22 or newer.
254
+ > SmartMTA requires Node.js 22 or newer.
245
255
  >
246
256
  > `hostname` is the public SMTP identity for greetings and outbound delivery by default. It is not a bind address.
247
257
  >
@@ -268,11 +278,13 @@ Bounce delivery is at-least-once. `BounceManager` uses the operation ID as a dur
268
278
 
269
279
  SmartMTA validates each `RCPT TO` before the client can enter `DATA` by default. The default policy evaluates routes and accepts configured local domains. External recipients are rejected unless the matched route action explicitly sets `allowRelay: true`. Set `smtp.recipientValidation: false` only on fully trusted private listeners where another layer performs recipient and relay policy.
270
280
 
271
- Use `allowRelay` only for intentional relay routes, for example authenticated submission or tightly scoped internal networks. Broad `process` or `deliver` routes do not grant relay permission by themselves.
281
+ Use `allowRelay` only for intentional relay routes, for example authenticated submission or tightly scoped internal networks. Broad `process` routes do not grant relay permission by themselves, and `deliver` routes can never relay — `allowRelay` is ignored for them.
282
+
283
+ `hooks.onRcptTo`, `hooks.onMessageData`, and `hooks.onAcceptEnvelope` are awaited asynchronously. If a hook throws or times out, SmartMTA returns a temporary SMTP failure. The hook context includes `abortSignal`; long-running consumers should observe it and stop work promptly. A custom `onRcptTo` hook may accept local recipients using application-specific lookup, but external relay is still blocked unless a matched route explicitly sets `allowRelay: true`.
272
284
 
273
- `hooks.onRcptTo` and `hooks.onMessageData` are awaited asynchronously. If a hook throws or times out, SmartMTA returns a temporary SMTP failure. The hook context includes `abortSignal`; long-running consumers should observe it and stop work promptly. A custom `onRcptTo` hook may accept local recipients using application-specific lookup, but external relay is still blocked unless a matched route explicitly sets `allowRelay: true`.
285
+ `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`. 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.
274
286
 
275
- `hooks.onMessageData` runs after `DATA` and before the final SMTP response. 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.
287
+ `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 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.
276
288
 
277
289
  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.
278
290
 
@@ -344,6 +356,8 @@ public async sendEmail(
344
356
 
345
357
  **Self-MX loop guard:** MTA delivery refuses any MX host that equals the server's own hostname (`hostname` or `outbound.hostname`). A domain whose MX record points back at this server would otherwise re-enter the inbound pipeline on every delivery attempt — an infinite mail loop. Such deliveries fail with `MX for <domain> points back at this server — refusing self-delivery to avoid a mail loop`. The comparison is hostname-based; if a target only reveals the loop at the IP level (e.g. an A-record fallback), embedders should add an IP-level guard in their `connectionProxyProvider`.
346
358
 
359
+ Routes with action `deliver` never enter MTA mode: they are terminal local deliveries through the durable acceptance path (see Route Action Types below), so the self-MX guard is structurally unreachable for them. Pre-9.x persisted queue items that still carry a `deliver` action fail loudly at delivery time with error type `deliver_action_relay_unsupported` (failure class `localTopology`) instead of being relayed or bounced as a misleading self-MX loop.
360
+
347
361
  ### 📬 Direct SMTP Delivery (Low-Level)
348
362
 
349
363
  For cases where you know the exact target SMTP server (e.g. relaying to a specific host), use the lower-level `sendOutboundEmail()`:
@@ -427,7 +441,7 @@ When `UnifiedEmailServer.start()` is called:
427
441
  - A requested missing or invalid key fails closed before SMTP delivery. SmartMTA never falls back to unsigned delivery.
428
442
  - Signing is applied exactly once, by the Rust SMTP client at final outbound transmission.
429
443
 
430
- Embedders can check `smartMtaCapabilities.selectorCorrectDkimSigning`, `callerManagedDkimKeys`, `structuredSmtpTransactions`, and `typedDeliveryFailures` as compatibility probes.
444
+ Embedders can check `smartMtaCapabilities.selectorCorrectDkimSigning`, `callerManagedDkimKeys`, `structuredSmtpTransactions`, `typedDeliveryFailures`, and `terminalLocalDeliverActions` (deliver routes are terminal local deliveries through the durable acceptance path) as compatibility probes.
431
445
 
432
446
  ### 🛡️ Email Authentication (SPF, DKIM, DMARC)
433
447
 
@@ -460,6 +474,8 @@ const router = new EmailRouter([
460
474
  authenticated: true,
461
475
  },
462
476
  action: {
477
+ // Terminal local delivery. In UnifiedEmailServer this requires a
478
+ // configured hooks.onAcceptEnvelope durable local-delivery owner.
463
479
  type: 'deliver',
464
480
  },
465
481
  },
@@ -505,11 +521,23 @@ const matchedRoute = await router.evaluateRoutes(emailContext);
505
521
  | Action | Description |
506
522
  |---|---|
507
523
  | `forward` | Forward the email to another SMTP server via the Rust SMTP client |
508
- | `deliver` | Queue for local MTA delivery |
509
- | `process` | Queue for processing (with optional content scanning and DKIM signing) |
524
+ | `deliver` | **Terminal local delivery.** The envelope is durably accepted through `hooks.onAcceptEnvelope` before the SMTP 250; nothing is enqueued for outbound MX relay. |
525
+ | `store` | Terminal local delivery like `deliver`, with explicit retention configuration (`store.retentionDays`) |
526
+ | `process` | Queue for processing (with optional content scanning and DKIM signing), then deliver via automatic MX resolution |
510
527
  | `reject` | Reject with a configurable SMTP error code and message |
511
528
 
512
- Every action can include `allowRelay?: boolean`. It defaults to `false`. Non-local recipients are accepted only when the matched action explicitly sets `allowRelay: true`; configured local domains remain eligible for inbound delivery without relay permission.
529
+ ##### Local delivery: `deliver` and `store`
530
+
531
+ `deliver` and `store` are the two terminal local-delivery actions. An envelope that resolves to either goes through the durable acceptance path: SmartMTA calls `hooks.onAcceptEnvelope` with the exact raw message and the complete per-recipient plan, and only sends the SMTP `250` after the hook resolves — the consumer's durably persisted copy IS the delivery. In `dispatchAcceptedEnvelope`, both report `status: 'stored'` with no `queueId`; the `actionType` field distinguishes them.
532
+
533
+ - A `deliver` route **requires** a configured `hooks.onAcceptEnvelope`. Without it the configuration is unservable and is rejected loudly whenever routes are supplied or loaded: the constructor, `updateEmailRoutes`, `updateOptions` calls that include `routes`, and persisted-route hydration all throw. If hook state is nevertheless broken at runtime (for example an `updateOptions` call that replaces `hooks` without passing `routes`), deliver recipients are refused with a temporary `451 Local delivery temporarily unavailable` at `RCPT TO`/`DATA`. Mail is never accepted and then destroyed.
534
+ - `deliver` never relays. Non-local recipients matched by a deliver route are refused with `550` at `RCPT TO`, and `allowRelay` is ignored for deliver actions.
535
+ - `deliver` envelopes skip `hooks.onMessageData`, bounce-notification detection, and the inbound `processEmailByMode` pipeline — exactly like `store` envelopes. Rust-computed security verdicts are still delivered in the acceptance context.
536
+ - `deliver` carries no retention metadata; the consumer owns retention for delivered messages. Use `store` with `store.retentionDays` when retention must be explicit.
537
+
538
+ > ⚠️ **Changed in v9:** before v9, `deliver` silently meant "relay via MX discovery". On a domain whose MX points back at this server, that accepted mail with `250` and then terminally bounced it on the self-MX loop guard — destroying mail. `deliver` now delivers locally, and configurations that cannot do so fail at config/accept time.
539
+
540
+ Every action can include `allowRelay?: boolean`. It defaults to `false`. Non-local recipients are accepted only when the matched action explicitly sets `allowRelay: true`; configured local domains remain eligible for inbound delivery without relay permission. `allowRelay` has no effect on `deliver` actions.
513
541
 
514
542
  #### Match Criteria
515
543
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartmta',
6
- version: '8.2.1',
6
+ version: '9.0.0',
7
7
  description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
8
8
  }
package/ts/index.ts CHANGED
@@ -14,4 +14,5 @@ export const smartMtaCapabilities = Object.freeze({
14
14
  topologyAwareSelfMx: true,
15
15
  durableDeliveryFailureOutbox: true,
16
16
  exactAcceptedEnvelopeDispatch: true,
17
+ terminalLocalDeliverActions: true,
17
18
  } as const);
@@ -675,7 +675,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
675
675
  const transition = await this.queue.markFailedWithTransition(item.id, deliveryError.message, {
676
676
  retryable: deliveryError.retryable,
677
677
  terminalFailure: {
678
- failureClass: deliveryError.errorType.startsWith('self_mx_')
678
+ failureClass: (
679
+ deliveryError.errorType.startsWith('self_mx_')
680
+ || deliveryError.errorType === 'deliver_action_relay_unsupported'
681
+ )
679
682
  ? 'localTopology'
680
683
  : 'transport',
681
684
  ...(deliveryError.smtpCode !== undefined ? { smtpCode: deliveryError.smtpCode } : {}),
@@ -926,6 +929,22 @@ export class MultiModeDeliverySystem extends EventEmitter {
926
929
 
927
930
  const email = item.processingResult as Email;
928
931
 
932
+ // Structural invariant: `deliver` actions are terminal local deliveries
933
+ // and must never reach outbound MX relay (and thus never the self-MX
934
+ // guard below). Items carrying a deliver action can only be pre-9.x
935
+ // persisted queue state or a direct-enqueue misuse; both fail loudly
936
+ // here instead of relaying or bouncing as a misleading self-MX loop.
937
+ if (item.route?.action.type === 'deliver') {
938
+ throw new SmtpDeliveryError({
939
+ message: `Queue item ${item.id} carries a 'deliver' action; deliver is a terminal local delivery and cannot be relayed via MX`,
940
+ errorType: 'deliver_action_relay_unsupported',
941
+ retryable: false,
942
+ phase: 'connect',
943
+ transactionLog: [],
944
+ recipients: email.getAllRecipients(),
945
+ });
946
+ }
947
+
929
948
  if (!this.emailServer) {
930
949
  throw new Error('No email server available for MTA delivery');
931
950
  }
@@ -47,7 +47,10 @@ export interface IActionExecutionResult {
47
47
  }
48
48
 
49
49
  /**
50
- * Executes email routing actions (forward, process, deliver, reject)
50
+ * Executes queue-backed and rejecting email routing actions (forward,
51
+ * process, reject). Terminal local-delivery actions (`deliver`, `store`) are
52
+ * owned by the durable `hooks.onAcceptEnvelope` acceptance path and are
53
+ * refused here.
51
54
  */
52
55
  export class EmailActionExecutor {
53
56
  constructor(private deps: IActionExecutorDeps) {}
@@ -97,7 +100,9 @@ export class EmailActionExecutor {
97
100
  case 'process':
98
101
  return this.handleProcessAction(action, email, context, options);
99
102
  case 'deliver':
100
- return this.handleDeliverAction(action, email, context, options);
103
+ throw new Error(
104
+ 'Deliver actions are terminal local deliveries and require the durable hooks.onAcceptEnvelope acceptance path; they never enqueue outbound MX relay',
105
+ );
101
106
  case 'store':
102
107
  throw new Error(
103
108
  'Store actions require raw SMTP context and an awaited hooks.onAcceptEnvelope handler',
@@ -250,32 +255,6 @@ export class EmailActionExecutor {
250
255
  return { outcome: 'queued', queueId };
251
256
  }
252
257
 
253
- private async handleDeliverAction(
254
- _action: IEmailAction,
255
- email: Email,
256
- context: IEmailContext,
257
- options: IActionExecutionOptions,
258
- ): Promise<IActionExecutionResult> {
259
- logger.log('info', `Delivering email locally`);
260
-
261
- // Queue for local delivery
262
- const queueId = await this.deps.deliveryQueue.enqueue(
263
- email,
264
- 'mta',
265
- context.session.matchedRoute!,
266
- options.durableQueue
267
- ? {
268
- idempotencyKey: options.durableQueue.idempotencyKey,
269
- idempotencyFingerprint: options.durableQueue.fingerprint,
270
- requireDurable: true,
271
- }
272
- : {},
273
- );
274
-
275
- logger.log('info', 'Email queued for local delivery');
276
- return { outcome: 'queued', queueId };
277
- }
278
-
279
258
  private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
280
259
  const code = action.reject?.code || 550;
281
260
  const message = action.reject?.message || 'Message rejected';
@@ -14,8 +14,9 @@ export class EmailRouter extends EventEmitter {
14
14
  private patternCache: Map<string, boolean> = new Map();
15
15
  private storageManager?: IStorageManager;
16
16
  private persistChanges: boolean;
17
+ private validateAction?: (route: IEmailRoute) => void;
17
18
  private initializationPromise: Promise<void>;
18
-
19
+
19
20
  /**
20
21
  * Create a new email router
21
22
  * @param routes Array of email routes
@@ -24,8 +25,15 @@ export class EmailRouter extends EventEmitter {
24
25
  constructor(routes: IEmailRoute[], options?: {
25
26
  storageManager?: IStorageManager;
26
27
  persistChanges?: boolean;
28
+ /**
29
+ * Additional owner-level action policy applied to every configured,
30
+ * updated, and hydrated route (e.g. an embedding server requiring a
31
+ * durable local-delivery owner for `deliver` actions).
32
+ */
33
+ validateAction?: (route: IEmailRoute) => void;
27
34
  }) {
28
35
  super();
36
+ this.validateAction = options?.validateAction;
29
37
  this.validateRoutes(routes);
30
38
  this.routes = this.sortRoutesByPriority(routes);
31
39
  this.storageManager = options?.storageManager;
@@ -49,6 +57,8 @@ export class EmailRouter extends EventEmitter {
49
57
  throw new Error('Invalid route: missing required fields');
50
58
  }
51
59
 
60
+ this.validateAction?.(route);
61
+
52
62
  if (route.action.type !== 'store') {
53
63
  return;
54
64
  }
@@ -69,6 +79,16 @@ export class EmailRouter extends EventEmitter {
69
79
  this.validateRoute(route);
70
80
  }
71
81
  }
82
+
83
+ /**
84
+ * Synchronously validate routes against structural rules and the owner's
85
+ * action policy without mutating router state. Callers that stage route
86
+ * updates in their own configuration should validate first so an invalid
87
+ * route set fails loudly before any state changes.
88
+ */
89
+ public assertRoutesValid(routes: IEmailRoute[]): void {
90
+ this.validateRoutes(routes);
91
+ }
72
92
 
73
93
  /**
74
94
  * Sort routes by priority (higher priority first)
@@ -147,8 +147,9 @@ export class AcceptEnvelopeRejectionError extends Error {
147
147
  }
148
148
 
149
149
  /**
150
- * Terminal durable acceptance context for an envelope containing at least one `store` route.
151
- * The consumer owns persistence and all recipient fan-out before this hook resolves.
150
+ * Terminal durable acceptance context for an envelope containing at least one
151
+ * `store` or `deliver` route. The consumer owns persistence and all recipient
152
+ * fan-out before this hook resolves.
152
153
  */
153
154
  export interface IAcceptEnvelopeContext {
154
155
  email: Email;
@@ -338,9 +339,14 @@ export interface IUnifiedEmailServerOptions {
338
339
  hooks?: {
339
340
  /** Async recipient policy hook that may further restrict an accepted built-in decision. */
340
341
  onRcptTo?: (context: IRcptToPolicyContext) => Promise<ISmtpDecision>;
341
- /** Async message acceptance hook for envelopes with no resolved `store` action. */
342
+ /** Async message acceptance hook for envelopes with no resolved `store` or `deliver` action. */
342
343
  onMessageData?: (context: IMessageAcceptanceContext) => Promise<IMessageAcceptanceDecision>;
343
- /** Required terminal durable owner for any envelope containing a resolved `store` action. */
344
+ /**
345
+ * Required terminal durable owner for any envelope containing a resolved
346
+ * `store` or `deliver` action. The hook must durably persist the exact
347
+ * raw message before resolving; its resolution is what authorizes the
348
+ * SMTP 250 for those envelopes.
349
+ */
344
350
  onAcceptEnvelope?: (context: IAcceptEnvelopeContext) => Promise<void>;
345
351
  /** Observer-safe callback for SMTP recipient rejections. */
346
352
  onRecipientRejected?: (event: IRecipientRejectedEvent) => Promise<void> | void;
@@ -488,10 +494,13 @@ export class UnifiedEmailServer extends EventEmitter {
488
494
  // Initialize domain registry
489
495
  this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
490
496
 
491
- // Initialize email router with routes and storage manager
497
+ // Initialize email router with routes and storage manager. The action
498
+ // policy applies to configured, updated, and storage-hydrated routes so
499
+ // an unservable route set fails loudly at configuration time.
492
500
  this.emailRouter = new EmailRouter(options.routes || [], {
493
501
  storageManager: dcRouter.storageManager,
494
- persistChanges: options.persistRoutes ?? true
502
+ persistChanges: options.persistRoutes ?? true,
503
+ validateAction: (route) => this.assertRouteActionServable(route),
495
504
  });
496
505
 
497
506
  // Initialize rate limiter
@@ -1354,6 +1363,37 @@ export class UnifiedEmailServer extends EventEmitter {
1354
1363
  };
1355
1364
  }
1356
1365
 
1366
+ if (route.action.type === 'deliver') {
1367
+ if (!isLocalRecipient) {
1368
+ // Deliver actions are terminal local deliveries and never relay;
1369
+ // `allowRelay` is ignored for them.
1370
+ return {
1371
+ recipient: context.rcptTo,
1372
+ localDomain: null,
1373
+ route,
1374
+ source: 'explicit-route',
1375
+ decision: { accepted: false, smtpCode: 550, smtpMessage: 'Relay not permitted' },
1376
+ rejectionReason: 'relay-denied',
1377
+ };
1378
+ }
1379
+ if (!this.options.hooks?.onAcceptEnvelope) {
1380
+ // Defense in depth: route validation rejects this configuration,
1381
+ // so refuse loudly at accept time rather than accept-then-fail.
1382
+ return {
1383
+ recipient: context.rcptTo,
1384
+ localDomain,
1385
+ route,
1386
+ source: 'explicit-route',
1387
+ decision: {
1388
+ accepted: false,
1389
+ smtpCode: 451,
1390
+ smtpMessage: 'Local delivery temporarily unavailable',
1391
+ },
1392
+ rejectionReason: 'policy-error',
1393
+ };
1394
+ }
1395
+ }
1396
+
1357
1397
  if (!isLocalRecipient && !route.action.allowRelay) {
1358
1398
  return {
1359
1399
  recipient: context.rcptTo,
@@ -2088,7 +2128,6 @@ export class UnifiedEmailServer extends EventEmitter {
2088
2128
  }
2089
2129
  const hasQueueAction = plan.some((entry) => (
2090
2130
  entry.action.type === 'forward'
2091
- || entry.action.type === 'deliver'
2092
2131
  || entry.action.type === 'process'
2093
2132
  ));
2094
2133
  if (hasQueueAction) {
@@ -2168,7 +2207,16 @@ export class UnifiedEmailServer extends EventEmitter {
2168
2207
 
2169
2208
  try {
2170
2209
  let result: IAcceptedEnvelopeRecipientDispatchResult;
2171
- if (entry.action.type === 'store') {
2210
+ if (entry.action.type === 'store' || entry.action.type === 'deliver') {
2211
+ // Terminal local deliveries: the consumer's durably persisted copy
2212
+ // of the accepted envelope IS the delivery. Nothing is enqueued.
2213
+ if (entry.action.type === 'deliver' && entry.localDomain === null) {
2214
+ // Pre-9.x plans could contain relay-intended deliver entries.
2215
+ // Deliver never relays, so fail loudly instead of dropping.
2216
+ throw new Error(
2217
+ `Deliver action for ${entry.recipient} has no local domain; deliver actions never relay`,
2218
+ );
2219
+ }
2172
2220
  result = { ...commonResult, status: 'stored' };
2173
2221
  } else {
2174
2222
  parsedEmail ||= await this.createEmailFromBuffer(rawCopy, baseSession);
@@ -2298,10 +2346,13 @@ export class UnifiedEmailServer extends EventEmitter {
2298
2346
  return;
2299
2347
  }
2300
2348
 
2301
- const hasStoreRoute = resolvedRecipientRoutes.some(
2302
- (resolution) => resolution.route?.action.type === 'store',
2349
+ // `store` and `deliver` are both terminal local deliveries: the
2350
+ // envelope must be durably owned by the consumer before SMTP 250.
2351
+ const hasDurableLocalRoute = resolvedRecipientRoutes.some(
2352
+ (resolution) => resolution.route?.action.type === 'store'
2353
+ || resolution.route?.action.type === 'deliver',
2303
2354
  );
2304
- if (hasStoreRoute) {
2355
+ if (hasDurableLocalRoute) {
2305
2356
  try {
2306
2357
  await this.acceptDurableEnvelope(
2307
2358
  email,
@@ -2807,6 +2858,23 @@ export class UnifiedEmailServer extends EventEmitter {
2807
2858
  return routes;
2808
2859
  }
2809
2860
 
2861
+ /**
2862
+ * Server-level action policy for a single route. A `deliver` action is a
2863
+ * terminal local delivery owned by the durable `hooks.onAcceptEnvelope`
2864
+ * acceptance path; without that hook the route could only accept-then-fail,
2865
+ * so it is rejected at configuration time instead.
2866
+ */
2867
+ private assertRouteActionServable(route: IEmailRoute): void {
2868
+ if (
2869
+ route.action.type === 'deliver'
2870
+ && !this.options.hooks?.onAcceptEnvelope
2871
+ ) {
2872
+ throw new Error(
2873
+ `Route ${route.name}: action type 'deliver' requires a configured hooks.onAcceptEnvelope durable local-delivery owner`,
2874
+ );
2875
+ }
2876
+ }
2877
+
2810
2878
  /**
2811
2879
  * Update server configuration
2812
2880
  */
@@ -2821,7 +2889,21 @@ export class UnifiedEmailServer extends EventEmitter {
2821
2889
  this.start();
2822
2890
  });
2823
2891
  } else {
2824
- this.options = { ...this.options, ...options };
2892
+ if (options.routes) {
2893
+ // Validate synchronously against the incoming hook configuration
2894
+ // before any option state is mutated, so invalid routes throw here
2895
+ // instead of surfacing as an unhandled rejection.
2896
+ const previousOptions = this.options;
2897
+ this.options = { ...this.options, ...options };
2898
+ try {
2899
+ this.emailRouter.assertRoutesValid(options.routes);
2900
+ } catch (error) {
2901
+ this.options = previousOptions;
2902
+ throw error;
2903
+ }
2904
+ } else {
2905
+ this.options = { ...this.options, ...options };
2906
+ }
2825
2907
 
2826
2908
  if (options.domains) {
2827
2909
  this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
@@ -2837,6 +2919,8 @@ export class UnifiedEmailServer extends EventEmitter {
2837
2919
  * Update email routes
2838
2920
  */
2839
2921
  public updateEmailRoutes(routes: IEmailRoute[]): void {
2922
+ // Fail loudly and synchronously before mutating server options.
2923
+ this.emailRouter.assertRoutesValid(routes);
2840
2924
  this.options.routes = routes;
2841
2925
  this.emailRouter.updateRoutes(routes);
2842
2926
  }
@@ -43,9 +43,21 @@ export interface IEmailMatch {
43
43
  * Action to take when route matches
44
44
  */
45
45
  export interface IEmailAction {
46
- /** Type of action to perform */
46
+ /**
47
+ * Type of action to perform.
48
+ * - `forward`: relay to a configured SMTP host.
49
+ * - `process`: queue for scanning and outbound MX delivery.
50
+ * - `deliver`: terminal local delivery through the durable
51
+ * `hooks.onAcceptEnvelope` acceptance path. Never relays.
52
+ * - `store`: terminal local delivery like `deliver`, with explicit
53
+ * retention configuration.
54
+ * - `reject`: refuse with a configurable SMTP error.
55
+ */
47
56
  type: 'forward' | 'deliver' | 'reject' | 'process' | 'store';
48
- /** Explicitly permits this route to relay to non-local recipients. */
57
+ /**
58
+ * Explicitly permits this route to relay to non-local recipients.
59
+ * Ignored for `deliver` actions, which are local-only and never relay.
60
+ */
49
61
  allowRelay?: boolean;
50
62
 
51
63
  /** Forward action configuration */
@@ -124,7 +136,7 @@ export interface IEmailAction {
124
136
  }>;
125
137
  };
126
138
 
127
- /** Delivery options (applies to forward/process/deliver) */
139
+ /** Delivery options (applies to the queue-backed forward/process actions) */
128
140
  delivery?: {
129
141
  /** Rate limit (messages per minute) */
130
142
  rateLimit?: number;