@serve.zone/dcrouter 15.10.0 → 15.11.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.
@@ -7,7 +7,7 @@ import type {
7
7
  } from '@push.rocks/smartmta';
8
8
  import type { Buffer } from 'node:buffer';
9
9
  import * as plugins from '../plugins.js';
10
- import type { CachedEmail } from '../db/index.js';
10
+ import { CachedEmail } from '../db/index.js';
11
11
  import type { DcRouter } from '../classes.dcrouter.js';
12
12
  import type * as interfaces from '../../ts_interfaces/index.js';
13
13
 
@@ -30,8 +30,38 @@ type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail
30
30
  type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
31
31
  type TMailCredentialRotateResponse = plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential['response'];
32
32
  type TMailEnqueueResponse = plugins.servezoneInterfaces.requests.mail.IReq_EnqueueMail['response'];
33
+ type TMailDeliveryStatusResponse = plugins.servezoneInterfaces.requests.mail.IReq_GetMailDeliveryStatus['response'];
33
34
  type TMailEndpointRegistrationResponse = plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint['response'];
34
35
  type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
36
+ type TCachedEmailWithTimestamps = CachedEmail & {
37
+ createdAt?: Date | number | string;
38
+ updatedAt?: Date | number | string;
39
+ };
40
+ type TSmartMtaStatusQueueItem = {
41
+ status?: 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
42
+ attempts?: number;
43
+ nextAttempt?: Date;
44
+ lastError?: string;
45
+ processingResult?: {
46
+ headers?: Record<string, string>;
47
+ email?: {
48
+ headers?: Record<string, string>;
49
+ };
50
+ };
51
+ };
52
+ type TCachedEmailRouteData = {
53
+ session?: {
54
+ user?: {
55
+ username?: string;
56
+ };
57
+ };
58
+ smartMta?: {
59
+ status?: 'queued' | 'deferred' | 'delivered' | 'failed';
60
+ nextAttempt?: string;
61
+ lastError?: string;
62
+ updatedAt?: string;
63
+ };
64
+ };
35
65
 
36
66
  interface IStoredWorkAppMailIdentity {
37
67
  id: string;
@@ -370,6 +400,31 @@ export class WorkAppMailManager {
370
400
  }
371
401
  }
372
402
 
403
+ public async getMailDeliveryStatus(
404
+ spoolItemIdArg: string,
405
+ ): Promise<TMailDeliveryStatusResponse> {
406
+ const cachedEmail = await this.getCachedEmailById(spoolItemIdArg);
407
+ if (!cachedEmail) {
408
+ return { attempts: [] };
409
+ }
410
+ return await this.buildMailDeliveryStatus(cachedEmail);
411
+ }
412
+
413
+ public async getMailDeliveryStatusForCredential(
414
+ authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
415
+ spoolItemIdArg: string,
416
+ ): Promise<TMailDeliveryStatusResponse> {
417
+ const identity = await this.authenticateMailCredential(authArg);
418
+ const cachedEmail = await this.getCachedEmailById(spoolItemIdArg);
419
+ if (!cachedEmail) {
420
+ return { attempts: [] };
421
+ }
422
+ if (!this.cachedEmailBelongsToIdentity(cachedEmail, identity)) {
423
+ throw new Error('Mail credential is not authorized for this spool item');
424
+ }
425
+ return await this.buildMailDeliveryStatus(cachedEmail);
426
+ }
427
+
373
428
  public async registerServiceMailEndpoint(
374
429
  authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
375
430
  peerArg: { tags?: Set<string> },
@@ -889,6 +944,144 @@ export class WorkAppMailManager {
889
944
  return this.normalizeInboundTarget(identity.inboundTarget || this.toMailInboundTarget(identity.inbound));
890
945
  }
891
946
 
947
+ private async getCachedEmailById(spoolItemIdArg: string): Promise<CachedEmail | null> {
948
+ const spoolItemId = String(spoolItemIdArg || '').trim();
949
+ if (!spoolItemId || !this.dcRouterRef.dcRouterDb?.isReady()) {
950
+ return null;
951
+ }
952
+ return await CachedEmail.findById(spoolItemId);
953
+ }
954
+
955
+ private async buildMailDeliveryStatus(
956
+ cachedEmailArg: CachedEmail,
957
+ ): Promise<TMailDeliveryStatusResponse> {
958
+ const queueItem = this.findQueueItemForCachedEmail(cachedEmailArg.id);
959
+ const routeData = this.parseCachedEmailRouteData(cachedEmailArg);
960
+ const owner = await this.resolveCachedEmailOwner(cachedEmailArg);
961
+ const status = this.mapMailSpoolStatus(cachedEmailArg, queueItem);
962
+ const cachedEmailWithTimestamps = cachedEmailArg as TCachedEmailWithTimestamps;
963
+ const createdAt = this.toTimestamp(cachedEmailWithTimestamps.createdAt) || Date.now();
964
+ const updatedAt = this.toTimestamp(cachedEmailWithTimestamps.updatedAt) || createdAt;
965
+ const nextAttemptAt = status === 'delivered' || status === 'failed'
966
+ ? undefined
967
+ : this.toTimestamp(queueItem?.nextAttempt) || this.toTimestamp(routeData.smartMta?.nextAttempt) || this.toTimestamp(cachedEmailArg.nextAttempt);
968
+ const lastError = queueItem?.lastError || cachedEmailArg.lastError || routeData.smartMta?.lastError || undefined;
969
+
970
+ return {
971
+ spoolItem: {
972
+ id: cachedEmailArg.id,
973
+ owner,
974
+ direction: this.getCachedEmailSessionUsername(cachedEmailArg) ? 'outbound' : 'inbound',
975
+ status,
976
+ envelope: {
977
+ mailFrom: cachedEmailArg.from,
978
+ rcptTo: cachedEmailArg.to || [],
979
+ },
980
+ messageId: cachedEmailArg.messageId,
981
+ subject: cachedEmailArg.subject,
982
+ target: this.getCachedEmailSessionUsername(cachedEmailArg)
983
+ ? { type: 'mtaRelay', label: 'SmartMTA outbound relay' }
984
+ : undefined,
985
+ attempts: Math.max(cachedEmailArg.attempts || 0, queueItem?.attempts || 0),
986
+ nextAttemptAt,
987
+ lastError,
988
+ createdAt,
989
+ updatedAt,
990
+ },
991
+ attempts: [],
992
+ };
993
+ }
994
+
995
+ private findQueueItemForCachedEmail(cachedEmailIdArg: string): TSmartMtaStatusQueueItem | undefined {
996
+ const emailServer = this.dcRouterRef.emailServer;
997
+ if (!emailServer?.getQueueItems) {
998
+ return undefined;
999
+ }
1000
+ return (emailServer.getQueueItems() as TSmartMtaStatusQueueItem[]).find((itemArg) => {
1001
+ return this.getQueueItemCachedEmailId(itemArg) === cachedEmailIdArg;
1002
+ });
1003
+ }
1004
+
1005
+ private getQueueItemCachedEmailId(itemArg: TSmartMtaStatusQueueItem): string | undefined {
1006
+ const headerName = 'x-dcrouter-cached-email-id';
1007
+ const headers = itemArg?.processingResult?.headers || itemArg?.processingResult?.email?.headers;
1008
+ if (!headers) {
1009
+ return undefined;
1010
+ }
1011
+ const matchingHeaderName = Object.keys(headers).find((keyArg) => keyArg.toLowerCase() === headerName);
1012
+ return matchingHeaderName ? headers[matchingHeaderName] : undefined;
1013
+ }
1014
+
1015
+ private async resolveCachedEmailOwner(
1016
+ cachedEmailArg: CachedEmail,
1017
+ ): Promise<plugins.servezoneInterfaces.data.IMailResourceOwner | undefined> {
1018
+ const username = this.getCachedEmailSessionUsername(cachedEmailArg);
1019
+ if (!username) {
1020
+ return undefined;
1021
+ }
1022
+ const identity = (await this.readStoredIdentities()).find((identityArg) => identityArg.smtp.username === username);
1023
+ return identity ? this.toMailOwner(identity.ownership) : undefined;
1024
+ }
1025
+
1026
+ private cachedEmailBelongsToIdentity(
1027
+ cachedEmailArg: CachedEmail,
1028
+ identityArg: IStoredWorkAppMailIdentity,
1029
+ ): boolean {
1030
+ return this.normalizeAddress(cachedEmailArg.from || '') === identityArg.address
1031
+ && this.getCachedEmailSessionUsername(cachedEmailArg) === identityArg.smtp.username;
1032
+ }
1033
+
1034
+ private getCachedEmailSessionUsername(cachedEmailArg: CachedEmail): string | undefined {
1035
+ const routeData = this.parseCachedEmailRouteData(cachedEmailArg);
1036
+ const username = routeData.session?.user?.username;
1037
+ return typeof username === 'string' && username ? username : undefined;
1038
+ }
1039
+
1040
+ private parseCachedEmailRouteData(cachedEmailArg: CachedEmail): TCachedEmailRouteData {
1041
+ try {
1042
+ return cachedEmailArg.routeData ? JSON.parse(cachedEmailArg.routeData) : {};
1043
+ } catch {
1044
+ return {};
1045
+ }
1046
+ }
1047
+
1048
+ private mapMailSpoolStatus(
1049
+ cachedEmailArg: CachedEmail,
1050
+ queueItemArg?: TSmartMtaStatusQueueItem,
1051
+ ): plugins.servezoneInterfaces.data.TMailSpoolStatus {
1052
+ if (!queueItemArg && cachedEmailArg.status === 'pending') {
1053
+ return 'accepted';
1054
+ }
1055
+ const status = queueItemArg?.status || cachedEmailArg.status;
1056
+ switch (status) {
1057
+ case 'processing':
1058
+ return 'delivering';
1059
+ case 'delivered':
1060
+ return 'delivered';
1061
+ case 'deferred':
1062
+ return 'deferred';
1063
+ case 'failed':
1064
+ return 'failed';
1065
+ case 'pending':
1066
+ case 'queued':
1067
+ default:
1068
+ return 'queued';
1069
+ }
1070
+ }
1071
+
1072
+ private toTimestamp(valueArg: unknown): number | undefined {
1073
+ if (!valueArg) {
1074
+ return undefined;
1075
+ }
1076
+ if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
1077
+ return valueArg;
1078
+ }
1079
+ const timestamp = valueArg instanceof Date
1080
+ ? valueArg.getTime()
1081
+ : new Date(String(valueArg)).getTime();
1082
+ return Number.isFinite(timestamp) ? timestamp : undefined;
1083
+ }
1084
+
892
1085
  private async authenticateMailCredential(
893
1086
  authArg: plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth,
894
1087
  addressArg?: string,
@@ -350,6 +350,34 @@ export class GatewayClientHandler {
350
350
  ),
351
351
  );
352
352
 
353
+ this.typedrouter.addTypedHandler(
354
+ new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_GetMailDeliveryStatus>(
355
+ 'getMailDeliveryStatus',
356
+ async (dataArg) => {
357
+ const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
358
+ if (!manager) {
359
+ return { attempts: [] };
360
+ }
361
+
362
+ const authData = dataArg.auth || {};
363
+ const mailSubmissionAuth = authData as plugins.servezoneInterfaces.requests.mail.IMailSubmissionRequestAuth;
364
+ if (mailSubmissionAuth.credentialId || mailSubmissionAuth.credentialSecret) {
365
+ return await manager.getMailDeliveryStatusForCredential(
366
+ mailSubmissionAuth,
367
+ dataArg.spoolItemId,
368
+ );
369
+ }
370
+
371
+ const auth = await this.requireAuth(authData, 'gateway-clients:read');
372
+ const status = await manager.getMailDeliveryStatus(dataArg.spoolItemId);
373
+ if (!this.isMailDeliveryStatusAllowed(auth, status)) {
374
+ return { attempts: [] };
375
+ }
376
+ return status;
377
+ },
378
+ ),
379
+ );
380
+
353
381
  this.typedrouter.addTypedHandler(
354
382
  new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_RegisterServiceMailEndpoint>(
355
383
  'registerServiceMailEndpoint',
@@ -609,6 +637,22 @@ export class GatewayClientHandler {
609
637
  });
610
638
  }
611
639
 
640
+ private isMailDeliveryStatusAllowed(
641
+ auth: TAuthContext,
642
+ statusArg: plugins.servezoneInterfaces.data.IMailDeliveryStatus,
643
+ ): boolean {
644
+ const policy = auth.token?.policy;
645
+ if (!policy || policy.role !== 'gatewayClient') {
646
+ return true;
647
+ }
648
+ const owner = statusArg.spoolItem?.owner;
649
+ if (!owner || !policy.gatewayClient) {
650
+ return false;
651
+ }
652
+ return owner.gatewayClientType === policy.gatewayClient.type
653
+ && owner.gatewayClientId === policy.gatewayClient.id;
654
+ }
655
+
612
656
  private async filterWorkAppMailBindingsAllowed(
613
657
  auth: TAuthContext,
614
658
  managerArg: any,
package/ts/readme.md CHANGED
@@ -68,7 +68,7 @@ await router.start();
68
68
  - The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
69
69
  - System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
70
70
  - API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
71
- - Gateway-client mail bindings can create inbound `smtpForward` routes and managed SMTP users. Managed users are restricted to their exact claimed address for envelope and header `From`.
71
+ - Gateway-client mail bindings can create inbound `smtpForward` routes and managed SMTP users. Managed users are restricted to their exact claimed address for envelope and header `From`, and service-mail credentials can query their own delivery spool status after enqueueing mail.
72
72
  - Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
73
73
  - The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
74
74
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '15.10.0',
6
+ version: '15.11.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }