@serve.zone/dcrouter 18.0.2 → 18.1.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 (71) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1522 -1375
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +3 -1
  5. package/dist_ts/classes.dcrouter.js +32 -4
  6. package/dist_ts/db/documents/classes.smtp-account.doc.d.ts +31 -0
  7. package/dist_ts/db/documents/classes.smtp-account.doc.js +176 -0
  8. package/dist_ts/db/documents/index.d.ts +1 -0
  9. package/dist_ts/db/documents/index.js +2 -1
  10. package/dist_ts/email/classes.email-settings.manager.d.ts +7 -0
  11. package/dist_ts/email/classes.email-settings.manager.js +30 -1
  12. package/dist_ts/email/classes.smtp-account.manager.d.ts +110 -0
  13. package/dist_ts/email/classes.smtp-account.manager.js +507 -0
  14. package/dist_ts/email/classes.workapp-mail-manager.d.ts +23 -4
  15. package/dist_ts/email/classes.workapp-mail-manager.js +39 -46
  16. package/dist_ts/email/index.d.ts +2 -0
  17. package/dist_ts/email/index.js +3 -1
  18. package/dist_ts/email/smtp-scram.d.ts +13 -0
  19. package/dist_ts/email/smtp-scram.js +19 -0
  20. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  21. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  22. package/dist_ts/opsserver/handlers/email-settings.handler.js +14 -2
  23. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  24. package/dist_ts/opsserver/handlers/index.js +2 -1
  25. package/dist_ts/opsserver/handlers/smtp-account.handler.d.ts +20 -0
  26. package/dist_ts/opsserver/handlers/smtp-account.handler.js +155 -0
  27. package/dist_ts_interfaces/data/email-settings.d.ts +10 -0
  28. package/dist_ts_interfaces/data/index.d.ts +1 -0
  29. package/dist_ts_interfaces/data/index.js +2 -1
  30. package/dist_ts_interfaces/data/route-management.d.ts +1 -1
  31. package/dist_ts_interfaces/data/route-management.js +3 -1
  32. package/dist_ts_interfaces/data/smtp-account.d.ts +50 -0
  33. package/dist_ts_interfaces/data/smtp-account.js +9 -0
  34. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  35. package/dist_ts_interfaces/requests/index.js +2 -1
  36. package/dist_ts_interfaces/requests/smtp-accounts.d.ts +125 -0
  37. package/dist_ts_interfaces/requests/smtp-accounts.js +2 -0
  38. package/dist_ts_migrations/index.js +127 -13
  39. package/dist_ts_web/00_commitinfo_data.js +1 -1
  40. package/dist_ts_web/appstate/smtp-accounts.d.ts +38 -0
  41. package/dist_ts_web/appstate/smtp-accounts.js +94 -0
  42. package/dist_ts_web/appstate.d.ts +1 -0
  43. package/dist_ts_web/appstate.js +2 -1
  44. package/dist_ts_web/elements/email/index.d.ts +1 -0
  45. package/dist_ts_web/elements/email/index.js +2 -1
  46. package/dist_ts_web/elements/email/ops-view-smtp-accounts.d.ts +25 -0
  47. package/dist_ts_web/elements/email/ops-view-smtp-accounts.js +598 -0
  48. package/dist_ts_web/elements/ops-dashboard.js +3 -1
  49. package/dist_ts_web/router.js +2 -2
  50. package/package.json +2 -2
  51. package/readme.md +12 -0
  52. package/ts/00_commitinfo_data.ts +1 -1
  53. package/ts/classes.dcrouter.ts +35 -3
  54. package/ts/db/documents/classes.smtp-account.doc.ts +103 -0
  55. package/ts/db/documents/index.ts +1 -0
  56. package/ts/email/classes.email-settings.manager.ts +34 -0
  57. package/ts/email/classes.smtp-account.manager.ts +601 -0
  58. package/ts/email/classes.workapp-mail-manager.ts +53 -55
  59. package/ts/email/index.ts +2 -0
  60. package/ts/email/smtp-scram.ts +24 -0
  61. package/ts/opsserver/classes.opsserver.ts +2 -0
  62. package/ts/opsserver/handlers/email-settings.handler.ts +13 -1
  63. package/ts/opsserver/handlers/index.ts +1 -0
  64. package/ts/opsserver/handlers/smtp-account.handler.ts +199 -0
  65. package/ts_web/00_commitinfo_data.ts +1 -1
  66. package/ts_web/appstate/smtp-accounts.ts +143 -0
  67. package/ts_web/appstate.ts +1 -0
  68. package/ts_web/elements/email/index.ts +1 -0
  69. package/ts_web/elements/email/ops-view-smtp-accounts.ts +586 -0
  70. package/ts_web/elements/ops-dashboard.ts +2 -0
  71. package/ts_web/router.ts +1 -1
@@ -0,0 +1,601 @@
1
+ import type {
2
+ IEmailRoute,
3
+ ISmtpAuthAccount,
4
+ IUnifiedEmailServerOptions,
5
+ } from '@push.rocks/smartmta';
6
+ import { assertValidAuthAccounts } from '@push.rocks/smartmta';
7
+ import * as plugins from '../plugins.js';
8
+ import { logger } from '../logger.js';
9
+ import { SmtpAccountDoc } from '../db/index.js';
10
+ import type { DcRouter } from '../classes.dcrouter.js';
11
+ import type {
12
+ ISmtpAccountDomainReadiness,
13
+ ISmtpAccountInfo,
14
+ ISmtpAccountMailPolicy,
15
+ ISmtpAccountRecipientScope,
16
+ ISmtpAccountSenderScope,
17
+ } from '../../ts_interfaces/data/smtp-account.js';
18
+ import {
19
+ isWorkAppManagedMailRouteName,
20
+ isWorkAppManagedSmtpUsername,
21
+ type IWorkAppMailRuntimeContribution,
22
+ } from './classes.workapp-mail-manager.js';
23
+ import { deriveSmtpScramVerifier, generateSmtpAccountPassword } from './smtp-scram.js';
24
+
25
+ /** Route names generated (and owned wholesale) by SmtpAccountManager. */
26
+ export function isSmtpAccountRouteName(routeName: string): boolean {
27
+ return routeName.startsWith('smtp-account-');
28
+ }
29
+
30
+ const SMTP_ACCOUNT_USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{2,63}$/;
31
+ /** Literal domain label chain — no wildcards, at least one dot. */
32
+ const SMTP_ACCOUNT_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
33
+ /** Address pattern: optional '*' glob in the local part only, literal domain. */
34
+ const SMTP_ACCOUNT_ADDRESS_LOCAL_PATTERN = /^[a-z0-9*][a-z0-9.*_+-]*$/;
35
+
36
+ export interface ISmtpAccountCreateOptions {
37
+ username: string;
38
+ description?: string;
39
+ senderScope?: ISmtpAccountSenderScope;
40
+ recipientScope?: ISmtpAccountRecipientScope;
41
+ mailPolicy?: ISmtpAccountMailPolicy;
42
+ createdBy: string;
43
+ }
44
+
45
+ export interface ISmtpAccountUpdateOptions {
46
+ description?: string;
47
+ senderScope?: ISmtpAccountSenderScope;
48
+ recipientScope?: ISmtpAccountRecipientScope;
49
+ mailPolicy?: ISmtpAccountMailPolicy;
50
+ }
51
+
52
+ export interface ISmtpAccountMutationResult {
53
+ account: ISmtpAccountInfo;
54
+ warnings: string[];
55
+ }
56
+
57
+ export interface ISmtpAccountSecretResult extends ISmtpAccountMutationResult {
58
+ /** Plaintext password — exists only in this response, never persisted. */
59
+ password: string;
60
+ }
61
+
62
+ /**
63
+ * SmtpAccountManager — operator-managed authenticated SMTP submission
64
+ * accounts, backed by SmtpAccountDoc rows holding hashed SCRAM verifiers only.
65
+ *
66
+ * Single composition owner of the email runtime `auth` block and route list:
67
+ * every runtime application of auth/routes goes through composeEmailConfig /
68
+ * applyToRuntime here. WorkAppMailManager identities feed in as an explicit
69
+ * contribution; nothing else writes `auth` wholesale.
70
+ *
71
+ * Accounts are served from an in-memory map loaded at start() — the SMTP auth
72
+ * path (smartmta's Rust bridge, 5s callback timeout) never touches the DB.
73
+ */
74
+ export class SmtpAccountManager {
75
+ private accounts = new Map<string, SmtpAccountDoc>();
76
+ private started = false;
77
+ private mutationChain: Promise<unknown> = Promise.resolve();
78
+
79
+ /** Explicit upstream capability marker; absence is deliberately fail-closed. */
80
+ private get authAccountsCapability(): boolean {
81
+ return (plugins.smartmta as any).smartMtaCapabilities?.smtpAuthAccountsAndScopes === true;
82
+ }
83
+
84
+ constructor(private dcRouterRef: DcRouter) {}
85
+
86
+ public async start(): Promise<void> {
87
+ const docs = await SmtpAccountDoc.findAll();
88
+ this.accounts = new Map(docs.map((doc) => [doc.id, doc]));
89
+ this.started = true;
90
+ if (this.accounts.size > 0) {
91
+ logger.log('info', `SmtpAccountManager loaded ${this.accounts.size} SMTP account(s)`);
92
+ }
93
+ }
94
+
95
+ public async stop(): Promise<void> {
96
+ this.accounts.clear();
97
+ this.started = false;
98
+ }
99
+
100
+ public isStarted(): boolean {
101
+ return this.started;
102
+ }
103
+
104
+ public getAccountCount(): number {
105
+ return this.accounts.size;
106
+ }
107
+
108
+ // ==========================================================================
109
+ // Composition — the single owner of auth + route-list assembly
110
+ // ==========================================================================
111
+
112
+ /**
113
+ * Compose the runtime email config: operator-configured routes/users plus
114
+ * the workapp identity contribution plus DB-backed SMTP accounts and their
115
+ * generated relay routes. Idempotent — previously generated entries are
116
+ * stripped by their reserved name prefixes before re-adding.
117
+ */
118
+ public async composeEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
119
+ emailConfig: TConfig,
120
+ workappContribution?: IWorkAppMailRuntimeContribution,
121
+ ): Promise<TConfig> {
122
+ const contribution = workappContribution
123
+ ?? await this.dcRouterRef.workAppMailManager.getStoredIdentityContribution(emailConfig);
124
+
125
+ if (!this.authAccountsCapability) {
126
+ // Fail closed: without the smartmta capability the accounts field would
127
+ // be silently ignored (or rejected), so never pretend they are active —
128
+ // any pre-existing accounts entry is dropped from the composed auth too.
129
+ if (this.accounts.size > 0) {
130
+ logger.log('error', `SmartMTA is missing the smtpAuthAccountsAndScopes capability — ${this.accounts.size} stored SMTP account(s) are NOT active`);
131
+ }
132
+ const authWithoutAccounts = { ...(emailConfig.auth || {}) } as NonNullable<IUnifiedEmailServerOptions['auth']>;
133
+ delete authWithoutAccounts.accounts;
134
+ return {
135
+ ...emailConfig,
136
+ routes: [
137
+ ...(emailConfig.routes || [])
138
+ .filter((route) => !isWorkAppManagedMailRouteName(route.name) && !isSmtpAccountRouteName(route.name)),
139
+ ...contribution.routes,
140
+ ],
141
+ auth: {
142
+ ...authWithoutAccounts,
143
+ users: [
144
+ ...(emailConfig.auth?.users || []).filter((user) => !isWorkAppManagedSmtpUsername(user.username)),
145
+ ...contribution.users,
146
+ ],
147
+ },
148
+ };
149
+ }
150
+
151
+ const accountDocs = [...this.accounts.values()]
152
+ .sort((a, b) => a.username.localeCompare(b.username));
153
+ const smtpAccounts: ISmtpAuthAccount[] = [];
154
+ const accountRoutes: IEmailRoute[] = [];
155
+ for (const doc of accountDocs) {
156
+ const account = this.buildAuthAccount(doc);
157
+ try {
158
+ // smartmta's own validator — construction and updateOptions throw on
159
+ // invalid accounts, so a bad DB row must never reach the push.
160
+ // Validated per account so one row cannot take the whole auth block
161
+ // down.
162
+ assertValidAuthAccounts([account]);
163
+ } catch (error: unknown) {
164
+ const message = (error as Error).message;
165
+ logger.log('error', `SMTP account ${doc.username} excluded from runtime auth (invalid stored configuration): ${message}`);
166
+ this.recordAccountConflictEvent(doc.username, message);
167
+ continue;
168
+ }
169
+ smtpAccounts.push(account);
170
+ if (doc.enabled) {
171
+ accountRoutes.push(...this.buildAccountRoutes(doc));
172
+ }
173
+ }
174
+
175
+ const configuredRoutes = (emailConfig.routes || [])
176
+ .filter((route) => !isWorkAppManagedMailRouteName(route.name) && !isSmtpAccountRouteName(route.name));
177
+ const configuredUsers = (emailConfig.auth?.users || [])
178
+ .filter((user) => !isWorkAppManagedSmtpUsername(user.username));
179
+
180
+ return {
181
+ ...emailConfig,
182
+ routes: [...configuredRoutes, ...contribution.routes, ...accountRoutes],
183
+ auth: {
184
+ ...(emailConfig.auth || {}),
185
+ users: [...configuredUsers, ...contribution.users],
186
+ accounts: smtpAccounts,
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Recompose from the live runtime options and push the result: replace the
193
+ * whole `auth` block (smartmta toggles listener AUTH live) and re-apply the
194
+ * route list.
195
+ */
196
+ public async applyToRuntime(workappContribution?: IWorkAppMailRuntimeContribution): Promise<void> {
197
+ const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
198
+ if (!emailConfig) return;
199
+
200
+ const nextConfig = await this.composeEmailConfig(emailConfig, workappContribution);
201
+ this.dcRouterRef.options.emailConfig = nextConfig;
202
+ if (this.dcRouterRef.emailServer) {
203
+ this.dcRouterRef.emailServer.updateOptions({ auth: nextConfig.auth });
204
+ await this.dcRouterRef.updateEmailRoutes(nextConfig.routes);
205
+ }
206
+ }
207
+
208
+ private buildAuthAccount(doc: SmtpAccountDoc): ISmtpAuthAccount {
209
+ const senders = [
210
+ ...(doc.senderScope?.addresses || []),
211
+ ...(doc.senderScope?.domains || []).map((domain) => `*@${domain}`),
212
+ ];
213
+ const recipientsRestricted = doc.recipientScope?.mode === 'restricted';
214
+ const recipientPatterns = [
215
+ ...(doc.recipientScope?.addresses || []),
216
+ ...(doc.recipientScope?.domains || []).map((domain) => `*@${domain}`),
217
+ ];
218
+
219
+ let scope: ISmtpAuthAccount['scope'];
220
+ if (senders.length > 0 || recipientsRestricted) {
221
+ scope = {
222
+ // Unrestricted senders with restricted recipients still needs a scope
223
+ // block; '*' matches every sender including the null sender.
224
+ senders: senders.length > 0 ? senders : ['*'],
225
+ ...(recipientsRestricted
226
+ ? { recipients: { mode: 'patterns' as const, patterns: recipientPatterns } }
227
+ : {}),
228
+ };
229
+ }
230
+
231
+ return {
232
+ username: doc.username,
233
+ enabled: doc.enabled,
234
+ credential: { verifier: doc.credentialVerifier },
235
+ ...(scope ? { scope } : {}),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * One generated relay route per (account × sender-scope domain), name
241
+ * `smtp-account-<id>-<domainHash>`, priority 850 — below workapp
242
+ * per-address routes (900), above operator-configured DB routes.
243
+ *
244
+ * Accounts without a sender scope generate no routes: their relay
245
+ * permission stays exactly whatever operator-configured routes grant
246
+ * (this is what keeps the legacy-user migration from widening relay).
247
+ *
248
+ * DKIM policy rides on `process.dkim` only — smartmta ≥9.1 resolves the
249
+ * active selector per sender domain from its domain registry, so no
250
+ * selector is snapshotted into routes and rotation stays owned by
251
+ * EmailDomainManager/mail-dns-sync.
252
+ */
253
+ private buildAccountRoutes(doc: SmtpAccountDoc): IEmailRoute[] {
254
+ const senderDomains = this.collectSenderDomainPatterns(doc.senderScope);
255
+ const routes: IEmailRoute[] = [];
256
+ for (const [domain, patterns] of senderDomains) {
257
+ routes.push({
258
+ name: `smtp-account-${doc.id}-${this.hashDomain(domain)}`,
259
+ priority: 850,
260
+ match: {
261
+ authenticated: true,
262
+ authenticatedUser: doc.username,
263
+ senders: patterns,
264
+ },
265
+ action: {
266
+ type: 'process',
267
+ allowRelay: true,
268
+ process: {
269
+ dkim: Boolean(doc.mailPolicy?.dkimSign),
270
+ queue: doc.mailPolicy?.queue ?? 'normal',
271
+ },
272
+ },
273
+ });
274
+ }
275
+ return routes;
276
+ }
277
+
278
+ /** Map of sender-scope domain -> the scope patterns belonging to it. */
279
+ private collectSenderDomainPatterns(senderScope: ISmtpAccountSenderScope | undefined): Map<string, string[]> {
280
+ const byDomain = new Map<string, string[]>();
281
+ for (const domain of senderScope?.domains || []) {
282
+ const key = domain.toLowerCase();
283
+ byDomain.set(key, [...(byDomain.get(key) || []), `*@${key}`]);
284
+ }
285
+ for (const address of senderScope?.addresses || []) {
286
+ const domainPart = address.split('@')[1]?.toLowerCase();
287
+ if (!domainPart) continue;
288
+ byDomain.set(domainPart, [...(byDomain.get(domainPart) || []), address.toLowerCase()]);
289
+ }
290
+ return byDomain;
291
+ }
292
+
293
+ private hashDomain(domain: string): string {
294
+ return plugins.crypto.createHash('sha256').update(domain.toLowerCase()).digest('hex').slice(0, 8);
295
+ }
296
+
297
+ private recordAccountConflictEvent(username: string, message: string): void {
298
+ const opsEventManager = this.dcRouterRef.opsEventManager;
299
+ if (!opsEventManager) return;
300
+ opsEventManager.recordEvent({
301
+ severity: 'error',
302
+ category: 'smtp-accounts',
303
+ title: 'SMTP account excluded from runtime authentication',
304
+ detail: `Stored SMTP account '${username}' failed validation and was excluded from the composed auth block: ${message}`,
305
+ context: { recordName: username },
306
+ }).catch((error: unknown) => {
307
+ logger.log('warn', `Failed to record SMTP account conflict event for ${username}: ${(error as Error).message}`);
308
+ });
309
+ }
310
+
311
+ // ==========================================================================
312
+ // CRUD
313
+ // ==========================================================================
314
+
315
+ public async listAccounts(): Promise<ISmtpAccountInfo[]> {
316
+ const docs = [...this.accounts.values()]
317
+ .sort((a, b) => a.username.localeCompare(b.username));
318
+ const accounts: ISmtpAccountInfo[] = [];
319
+ for (const doc of docs) {
320
+ accounts.push(await this.decorateWithDomainReadiness(doc));
321
+ }
322
+ return accounts;
323
+ }
324
+
325
+ public async createAccount(options: ISmtpAccountCreateOptions): Promise<ISmtpAccountSecretResult> {
326
+ return await this.runMutationExclusive(async () => {
327
+ if (!this.authAccountsCapability) {
328
+ throw new Error('SmartMTA runtime does not support SMTP auth accounts (smtpAuthAccountsAndScopes capability missing)');
329
+ }
330
+ const username = this.normalizeUsername(options.username);
331
+ const senderScope = this.normalizeSenderScope(options.senderScope);
332
+ const recipientScope = this.normalizeRecipientScope(options.recipientScope);
333
+ const mailPolicy = this.normalizeMailPolicy(options.mailPolicy);
334
+ await this.assertDkimPolicyAllowed(mailPolicy, senderScope, username);
335
+
336
+ if ([...this.accounts.values()].some((doc) => doc.username === username)
337
+ || await SmtpAccountDoc.findByUsername(username)) {
338
+ throw new Error(`SMTP account username is already taken: ${username}`);
339
+ }
340
+
341
+ const password = generateSmtpAccountPassword();
342
+ const now = Date.now();
343
+ const doc = new SmtpAccountDoc();
344
+ doc.id = `smtpacct_${now.toString(36)}_${plugins.crypto.randomBytes(6).toString('hex')}`;
345
+ doc.username = username;
346
+ doc.description = (options.description || '').trim();
347
+ doc.enabled = true;
348
+ doc.credentialVerifier = deriveSmtpScramVerifier(password);
349
+ doc.senderScope = senderScope;
350
+ doc.recipientScope = recipientScope;
351
+ doc.mailPolicy = mailPolicy;
352
+ doc.createdAt = now;
353
+ doc.updatedAt = now;
354
+ doc.createdBy = options.createdBy;
355
+ await doc.save();
356
+ this.accounts.set(doc.id, doc);
357
+
358
+ await this.applyToRuntime();
359
+ const warnings = await this.collectSenderDomainWarnings(senderScope);
360
+ logger.log('info', `SMTP account '${username}' created by ${options.createdBy} (id: ${doc.id})`);
361
+ return { account: await this.decorateWithDomainReadiness(doc), password, warnings };
362
+ });
363
+ }
364
+
365
+ public async updateAccount(
366
+ id: string,
367
+ updates: ISmtpAccountUpdateOptions,
368
+ updatedBy: string,
369
+ ): Promise<ISmtpAccountMutationResult> {
370
+ return await this.runMutationExclusive(async () => {
371
+ const doc = this.requireAccount(id);
372
+ const senderScope = updates.senderScope !== undefined
373
+ ? this.normalizeSenderScope(updates.senderScope)
374
+ : doc.senderScope;
375
+ const recipientScope = updates.recipientScope !== undefined
376
+ ? this.normalizeRecipientScope(updates.recipientScope)
377
+ : doc.recipientScope;
378
+ const mailPolicy = updates.mailPolicy !== undefined
379
+ ? this.normalizeMailPolicy(updates.mailPolicy)
380
+ : doc.mailPolicy;
381
+ await this.assertDkimPolicyAllowed(mailPolicy, senderScope, doc.username);
382
+
383
+ if (updates.description !== undefined) {
384
+ doc.description = updates.description.trim();
385
+ }
386
+ doc.senderScope = senderScope;
387
+ doc.recipientScope = recipientScope;
388
+ doc.mailPolicy = mailPolicy;
389
+ doc.updatedAt = Date.now();
390
+ await doc.save();
391
+ this.accounts.set(doc.id, doc);
392
+
393
+ await this.applyToRuntime();
394
+ const warnings = await this.collectSenderDomainWarnings(senderScope);
395
+ logger.log('info', `SMTP account '${doc.username}' updated by ${updatedBy}`);
396
+ return { account: await this.decorateWithDomainReadiness(doc), warnings };
397
+ });
398
+ }
399
+
400
+ public async toggleAccount(id: string, enabled: boolean, updatedBy: string): Promise<ISmtpAccountMutationResult> {
401
+ return await this.runMutationExclusive(async () => {
402
+ const doc = this.requireAccount(id);
403
+ doc.enabled = enabled;
404
+ doc.updatedAt = Date.now();
405
+ await doc.save();
406
+ this.accounts.set(doc.id, doc);
407
+
408
+ await this.applyToRuntime();
409
+ logger.log('info', `SMTP account '${doc.username}' ${enabled ? 'enabled' : 'disabled'} by ${updatedBy}`);
410
+ return { account: await this.decorateWithDomainReadiness(doc), warnings: [] };
411
+ });
412
+ }
413
+
414
+ public async rotatePassword(id: string, rotatedBy: string): Promise<ISmtpAccountSecretResult> {
415
+ return await this.runMutationExclusive(async () => {
416
+ const doc = this.requireAccount(id);
417
+ const password = generateSmtpAccountPassword();
418
+ doc.credentialVerifier = deriveSmtpScramVerifier(password);
419
+ doc.lastRotatedAt = Date.now();
420
+ doc.updatedAt = doc.lastRotatedAt;
421
+ await doc.save();
422
+ this.accounts.set(doc.id, doc);
423
+
424
+ await this.applyToRuntime();
425
+ logger.log('info', `SMTP account '${doc.username}' password rotated by ${rotatedBy}`);
426
+ return { account: await this.decorateWithDomainReadiness(doc), password, warnings: [] };
427
+ });
428
+ }
429
+
430
+ public async deleteAccount(id: string, deletedBy: string): Promise<void> {
431
+ return await this.runMutationExclusive(async () => {
432
+ const doc = this.requireAccount(id);
433
+ await doc.delete();
434
+ this.accounts.delete(id);
435
+
436
+ await this.applyToRuntime();
437
+ logger.log('info', `SMTP account '${doc.username}' deleted by ${deletedBy}`);
438
+ });
439
+ }
440
+
441
+ // ==========================================================================
442
+ // Validation / normalization
443
+ // ==========================================================================
444
+
445
+ private requireAccount(id: string): SmtpAccountDoc {
446
+ const doc = this.accounts.get(id);
447
+ if (!doc) {
448
+ throw new Error(`SMTP account not found: ${id}`);
449
+ }
450
+ return doc;
451
+ }
452
+
453
+ private normalizeUsername(usernameArg: string): string {
454
+ const username = (usernameArg || '').trim().toLowerCase();
455
+ if (!SMTP_ACCOUNT_USERNAME_PATTERN.test(username)) {
456
+ throw new Error('SMTP account username must be 3-64 characters of a-z, 0-9, dot, underscore, or dash, starting alphanumeric');
457
+ }
458
+ if (isWorkAppManagedSmtpUsername(username)) {
459
+ throw new Error(`SMTP account usernames may not use the reserved 'workapp-' prefix: ${username}`);
460
+ }
461
+ return username;
462
+ }
463
+
464
+ private normalizeDomain(domainArg: string, context: string): string {
465
+ const domain = (domainArg || '').trim().toLowerCase();
466
+ if (!SMTP_ACCOUNT_DOMAIN_PATTERN.test(domain)) {
467
+ throw new Error(`Invalid ${context} domain (wildcards are not allowed in domains): ${domainArg}`);
468
+ }
469
+ return domain;
470
+ }
471
+
472
+ private normalizeAddressPattern(addressArg: string, context: string): string {
473
+ const address = (addressArg || '').trim().toLowerCase();
474
+ const parts = address.split('@');
475
+ if (parts.length !== 2
476
+ || !SMTP_ACCOUNT_ADDRESS_LOCAL_PATTERN.test(parts[0])
477
+ || !SMTP_ACCOUNT_DOMAIN_PATTERN.test(parts[1])) {
478
+ throw new Error(`Invalid ${context} address pattern ('local@domain', '*' allowed in the local part only): ${addressArg}`);
479
+ }
480
+ return address;
481
+ }
482
+
483
+ private normalizeSenderScope(scopeArg: ISmtpAccountSenderScope | undefined): ISmtpAccountSenderScope {
484
+ return {
485
+ addresses: this.dedupe((scopeArg?.addresses || []).map((address) => this.normalizeAddressPattern(address, 'sender scope'))),
486
+ domains: this.dedupe((scopeArg?.domains || []).map((domain) => this.normalizeDomain(domain, 'sender scope'))),
487
+ };
488
+ }
489
+
490
+ private normalizeRecipientScope(scopeArg: ISmtpAccountRecipientScope | undefined): ISmtpAccountRecipientScope {
491
+ const mode = scopeArg?.mode === 'restricted' ? 'restricted' : 'any';
492
+ const addresses = this.dedupe((scopeArg?.addresses || []).map((address) => this.normalizeAddressPattern(address, 'recipient scope')));
493
+ const domains = this.dedupe((scopeArg?.domains || []).map((domain) => this.normalizeDomain(domain, 'recipient scope')));
494
+ if (mode === 'restricted' && addresses.length === 0 && domains.length === 0) {
495
+ throw new Error('A restricted recipient scope requires at least one address or domain (an empty list would silently deny all mail)');
496
+ }
497
+ return { mode, addresses, domains };
498
+ }
499
+
500
+ private normalizeMailPolicy(policyArg: ISmtpAccountMailPolicy | undefined): ISmtpAccountMailPolicy {
501
+ const queue = policyArg?.queue;
502
+ if (queue !== undefined && !['normal', 'priority', 'bulk'].includes(queue)) {
503
+ throw new Error(`Invalid mail policy queue: ${queue}`);
504
+ }
505
+ return {
506
+ dkimSign: Boolean(policyArg?.dkimSign),
507
+ ...(queue ? { queue } : {}),
508
+ };
509
+ }
510
+
511
+ /**
512
+ * DKIM signing fails at configuration time, never at delivery time: it is
513
+ * only allowed when every sender-scope domain is a managed email domain
514
+ * that is outbound-ready with active DKIM material.
515
+ */
516
+ private async assertDkimPolicyAllowed(
517
+ mailPolicy: ISmtpAccountMailPolicy,
518
+ senderScope: ISmtpAccountSenderScope,
519
+ username: string,
520
+ ): Promise<void> {
521
+ if (!mailPolicy.dkimSign) return;
522
+ const senderDomains = [...this.collectSenderDomainPatterns(senderScope).keys()];
523
+ if (senderDomains.length === 0) {
524
+ throw new Error(`DKIM signing for SMTP account '${username}' requires an explicit sender scope naming the domain(s) to sign for`);
525
+ }
526
+ const emailDomainManager = this.dcRouterRef.emailDomainManager;
527
+ if (!emailDomainManager) {
528
+ throw new Error('DKIM signing requires the email domain manager, which is not available');
529
+ }
530
+ for (const domain of senderDomains) {
531
+ if (!await emailDomainManager.getByDomain(domain)) {
532
+ throw new Error(`DKIM signing requires managed email domains; '${domain}' is not managed by dcrouter`);
533
+ }
534
+ const readiness = await emailDomainManager.getOutboundReadiness(domain);
535
+ if (!readiness.ready || !readiness.selector) {
536
+ throw new Error(`DKIM signing requires outbound-ready DKIM material for '${domain}': ${readiness.reason || 'no active DKIM selector'}`);
537
+ }
538
+ }
539
+ }
540
+
541
+ /** Warn-and-allow: sender domains outside managed email domains are permitted but flagged. */
542
+ private async collectSenderDomainWarnings(senderScope: ISmtpAccountSenderScope): Promise<string[]> {
543
+ const warnings: string[] = [];
544
+ const emailDomainManager = this.dcRouterRef.emailDomainManager;
545
+ if (!emailDomainManager) return warnings;
546
+ for (const domain of this.collectSenderDomainPatterns(senderScope).keys()) {
547
+ try {
548
+ if (!await emailDomainManager.getByDomain(domain)) {
549
+ warnings.push(`Sender domain '${domain}' is not a managed email domain — outbound DNS alignment (SPF/DKIM/DMARC) is not managed by dcrouter`);
550
+ }
551
+ } catch (error: unknown) {
552
+ warnings.push(`Could not verify sender domain '${domain}': ${(error as Error).message}`);
553
+ }
554
+ }
555
+ return warnings;
556
+ }
557
+
558
+ private async decorateWithDomainReadiness(doc: SmtpAccountDoc): Promise<ISmtpAccountInfo> {
559
+ const info = doc.toApiObject();
560
+ const emailDomainManager = this.dcRouterRef.emailDomainManager;
561
+ const senderDomains = [...this.collectSenderDomainPatterns(doc.senderScope).keys()];
562
+ if (!emailDomainManager || senderDomains.length === 0) return info;
563
+
564
+ const domainReadiness: ISmtpAccountDomainReadiness[] = [];
565
+ for (const domain of senderDomains) {
566
+ try {
567
+ if (!await emailDomainManager.getByDomain(domain)) {
568
+ domainReadiness.push({
569
+ domain,
570
+ ready: !doc.mailPolicy?.dkimSign,
571
+ reason: 'not a managed email domain',
572
+ });
573
+ continue;
574
+ }
575
+ const readiness = await emailDomainManager.getOutboundReadiness(domain);
576
+ domainReadiness.push({
577
+ domain,
578
+ ready: readiness.ready,
579
+ ...(readiness.reason ? { reason: readiness.reason } : {}),
580
+ });
581
+ } catch (error: unknown) {
582
+ domainReadiness.push({
583
+ domain,
584
+ ready: false,
585
+ reason: `readiness check failed: ${(error as Error).message}`,
586
+ });
587
+ }
588
+ }
589
+ return { ...info, domainReadiness };
590
+ }
591
+
592
+ private dedupe(values: string[]): string[] {
593
+ return [...new Set(values)];
594
+ }
595
+
596
+ private async runMutationExclusive<T>(action: () => Promise<T>): Promise<T> {
597
+ const run = this.mutationChain.then(action, action);
598
+ this.mutationChain = run.catch(() => undefined);
599
+ return await run;
600
+ }
601
+ }