@serve.zone/dcrouter 15.4.1 → 15.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.
@@ -1,11 +1,14 @@
1
1
  import type {
2
2
  IEmailRoute,
3
+ IExtendedSmtpSession,
3
4
  IMessageAcceptanceContext,
4
5
  IMessageAcceptanceDecision,
5
6
  IUnifiedEmailServerOptions,
6
7
  } from '@push.rocks/smartmta';
7
8
  import type { Buffer } from 'node:buffer';
8
9
  import * as plugins from '../plugins.js';
10
+ import type { CachedEmail } from '../db/index.js';
11
+ import type { DcRouter } from '../classes.dcrouter.js';
9
12
  import type * as interfaces from '../../ts_interfaces/index.js';
10
13
 
11
14
  type TSyncRequest = {
@@ -14,6 +17,7 @@ type TSyncRequest = {
14
17
  domain: string;
15
18
  displayName?: string;
16
19
  inbound?: interfaces.data.IWorkAppMailInboundRoute;
20
+ inboundTarget?: plugins.servezoneInterfaces.data.IMailInboundTarget;
17
21
  enabled?: boolean;
18
22
  smtpEnabled?: boolean;
19
23
  resetSmtpPassword?: boolean;
@@ -25,6 +29,8 @@ type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAd
25
29
  type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
26
30
  type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
27
31
  type TMailCredentialRotateResponse = plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential['response'];
32
+ type TMailEnqueueResponse = plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail['response'];
33
+ type TMailEndpointRegistrationResponse = plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response'];
28
34
  type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
29
35
 
30
36
  interface IStoredWorkAppMailIdentity {
@@ -37,6 +43,7 @@ interface IStoredWorkAppMailIdentity {
37
43
  enabled: boolean;
38
44
  displayName?: string;
39
45
  inbound?: interfaces.data.IWorkAppMailInboundRoute;
46
+ inboundTarget?: plugins.servezoneInterfaces.data.IMailInboundTarget;
40
47
  smtp: {
41
48
  enabled: boolean;
42
49
  username: string;
@@ -57,7 +64,7 @@ export class WorkAppMailManager {
57
64
  private readonly storageKey = '/workhosters/mail-identities.json';
58
65
  private identityMutationPromise: Promise<unknown> = Promise.resolve();
59
66
 
60
- constructor(private dcRouterRef: any) {}
67
+ constructor(private dcRouterRef: DcRouter) {}
61
68
 
62
69
  public async listMailIdentities(
63
70
  ownership?: Partial<interfaces.data.IWorkAppMailOwnership>,
@@ -130,7 +137,8 @@ export class WorkAppMailManager {
130
137
  domain,
131
138
  enabled: request.enabled ?? existingIdentity?.enabled ?? true,
132
139
  displayName: request.displayName ?? existingIdentity?.displayName,
133
- inbound: this.normalizeInboundRoute(request.inbound ?? existingIdentity?.inbound),
140
+ inboundTarget: this.resolveNextInboundTarget(request, existingIdentity),
141
+ inbound: this.toLegacyInboundRouteIfSmtpForward(this.resolveNextInboundTarget(request, existingIdentity)),
134
142
  smtp: {
135
143
  enabled: request.smtpEnabled ?? existingIdentity?.smtp.enabled ?? true,
136
144
  username: existingIdentity?.smtp.username || this.buildSmtpUsername(externalKey),
@@ -210,7 +218,8 @@ export class WorkAppMailManager {
210
218
  ownership,
211
219
  localPart,
212
220
  domain,
213
- inbound: this.toLegacyInboundRoute(binding.inboundTarget),
221
+ inboundTarget: binding.inboundTarget,
222
+ inbound: this.toLegacyInboundRouteIfSmtpForward(binding.inboundTarget),
214
223
  enabled: binding.enabled,
215
224
  };
216
225
 
@@ -320,6 +329,141 @@ export class WorkAppMailManager {
320
329
  return undefined;
321
330
  }
322
331
 
332
+ public async enqueueMail(
333
+ authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
334
+ messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload,
335
+ outboundIdentityIdArg?: string,
336
+ ): Promise<TMailEnqueueResponse> {
337
+ if (!this.dcRouterRef.emailServer) {
338
+ return { accepted: false, message: 'Email server is not running' };
339
+ }
340
+ try {
341
+ const identity = await this.authenticateMailCredential(
342
+ authArg,
343
+ messageArg.from,
344
+ outboundIdentityIdArg,
345
+ );
346
+ const rawMessage = this.buildOutboundRawMessage(identity, messageArg);
347
+ const recipients = this.getMessageRecipients(messageArg);
348
+ const session = this.buildAuthenticatedSession(identity, messageArg.from, recipients, rawMessage);
349
+ const acceptance = await this.dcRouterRef.acceptedEmailSpool.acceptRawMessage({
350
+ rawMessage,
351
+ envelope: {
352
+ mailFrom: identity.address,
353
+ rcptTo: recipients,
354
+ },
355
+ session,
356
+ messageId: this.extractHeader(rawMessage, 'message-id'),
357
+ subject: messageArg.subject,
358
+ });
359
+ return {
360
+ accepted: acceptance.accepted,
361
+ spoolItemId: acceptance.spoolItemId,
362
+ outboundIdentity: this.toMailOutboundIdentity(identity),
363
+ message: acceptance.smtpMessage,
364
+ };
365
+ } catch (error) {
366
+ return {
367
+ accepted: false,
368
+ message: (error as Error).message,
369
+ };
370
+ }
371
+ }
372
+
373
+ public async registerServiceMailEndpoint(
374
+ authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
375
+ peerArg: { tags?: Set<string> },
376
+ addressesArg?: string[],
377
+ ): Promise<TMailEndpointRegistrationResponse> {
378
+ if (!peerArg?.tags) {
379
+ return { success: false, message: 'TypedSocket peer context is required' };
380
+ }
381
+ try {
382
+ const identity = await this.authenticateMailCredential(authArg);
383
+ const requestedAddresses = addressesArg?.length
384
+ ? addressesArg.map((addressArg) => this.normalizeAddress(addressArg))
385
+ : [identity.address];
386
+ const registeredAddresses: string[] = [];
387
+ for (const address of requestedAddresses) {
388
+ const addressIdentity = await this.authenticateMailCredential(authArg, address);
389
+ peerArg.tags.add(this.buildServiceMailAddressTag(addressIdentity.address));
390
+ registeredAddresses.push(addressIdentity.address);
391
+ }
392
+ return { success: true, addresses: registeredAddresses };
393
+ } catch (error) {
394
+ return { success: false, message: (error as Error).message };
395
+ }
396
+ }
397
+
398
+ public async deliverCachedEmailToTypedEndpoint(
399
+ cachedEmailArg: CachedEmail,
400
+ rawMessageArg: Buffer | plugins.buffer.Buffer,
401
+ sessionArg: IExtendedSmtpSession,
402
+ ): Promise<boolean> {
403
+ const identities = await this.readStoredIdentities();
404
+ const recipientIdentity = identities.find((identityArg) => {
405
+ if (!identityArg.enabled) return false;
406
+ const inboundTarget = this.getCanonicalInboundTarget(identityArg);
407
+ return inboundTarget?.type === 'typedEndpoint'
408
+ && cachedEmailArg.to.some((recipientArg) => this.normalizeAddress(recipientArg) === identityArg.address);
409
+ });
410
+ if (!recipientIdentity) return false;
411
+
412
+ const inboundTarget = this.getCanonicalInboundTarget(recipientIdentity)!;
413
+ const typedEndpoint = inboundTarget.typedEndpoint;
414
+ if (!typedEndpoint?.method) {
415
+ throw new Error(`Typed mail endpoint method is missing for ${recipientIdentity.address}`);
416
+ }
417
+ const typedsocket = this.dcRouterRef.opsServer?.server?.typedserver?.typedsocket;
418
+ if (!typedsocket) {
419
+ throw new Error('OpsServer TypedSocket is not available for inbound mail delivery');
420
+ }
421
+ const connection = await typedsocket.findTargetConnectionByTag(
422
+ this.buildServiceMailAddressTag(recipientIdentity.address) as any,
423
+ );
424
+ if (!connection) {
425
+ throw new Error(`No registered typed mail endpoint for ${recipientIdentity.address}`);
426
+ }
427
+
428
+ const rawMessage = plugins.buffer.Buffer.isBuffer(rawMessageArg)
429
+ ? rawMessageArg.toString('utf8')
430
+ : plugins.buffer.Buffer.from(rawMessageArg).toString('utf8');
431
+ const delivery: plugins.servezoneInterfaces.data.IMailInboundMessagePayload = {
432
+ spoolItemId: cachedEmailArg.id,
433
+ owner: this.toMailOwner(recipientIdentity.ownership),
434
+ envelope: {
435
+ mailFrom: cachedEmailArg.from,
436
+ rcptTo: cachedEmailArg.to,
437
+ },
438
+ rawMessage,
439
+ sizeBytes: plugins.buffer.Buffer.byteLength(rawMessage),
440
+ messageId: cachedEmailArg.messageId,
441
+ subject: cachedEmailArg.subject,
442
+ headers: this.extractHeaders(rawMessage),
443
+ source: {
444
+ protocol: sessionArg.authenticated ? 'submission' : 'smtp',
445
+ remoteAddress: sessionArg.remoteAddress,
446
+ heloName: sessionArg.clientHostname,
447
+ tls: !!sessionArg.secure,
448
+ authUsername: sessionArg.user?.username,
449
+ },
450
+ receivedAt: Date.now(),
451
+ };
452
+
453
+ const request = typedsocket.createTypedRequest<plugins.servezoneInterfaces.requests.mail.IReq_DeliverInboundMail>(
454
+ typedEndpoint.method as 'deliverInboundMail',
455
+ connection,
456
+ );
457
+ const result = await request.fire({
458
+ auth: {},
459
+ delivery,
460
+ });
461
+ if (!result.accepted) {
462
+ throw new Error(result.message || `Typed mail endpoint rejected ${cachedEmailArg.id}`);
463
+ }
464
+ return true;
465
+ }
466
+
323
467
  public async deleteMailAddressBinding(
324
468
  id: string,
325
469
  createdBy: string,
@@ -378,7 +522,8 @@ export class WorkAppMailManager {
378
522
  }
379
523
 
380
524
  private async readStoredIdentities(): Promise<IStoredWorkAppMailIdentity[]> {
381
- const storedData = await this.dcRouterRef.storageManager.get(this.storageKey);
525
+ const storageManager = this.getRequiredStorageManager();
526
+ const storedData = await storageManager.get(this.storageKey);
382
527
  if (!storedData) return [];
383
528
  const parsed = JSON.parse(storedData) as IStoredWorkAppMailState | IStoredWorkAppMailIdentity[];
384
529
  return Array.isArray(parsed) ? parsed : parsed.identities || [];
@@ -389,7 +534,15 @@ export class WorkAppMailManager {
389
534
  version: 1,
390
535
  identities,
391
536
  };
392
- await this.dcRouterRef.storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
537
+ const storageManager = this.getRequiredStorageManager();
538
+ await storageManager.set(this.storageKey, JSON.stringify(state, null, 2));
539
+ }
540
+
541
+ private getRequiredStorageManager() {
542
+ if (!plugins.smartmta.hasStorageManagerMethods(this.dcRouterRef.storageManager, ['get', 'set'])) {
543
+ throw new Error('DcRouter storage manager does not support managed mail identity persistence');
544
+ }
545
+ return this.dcRouterRef.storageManager;
393
546
  }
394
547
 
395
548
  private mergeIdentitiesIntoEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
@@ -397,7 +550,10 @@ export class WorkAppMailManager {
397
550
  identities: IStoredWorkAppMailIdentity[],
398
551
  ): TConfig {
399
552
  const generatedRoutes = identities
400
- .filter((identity) => identity.enabled && identity.inbound?.enabled)
553
+ .filter((identity) => {
554
+ const inboundTarget = this.getCanonicalInboundTarget(identity);
555
+ return identity.enabled && ['smtpForward', 'typedEndpoint'].includes(inboundTarget?.type || '');
556
+ })
401
557
  .map((identity) => this.buildInboundRoute(identity));
402
558
  const generatedOutboundRoutes = identities
403
559
  .filter((identity) => identity.enabled && identity.smtp.enabled)
@@ -424,7 +580,27 @@ export class WorkAppMailManager {
424
580
  }
425
581
 
426
582
  private buildInboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
427
- const inbound = identity.inbound!;
583
+ const inboundTarget = this.getCanonicalInboundTarget(identity);
584
+ if (inboundTarget?.type === 'typedEndpoint') {
585
+ return {
586
+ name: this.buildRouteName(identity.externalKey),
587
+ priority: 1000,
588
+ match: {
589
+ recipients: identity.address,
590
+ },
591
+ action: {
592
+ type: 'process',
593
+ process: {
594
+ scan: true,
595
+ queue: 'normal',
596
+ },
597
+ },
598
+ };
599
+ }
600
+ const smtpForward = inboundTarget?.smtpForward;
601
+ if (inboundTarget?.type !== 'smtpForward' || !smtpForward) {
602
+ throw new Error(`SMTP forward target is missing for ${identity.address}`);
603
+ }
428
604
  return {
429
605
  name: this.buildRouteName(identity.externalKey),
430
606
  priority: 1000,
@@ -434,14 +610,14 @@ export class WorkAppMailManager {
434
610
  action: {
435
611
  type: 'forward',
436
612
  forward: {
437
- host: inbound.targetHost,
438
- port: inbound.targetPort,
439
- preserveHeaders: inbound.preserveHeaders ?? true,
613
+ host: smtpForward.host,
614
+ port: smtpForward.port,
615
+ preserveHeaders: smtpForward.preserveHeaders ?? true,
440
616
  addHeaders: {
441
617
  'X-Dcrouter-WorkHoster-Type': identity.ownership.workHosterType,
442
618
  'X-Dcrouter-WorkHoster-Id': identity.ownership.workHosterId,
443
619
  'X-Dcrouter-WorkApp-Id': identity.ownership.workAppId,
444
- ...(inbound.addHeaders || {}),
620
+ ...(smtpForward.addHeaders || {}),
445
621
  },
446
622
  },
447
623
  },
@@ -649,6 +825,334 @@ export class WorkAppMailManager {
649
825
  };
650
826
  }
651
827
 
828
+ private toLegacyInboundRouteIfSmtpForward(
829
+ inboundTarget?: plugins.servezoneInterfaces.data.IMailInboundTarget,
830
+ ): interfaces.data.IWorkAppMailInboundRoute | undefined {
831
+ if (!inboundTarget || inboundTarget.type !== 'smtpForward') return undefined;
832
+ return this.toLegacyInboundRoute(inboundTarget);
833
+ }
834
+
835
+ private resolveNextInboundTarget(
836
+ request: TSyncRequest,
837
+ existingIdentity?: IStoredWorkAppMailIdentity,
838
+ ): plugins.servezoneInterfaces.data.IMailInboundTarget | undefined {
839
+ if (request.inboundTarget !== undefined) {
840
+ return this.normalizeInboundTarget(request.inboundTarget);
841
+ }
842
+ if (request.inbound !== undefined) {
843
+ const inbound = this.normalizeInboundRoute(request.inbound);
844
+ return inbound ? this.toMailInboundTarget(inbound) : undefined;
845
+ }
846
+ return this.normalizeInboundTarget(
847
+ existingIdentity?.inboundTarget || this.toMailInboundTarget(existingIdentity?.inbound),
848
+ );
849
+ }
850
+
851
+ private normalizeInboundTarget(
852
+ inboundTarget?: plugins.servezoneInterfaces.data.IMailInboundTarget,
853
+ ): plugins.servezoneInterfaces.data.IMailInboundTarget | undefined {
854
+ if (!inboundTarget) return undefined;
855
+ if (inboundTarget.type === 'smtpForward') {
856
+ const smtpForward = inboundTarget.smtpForward;
857
+ if (!smtpForward?.host) throw new Error('smtpForward.host is required');
858
+ const port = Number(smtpForward.port);
859
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
860
+ throw new Error(`Invalid smtpForward.port: ${smtpForward.port}`);
861
+ }
862
+ return {
863
+ type: 'smtpForward',
864
+ smtpForward: {
865
+ ...smtpForward,
866
+ host: smtpForward.host.trim(),
867
+ port,
868
+ },
869
+ };
870
+ }
871
+ if (inboundTarget.type === 'typedEndpoint') {
872
+ const typedEndpoint = inboundTarget.typedEndpoint;
873
+ return {
874
+ type: 'typedEndpoint',
875
+ typedEndpoint: {
876
+ method: typedEndpoint?.method?.trim() || 'deliverInboundMail',
877
+ ...(typedEndpoint?.serviceRef ? { serviceRef: typedEndpoint.serviceRef } : {}),
878
+ ...(typedEndpoint?.queueName ? { queueName: typedEndpoint.queueName } : {}),
879
+ ...(typedEndpoint?.timeoutMs !== undefined ? { timeoutMs: typedEndpoint.timeoutMs } : {}),
880
+ },
881
+ };
882
+ }
883
+ throw new Error(`Unsupported WorkApp mail inbound target: ${inboundTarget.type}`);
884
+ }
885
+
886
+ private getCanonicalInboundTarget(
887
+ identity: Pick<IStoredWorkAppMailIdentity, 'inbound' | 'inboundTarget'>,
888
+ ): plugins.servezoneInterfaces.data.IMailInboundTarget | undefined {
889
+ return this.normalizeInboundTarget(identity.inboundTarget || this.toMailInboundTarget(identity.inbound));
890
+ }
891
+
892
+ private async authenticateMailCredential(
893
+ authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
894
+ addressArg?: string,
895
+ credentialIdArg?: string,
896
+ ): Promise<IStoredWorkAppMailIdentity> {
897
+ const credentialId = (credentialIdArg || authArg.credentialId || '').trim();
898
+ const credentialSecret = authArg.credentialSecret || '';
899
+ if (!credentialId || !credentialSecret) {
900
+ throw new Error('credentialId and credentialSecret are required');
901
+ }
902
+ const normalizedAddress = addressArg ? this.normalizeAddress(addressArg) : undefined;
903
+ const identities = await this.readStoredIdentities();
904
+ const identity = identities.find((identityArg) => {
905
+ return identityArg.id === credentialId
906
+ || identityArg.externalKey === credentialId
907
+ || identityArg.smtp.username === credentialId;
908
+ });
909
+ if (!identity || !identity.enabled || !identity.smtp.enabled) {
910
+ throw new Error('Mail credential is not active');
911
+ }
912
+ if (identity.smtpPassword !== credentialSecret) {
913
+ throw new Error('Mail credential secret is invalid');
914
+ }
915
+ if (normalizedAddress && identity.address !== normalizedAddress) {
916
+ throw new Error('Mail credential is not authorized for this address');
917
+ }
918
+ return identity;
919
+ }
920
+
921
+ private buildAuthenticatedSession(
922
+ identityArg: IStoredWorkAppMailIdentity,
923
+ fromArg: string,
924
+ recipientsArg: string[],
925
+ rawMessageArg: string,
926
+ ): IExtendedSmtpSession {
927
+ return {
928
+ id: `typed-${plugins.crypto.randomUUID()}`,
929
+ state: 'DATA' as IExtendedSmtpSession['state'],
930
+ mailFrom: fromArg,
931
+ rcptTo: recipientsArg,
932
+ emailData: rawMessageArg,
933
+ useTLS: true,
934
+ connectionEnded: false,
935
+ remoteAddress: '127.0.0.1',
936
+ clientHostname: 'typed-service-mail',
937
+ secure: true,
938
+ authenticated: true,
939
+ user: {
940
+ username: identityArg.smtp.username,
941
+ },
942
+ envelope: {
943
+ mailFrom: { address: fromArg, args: {} },
944
+ rcptTo: recipientsArg.map((recipientArg) => ({ address: recipientArg, args: {} })),
945
+ },
946
+ } as IExtendedSmtpSession;
947
+ }
948
+
949
+ private buildOutboundRawMessage(
950
+ identityArg: IStoredWorkAppMailIdentity,
951
+ messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload,
952
+ ): string {
953
+ const from = this.normalizeAddress(messageArg.from);
954
+ if (from !== identityArg.address) {
955
+ throw new Error('Mail credential is not authorized for this From address');
956
+ }
957
+ if (!messageArg.text && !messageArg.html) {
958
+ throw new Error('Either text or html is required');
959
+ }
960
+ const messageId = `<${plugins.crypto.randomUUID()}@${identityArg.domain}>`;
961
+ const headers: Record<string, string> = {
962
+ From: identityArg.address,
963
+ To: messageArg.to.join(', '),
964
+ ...(messageArg.cc?.length ? { Cc: messageArg.cc.join(', ') } : {}),
965
+ Subject: messageArg.subject,
966
+ Date: new Date().toUTCString(),
967
+ 'Message-ID': messageId,
968
+ 'MIME-Version': '1.0',
969
+ };
970
+ for (const [keyArg, valueArg] of Object.entries(messageArg.headers || {})) {
971
+ const headerKey = this.sanitizeHeaderKey(keyArg);
972
+ if (this.isProtectedOutboundHeader(headerKey)) {
973
+ throw new Error(`Custom mail header is not allowed to override ${headerKey}`);
974
+ }
975
+ headers[headerKey] = String(valueArg);
976
+ }
977
+ const body = this.buildMimeBody(messageArg, headers);
978
+ return `${Object.entries(headers)
979
+ .map(([keyArg, valueArg]) => `${this.sanitizeHeaderKey(keyArg)}: ${this.sanitizeHeaderValue(valueArg)}`)
980
+ .join('\r\n')}\r\n\r\n${body}`;
981
+ }
982
+
983
+ private buildMimeBody(
984
+ messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload,
985
+ headersArg: Record<string, string>,
986
+ ): string {
987
+ const attachments = messageArg.attachments || [];
988
+ if (attachments.length === 0) {
989
+ return this.buildTextHtmlPart(messageArg, headersArg);
990
+ }
991
+ const mixedBoundary = `mixed-${plugins.crypto.randomBytes(12).toString('hex')}`;
992
+ headersArg['Content-Type'] = `multipart/mixed; boundary="${mixedBoundary}"`;
993
+ const parts = [`--${mixedBoundary}\r\n${this.buildBodyPart(messageArg)}`];
994
+ for (const attachment of attachments) {
995
+ parts.push(`--${mixedBoundary}\r\n${this.buildAttachmentPart(attachment)}`);
996
+ }
997
+ parts.push(`--${mixedBoundary}--`);
998
+ return parts.join('\r\n');
999
+ }
1000
+
1001
+ private buildTextHtmlPart(
1002
+ messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload,
1003
+ headersArg: Record<string, string>,
1004
+ ): string {
1005
+ if (messageArg.text && messageArg.html) {
1006
+ const boundary = `alternative-${plugins.crypto.randomBytes(12).toString('hex')}`;
1007
+ headersArg['Content-Type'] = `multipart/alternative; boundary="${boundary}"`;
1008
+ return [
1009
+ `--${boundary}`,
1010
+ 'Content-Type: text/plain; charset=utf-8',
1011
+ 'Content-Transfer-Encoding: 8bit',
1012
+ '',
1013
+ this.normalizeNewlines(messageArg.text),
1014
+ `--${boundary}`,
1015
+ 'Content-Type: text/html; charset=utf-8',
1016
+ 'Content-Transfer-Encoding: 8bit',
1017
+ '',
1018
+ this.normalizeNewlines(messageArg.html),
1019
+ `--${boundary}--`,
1020
+ ].join('\r\n');
1021
+ }
1022
+ headersArg['Content-Type'] = `${messageArg.html ? 'text/html' : 'text/plain'}; charset=utf-8`;
1023
+ headersArg['Content-Transfer-Encoding'] = '8bit';
1024
+ return this.normalizeNewlines(messageArg.html || messageArg.text || '');
1025
+ }
1026
+
1027
+ private buildBodyPart(messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload): string {
1028
+ const headers: Record<string, string> = {};
1029
+ const body = this.buildTextHtmlPart(messageArg, headers);
1030
+ return `${Object.entries(headers).map(([keyArg, valueArg]) => `${keyArg}: ${valueArg}`).join('\r\n')}\r\n\r\n${body}`;
1031
+ }
1032
+
1033
+ private buildAttachmentPart(attachmentArg: plugins.servezoneInterfaces.data.IMailOutboundAttachmentPayload): string {
1034
+ const filename = this.sanitizeHeaderValue(attachmentArg.filename);
1035
+ return [
1036
+ `Content-Type: ${attachmentArg.contentType || 'application/octet-stream'}; name="${filename}"`,
1037
+ 'Content-Transfer-Encoding: base64',
1038
+ `Content-Disposition: attachment; filename="${filename}"`,
1039
+ '',
1040
+ this.wrapBase64(attachmentArg.binaryAttachmentString),
1041
+ ].join('\r\n');
1042
+ }
1043
+
1044
+ private getMessageRecipients(messageArg: plugins.servezoneInterfaces.data.IMailOutboundMessagePayload): string[] {
1045
+ const recipients = [
1046
+ ...(messageArg.to || []),
1047
+ ...(messageArg.cc || []),
1048
+ ...(messageArg.bcc || []),
1049
+ ].map((recipientArg) => this.normalizeAddress(recipientArg));
1050
+ if (recipients.length === 0) {
1051
+ throw new Error('At least one mail recipient is required');
1052
+ }
1053
+ return recipients;
1054
+ }
1055
+
1056
+ private toMailOutboundIdentity(
1057
+ identityArg: IStoredWorkAppMailIdentity,
1058
+ ): plugins.servezoneInterfaces.data.IMailOutboundIdentity {
1059
+ return {
1060
+ id: identityArg.smtp.username,
1061
+ owner: this.toMailOwner(identityArg.ownership),
1062
+ enabled: identityArg.enabled && identityArg.smtp.enabled,
1063
+ status: identityArg.enabled && identityArg.smtp.enabled ? 'active' : 'disabled',
1064
+ allowedFromPatterns: [{ type: 'exactAddress', domain: identityArg.domain, localPart: identityArg.localPart }],
1065
+ defaultFrom: identityArg.address,
1066
+ domain: identityArg.domain,
1067
+ dkimSelector: 'default',
1068
+ credential: this.toMailCredentialPublic(identityArg),
1069
+ createdAt: identityArg.createdAt,
1070
+ updatedAt: identityArg.updatedAt,
1071
+ createdBy: identityArg.createdBy,
1072
+ };
1073
+ }
1074
+
1075
+ private extractHeaders(rawMessageArg: string): Record<string, string | string[]> {
1076
+ const headers: Record<string, string | string[]> = {};
1077
+ const headerText = rawMessageArg.split(/\r?\n\r?\n/, 1)[0] || '';
1078
+ let currentHeader = '';
1079
+ for (const line of headerText.split(/\r?\n/)) {
1080
+ if (/^[\t ]/.test(line) && currentHeader) {
1081
+ headers[currentHeader] = `${headers[currentHeader] || ''} ${line.trim()}`;
1082
+ continue;
1083
+ }
1084
+ const separatorIndex = line.indexOf(':');
1085
+ if (separatorIndex < 1) continue;
1086
+ currentHeader = line.slice(0, separatorIndex).trim();
1087
+ const value = line.slice(separatorIndex + 1).trim();
1088
+ const existing = headers[currentHeader];
1089
+ if (Array.isArray(existing)) {
1090
+ existing.push(value);
1091
+ } else if (existing) {
1092
+ headers[currentHeader] = [String(existing), value];
1093
+ } else {
1094
+ headers[currentHeader] = value;
1095
+ }
1096
+ }
1097
+ return headers;
1098
+ }
1099
+
1100
+ private extractHeader(rawMessageArg: string, headerNameArg: string): string | undefined {
1101
+ const lowerName = `${headerNameArg.toLowerCase()}:`;
1102
+ for (const line of rawMessageArg.split(/\r?\n/)) {
1103
+ if (!line) return undefined;
1104
+ if (line.toLowerCase().startsWith(lowerName)) {
1105
+ return line.slice(lowerName.length).trim();
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ private sanitizeHeaderKey(valueArg: string): string {
1111
+ if (!/^[a-z0-9-]+$/i.test(valueArg)) {
1112
+ throw new Error(`Invalid header key: ${valueArg}`);
1113
+ }
1114
+ return valueArg;
1115
+ }
1116
+
1117
+ private isProtectedOutboundHeader(valueArg: string): boolean {
1118
+ return new Set([
1119
+ 'bcc',
1120
+ 'cc',
1121
+ 'content-disposition',
1122
+ 'content-transfer-encoding',
1123
+ 'content-type',
1124
+ 'date',
1125
+ 'dkim-signature',
1126
+ 'from',
1127
+ 'message-id',
1128
+ 'mime-version',
1129
+ 'received',
1130
+ 'reply-to',
1131
+ 'resent-from',
1132
+ 'resent-sender',
1133
+ 'return-path',
1134
+ 'sender',
1135
+ 'subject',
1136
+ 'to',
1137
+ ]).has(valueArg.toLowerCase());
1138
+ }
1139
+
1140
+ private sanitizeHeaderValue(valueArg: string): string {
1141
+ return String(valueArg).replace(/[\r\n]+/g, ' ').trim();
1142
+ }
1143
+
1144
+ private normalizeNewlines(valueArg: string): string {
1145
+ return valueArg.replace(/\r?\n/g, '\r\n');
1146
+ }
1147
+
1148
+ private wrapBase64(valueArg: string): string {
1149
+ return valueArg.match(/.{1,76}/g)?.join('\r\n') || '';
1150
+ }
1151
+
1152
+ private buildServiceMailAddressTag(addressArg: string): string {
1153
+ return `serviceMailAddress:${this.normalizeAddress(addressArg)}`;
1154
+ }
1155
+
652
1156
  private matchesOwnership(
653
1157
  ownership: interfaces.data.IWorkAppMailOwnership,
654
1158
  filter?: Partial<interfaces.data.IWorkAppMailOwnership>,
@@ -794,7 +1298,7 @@ export class WorkAppMailManager {
794
1298
  domain: identity.domain,
795
1299
  enabled: identity.enabled,
796
1300
  status: identity.enabled ? 'active' : 'disabled',
797
- inboundTarget: this.toMailInboundTarget(identity.inbound),
1301
+ inboundTarget: this.getCanonicalInboundTarget(identity as IStoredWorkAppMailIdentity),
798
1302
  outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
799
1303
  ...(identity.smtp.enabled ? {
800
1304
  outboundCredential: this.toMailCredentialPublic(identity as IStoredWorkAppMailIdentity),
@@ -825,7 +1329,7 @@ export class WorkAppMailManager {
825
1329
  addressBindingIds: identities.map((identity) => identity.id),
826
1330
  outboundIdentityIds: smtpIdentities.map((identity) => identity.smtp.username),
827
1331
  defaultFrom: enabledIdentities[0]?.address || firstIdentity.address,
828
- inboundTarget: identities.length === 1 ? this.toMailInboundTarget(firstIdentity.inbound) : undefined,
1332
+ inboundTarget: identities.length === 1 ? this.getCanonicalInboundTarget(firstIdentity) : undefined,
829
1333
  createdAt: Math.min(...identities.map((identity) => identity.createdAt)),
830
1334
  updatedAt: Math.max(...identities.map((identity) => identity.updatedAt)),
831
1335
  createdBy: firstIdentity.createdBy,
@@ -314,6 +314,41 @@ export class GatewayClientHandler {
314
314
  },
315
315
  ),
316
316
  );
317
+
318
+ this.typedrouter.addTypedHandler(
319
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail>(
320
+ 'enqueueMail',
321
+ async (dataArg) => {
322
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
323
+ if (!manager) {
324
+ return { accepted: false, message: 'WorkApp mail manager not initialized' };
325
+ }
326
+ return await manager.enqueueMail(
327
+ dataArg.auth || {},
328
+ dataArg.message,
329
+ dataArg.outboundIdentityId,
330
+ );
331
+ },
332
+ ),
333
+ );
334
+
335
+ this.typedrouter.addTypedHandler(
336
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint>(
337
+ 'registerServiceMailEndpoint',
338
+ async (dataArg, toolsArg) => {
339
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
340
+ if (!manager) {
341
+ return { success: false, message: 'WorkApp mail manager not initialized' };
342
+ }
343
+ const peer = toolsArg?.localData?.peer as { tags?: Set<string> } | undefined;
344
+ return await manager.registerServiceMailEndpoint(
345
+ dataArg.auth || {},
346
+ peer as { tags?: Set<string> },
347
+ dataArg.addresses,
348
+ );
349
+ },
350
+ ),
351
+ );
317
352
  }
318
353
 
319
354
  private getGatewayCapabilities(): plugins.servezoneInterfaces.data.IGatewayCapabilities {
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '15.4.1',
6
+ version: '15.6.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }