@serve.zone/dcrouter 17.0.0 → 17.1.1

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 (141) hide show
  1. package/.smartconfig.json +7 -4
  2. package/deno.json +1 -1
  3. package/dist_serve/bundle.js +1316 -1151
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/classes.dcrouter.d.ts +3 -1
  6. package/dist_ts/classes.dcrouter.js +113 -52
  7. package/dist_ts/db/classes.cache.cleaner.d.ts +3 -0
  8. package/dist_ts/db/classes.cache.cleaner.js +7 -3
  9. package/dist_ts/db/classes.dcrouter-db.js +1 -2
  10. package/dist_ts/db/documents/classes.cached.email.d.ts +41 -4
  11. package/dist_ts/db/documents/classes.cached.email.js +36 -5
  12. package/dist_ts/db/documents/classes.dns-record.doc.js +3 -1
  13. package/dist_ts/dns/classes.dns-server-runtime.d.ts +0 -1
  14. package/dist_ts/dns/classes.dns-server-runtime.js +4 -36
  15. package/dist_ts/dns/index.d.ts +1 -0
  16. package/dist_ts/dns/index.js +2 -1
  17. package/dist_ts/dns/manager.dns.d.ts +1 -0
  18. package/dist_ts/dns/manager.dns.js +49 -14
  19. package/dist_ts/dns/providers/cloudflare.provider.js +19 -4
  20. package/dist_ts/dns/txt-record-presentation.d.ts +20 -0
  21. package/dist_ts/dns/txt-record-presentation.js +131 -0
  22. package/dist_ts/email/classes.accepted-email-spool.d.ts +32 -3
  23. package/dist_ts/email/classes.accepted-email-spool.js +174 -13
  24. package/dist_ts/email/classes.email-domain.manager.d.ts +14 -1
  25. package/dist_ts/email/classes.email-domain.manager.js +151 -8
  26. package/dist_ts/email/classes.email-settings.manager.js +2 -2
  27. package/dist_ts/email/classes.mail-dns-sync.d.ts +31 -5
  28. package/dist_ts/email/classes.mail-dns-sync.js +508 -117
  29. package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +2 -2
  30. package/dist_ts/email/classes.smartmta-blob-storage-manager.js +4 -3
  31. package/dist_ts/email/classes.workapp-mail-manager.d.ts +1 -0
  32. package/dist_ts/email/classes.workapp-mail-manager.js +26 -2
  33. package/dist_ts/email/index.d.ts +2 -0
  34. package/dist_ts/email/index.js +3 -1
  35. package/dist_ts/email/mail-dns-txt-policy.d.ts +18 -0
  36. package/dist_ts/email/mail-dns-txt-policy.js +466 -0
  37. package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +47 -0
  38. package/dist_ts/email/reconcile-mail-dns-singleton.js +159 -0
  39. package/dist_ts/opsserver/classes.opsserver.d.ts +8 -0
  40. package/dist_ts/opsserver/classes.opsserver.js +34 -1
  41. package/dist_ts/opsserver/handlers/certificate.handler.js +25 -4
  42. package/dist_ts/opsserver/handlers/config.handler.js +9 -5
  43. package/dist_ts/opsserver/handlers/dns-provider.handler.js +19 -2
  44. package/dist_ts/opsserver/handlers/dns-record.handler.js +25 -4
  45. package/dist_ts/opsserver/handlers/domain.handler.js +37 -3
  46. package/dist_ts/opsserver/handlers/email-domain.handler.js +5 -7
  47. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +1 -0
  48. package/dist_ts/opsserver/handlers/email-ops.handler.js +26 -7
  49. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  50. package/dist_ts/opsserver/handlers/index.js +2 -1
  51. package/dist_ts/opsserver/handlers/logs.handler.d.ts +1 -1
  52. package/dist_ts/opsserver/handlers/logs.handler.js +3 -3
  53. package/dist_ts/opsserver/handlers/realtime.handler.d.ts +37 -0
  54. package/dist_ts/opsserver/handlers/realtime.handler.js +154 -0
  55. package/dist_ts/opsserver/handlers/remoteingress.handler.js +22 -1
  56. package/dist_ts/opsserver/handlers/route-management.handler.js +47 -5
  57. package/dist_ts/opsserver/handlers/stats.handler.js +5 -1
  58. package/dist_ts/paths.d.ts +0 -11
  59. package/dist_ts/paths.js +1 -22
  60. package/dist_ts/plugins.d.ts +0 -34
  61. package/dist_ts/plugins.js +1 -62
  62. package/dist_ts_interfaces/data/email-domain.d.ts +14 -0
  63. package/dist_ts_interfaces/requests/config.d.ts +1 -1
  64. package/dist_ts_interfaces/requests/email-ops.d.ts +34 -1
  65. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  66. package/dist_ts_interfaces/requests/index.js +2 -1
  67. package/dist_ts_interfaces/requests/realtime.d.ts +38 -0
  68. package/dist_ts_interfaces/requests/realtime.js +11 -0
  69. package/dist_ts_migrations/index.d.ts +6 -0
  70. package/dist_ts_migrations/index.js +208 -9
  71. package/dist_ts_web/00_commitinfo_data.js +1 -1
  72. package/dist_ts_web/appstate/certificates.js +9 -2
  73. package/dist_ts_web/appstate/domains.js +16 -5
  74. package/dist_ts_web/appstate/email-domains.d.ts +3 -0
  75. package/dist_ts_web/appstate/email-domains.js +47 -14
  76. package/dist_ts_web/appstate/email-ops.d.ts +1 -0
  77. package/dist_ts_web/appstate/email-ops.js +20 -2
  78. package/dist_ts_web/appstate/realtime.d.ts +27 -0
  79. package/dist_ts_web/appstate/realtime.js +71 -0
  80. package/dist_ts_web/appstate/remoteingress.js +9 -3
  81. package/dist_ts_web/appstate/routes.js +16 -5
  82. package/dist_ts_web/appstate/runtime.js +135 -7
  83. package/dist_ts_web/appstate.d.ts +1 -0
  84. package/dist_ts_web/appstate.js +2 -1
  85. package/dist_ts_web/elements/email/email-domain-creation-progress.d.ts +33 -0
  86. package/dist_ts_web/elements/email/email-domain-creation-progress.js +257 -0
  87. package/dist_ts_web/elements/email/ops-view-email-domains.js +233 -68
  88. package/dist_ts_web/elements/email/ops-view-emails.d.ts +4 -2
  89. package/dist_ts_web/elements/email/ops-view-emails.js +25 -13
  90. package/dist_ts_web/elements/overview/ops-view-config.js +5 -5
  91. package/package.json +22 -19
  92. package/ts/00_commitinfo_data.ts +1 -1
  93. package/ts/classes.dcrouter.ts +124 -57
  94. package/ts/db/classes.cache.cleaner.ts +18 -3
  95. package/ts/db/classes.dcrouter-db.ts +0 -2
  96. package/ts/db/documents/classes.cached.email.ts +78 -4
  97. package/ts/db/documents/classes.dns-record.doc.ts +1 -0
  98. package/ts/dns/classes.dns-server-runtime.ts +9 -45
  99. package/ts/dns/index.ts +1 -0
  100. package/ts/dns/manager.dns.ts +50 -16
  101. package/ts/dns/providers/cloudflare.provider.ts +21 -3
  102. package/ts/dns/txt-record-presentation.ts +134 -0
  103. package/ts/email/classes.accepted-email-spool.ts +220 -14
  104. package/ts/email/classes.email-domain.manager.ts +201 -7
  105. package/ts/email/classes.email-settings.manager.ts +1 -1
  106. package/ts/email/classes.mail-dns-sync.ts +588 -122
  107. package/ts/email/classes.smartmta-blob-storage-manager.ts +3 -2
  108. package/ts/email/classes.workapp-mail-manager.ts +34 -1
  109. package/ts/email/index.ts +2 -0
  110. package/ts/email/mail-dns-txt-policy.ts +505 -0
  111. package/ts/email/reconcile-mail-dns-singleton.ts +224 -0
  112. package/ts/opsserver/classes.opsserver.ts +42 -0
  113. package/ts/opsserver/handlers/certificate.handler.ts +24 -3
  114. package/ts/opsserver/handlers/config.handler.ts +9 -5
  115. package/ts/opsserver/handlers/dns-provider.handler.ts +18 -1
  116. package/ts/opsserver/handlers/dns-record.handler.ts +24 -3
  117. package/ts/opsserver/handlers/domain.handler.ts +36 -2
  118. package/ts/opsserver/handlers/email-domain.handler.ts +4 -5
  119. package/ts/opsserver/handlers/email-ops.handler.ts +29 -6
  120. package/ts/opsserver/handlers/index.ts +1 -0
  121. package/ts/opsserver/handlers/logs.handler.ts +2 -2
  122. package/ts/opsserver/handlers/realtime.handler.ts +222 -0
  123. package/ts/opsserver/handlers/remoteingress.handler.ts +21 -0
  124. package/ts/opsserver/handlers/route-management.handler.ts +46 -4
  125. package/ts/opsserver/handlers/stats.handler.ts +4 -0
  126. package/ts/paths.ts +0 -25
  127. package/ts/plugins.ts +0 -68
  128. package/ts_web/00_commitinfo_data.ts +1 -1
  129. package/ts_web/appstate/certificates.ts +6 -1
  130. package/ts_web/appstate/domains.ts +11 -4
  131. package/ts_web/appstate/email-domains.ts +68 -14
  132. package/ts_web/appstate/email-ops.ts +20 -1
  133. package/ts_web/appstate/realtime.ts +109 -0
  134. package/ts_web/appstate/remoteingress.ts +6 -2
  135. package/ts_web/appstate/routes.ts +11 -4
  136. package/ts_web/appstate/runtime.ts +176 -7
  137. package/ts_web/appstate.ts +1 -0
  138. package/ts_web/elements/email/email-domain-creation-progress.ts +340 -0
  139. package/ts_web/elements/email/ops-view-email-domains.ts +273 -74
  140. package/ts_web/elements/email/ops-view-emails.ts +23 -16
  141. package/ts_web/elements/overview/ops-view-config.ts +4 -4
@@ -1,7 +1,10 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import { logger } from '../logger.js';
3
3
  import { CachedEmail } from '../db/index.js';
4
- import type { TCachedEmailDirection } from '../db/documents/classes.cached.email.js';
4
+ import type {
5
+ ICachedEmailSmtpTransaction,
6
+ TCachedEmailDirection,
7
+ } from '../db/documents/classes.cached.email.js';
5
8
  import type {
6
9
  Email,
7
10
  IExtendedSmtpSession,
@@ -19,6 +22,7 @@ const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
19
22
  const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
20
23
 
21
24
  export type TSmartMtaQueueItemLike = {
25
+ id?: string;
22
26
  processingResult?: {
23
27
  headers?: Record<string, string>;
24
28
  email?: { headers?: Record<string, string> };
@@ -27,8 +31,20 @@ export type TSmartMtaQueueItemLike = {
27
31
  attempts?: number;
28
32
  nextAttempt?: Date;
29
33
  lastError?: string;
34
+ smtpTransactions?: ICachedEmailSmtpTransaction[];
30
35
  };
31
36
 
37
+ export interface IEmailQueuePersistedEvent {
38
+ cachedEmailId: string;
39
+ status: string;
40
+ reason: string;
41
+ }
42
+
43
+ export interface ISmtpTransactionPersistedEvent {
44
+ cachedEmailId: string;
45
+ transaction: ICachedEmailSmtpTransaction;
46
+ }
47
+
32
48
  type TSmartMtaQueueReader = UnifiedEmailServer & {
33
49
  getQueueItems?: () => TSmartMtaQueueItemLike[];
34
50
  };
@@ -86,6 +102,13 @@ export class AcceptedEmailSpool {
86
102
  private processing = false;
87
103
  private stopping = false;
88
104
  private queueUpdatePromises = new Set<Promise<void>>();
105
+ private cachedEmailUpdateChains = new Map<string, Promise<void>>();
106
+ private smtpTransactionListeners = new Set<(
107
+ eventArg: ISmtpTransactionPersistedEvent,
108
+ ) => void | Promise<void>>();
109
+ private emailQueuePersistedListeners = new Set<(
110
+ eventArg: IEmailQueuePersistedEvent,
111
+ ) => void | Promise<void>>();
89
112
 
90
113
  constructor(private dcRouterRef: DcRouter) {}
91
114
 
@@ -118,7 +141,8 @@ export class AcceptedEmailSpool {
118
141
  cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
119
142
  cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
120
143
  cachedEmail.subject = email.subject || '';
121
- cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
144
+ const persistedRawMessage = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
145
+ await this.persistRawMessage(cachedEmail, persistedRawMessage);
122
146
  cachedEmail.status = 'pending';
123
147
  cachedEmail.nextAttempt = new Date();
124
148
  cachedEmail.direction = session.authenticated ? 'outbound' : 'inbound';
@@ -137,10 +161,17 @@ export class AcceptedEmailSpool {
137
161
  });
138
162
  cachedEmail.updateSenderDomain();
139
163
  cachedEmail.updateRecipientDomains();
140
- await cachedEmail.save();
164
+ try {
165
+ await cachedEmail.save();
166
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-accepted');
167
+ } catch (error) {
168
+ await this.deleteRawMessage(cachedEmail).catch(() => undefined);
169
+ throw error;
170
+ }
141
171
  if (context.abortSignal?.aborted) {
142
172
  cachedEmail.markFailed('Message acceptance aborted before SMTP success');
143
173
  await cachedEmail.save();
174
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-acceptance-aborted');
144
175
  throw new Error('Message acceptance aborted before SMTP success');
145
176
  }
146
177
 
@@ -149,6 +180,7 @@ export class AcceptedEmailSpool {
149
180
  } else {
150
181
  cachedEmail.markDelivered();
151
182
  await cachedEmail.save();
183
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-accepted-without-queue');
152
184
  }
153
185
 
154
186
  return {
@@ -174,7 +206,8 @@ export class AcceptedEmailSpool {
174
206
  cachedEmail.cc = [];
175
207
  cachedEmail.bcc = [];
176
208
  cachedEmail.subject = optionsArg.subject || this.extractHeader(rawMessage, 'subject') || '';
177
- cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
209
+ const persistedRawMessage = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
210
+ await this.persistRawMessage(cachedEmail, persistedRawMessage);
178
211
  cachedEmail.status = 'pending';
179
212
  cachedEmail.nextAttempt = new Date();
180
213
  cachedEmail.direction = optionsArg.direction
@@ -194,13 +227,20 @@ export class AcceptedEmailSpool {
194
227
  });
195
228
  cachedEmail.updateSenderDomain();
196
229
  cachedEmail.updateRecipientDomains();
197
- await cachedEmail.save();
230
+ try {
231
+ await cachedEmail.save();
232
+ await this.notifyEmailQueuePersisted(cachedEmail, 'raw-email-accepted');
233
+ } catch (error) {
234
+ await this.deleteRawMessage(cachedEmail).catch(() => undefined);
235
+ throw error;
236
+ }
198
237
 
199
238
  if (optionsArg.processAfterAccept !== false) {
200
239
  this.run();
201
240
  } else {
202
241
  cachedEmail.markDelivered();
203
242
  await cachedEmail.save();
243
+ await this.notifyEmailQueuePersisted(cachedEmail, 'raw-email-accepted-without-queue');
204
244
  }
205
245
 
206
246
  return {
@@ -268,14 +308,73 @@ export class AcceptedEmailSpool {
268
308
  item: TSmartMtaQueueItemLike,
269
309
  status: 'queued' | 'deferred' | 'delivered' | 'failed',
270
310
  failureMessage: string,
271
- ): void {
272
- const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
311
+ ): Promise<void> {
312
+ const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
313
+ const updatePromise = this.runCachedEmailUpdate(cachedEmailId, async () => {
314
+ await this.updateAcceptedEmailFromQueueItem(item, status);
315
+ }).catch((error) => {
273
316
  logger.log('warn', `${failureMessage}: ${(error as Error).message}`);
274
317
  });
275
- this.queueUpdatePromises.add(updatePromise);
276
- void updatePromise.finally(() => {
277
- this.queueUpdatePromises.delete(updatePromise);
318
+ this.trackUpdatePromise(updatePromise);
319
+ return updatePromise;
320
+ }
321
+
322
+ public trackSmtpTransaction(
323
+ transactionArg: ICachedEmailSmtpTransaction,
324
+ emailServerArg: UnifiedEmailServer,
325
+ ): Promise<void> {
326
+ const queueItem = emailServerArg.getQueueItem(
327
+ transactionArg.queueItemId,
328
+ ) as TSmartMtaQueueItemLike | undefined;
329
+ const cachedEmailId = queueItem ? this.getCachedEmailIdFromQueueItem(queueItem) : undefined;
330
+ const updatePromise = this.runCachedEmailUpdate(cachedEmailId, async () => {
331
+ if (!cachedEmailId || !this.dcRouterRef.dcRouterDb?.isReady()) return;
332
+ const cachedEmail = await CachedEmail.findById(cachedEmailId);
333
+ if (!cachedEmail) return;
334
+ this.appendSmtpTransaction(cachedEmail, transactionArg);
335
+ await cachedEmail.save();
336
+ const event: ISmtpTransactionPersistedEvent = {
337
+ cachedEmailId,
338
+ transaction: structuredClone(transactionArg),
339
+ };
340
+ await Promise.allSettled(
341
+ [...this.smtpTransactionListeners].map(async (listener) => await listener(event)),
342
+ );
343
+ }).catch((error) => {
344
+ logger.log('warn', `Unable to persist SMTP transaction: ${(error as Error).message}`);
278
345
  });
346
+ this.trackUpdatePromise(updatePromise);
347
+ return updatePromise;
348
+ }
349
+
350
+ /** Subscribe to updates only after the SMTP transaction is durably stored. */
351
+ public onSmtpTransactionPersisted(
352
+ listenerArg: (eventArg: ISmtpTransactionPersistedEvent) => void | Promise<void>,
353
+ ): () => void {
354
+ this.smtpTransactionListeners.add(listenerArg);
355
+ return () => this.smtpTransactionListeners.delete(listenerArg);
356
+ }
357
+ /** Subscribe to queue state changes only after the CachedEmail row is durable. */
358
+ public onEmailQueuePersisted(
359
+ listenerArg: (eventArg: IEmailQueuePersistedEvent) => void | Promise<void>,
360
+ ): () => void {
361
+ this.emailQueuePersistedListeners.add(listenerArg);
362
+ return () => this.emailQueuePersistedListeners.delete(listenerArg);
363
+ }
364
+
365
+ private async notifyEmailQueuePersisted(
366
+ cachedEmailArg: CachedEmail,
367
+ reasonArg: string,
368
+ ): Promise<void> {
369
+ if (this.emailQueuePersistedListeners.size === 0) return;
370
+ const event: IEmailQueuePersistedEvent = {
371
+ cachedEmailId: cachedEmailArg.id,
372
+ status: cachedEmailArg.status,
373
+ reason: reasonArg,
374
+ };
375
+ await Promise.allSettled(
376
+ [...this.emailQueuePersistedListeners].map(async (listener) => await listener(event)),
377
+ );
279
378
  }
280
379
 
281
380
  public async drainQueueUpdates(): Promise<void> {
@@ -295,6 +394,22 @@ export class AcceptedEmailSpool {
295
394
  }
296
395
  }
297
396
 
397
+ /** Reconcile persisted queue traces after restart before queue recovery runs. */
398
+ public async recoverSmtpTransactionHistory(emailServerArg: UnifiedEmailServer): Promise<void> {
399
+ for (const item of (emailServerArg as TSmartMtaQueueReader).getQueueItems?.() || []) {
400
+ const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
401
+ if (!cachedEmailId || !item.smtpTransactions?.length) continue;
402
+ await this.runCachedEmailUpdate(cachedEmailId, async () => {
403
+ const cachedEmail = await CachedEmail.findById(cachedEmailId);
404
+ if (!cachedEmail) return;
405
+ for (const transaction of item.smtpTransactions || []) {
406
+ this.appendSmtpTransaction(cachedEmail, transaction);
407
+ }
408
+ await cachedEmail.save();
409
+ });
410
+ }
411
+ }
412
+
298
413
  /** Requeue emails left in 'queued' state by a previous process as pending. */
299
414
  public async recoverQueuedEmails(): Promise<void> {
300
415
  while (true) {
@@ -309,6 +424,7 @@ export class AcceptedEmailSpool {
309
424
  queuedEmail.status = 'pending';
310
425
  queuedEmail.nextAttempt = new Date();
311
426
  await queuedEmail.save();
427
+ await this.notifyEmailQueuePersisted(queuedEmail, 'email-queue-recovered');
312
428
  }
313
429
  if (queuedEmails.length < ACCEPTED_EMAIL_SPOOL_BATCH_SIZE) {
314
430
  return;
@@ -332,7 +448,7 @@ export class AcceptedEmailSpool {
332
448
  continue;
333
449
  }
334
450
  const session = this.buildCachedEmailSession(cachedEmail);
335
- const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
451
+ const rawMessage = await this.readRawMessage(cachedEmail);
336
452
  await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
337
453
  }
338
454
  } finally {
@@ -349,6 +465,7 @@ export class AcceptedEmailSpool {
349
465
  cachedEmail.status = 'processing';
350
466
  cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
351
467
  await cachedEmail.save();
468
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-processing');
352
469
 
353
470
  try {
354
471
  if (plugins.buffer.Buffer.isBuffer(emailData)) {
@@ -360,13 +477,18 @@ export class AcceptedEmailSpool {
360
477
  if (handledByTypedEndpoint) {
361
478
  cachedEmail.markDelivered();
362
479
  await cachedEmail.save();
480
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-delivered-to-typed-endpoint');
363
481
  return;
364
482
  }
365
483
  }
366
484
 
367
485
  await emailServer.processEmailByMode(emailData, session);
368
486
  if (session.matchedRoute?.action.type === 'forward') {
369
- cachedEmail.markDelivered();
487
+ const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
488
+ currentCachedEmail.markDelivered();
489
+ await currentCachedEmail.save();
490
+ await this.notifyEmailQueuePersisted(currentCachedEmail, 'email-forwarded');
491
+ return;
370
492
  } else {
371
493
  const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
372
494
  if (this.isCachedEmailTerminal(currentCachedEmail) || currentCachedEmail.status === 'deferred') {
@@ -375,13 +497,14 @@ export class AcceptedEmailSpool {
375
497
  currentCachedEmail.status = 'queued';
376
498
  currentCachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
377
499
  await currentCachedEmail.save();
500
+ await this.notifyEmailQueuePersisted(currentCachedEmail, 'email-queued');
378
501
  return;
379
502
  }
380
- await cachedEmail.save();
381
503
  } catch (error: unknown) {
382
504
  cachedEmail.scheduleRetry(ACCEPTED_EMAIL_RETRY_DELAY_MS);
383
505
  cachedEmail.lastError = (error as Error).message;
384
506
  await cachedEmail.save();
507
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-spool-deferred');
385
508
  logger.log('warn', `Accepted email ${cachedEmail.id} deferred after SmartMTA handoff failure: ${(error as Error).message}`);
386
509
  }
387
510
  }
@@ -405,7 +528,7 @@ export class AcceptedEmailSpool {
405
528
  clientHostname: storedSession.clientHostname || '',
406
529
  mailFrom: mailFrom.address,
407
530
  rcptTo: rcptTo.map((recipient) => recipient.address),
408
- emailData: cachedEmail.rawContent || '',
531
+ emailData: '',
409
532
  useTLS: !!storedSession.secure,
410
533
  connectionEnded: false,
411
534
  remoteAddress: storedSession.remoteAddress || '127.0.0.1',
@@ -456,6 +579,9 @@ export class AcceptedEmailSpool {
456
579
  return;
457
580
  }
458
581
  cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
582
+ for (const transaction of item.smtpTransactions || []) {
583
+ this.appendSmtpTransaction(cachedEmail, transaction);
584
+ }
459
585
  this.updateSmartMtaRouteData(cachedEmail, item, status);
460
586
  if (status === 'delivered') {
461
587
  cachedEmail.markDelivered();
@@ -471,6 +597,7 @@ export class AcceptedEmailSpool {
471
597
  cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
472
598
  }
473
599
  await cachedEmail.save();
600
+ await this.notifyEmailQueuePersisted(cachedEmail, `email-queue-${status}`);
474
601
  }
475
602
 
476
603
  private async waitForPromiseToSettleWithTimeout(
@@ -538,10 +665,14 @@ export class AcceptedEmailSpool {
538
665
  if (queueItem.lastError) {
539
666
  cachedEmail.lastError = queueItem.lastError;
540
667
  }
668
+ for (const transaction of queueItem.smtpTransactions || []) {
669
+ this.appendSmtpTransaction(cachedEmail, transaction);
670
+ }
541
671
  this.updateSmartMtaRouteData(cachedEmail, queueItem, status);
542
672
  const smartMtaNextAttempt = queueItem.nextAttempt || new Date(Date.now() + ACCEPTED_EMAIL_RETRY_DELAY_MS);
543
673
  cachedEmail.nextAttempt = new Date(smartMtaNextAttempt.getTime() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
544
674
  await cachedEmail.save();
675
+ await this.notifyEmailQueuePersisted(cachedEmail, 'email-live-queue-postponed');
545
676
  return true;
546
677
  }
547
678
 
@@ -577,6 +708,81 @@ export class AcceptedEmailSpool {
577
708
  cachedEmail.routeData = JSON.stringify(routeData);
578
709
  }
579
710
 
711
+ public async readRawMessage(cachedEmailArg: CachedEmail): Promise<plugins.buffer.Buffer> {
712
+ if (cachedEmailArg.rawContentObjectKey) {
713
+ const blobStorage = this.dcRouterRef.getSmartMtaBlobStorageManager();
714
+ if (!blobStorage) {
715
+ throw new Error(`SmartBucket storage is unavailable for accepted email ${cachedEmailArg.id}`);
716
+ }
717
+ const rawMessage = await blobStorage.get(cachedEmailArg.rawContentObjectKey);
718
+ if (!rawMessage) {
719
+ throw new Error(`Raw RFC822 object is missing for accepted email ${cachedEmailArg.id}`);
720
+ }
721
+ return plugins.buffer.Buffer.from(rawMessage);
722
+ }
723
+ throw new Error(`Accepted email ${cachedEmailArg.id} has no raw RFC822 payload`);
724
+ }
725
+
726
+ public async deleteRawMessage(cachedEmailArg: CachedEmail): Promise<void> {
727
+ if (!cachedEmailArg.rawContentObjectKey) return;
728
+ const blobStorage = this.dcRouterRef.getSmartMtaBlobStorageManager();
729
+ if (!blobStorage) {
730
+ throw new Error(`SmartBucket storage is unavailable for accepted email ${cachedEmailArg.id}`);
731
+ }
732
+ await blobStorage.delete(cachedEmailArg.rawContentObjectKey);
733
+ }
734
+
735
+ private async persistRawMessage(cachedEmailArg: CachedEmail, rawMessageArg: string): Promise<void> {
736
+ const blobStorage = this.dcRouterRef.getSmartMtaBlobStorageManager();
737
+ if (!blobStorage) {
738
+ throw new Error('SmartBucket storage is required before accepting SMTP messages');
739
+ }
740
+ const objectKey = `/email/messages/${cachedEmailArg.id}.eml`;
741
+ const rawMessage = plugins.buffer.Buffer.from(rawMessageArg, 'utf8');
742
+ await blobStorage.set(objectKey, rawMessage);
743
+ cachedEmailArg.rawContent = undefined;
744
+ cachedEmailArg.rawContentObjectKey = objectKey;
745
+ cachedEmailArg.rawContentSize = rawMessage.length;
746
+ }
747
+
748
+ private runCachedEmailUpdate(
749
+ cachedEmailIdArg: string | undefined,
750
+ operationArg: () => Promise<void>,
751
+ ): Promise<void> {
752
+ if (!cachedEmailIdArg) return operationArg();
753
+ const previous = this.cachedEmailUpdateChains.get(cachedEmailIdArg) || Promise.resolve();
754
+ const current = previous.catch(() => undefined).then(operationArg);
755
+ this.cachedEmailUpdateChains.set(cachedEmailIdArg, current);
756
+ const cleanup = () => {
757
+ if (this.cachedEmailUpdateChains.get(cachedEmailIdArg) === current) {
758
+ this.cachedEmailUpdateChains.delete(cachedEmailIdArg);
759
+ }
760
+ };
761
+ void current.then(cleanup, cleanup);
762
+ return current;
763
+ }
764
+
765
+ private trackUpdatePromise(updatePromiseArg: Promise<void>): void {
766
+ this.queueUpdatePromises.add(updatePromiseArg);
767
+ const cleanup = () => this.queueUpdatePromises.delete(updatePromiseArg);
768
+ void updatePromiseArg.then(cleanup, cleanup);
769
+ }
770
+
771
+ private appendSmtpTransaction(
772
+ cachedEmailArg: CachedEmail,
773
+ transactionArg: ICachedEmailSmtpTransaction,
774
+ ): void {
775
+ if (typeof cachedEmailArg.appendSmtpTransaction === 'function') {
776
+ cachedEmailArg.appendSmtpTransaction(transactionArg);
777
+ return;
778
+ }
779
+ const transactions = (cachedEmailArg.smtpTransactions || []).filter(
780
+ (transaction) => transaction.id !== transactionArg.id,
781
+ );
782
+ transactions.push(structuredClone(transactionArg));
783
+ cachedEmailArg.smtpTransactions = transactions.slice(-20);
784
+ }
785
+
580
786
  private removeHeader(headers: Record<string, string>, headerName: string): void {
581
787
  const normalizedHeaderName = headerName.toLowerCase();
582
788
  for (const key of Object.keys(headers)) {
@@ -26,6 +26,11 @@ export interface IEmailDomainManagerActionResult {
26
26
  provisioned?: number;
27
27
  }
28
28
 
29
+ export interface IManagedDkimRepairResult {
30
+ repairedDomainIds: string[];
31
+ failedDomainIds: string[];
32
+ }
33
+
29
34
  /**
30
35
  * Persists managed email domains and projects them into SmartMTA. DNS mutation
31
36
  * is delegated exclusively to MailDnsSync.
@@ -340,13 +345,23 @@ export class EmailDomainManager {
340
345
  }> {
341
346
  const doc = await EmailDomainDoc.findByDomain(domainName.toLowerCase());
342
347
  if (!doc) return { ready: true, edgeIds: [] }; // unmanaged/static domain policy
348
+ const repairFailure = doc.reconciliation?.errors?.find(
349
+ (error) => error.code === 'DKIM_KEY_REPAIR_FAILED',
350
+ );
351
+ if (repairFailure) {
352
+ return { ready: false, reason: repairFailure.message, edgeIds: [] };
353
+ }
343
354
  const activeDkim = this.getActiveDkim(doc);
344
355
  if (!activeDkim) return { ready: false, reason: 'no usable active DKIM key', edgeIds: [] };
345
356
  if (activeDkim.selector !== 'default' && !this.selectorCorrectSigningCapability) {
346
357
  return { ready: false, reason: 'SmartMTA selector-correct signing capability is unavailable', edgeIds: [] };
347
358
  }
348
- if (!doc.reconciliation?.activeRevision) {
349
- return { ready: false, reason: 'no validated active DNS revision', edgeIds: [] };
359
+ const reconciliation = doc.reconciliation;
360
+ if (
361
+ !reconciliation?.activeRevision
362
+ || reconciliation.activeRevision.generation !== reconciliation.desiredGeneration
363
+ ) {
364
+ return { ready: false, reason: 'no current validated active DNS revision', edgeIds: [] };
350
365
  }
351
366
  const live = await this.dcRouter.mailEdgeEligibility.resolveActiveForDomain(doc);
352
367
  if (live.edges.length === 0) {
@@ -359,6 +374,123 @@ export class EmailDomainManager {
359
374
  };
360
375
  }
361
376
 
377
+ /**
378
+ * Caller-owned repair for legacy managed domains. SmartMTA remains in
379
+ * validation-only mode; dcrouter explicitly owns any required key creation.
380
+ */
381
+ public async repairManagedDkimBeforeEmailStart(): Promise<IManagedDkimRepairResult> {
382
+ const repairedDomainIds: string[] = [];
383
+ const failedDomainIds: string[] = [];
384
+
385
+ await this.runMutationExclusive(async () => {
386
+ const dkimCreator = this.dkimCreator;
387
+ if (!dkimCreator) {
388
+ throw new Error('DKIM key creator is not ready for managed-domain repair');
389
+ }
390
+
391
+ for (const doc of await EmailDomainDoc.findAll()) {
392
+ if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
393
+ const currentActive = this.getActiveDkim(doc);
394
+ const selector = currentActive?.selector || doc.dkim?.selector || 'default';
395
+ const keySize = currentActive?.keySize || doc.dkim?.keySize || 2048;
396
+ let regenerated = false;
397
+ let outcome: 'repaired' | 'failed' | undefined;
398
+ let failureMessage: string | undefined;
399
+
400
+ try {
401
+ try {
402
+ await dkimCreator.readValidatedDKIMKeysForSelector(doc.domain, selector);
403
+ } catch {
404
+ await dkimCreator.createAndStoreDKIMKeysForSelector(doc.domain, selector, keySize);
405
+ await dkimCreator.readValidatedDKIMKeysForSelector(doc.domain, selector);
406
+ regenerated = true;
407
+ }
408
+
409
+ const record = await dkimCreator.getDNSRecordForSelector(doc.domain, selector);
410
+ const publicKey = this.extractDkimPublicKey(record?.value);
411
+ if (!publicKey) {
412
+ throw new Error('validated DKIM material produced no usable public key');
413
+ }
414
+
415
+ if (
416
+ !regenerated
417
+ && !this.hasDkimRepairFailure(doc)
418
+ && currentActive?.selector === selector
419
+ && currentActive.publicKey === publicKey
420
+ && doc.dkim?.selector === selector
421
+ && doc.dkim.publicKey === publicKey
422
+ ) {
423
+ continue;
424
+ }
425
+
426
+ const now = new Date().toISOString();
427
+ doc.activeDkim = {
428
+ selector,
429
+ keySize,
430
+ publicKey,
431
+ createdAt: regenerated ? now : currentActive?.createdAt || now,
432
+ validatedAt: now,
433
+ };
434
+ doc.dkim = {
435
+ ...doc.dkim,
436
+ selector,
437
+ keySize,
438
+ publicKey,
439
+ ...(regenerated ? { lastRotatedAt: now } : {}),
440
+ };
441
+ doc.dnsStatus = {
442
+ ...doc.dnsStatus,
443
+ dkim: 'unchecked',
444
+ };
445
+ doc.reconciliation = this.invalidateDkimReconciliation(
446
+ doc,
447
+ regenerated ? 'DKIM_KEY_MATERIAL_REGENERATED' : 'DKIM_KEY_MATERIAL_REPAIRED',
448
+ regenerated
449
+ ? 'Caller-managed DKIM key material was regenerated; DNS deployment must be revalidated'
450
+ : 'Caller-managed DKIM metadata was repaired; DNS deployment must be revalidated',
451
+ );
452
+ doc.updatedAt = now;
453
+ outcome = 'repaired';
454
+ } catch (error: unknown) {
455
+ const message = `Failed to repair caller-managed DKIM for ${selector}._domainkey.${doc.domain}: ${(error as Error).message}`;
456
+ doc.reconciliation = this.invalidateDkimReconciliation(
457
+ doc,
458
+ 'DKIM_KEY_REPAIR_FAILED',
459
+ message,
460
+ false,
461
+ );
462
+ doc.dnsStatus = {
463
+ ...doc.dnsStatus,
464
+ dkim: 'invalid',
465
+ };
466
+ doc.updatedAt = new Date().toISOString();
467
+ outcome = 'failed';
468
+ failureMessage = message;
469
+ }
470
+
471
+ // Persistence failures are global prerequisites, not per-domain key
472
+ // failures. Keep this save outside the material-repair catch.
473
+ await doc.save();
474
+ if (outcome === 'repaired') {
475
+ repairedDomainIds.push(doc.id);
476
+ } else if (outcome === 'failed') {
477
+ failedDomainIds.push(doc.id);
478
+ logger.log('error', failureMessage!);
479
+ }
480
+ }
481
+
482
+ await this.syncManagedDomainsToRuntime({ excludeDkimRepairFailures: true });
483
+ });
484
+
485
+ if (repairedDomainIds.length > 0) {
486
+ this.dcRouter.mailDnsSync?.requestSync(
487
+ 'caller-managed DKIM material repaired before SmartMTA start',
488
+ [...new Set(repairedDomainIds)],
489
+ );
490
+ }
491
+ return { repairedDomainIds, failedDomainIds };
492
+ }
493
+
362
494
  public async requestDkimRotation(id: string): Promise<IEmailDomainManagerActionResult> {
363
495
  const mutation = await this.runMutationExclusive(async () => {
364
496
  const doc = await EmailDomainDoc.findById(id);
@@ -472,6 +604,45 @@ export class EmailDomainManager {
472
604
  };
473
605
  }
474
606
 
607
+ private invalidateDkimReconciliation(
608
+ doc: EmailDomainDoc,
609
+ code: string,
610
+ message: string,
611
+ retryable = true,
612
+ ) {
613
+ const previous = doc.reconciliation || {
614
+ lifecycleStatus: 'pending' as const,
615
+ desiredGeneration: 0,
616
+ intents: [],
617
+ errors: [],
618
+ };
619
+ const {
620
+ activeRevision: _activeRevision,
621
+ pendingRevision: _pendingRevision,
622
+ intents: _intents,
623
+ errors: _errors,
624
+ lastSuccessAt: _lastSuccessAt,
625
+ retryAt: _retryAt,
626
+ ...preserved
627
+ } = previous;
628
+ const alreadyInvalidated = (
629
+ !previous.activeRevision
630
+ && previous.errors.length === 1
631
+ && previous.errors[0].code === code
632
+ && previous.errors[0].message === message
633
+ );
634
+ return {
635
+ ...preserved,
636
+ lifecycleStatus: retryable ? 'pending' as const : 'failed' as const,
637
+ desiredGeneration: alreadyInvalidated
638
+ ? previous.desiredGeneration
639
+ : previous.desiredGeneration + 1,
640
+ intents: [],
641
+ errors: [{ code, message, retryable }],
642
+ ...(retryable ? { retryAt: new Date().toISOString() } : {}),
643
+ };
644
+ }
645
+
475
646
  private capabilityError(code: string, message: string): Error {
476
647
  const error = new Error(message) as Error & Record<string, unknown>;
477
648
  error.code = code;
@@ -545,17 +716,23 @@ export class EmailDomainManager {
545
716
  .sort((left, right) => right.name.length - left.name.length)[0] || null;
546
717
  }
547
718
 
548
- private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
719
+ private async buildManagedDomainConfigs(optionsArg: {
720
+ excludeDkimRepairFailures?: boolean;
721
+ } = {}): Promise<IEmailDomainConfig[]> {
549
722
  const docs = await EmailDomainDoc.findAll();
550
723
  const managedConfigs: IEmailDomainConfig[] = [];
551
724
  for (const doc of docs) {
552
725
  if (doc.reconciliation?.lifecycleStatus === 'deleting') continue;
726
+ const dkimRepairFailed = this.hasDkimRepairFailure(doc);
727
+ if (dkimRepairFailed && optionsArg.excludeDkimRepairFailures) continue;
553
728
  if (!await DomainDoc.findById(doc.linkedDomainId)) {
554
729
  logger.log('warn', `Managed email domain ${doc.domain} has no linked DNS domain; keeping it out of runtime`);
555
730
  continue;
556
731
  }
557
732
  const activeDkim = this.getActiveDkim(doc);
558
- const dkimConfig = activeDkim && (activeDkim.selector === 'default' || this.selectorCorrectSigningCapability)
733
+ const dkimConfig = !dkimRepairFailed
734
+ && activeDkim
735
+ && (activeDkim.selector === 'default' || this.selectorCorrectSigningCapability)
559
736
  ? {
560
737
  selector: activeDkim.selector,
561
738
  keySize: activeDkim.keySize,
@@ -574,13 +751,24 @@ export class EmailDomainManager {
574
751
  return managedConfigs;
575
752
  }
576
753
 
577
- public async syncManagedDomainsToRuntime(): Promise<void> {
754
+ public async syncManagedDomainsToRuntime(optionsArg: {
755
+ excludeDkimRepairFailures?: boolean;
756
+ } = {}): Promise<void> {
578
757
  if (!this.dcRouter.options?.emailConfig) return;
758
+ const failedDomainNames = new Set(
759
+ (await EmailDomainDoc.findAll())
760
+ .filter((doc) => this.hasDkimRepairFailure(doc))
761
+ .map((doc) => doc.domain.toLowerCase()),
762
+ );
579
763
  const mergedDomains = new Map<string, IEmailDomainConfig>();
580
764
  for (const domainConfig of this.baseEmailDomains) {
581
- mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
765
+ const key = domainConfig.domain.toLowerCase();
766
+ if (failedDomainNames.has(key) && optionsArg.excludeDkimRepairFailures) continue;
767
+ const projectedConfig = JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig;
768
+ if (failedDomainNames.has(key)) delete projectedConfig.dkim;
769
+ mergedDomains.set(key, projectedConfig);
582
770
  }
583
- for (const managedConfig of await this.buildManagedDomainConfigs()) {
771
+ for (const managedConfig of await this.buildManagedDomainConfigs(optionsArg)) {
584
772
  const key = managedConfig.domain.toLowerCase();
585
773
  if (mergedDomains.has(key)) {
586
774
  logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
@@ -592,4 +780,10 @@ export class EmailDomainManager {
592
780
  this.dcRouter.options.emailConfig.domains = domains;
593
781
  this.dcRouter.emailServer?.updateOptions({ domains });
594
782
  }
783
+
784
+ private hasDkimRepairFailure(doc: EmailDomainDoc): boolean {
785
+ return (doc.reconciliation?.errors || []).some(
786
+ (error) => error.code === 'DKIM_KEY_REPAIR_FAILED',
787
+ );
788
+ }
595
789
  }
@@ -155,7 +155,7 @@ export class EmailSettingsManager {
155
155
  const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
156
156
  hostname: 'localhost',
157
157
  ports: [...defaultEmailPorts],
158
- queue: { storageMode: 'memory' },
158
+ queue: { storageMode: 'managed' },
159
159
  domains: [],
160
160
  routes: [],
161
161
  } : undefined);