@serve.zone/dcrouter 18.4.1 → 18.6.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 (35) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +2 -1
  4. package/dist_ts/classes.dcrouter.js +4 -2
  5. package/dist_ts/db/documents/classes.gateway-mail-domain.doc.d.ts +16 -0
  6. package/dist_ts/db/documents/classes.gateway-mail-domain.doc.js +107 -0
  7. package/dist_ts/db/documents/index.d.ts +1 -0
  8. package/dist_ts/db/documents/index.js +2 -1
  9. package/dist_ts/email/classes.gateway-mail-domain.store.d.ts +11 -0
  10. package/dist_ts/email/classes.gateway-mail-domain.store.js +70 -0
  11. package/dist_ts/email/classes.workapp-mail-manager.d.ts +5 -23
  12. package/dist_ts/email/classes.workapp-mail-manager.js +63 -35
  13. package/dist_ts/email/gateway-mail-domain-authority.d.ts +44 -0
  14. package/dist_ts/email/gateway-mail-domain-authority.js +160 -0
  15. package/dist_ts/email/index.d.ts +2 -0
  16. package/dist_ts/email/index.js +3 -1
  17. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +33 -1
  18. package/dist_ts/opsserver/helpers/gateway-client-auth.d.ts +2 -0
  19. package/dist_ts/opsserver/helpers/gateway-client-auth.js +5 -3
  20. package/dist_ts_migrations/index.js +196 -11
  21. package/dist_ts_web/00_commitinfo_data.js +1 -1
  22. package/package.json +2 -2
  23. package/readme.md +1 -1
  24. package/ts/00_commitinfo_data.ts +1 -1
  25. package/ts/classes.dcrouter.ts +3 -1
  26. package/ts/db/documents/classes.gateway-mail-domain.doc.ts +42 -0
  27. package/ts/db/documents/index.ts +1 -0
  28. package/ts/email/classes.gateway-mail-domain.store.ts +89 -0
  29. package/ts/email/classes.workapp-mail-manager.ts +90 -68
  30. package/ts/email/gateway-mail-domain-authority.ts +222 -0
  31. package/ts/email/index.ts +2 -0
  32. package/ts/opsserver/handlers/gatewayclient.handler.ts +43 -1
  33. package/ts/opsserver/helpers/gateway-client-auth.ts +6 -2
  34. package/ts_web/00_commitinfo_data.ts +1 -1
  35. package/readme.plan.md +0 -22
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.4.1',
6
+ version: '18.6.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vdHNfd2ViLzAwX2NvbW1pdGluZm9fZGF0YS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7R0FFRztBQUNILE1BQU0sQ0FBQyxNQUFNLFVBQVUsR0FBRztJQUN4QixJQUFJLEVBQUUsc0JBQXNCO0lBQzVCLE9BQU8sRUFBRSxRQUFRO0lBQ2pCLFdBQVcsRUFBRSwwRUFBMEU7Q0FDeEYsQ0FBQSJ9
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "18.4.1",
4
+ "version": "18.6.0",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -64,7 +64,7 @@
64
64
  "@push.rocks/smartvpn": "1.22.0",
65
65
  "@push.rocks/taskbuffer": "^8.0.2",
66
66
  "@serve.zone/catalog": "^2.21.1",
67
- "@serve.zone/interfaces": "^19.2.0",
67
+ "@serve.zone/interfaces": "^19.8.0",
68
68
  "@serve.zone/remoteingress": "^5.1.0",
69
69
  "@tsclass/tsclass": "^9.5.1",
70
70
  "@types/qrcode": "^1.5.6",
package/readme.md CHANGED
@@ -472,7 +472,7 @@ await route.toggle(true);
472
472
 
473
473
  Use `@serve.zone/dcrouter/interfaces` or `@serve.zone/dcrouter-interfaces` when you want dcrouter-local raw TypedRequest contracts instead of resource managers. Use `@serve.zone/interfaces` for the canonical machine-facing gateway client route, DNS, domain, and mail contracts shared with Onebox and Cloudly.
474
474
 
475
- Gateway-client mail contracts let Cloudly or Onebox claim exact app addresses, attach inbound `smtpForward` targets, enable managed outbound SMTP credentials, rotate those credentials, enqueue service mail, and query delivery status through TypedRequest. Managed SMTP users can only send as their exact claimed address; mismatched envelope or header `From` values are rejected before relay. Delivery status queries return the dcrouter spool item state, including deferred SmartMTA errors and next retry timestamps when outbound delivery is temporarily delayed.
475
+ Gateway-client mail contracts let Cloudly or Onebox claim exact app addresses, attach inbound `smtpForward` targets, enable managed outbound SMTP credentials, rotate those credentials, enqueue service mail, and query delivery status through TypedRequest. `getGatewayClientMailDomainCount` requires an authenticated, enabled gateway-client credential with the `gateway-clients:read` scope and `readMail` capability. It returns only that credential owner's distinct domain count; it does not accept a caller-selected owner and does not load bindings, DNS details, or recent mail. Managed SMTP users can only send as their exact claimed address; mismatched envelope or header `From` values are rejected before relay. Typed submissions may set `replyTo` to one bare printable-ASCII mailbox address. Dcrouter validates it authoritatively and renders one `Reply-To` header; custom headers remain unable to override `Reply-To` or any other protected MIME field. Delivery status queries return the dcrouter spool item state, including deferred SmartMTA errors and next retry timestamps when outbound delivery is temporarily delayed.
476
476
 
477
477
  ### Email Operations Views
478
478
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '18.4.1',
6
+ version: '18.6.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -42,7 +42,7 @@ import { DomainOwnershipError } from './dns/domain-ownership.js';
42
42
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
43
43
  import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
44
44
  import { AcmePermanentFailureError, classifyAcmeFailure } from './acme/acme-failure-classification.js';
45
- import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
45
+ import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, GatewayMailDomainStore, MailDnsSync, MailEdgeEligibility, MailEgressCoordinator, ReleasedRemoteIngressEgressIdentitySource, SmartMtaBlobStorageManager, SmartMtaStorageManager, SmtpAccountManager, WorkAppMailManager, type ISmartMtaBlobStorageConfig, type TSmartMtaQueueItemLike } from './email/index.js';
46
46
  import { WebPushManager } from './webpush/index.js';
47
47
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
48
48
  import type { IEmailOutboundEgressStatus, IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailOutboundMode, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
@@ -355,6 +355,7 @@ export class DcRouter {
355
355
  public acmeConfigManager?: AcmeConfigManager;
356
356
  public emailSettingsManager?: EmailSettingsManager;
357
357
  public emailDomainManager?: EmailDomainManager;
358
+ public gatewayMailDomainStore: GatewayMailDomainStore;
358
359
  public workAppMailManager: WorkAppMailManager;
359
360
  /** Single composition owner of the email runtime auth block and route list. */
360
361
  public smtpAccountManager: SmtpAccountManager;
@@ -427,6 +428,7 @@ export class DcRouter {
427
428
 
428
429
  // Resolve all data paths from baseDir
429
430
  this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
431
+ this.gatewayMailDomainStore = new GatewayMailDomainStore();
430
432
  this.workAppMailManager = new WorkAppMailManager(this);
431
433
  this.smtpAccountManager = new SmtpAccountManager(this);
432
434
  this.acceptedEmailSpool = new AcceptedEmailSpool(this);
@@ -0,0 +1,42 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+ import type { TGatewayClientType } from '../../../ts_interfaces/data/route-management.js';
4
+ import type { IStoredWorkAppMailIdentity } from '../../email/gateway-mail-domain-authority.js';
5
+
6
+ const getDb = () => DcRouterDb.getInstance().getDb();
7
+
8
+ /**
9
+ * Authoritative WorkApp mail identity storage, grouped so one document equals
10
+ * one distinct gateway-client-owned mail domain.
11
+ */
12
+ @plugins.smartdata.Collection(() => getDb())
13
+ export class GatewayMailDomainDoc extends plugins.smartdata.SmartDataDbDoc<
14
+ GatewayMailDomainDoc,
15
+ GatewayMailDomainDoc
16
+ > {
17
+ @plugins.smartdata.unI()
18
+ @plugins.smartdata.svDb()
19
+ public id!: string;
20
+
21
+ @plugins.smartdata.index()
22
+ @plugins.smartdata.svDb()
23
+ public ownerGatewayClientType!: TGatewayClientType;
24
+
25
+ @plugins.smartdata.index()
26
+ @plugins.smartdata.svDb()
27
+ public ownerGatewayClientId!: string;
28
+
29
+ @plugins.smartdata.index()
30
+ @plugins.smartdata.svDb()
31
+ public domain!: string;
32
+
33
+ @plugins.smartdata.svDb()
34
+ public identities: IStoredWorkAppMailIdentity[] = [];
35
+
36
+ @plugins.smartdata.svDb()
37
+ public createdAt!: number;
38
+
39
+ @plugins.smartdata.svDb()
40
+ public updatedAt!: number;
41
+ }
42
+
@@ -43,6 +43,7 @@ export * from './classes.acme-config.doc.js';
43
43
 
44
44
  // Email domain management
45
45
  export * from './classes.email-domain.doc.js';
46
+ export * from './classes.gateway-mail-domain.doc.js';
46
47
  export * from './classes.email-server-settings.doc.js';
47
48
  export * from './classes.smtp-account.doc.js';
48
49
 
@@ -0,0 +1,89 @@
1
+ import { DcRouterDb } from '../db/classes.dcrouter-db.js';
2
+ import { GatewayMailDomainDoc } from '../db/documents/classes.gateway-mail-domain.doc.js';
3
+ import {
4
+ createGatewayMailDomainId,
5
+ groupGatewayMailIdentities,
6
+ normalizeGatewayMailDomain,
7
+ normalizeGatewayMailDomainOwner,
8
+ type IGatewayMailDomainOwner,
9
+ type IStoredWorkAppMailIdentity,
10
+ } from './gateway-mail-domain-authority.js';
11
+
12
+ export interface IGatewayMailDomainStore {
13
+ readAllIdentities(): Promise<IStoredWorkAppMailIdentity[]>;
14
+ writeAllIdentities(identitiesArg: IStoredWorkAppMailIdentity[]): Promise<IStoredWorkAppMailIdentity[]>;
15
+ countDomains(ownerArg: IGatewayMailDomainOwner): Promise<number>;
16
+ }
17
+
18
+ const validateDocument = (documentArg: GatewayMailDomainDoc): IStoredWorkAppMailIdentity[] => {
19
+ const owner = normalizeGatewayMailDomainOwner({
20
+ gatewayClientType: documentArg.ownerGatewayClientType,
21
+ gatewayClientId: documentArg.ownerGatewayClientId,
22
+ });
23
+ const domain = normalizeGatewayMailDomain(documentArg.domain);
24
+ const expectedId = createGatewayMailDomainId(owner, domain);
25
+ if (documentArg.id !== expectedId) {
26
+ throw new Error(`GatewayMailDomainDoc has a non-canonical id: ${documentArg.id}`);
27
+ }
28
+ const groups = groupGatewayMailIdentities(documentArg.identities || []);
29
+ if (groups.length !== 1 || groups[0].id !== expectedId) {
30
+ throw new Error(`GatewayMailDomainDoc identities do not match owner/domain: ${documentArg.id}`);
31
+ }
32
+ return groups[0].identities;
33
+ };
34
+
35
+ export class GatewayMailDomainStore implements IGatewayMailDomainStore {
36
+ public async readAllIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
37
+ const documents = await GatewayMailDomainDoc.getInstances({});
38
+ const identities = documents.flatMap((documentArg) => validateDocument(documentArg));
39
+ return groupGatewayMailIdentities(identities).flatMap((groupArg) => groupArg.identities);
40
+ }
41
+
42
+ public async writeAllIdentities(
43
+ identitiesArg: IStoredWorkAppMailIdentity[],
44
+ ): Promise<IStoredWorkAppMailIdentity[]> {
45
+ const desiredGroups = groupGatewayMailIdentities(identitiesArg);
46
+ const desiredById = new Map(desiredGroups.map((groupArg) => [groupArg.id, groupArg]));
47
+ const db = DcRouterDb.getInstance().getDb();
48
+ const session = db.startSession();
49
+ try {
50
+ await session.withTransaction(async () => {
51
+ const existingDocuments = await GatewayMailDomainDoc.getInstances({}, { session });
52
+ const existingById = new Map(existingDocuments.map((documentArg) => {
53
+ validateDocument(documentArg);
54
+ return [documentArg.id, documentArg];
55
+ }));
56
+ const now = Date.now();
57
+
58
+ for (const group of desiredGroups) {
59
+ const document = existingById.get(group.id) || new GatewayMailDomainDoc();
60
+ document.id = group.id;
61
+ document.ownerGatewayClientType = group.gatewayClientType;
62
+ document.ownerGatewayClientId = group.gatewayClientId;
63
+ document.domain = group.domain;
64
+ document.identities = group.identities;
65
+ document.createdAt = document.createdAt || now;
66
+ document.updatedAt = now;
67
+ await document.save({ session });
68
+ }
69
+ for (const existingDocument of existingDocuments) {
70
+ if (!desiredById.has(existingDocument.id)) {
71
+ await existingDocument.delete({ session });
72
+ }
73
+ }
74
+ });
75
+ } finally {
76
+ await session.endSession();
77
+ }
78
+
79
+ return await this.readAllIdentities();
80
+ }
81
+
82
+ public async countDomains(ownerArg: IGatewayMailDomainOwner): Promise<number> {
83
+ const owner = normalizeGatewayMailDomainOwner(ownerArg);
84
+ return await GatewayMailDomainDoc.getCount({
85
+ ownerGatewayClientType: owner.gatewayClientType,
86
+ ownerGatewayClientId: owner.gatewayClientId,
87
+ });
88
+ }
89
+ }
@@ -4,7 +4,6 @@ import type {
4
4
  IMessageAcceptanceContext,
5
5
  IMessageAcceptanceDecision,
6
6
  IUnifiedEmailServerOptions,
7
- IStorageManagerLike,
8
7
  } from '@push.rocks/smartmta';
9
8
  import type { Buffer } from 'node:buffer';
10
9
  import * as plugins from '../plugins.js';
@@ -12,6 +11,12 @@ import { CachedEmail } from '../db/index.js';
12
11
  import type { ICachedEmailSmtpTransaction } from '../db/documents/classes.cached.email.js';
13
12
  import type { DcRouter } from '../classes.dcrouter.js';
14
13
  import type * as interfaces from '../../ts_interfaces/index.js';
14
+ import { GatewayMailDomainStore, type IGatewayMailDomainStore } from './classes.gateway-mail-domain.store.js';
15
+ import {
16
+ normalizeGatewayMailDomain,
17
+ normalizeGatewayMailDomainOwner,
18
+ type IStoredWorkAppMailIdentity,
19
+ } from './gateway-mail-domain-authority.js';
15
20
 
16
21
  type TSyncRequest = {
17
22
  ownership: interfaces.data.IWorkAppMailOwnership;
@@ -32,6 +37,7 @@ type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail
32
37
  type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
33
38
  type TMailCredentialRotateResponse = plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential['response'];
34
39
  type TMailEnqueueResponse = plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail['response'];
40
+ type TMailSubmissionErrorCode = plugins.servezoneInterfaces.data.TMailSubmissionErrorCode;
35
41
  type TMailDeliveryStatusResponse = plugins.servezoneInterfaces.requests.mail.IReq_GetMailDeliveryStatus['response'];
36
42
  type TMailEndpointRegistrationResponse = plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response'];
37
43
  type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
@@ -66,31 +72,14 @@ type TCachedEmailRouteData = {
66
72
  };
67
73
  };
68
74
 
69
- interface IStoredWorkAppMailIdentity {
70
- id: string;
71
- externalKey: string;
72
- ownership: interfaces.data.IWorkAppMailOwnership;
73
- address: string;
74
- localPart: string;
75
- domain: string;
76
- enabled: boolean;
77
- displayName?: string;
78
- inbound?: interfaces.data.IWorkAppMailInboundRoute;
79
- inboundTarget?: plugins.servezoneInterfaces.data.IMailInboundTarget;
80
- smtp: {
81
- enabled: boolean;
82
- username: string;
83
- };
84
- createdAt: number;
85
- updatedAt: number;
86
- createdBy: string;
87
- smtpPassword: string;
88
- smtpLastRotatedAt?: number;
89
- }
90
-
91
- interface IStoredWorkAppMailState {
92
- version: 1;
93
- identities: IStoredWorkAppMailIdentity[];
75
+ class MailSubmissionValidationError extends Error {
76
+ constructor(
77
+ public readonly code: TMailSubmissionErrorCode,
78
+ messageArg: string,
79
+ ) {
80
+ super(messageArg);
81
+ this.name = 'MailSubmissionValidationError';
82
+ }
94
83
  }
95
84
 
96
85
  /**
@@ -114,10 +103,13 @@ export function isWorkAppManagedSmtpUsername(username: string): boolean {
114
103
  }
115
104
 
116
105
  export class WorkAppMailManager {
117
- private readonly storageKey = '/workhosters/mail-identities.json';
106
+ private readonly emailValidator = new plugins.smartmta.Core.EmailValidator();
107
+ private readonly gatewayMailDomainStore: IGatewayMailDomainStore;
118
108
  private identityMutationPromise: Promise<unknown> = Promise.resolve();
119
109
 
120
- constructor(private dcRouterRef: DcRouter) {}
110
+ constructor(private dcRouterRef: DcRouter) {
111
+ this.gatewayMailDomainStore = dcRouterRef.gatewayMailDomainStore || new GatewayMailDomainStore();
112
+ }
121
113
 
122
114
  public async listMailIdentities(
123
115
  ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
@@ -164,8 +156,8 @@ export class WorkAppMailManager {
164
156
  return { success: true, action: 'unchanged' };
165
157
  }
166
158
  const [deletedIdentity] = identities.splice(existingIndex, 1);
167
- await this.writeStoredIdentities(identities);
168
- await this.applyStoredIdentitiesToRuntime(identities);
159
+ const persistedIdentities = await this.writeStoredIdentities(identities);
160
+ await this.applyStoredIdentitiesToRuntime(persistedIdentities);
169
161
  return {
170
162
  success: true,
171
163
  action: 'deleted',
@@ -215,8 +207,8 @@ export class WorkAppMailManager {
215
207
  identities.push(identity);
216
208
  }
217
209
 
218
- await this.writeStoredIdentities(identities);
219
- await this.applyStoredIdentitiesToRuntime(identities);
210
+ const persistedIdentities = await this.writeStoredIdentities(identities);
211
+ await this.applyStoredIdentitiesToRuntime(persistedIdentities);
220
212
 
221
213
  const response: interfaces.data.IWorkAppMailIdentitySyncResult = {
222
214
  success: true,
@@ -267,6 +259,14 @@ export class WorkAppMailManager {
267
259
  return Array.from(groups.values()).map((group) => this.toWorkAppMailBinding(group));
268
260
  }
269
261
 
262
+ public async countMailDomains(
263
+ ownerArg: Pick<TMailResourceOwner, 'gatewayClientType' | 'gatewayClientId'>,
264
+ ): Promise<number> {
265
+ return await this.gatewayMailDomainStore.countDomains(
266
+ normalizeGatewayMailDomainOwner(ownerArg),
267
+ );
268
+ }
269
+
270
270
  public async syncMailAddressBinding(
271
271
  binding: TMailAddressBindingSync,
272
272
  createdBy: string,
@@ -357,12 +357,14 @@ export class WorkAppMailManager {
357
357
  updatedAt: now,
358
358
  };
359
359
 
360
- await this.writeStoredIdentities(identities);
361
- await this.applyStoredIdentitiesToRuntime(identities);
360
+ const persistedIdentities = await this.writeStoredIdentities(identities);
361
+ await this.applyStoredIdentitiesToRuntime(persistedIdentities);
362
362
 
363
363
  return {
364
364
  success: true,
365
- credential: this.toMailCredentialSecret(identities[identityIndex]),
365
+ credential: this.toMailCredentialSecret(
366
+ persistedIdentities.find((identityArg) => identityArg.id === identity.id)!,
367
+ ),
366
368
  };
367
369
  }
368
370
 
@@ -459,6 +461,7 @@ export class WorkAppMailManager {
459
461
  } catch (error) {
460
462
  return {
461
463
  accepted: false,
464
+ ...(error instanceof MailSubmissionValidationError ? { errorCode: error.code } : {}),
462
465
  message: (error as Error).message,
463
466
  };
464
467
  }
@@ -722,36 +725,13 @@ export class WorkAppMailManager {
722
725
  }
723
726
 
724
727
  private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
725
- const storageManager = this.getRequiredStorageManager();
726
- const storedData = await storageManager.get(this.storageKey);
727
- if (!storedData) return [];
728
- const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
729
- return Array.isArray(parsed) ? parsed : parsed.identities || [];
728
+ return await this.gatewayMailDomainStore.readAllIdentities();
730
729
  }
731
730
 
732
- private async writeStoredIdentities(identities: IStoredWorkAppMailIdentity[]): Promise<void> {
733
- const state: IStoredWorkAppMailState = {
734
- version: 1,
735
- identities,
736
- };
737
- const storageManager = this.getRequiredStorageManager();
738
- await storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
739
- }
740
-
741
- private getRequiredStorageManager(): IStorageManagerLike & Required<
742
- Pick<IStorageManagerLike, 'get' | 'set'>
743
- > {
744
- const storageManager = this.dcRouterRef.storageManager;
745
- if (
746
- !storageManager
747
- || typeof storageManager.get !== 'function'
748
- || typeof storageManager.set !== 'function'
749
- ) {
750
- throw new Error('DcRouter storage manager does not support managed mail identity persistence');
751
- }
752
- return storageManager as IStorageManagerLike & Required<
753
- Pick<IStorageManagerLike, 'get' | 'set'>
754
- >;
731
+ private async writeStoredIdentities(
732
+ identities: IStoredWorkAppMailIdentity[],
733
+ ): Promise<IStoredWorkAppMailIdentity[]> {
734
+ return await this.gatewayMailDomainStore.writeAllIdentities(identities);
755
735
  }
756
736
 
757
737
  private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
@@ -898,11 +878,7 @@ export class WorkAppMailManager {
898
878
  }
899
879
 
900
880
  private normalizeDomain(domain: string): string {
901
- const normalized = domain?.trim().toLowerCase();
902
- if (!normalized || normalized.includes('@') || !normalized.includes('.')) {
903
- throw new Error(`Invalid email domain: ${domain}`);
904
- }
905
- return normalized;
881
+ return normalizeGatewayMailDomain(domain);
906
882
  }
907
883
 
908
884
  private normalizeLocalPart(localPart: string): string {
@@ -1341,11 +1317,23 @@ export class WorkAppMailManager {
1341
1317
  if (!messageArg.text && !messageArg.html) {
1342
1318
  throw new Error('Either text or html is required');
1343
1319
  }
1320
+ const customReplyToHeader = Object.keys(messageArg.headers || {})
1321
+ .find((keyArg) => keyArg.toLowerCase() === 'reply-to');
1322
+ if (messageArg.replyTo !== undefined && customReplyToHeader) {
1323
+ throw new MailSubmissionValidationError(
1324
+ 'REPLY_TO_FIELD_HEADER_CONFLICT',
1325
+ 'replyTo cannot be combined with a custom Reply-To header',
1326
+ );
1327
+ }
1328
+ const replyTo = messageArg.replyTo === undefined
1329
+ ? undefined
1330
+ : this.validateReplyTo(messageArg.replyTo);
1344
1331
  const messageId = `<${plugins.crypto.randomUUID()}@${identityArg.domain}>`;
1345
1332
  const headers: Record<string, string> = {
1346
1333
  From: identityArg.address,
1347
1334
  To: messageArg.to.join(', '),
1348
1335
  ...(messageArg.cc?.length ? { Cc: messageArg.cc.join(', ') } : {}),
1336
+ ...(replyTo ? { 'Reply-To': replyTo } : {}),
1349
1337
  Subject: messageArg.subject,
1350
1338
  Date: new Date().toUTCString(),
1351
1339
  'Message-ID': messageId,
@@ -1521,6 +1509,40 @@ export class WorkAppMailManager {
1521
1509
  ]).has(valueArg.toLowerCase());
1522
1510
  }
1523
1511
 
1512
+ private validateReplyTo(valueArg: unknown): string {
1513
+ const invalid = (): never => {
1514
+ throw new MailSubmissionValidationError(
1515
+ 'INVALID_REPLY_TO',
1516
+ 'replyTo must be one bare printable ASCII mailbox address',
1517
+ );
1518
+ };
1519
+ if (
1520
+ typeof valueArg !== 'string'
1521
+ || valueArg.length === 0
1522
+ || valueArg.length > 254
1523
+ || !/^[\x21-\x7e]+$/.test(valueArg)
1524
+ || /[(),:;<>"[\]\\]/.test(valueArg)
1525
+ ) {
1526
+ return invalid();
1527
+ }
1528
+ const parts = valueArg.split('@');
1529
+ if (parts.length !== 2) return invalid();
1530
+ const [localPart, domain] = parts;
1531
+ if (!localPart || localPart.length > 64 || !domain) return invalid();
1532
+ const domainLabels = domain.split('.');
1533
+ if (
1534
+ domainLabels.some((labelArg) => (
1535
+ labelArg.length === 0
1536
+ || labelArg.length > 63
1537
+ || !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(labelArg)
1538
+ ))
1539
+ || !this.emailValidator.isValidFormat(valueArg)
1540
+ ) {
1541
+ return invalid();
1542
+ }
1543
+ return valueArg;
1544
+ }
1545
+
1524
1546
  private sanitizeHeaderValue(valueArg: string): string {
1525
1547
  return String(valueArg).replace(/[\r\n]+/g, ' ').trim();
1526
1548
  }