@serve.zone/dcrouter 32.6.3 → 32.7.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.
@@ -50,10 +50,10 @@ import { TestingOperationWorker } from './testing/classes.testing-operation-work
50
50
  import { TestingDnsCleanupManager } from './testing/classes.testing-dns-cleanup-manager.js';
51
51
  import { AcmeAccountDoc, AcmeIssuanceDoc, AcmeDnsChallengeDoc, TestingRegistrationDoc, TestingZoneDoc,
52
52
  TestingDomainGrantDoc, TestingRequestReceiptDoc, TestingDnsMutationDoc, TestingCertificateJobDoc } from './db/documents/index.js';
53
- import { AcceptedEmailSpool, CoreMailGatewayManager, CoreMailGatewayStore, createCoreMailGatewayRuntime, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, GatewayMailDomainStore, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
53
+ import { AcceptedEmailSpool, composeEmailServerTls, CoreMailGatewayManager, CoreMailGatewayStore, createCoreMailGatewayRuntime, deriveEmailTlsSource, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, EmailTlsManager, GatewayMailDomainStore, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
54
54
  import { WebPushManager } from './webpush/index.js';
55
55
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
56
- import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
56
+ import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, IEmailTlsCertificateStatus, TEmailOutboundMode, TEmailServerSettingsUpdate, TEmailTlsSource } from '../ts_interfaces/data/email-settings.js';
57
57
  import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
58
58
  import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
59
59
 
@@ -312,6 +312,8 @@ export class DcRouter {
312
312
  public smartAcme?: plugins.smartacme.SmartAcme;
313
313
  public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
314
314
  public emailServer?: UnifiedEmailServer;
315
+ /** Owns the SMTP listener certificate: source resolution, renewal relevance, served status. */
316
+ public readonly emailTlsManager = new EmailTlsManager();
315
317
  public radiusServer?: RadiusServer;
316
318
  public opsServer!: OpsServer;
317
319
  private opsServerStopped = false;
@@ -1679,9 +1681,10 @@ export class DcRouter {
1679
1681
  source: event.source,
1680
1682
  });
1681
1683
  // Renewals arrive here too. The SMTP listener holds its PEM material as
1682
- // listener-level Rust configuration, so a renewed mail-hostname
1683
- // certificate must be pushed or STARTTLS keeps serving the stale one.
1684
- void this.reapplyEmailTlsMaterial(event.domain);
1684
+ // listener-level Rust configuration, so a renewed mail certificate
1685
+ // must be pushed or STARTTLS keeps serving the stale one. Queued with
1686
+ // the email lifecycle so it never races a listener restart.
1687
+ void this.queueEmailLifecycleTask(() => this.reapplyEmailTlsMaterial(event.domain));
1685
1688
  });
1686
1689
 
1687
1690
  // Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
@@ -2082,7 +2085,12 @@ export class DcRouter {
2082
2085
  storageManager: this.smartMtaBlobStorageManager,
2083
2086
  };
2084
2087
 
2085
- const emailTls = await this.resolveEmailTlsMaterial();
2088
+ const emailTls = await this.emailTlsManager.resolve({
2089
+ source: this.getEmailTlsSource(),
2090
+ tls: baseEmailConfig.tls,
2091
+ requiredHostnames: this.emailRouteBuilder.getMailCertificateHostnames(),
2092
+ });
2093
+ const emailServerTls = composeEmailServerTls(baseEmailConfig.tls, emailTls);
2086
2094
  const tlsTerminatedPorts = this.resolveEdgeTerminatedEmailPorts(portMapping);
2087
2095
  this.logEmailAuthTransportPosture(emailConfigHasAuth(baseEmailConfig), emailTls, mappedEmailPorts, tlsTerminatedPorts, mappedSecurePort);
2088
2096
 
@@ -2093,7 +2101,7 @@ export class DcRouter {
2093
2101
  dkimKeyProvisioning: 'caller-managed',
2094
2102
  persistRoutes: this.options.emailConfig.persistRoutes ?? false,
2095
2103
  queue: queueOptions,
2096
- ...(emailTls ? { tls: { ...baseEmailConfig.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem } } : {}),
2104
+ tls: emailServerTls,
2097
2105
  outbound: {
2098
2106
  ...baseEmailConfig.outbound,
2099
2107
  connectionProxyProvider: (context) => this.mailEgressCoordinator.provideConnectionProxy(context),
@@ -2335,101 +2343,42 @@ export class DcRouter {
2335
2343
  this.mailDnsSync.requestSync('email server start');
2336
2344
  }
2337
2345
 
2338
- /**
2339
- * Resolve the PEM material for the SMTP listener.
2340
- *
2341
- * SmartMTA consumes `tls.certPem`/`tls.keyPem`; a path-shaped `tls` block is
2342
- * ignored, which leaves the Rust listener with no TLS material at all — no
2343
- * STARTTLS on the plain submission ports. Resolution order matches the
2344
- * RemoteIngress tunnel precedent: explicit paths, then the ACME cert store.
2345
- *
2346
- * Explicitly configured paths that cannot be read FAIL STARTUP. Silently
2347
- * continuing without TLS is what produced a cleartext submission port in
2348
- * production, so a broken explicit configuration must be impossible to miss.
2349
- */
2350
- private async resolveEmailTlsMaterial(): Promise<{ certPem: string; keyPem: string; source: string } | undefined> {
2351
- const tlsConfig = this.options.emailConfig?.tls as (IUnifiedEmailServerOptions['tls'] & {
2352
- certPath?: string;
2353
- keyPath?: string;
2354
- }) | undefined;
2355
-
2356
- if (tlsConfig?.certPem && tlsConfig?.keyPem) {
2357
- return { certPem: tlsConfig.certPem, keyPem: tlsConfig.keyPem, source: 'inline PEM' };
2358
- }
2359
-
2360
- if (tlsConfig?.certPath || tlsConfig?.keyPath) {
2361
- if (!tlsConfig.certPath || !tlsConfig.keyPath) {
2362
- throw new Error(
2363
- 'emailConfig.tls requires both certPath and keyPath when either is configured',
2364
- );
2365
- }
2366
- let certPem: string;
2367
- let keyPem: string;
2368
- try {
2369
- certPem = await plugins.fs.promises.readFile(tlsConfig.certPath, 'utf8');
2370
- keyPem = await plugins.fs.promises.readFile(tlsConfig.keyPath, 'utf8');
2371
- } catch (error: unknown) {
2372
- throw new Error(
2373
- `Unable to read the configured SMTP TLS material (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath}): ${(error as Error).message}`,
2374
- );
2375
- }
2376
- if (!certPem.trim() || !keyPem.trim()) {
2377
- throw new Error(
2378
- `The configured SMTP TLS material is empty (certPath=${tlsConfig.certPath}, keyPath=${tlsConfig.keyPath})`,
2379
- );
2380
- }
2381
- logger.log('info', `SMTP TLS material loaded from configured paths (${tlsConfig.certPath})`);
2382
- return { certPem, keyPem, source: `configured paths (${tlsConfig.certPath})` };
2383
- }
2384
-
2385
- const mailHostname = this.options.emailConfig?.hostname;
2386
- if (mailHostname) {
2387
- try {
2388
- const stored = await ProxyCertDoc.findByDomain(mailHostname);
2389
- if (stored?.publicKey && stored?.privateKey) {
2390
- logger.log('info', `SMTP TLS material loaded from the stored ACME certificate for ${mailHostname}`);
2391
- return { certPem: stored.publicKey, keyPem: stored.privateKey, source: `stored ACME certificate for ${mailHostname}` };
2392
- }
2393
- } catch (error: unknown) {
2394
- logger.log('warn', `Unable to read the stored certificate for the mail hostname ${mailHostname}: ${(error as Error).message}`);
2395
- }
2396
- }
2346
+ /** Effective SMTP certificate source: the DB-backed setting, or the documented default. */
2347
+ public getEmailTlsSource(): TEmailTlsSource {
2348
+ return this.emailSettingsManager?.getTlsSource() ?? deriveEmailTlsSource(this.options.emailConfig?.tls);
2349
+ }
2397
2350
 
2398
- return undefined;
2351
+ /** The certificate the running SMTP listener serves, checked now; undefined while it is stopped. */
2352
+ public getEmailTlsCertificateStatus(): IEmailTlsCertificateStatus | undefined {
2353
+ if (!this.emailServer || !this.options.emailConfig) return undefined;
2354
+ return this.emailTlsManager.getStatus(this.emailRouteBuilder.getMailCertificateHostnames());
2399
2355
  }
2400
2356
 
2401
2357
  /**
2402
- * Push renewed TLS material to the running SMTP listener.
2358
+ * Push an issued or renewed certificate to the running SMTP listener.
2403
2359
  *
2404
- * Only reacts to the mail hostname, and only when explicit paths are NOT
2405
- * configured — an operator-managed file pair is not superseded by an ACME
2406
- * renewal for the same name. SmartMTA restarts just the Rust listener when
2407
- * the PEM material actually changes.
2360
+ * Material is resolved from the source of truth on every call and is never
2361
+ * written back into the email configuration, so every renewal applies, not
2362
+ * just the first one per process. Configured material is never superseded
2363
+ * by an ACME issuance. SmartMTA restarts just the Rust listener when the PEM
2364
+ * material actually changes.
2408
2365
  */
2409
2366
  private async reapplyEmailTlsMaterial(domainArg: string): Promise<void> {
2410
2367
  const emailServer = this.emailServer;
2411
- const mailHostname = this.options.emailConfig?.hostname;
2412
- if (!emailServer || !mailHostname) return;
2413
- if (domainArg.toLowerCase() !== mailHostname.toLowerCase()) return;
2414
- const tlsConfig = this.options.emailConfig?.tls;
2415
- if (tlsConfig?.certPath || tlsConfig?.keyPath) return;
2368
+ const emailConfig = this.options.emailConfig;
2369
+ if (!emailServer || !emailConfig) return;
2416
2370
 
2417
2371
  try {
2418
- const emailTls = await this.resolveEmailTlsMaterial();
2419
- if (!emailTls) return;
2420
- emailServer.updateOptions({
2421
- tls: { ...this.options.emailConfig?.tls, certPem: emailTls.certPem, keyPem: emailTls.keyPem },
2372
+ const emailTls = await this.emailTlsManager.applyIssuance(domainArg, emailServer, {
2373
+ source: this.getEmailTlsSource(),
2374
+ tls: emailConfig.tls,
2375
+ requiredHostnames: this.emailRouteBuilder.getMailCertificateHostnames(),
2422
2376
  });
2423
- if (this.options.emailConfig) {
2424
- this.options.emailConfig.tls = {
2425
- ...this.options.emailConfig.tls,
2426
- certPem: emailTls.certPem,
2427
- keyPem: emailTls.keyPem,
2428
- };
2377
+ if (emailTls) {
2378
+ logger.log('info', `Applied the SMTP TLS certificate (${emailTls.origin}) after issuance for ${domainArg}`);
2429
2379
  }
2430
- logger.log('info', `Pushed renewed SMTP TLS material for ${mailHostname} to the email listener`);
2431
2380
  } catch (error: unknown) {
2432
- logger.log('error', `Unable to apply renewed SMTP TLS material for ${mailHostname}: ${(error as Error).message}`);
2381
+ logger.log('error', `Unable to apply the SMTP TLS certificate after issuance for ${domainArg}: ${error instanceof Error ? error.message : String(error)}`);
2433
2382
  }
2434
2383
  }
2435
2384
 
@@ -2471,7 +2420,7 @@ export class DcRouter {
2471
2420
  */
2472
2421
  private logEmailAuthTransportPosture(
2473
2422
  authConfigured: boolean,
2474
- emailTls: { source: string } | undefined,
2423
+ emailTls: { origin: string } | undefined,
2475
2424
  mappedPorts: number[],
2476
2425
  tlsTerminatedPorts: number[],
2477
2426
  mappedSecurePort: number | undefined,
@@ -2487,12 +2436,12 @@ export class DcRouter {
2487
2436
  }
2488
2437
  logger.log(
2489
2438
  'info',
2490
- `SMTP transport posture: TLS material=${emailTls ? emailTls.source : 'NONE'}, edge-terminated ports=[${tlsTerminatedPorts.join(', ') || 'none'}], implicit-TLS port=${mappedSecurePort ?? 'none'}, AUTH-capable ports=[${authCapable.join(', ') || 'none'}]`,
2439
+ `SMTP transport posture: TLS material=${emailTls ? emailTls.origin : 'NONE'}, edge-terminated ports=[${tlsTerminatedPorts.join(', ') || 'none'}], implicit-TLS port=${mappedSecurePort ?? 'none'}, AUTH-capable ports=[${authCapable.join(', ') || 'none'}]`,
2491
2440
  );
2492
2441
  if (authConfigured && !emailTls && cleartext.length > 0) {
2493
2442
  logger.log(
2494
2443
  'error',
2495
- `SMTP AUTH is configured but ports [${cleartext.join(', ')}] have no TLS material and no upstream TLS terminator — AUTH is refused there because credentials must never cross a cleartext channel. Configure emailConfig.tls.certPath/keyPath, or provision an ACME certificate for ${this.options.emailConfig?.hostname || 'the mail hostname'}.`,
2444
+ `SMTP AUTH is configured but ports [${cleartext.join(', ')}] have no TLS material and no upstream TLS terminator — AUTH is refused there because credentials must never cross a cleartext channel. Provision an ACME certificate for ${this.options.emailConfig?.hostname || 'the mail hostname'} (managedCertificate source), or configure emailConfig.tls material (configuredMaterial source).`,
2496
2445
  );
2497
2446
  }
2498
2447
  }
@@ -2535,6 +2484,12 @@ export class DcRouter {
2535
2484
  throw new Error('EmailSettingsManager is not initialized');
2536
2485
  }
2537
2486
 
2487
+ if (settings.tlsSource === 'managedCertificate') {
2488
+ const hostname = settings.hostname?.trim() || this.emailSettingsManager.getPublicSettings().hostname || undefined;
2489
+ await this.emailTlsManager.assertManagedCertificateAvailable(
2490
+ this.emailRouteBuilder.getMailCertificateHostnames(hostname),
2491
+ );
2492
+ }
2538
2493
  const updatedSettings = await this.emailSettingsManager.updateSettings(settings, updatedBy);
2539
2494
  this.emailDomainManager?.setBaseEmailDomains(this.options.emailConfig?.domains as IEmailDomainConfig[] | undefined);
2540
2495
  await this.emailDomainManager?.syncManagedDomainsToRuntime();
@@ -2559,7 +2514,8 @@ export class DcRouter {
2559
2514
  await this.stopUnifiedEmailComponents();
2560
2515
  }
2561
2516
 
2562
- return updatedSettings;
2517
+ const tlsCertificate = this.getEmailTlsCertificateStatus();
2518
+ return tlsCertificate ? { ...updatedSettings, tlsCertificate } : updatedSettings;
2563
2519
  });
2564
2520
  }
2565
2521
 
@@ -2572,6 +2528,7 @@ export class DcRouter {
2572
2528
  if (this.emailServer) {
2573
2529
  const emailServer = this.emailServer;
2574
2530
  this.emailServer = undefined;
2531
+ this.emailTlsManager.clear();
2575
2532
  this.acceptedEmailSpool.beginStop();
2576
2533
  try {
2577
2534
  await emailServer.stop();
@@ -1,7 +1,7 @@
1
1
  import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
2
2
  import * as plugins from '../../plugins.js';
3
3
  import { DcRouterDb } from '../classes.dcrouter-db.js';
4
- import type { IEmailPortConfig, TEmailOutboundMode } from '../../../ts_interfaces/data/email-settings.js';
4
+ import type { IEmailPortConfig, TEmailOutboundMode, TEmailTlsSource } from '../../../ts_interfaces/data/email-settings.js';
5
5
 
6
6
  const getDb = () => DcRouterDb.getInstance().getDb();
7
7
 
@@ -23,6 +23,10 @@ export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<Ema
23
23
  @plugins.smartdata.svDb()
24
24
  public emailPortConfig?: IEmailPortConfig;
25
25
 
26
+ /** SMTP certificate source; absent on settings persisted before it existed (see TEmailTlsSource). */
27
+ @plugins.smartdata.svDb()
28
+ public tlsSource?: TEmailTlsSource;
29
+
26
30
  @plugins.smartdata.svDb()
27
31
  public updatedAt: number = 0;
28
32
 
@@ -214,28 +214,37 @@ export class EmailRouteBuilder {
214
214
  return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
215
215
  }
216
216
 
217
- private getSmtpsCertificateHostnames(): string[] {
218
- const configuredHostname = this.dcRouterRef.options.emailConfig?.hostname;
219
- if (typeof configuredHostname !== 'string' || !configuredHostname.trim()) {
220
- throw new Error('SMTPS port 465 requires a non-empty configured email hostname');
217
+ /**
218
+ * Hostnames every mail certificate must cover, normalized: the mail
219
+ * hostname and every enabled mail-edge hostname. The SMTPS route requests
220
+ * its certificate for exactly these names, and the SMTP listener's
221
+ * certificate is checked against them. `hostnameArg` evaluates a hostname
222
+ * that is not applied yet.
223
+ */
224
+ public getMailCertificateHostnames(
225
+ hostnameArg: string | undefined = this.dcRouterRef.options.emailConfig?.hostname,
226
+ ): string[] {
227
+ if (typeof hostnameArg !== 'string' || !hostnameArg.trim()) {
228
+ throw new Error('Mail certificates require a non-empty configured email hostname');
221
229
  }
222
-
223
230
  const candidates = [
224
- configuredHostname,
231
+ hostnameArg,
225
232
  ...(this.dcRouterRef.remoteIngressManager?.getMailEdges() || [])
226
233
  .filter((edge) => edge.enabled && edge.tags?.includes(REMOTE_INGRESS_MAIL_TAG))
227
234
  .map((edge) => edge.mailHostname)
228
235
  .filter((hostname): hostname is string => typeof hostname === 'string' && Boolean(hostname.trim())),
229
236
  ];
230
- const hostnames = new Set<string>();
231
- for (const candidate of candidates) {
232
- const normalized = candidate.trim().toLowerCase().replace(/\.$/, '');
233
- if (normalizeOwnershipHostname(normalized) !== normalized) {
234
- throw new Error(`SMTPS certificate hostname '${candidate}' is not a valid fully qualified domain name`);
237
+ return [...new Set(candidates.map((candidate) => candidate.trim().toLowerCase().replace(/\.$/, '')))].sort();
238
+ }
239
+
240
+ private getSmtpsCertificateHostnames(): string[] {
241
+ const hostnames = this.getMailCertificateHostnames();
242
+ for (const hostname of hostnames) {
243
+ if (normalizeOwnershipHostname(hostname) !== hostname) {
244
+ throw new Error(`SMTPS certificate hostname '${hostname}' is not a valid fully qualified domain name`);
235
245
  }
236
- hostnames.add(normalized);
237
246
  }
238
- return [...hostnames].sort();
247
+ return hostnames;
239
248
  }
240
249
 
241
250
  private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean {
@@ -3,12 +3,18 @@ import { EmailServerSettingsDoc } from '../db/index.js';
3
3
  import { logger } from '../logger.js';
4
4
  import { isSmtpAccountRouteName } from './classes.smtp-account.manager.js';
5
5
  import { isWorkAppManagedMailRouteName } from './classes.workapp-mail-manager.js';
6
+ import {
7
+ deriveEmailTlsSource,
8
+ hasConfiguredEmailTlsMaterial,
9
+ withoutConfiguredEmailTlsMaterial,
10
+ } from './classes.email-tls-manager.js';
6
11
  import type { IDcRouterOptions } from '../classes.dcrouter.js';
7
12
  import type {
8
13
  IEmailPortConfig,
9
14
  IEmailServerSettings,
10
15
  TEmailOutboundMode,
11
16
  TEmailServerSettingsUpdate,
17
+ TEmailTlsSource,
12
18
  } from '../../ts_interfaces/data/email-settings.js';
13
19
 
14
20
  const defaultEmailPorts = [25, 587, 465];
@@ -26,6 +32,7 @@ export class EmailSettingsManager {
26
32
  private cachedEmailConfig?: IUnifiedEmailServerOptions;
27
33
  private cachedEmailPortConfig?: IEmailPortConfig;
28
34
  private outboundMode: TEmailOutboundMode = 'remoteIngress';
35
+ private tlsSource: TEmailTlsSource = 'managedCertificate';
29
36
  private enabled = false;
30
37
  private updatedAt = 0;
31
38
  private updatedBy = 'default';
@@ -99,6 +106,11 @@ export class EmailSettingsManager {
99
106
  return this.outboundMode;
100
107
  }
101
108
 
109
+ /** Effective SMTP certificate source, including the default for settings persisted without one. */
110
+ public getTlsSource(): TEmailTlsSource {
111
+ return this.tlsSource;
112
+ }
113
+
102
114
  public getPublicSettings(): IEmailServerSettings {
103
115
  const emailConfig = this.cachedEmailConfig;
104
116
  const emailPortConfig = this.cachedEmailPortConfig;
@@ -110,6 +122,7 @@ export class EmailSettingsManager {
110
122
  portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
111
123
  receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
112
124
  maxMessageSize: emailConfig?.maxMessageSize ?? null,
125
+ tlsSource: this.tlsSource,
113
126
  domainCount: emailConfig?.domains?.length || 0,
114
127
  routeCount: emailConfig?.routes?.length || 0,
115
128
  authUserCount: emailConfig?.auth?.users?.length || 0,
@@ -129,7 +142,15 @@ export class EmailSettingsManager {
129
142
  }
130
143
 
131
144
  const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
145
+ const nextTlsSource = updates.tlsSource === undefined
146
+ ? doc.tlsSource
147
+ : this.normalizeTlsSource(updates.tlsSource);
132
148
  const nextEmailConfig = this.patchEmailConfig(doc.emailConfig ?? undefined, updates, nextEnabled);
149
+ if (updates.tlsSource === 'configuredMaterial' && !hasConfiguredEmailTlsMaterial(nextEmailConfig?.tls)) {
150
+ throw new Error(
151
+ 'The configuredMaterial email TLS source requires emailConfig.tls certificate material (certPem/keyPem or certPath/keyPath); none is persisted',
152
+ );
153
+ }
133
154
  const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig ?? undefined, updates);
134
155
  const nextOutboundMode = this.patchOutboundMode(doc.outboundMode, updates);
135
156
 
@@ -137,6 +158,7 @@ export class EmailSettingsManager {
137
158
  doc.emailConfig = nextEmailConfig;
138
159
  doc.emailPortConfig = nextEmailPortConfig;
139
160
  doc.outboundMode = nextOutboundMode;
161
+ doc.tlsSource = nextTlsSource;
140
162
  doc.updatedAt = Date.now();
141
163
  doc.updatedBy = updatedBy;
142
164
  if (doc.creationStatus === 'new') {
@@ -150,6 +172,7 @@ export class EmailSettingsManager {
150
172
  updatedBy: doc.updatedBy,
151
173
  ...(doc.emailConfig !== undefined ? { emailConfig: doc.emailConfig } : {}),
152
174
  ...(doc.emailPortConfig !== undefined ? { emailPortConfig: doc.emailPortConfig } : {}),
175
+ ...(doc.tlsSource !== undefined ? { tlsSource: doc.tlsSource } : {}),
153
176
  },
154
177
  $unset: {
155
178
  ...((hasOwn(updates, 'portMapping') || hasOwn(updates, 'receivedEmailsPath'))
@@ -169,6 +192,7 @@ export class EmailSettingsManager {
169
192
  this.cachedEmailConfig = clonePlain(doc.emailConfig ?? undefined);
170
193
  this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig ?? undefined);
171
194
  this.outboundMode = this.normalizeOutboundMode(doc.outboundMode);
195
+ this.tlsSource = doc.tlsSource ?? deriveEmailTlsSource(doc.emailConfig?.tls);
172
196
  this.updatedAt = doc.updatedAt;
173
197
  this.updatedBy = doc.updatedBy;
174
198
  }
@@ -197,6 +221,13 @@ export class EmailSettingsManager {
197
221
  return mode;
198
222
  }
199
223
 
224
+ private normalizeTlsSource(sourceArg: TEmailTlsSource): TEmailTlsSource {
225
+ if (sourceArg !== 'managedCertificate' && sourceArg !== 'configuredMaterial') {
226
+ throw new Error(`Invalid email TLS source: ${String(sourceArg)}`);
227
+ }
228
+ return sourceArg;
229
+ }
230
+
200
231
  private patchEmailConfig(
201
232
  existingConfig: IUnifiedEmailServerOptions | undefined,
202
233
  updates: TEmailServerSettingsUpdate,
@@ -240,6 +271,17 @@ export class EmailSettingsManager {
240
271
  nextConfig.routes = this.removeRoutesByName(nextConfig.routes || [], updates.removeRouteNames);
241
272
  }
242
273
 
274
+ // The managed certificate supersedes operator-managed material, so the
275
+ // material is removed rather than left behind as a dormant override.
276
+ if (updates.tlsSource === 'managedCertificate') {
277
+ const remainingTls = withoutConfiguredEmailTlsMaterial(nextConfig.tls);
278
+ if (remainingTls) {
279
+ nextConfig.tls = remainingTls;
280
+ } else {
281
+ delete nextConfig.tls;
282
+ }
283
+ }
284
+
243
285
  if (nextEnabled) {
244
286
  if (!nextConfig.hostname?.trim()) {
245
287
  throw new Error('Email hostname is required when email is enabled');