@serve.zone/dcrouter 32.8.0 → 33.0.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@serve.zone/dcrouter",
3
3
  "private": false,
4
- "version": "32.8.0",
4
+ "version": "33.0.1",
5
5
  "description": "A multifaceted routing service handling mail and SMS delivery functions.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -16,15 +16,15 @@
16
16
  "license": "MIT",
17
17
  "devDependencies": {
18
18
  "@git.zone/tsbuild": "^6.0.2",
19
- "@git.zone/tsbundle": "^3.2.0",
19
+ "@git.zone/tsbundle": "^3.7.1",
20
20
  "@git.zone/tsdeno": "^1.15.0",
21
- "@git.zone/tsdocker": "^3.11.0",
21
+ "@git.zone/tsdocker": "^3.12.0",
22
22
  "@git.zone/tsrun": "^3.0.3",
23
- "@git.zone/tstest": "^7.2.0",
24
- "@git.zone/tswatch": "^7.0.0",
23
+ "@git.zone/tstest": "^8.0.0",
24
+ "@git.zone/tswatch": "^7.1.1",
25
25
  "@push.rocks/smartdb-v2": "npm:@push.rocks/smartdb@2.14.6",
26
26
  "@push.rocks/smartdb-v5.2": "npm:@push.rocks/smartdb@5.2.2",
27
- "@types/node": "26.6.3",
27
+ "@types/node": "26.6.5",
28
28
  "@types/web-push": "3.6.4"
29
29
  },
30
30
  "dependencies": {
@@ -52,7 +52,7 @@
52
52
  "@push.rocks/smartmcp": "^0.3.0",
53
53
  "@push.rocks/smartmetrics": "^3.0.3",
54
54
  "@push.rocks/smartmigration": "2.0.1",
55
- "@push.rocks/smartmta": "^9.5.0",
55
+ "@push.rocks/smartmta": "^13.0.1",
56
56
  "@push.rocks/smartnetwork": "^4.10.1",
57
57
  "@push.rocks/smartpath": "^6.0.0",
58
58
  "@push.rocks/smartpromise": "^4.2.5",
@@ -123,7 +123,7 @@
123
123
  "third-party-notices.md"
124
124
  ],
125
125
  "engines": {
126
- "node": ">=22"
126
+ "node": ">=22.12.0"
127
127
  },
128
128
  "scripts": {
129
129
  "test": "(tstest test/ --verbose --logfile --timeout 60)",
package/readme.md CHANGED
@@ -744,7 +744,7 @@ Use `@serve.zone/dcrouter-apiclient/interfaces` (or `@serve.zone/dcrouter/interf
744
744
 
745
745
  dcrouter 32.0.0 builds against `@serve.zone/interfaces` 32.0.0: the installed interfaces release is the contract version, there is no version field on the wire and no per-shape version suffix. dcrouter serves no session that carries the 32 protocol handshake and opens none — its gateway-client, mail, Web Push and CoreMail gateway contracts are unchanged in 32.0.0 and carry no protocol offer — so no caller has to send one.
746
746
 
747
- 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.
747
+ 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 credentials can only send as their exact claimed address: each one is a SmartMTA SMTP account whose sender scope is that address, so SmartMTA refuses with 553 a MAIL FROM outside it and any header `From` mailbox outside it (every mailbox of every From field, group members included). Their outbound relay route matches the credential itself (`authenticatedUser`), so no other login relays through it or signs with its DKIM key. 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.
748
748
 
749
749
  ### Email Operations Views
750
750
 
@@ -812,6 +812,8 @@ Each account carries a sender scope ("send AS who": exact addresses plus whole d
812
812
 
813
813
  Accounts without a sender scope authenticate unrestricted but generate no relay routes — their relay permission remains whatever operator-configured routes grant. Sender domains that are not managed email domains are allowed with a warning (no managed SPF/DKIM/DMARC alignment); enabling DKIM signing is stricter and requires every sender-scope domain to be a managed, outbound-ready email domain with active DKIM material — this fails at configuration time, never at delivery time.
814
814
 
815
+ Sender authorization is SmartMTA's scope checker alone. dcrouter never parses a `From` field or the envelope sender itself to decide who may send: it expresses every credential's sender scope as an `auth.accounts` scope and binds generated relay routes to `authenticatedUser`. A plain `auth.users` entry or an account without a sender scope is unrestricted in what it may claim, so it must never be given a credential that belongs to a tenant.
816
+
815
817
  Submission accounts can only authenticate over an encrypted transport (see below). A message an authenticated account sends to a domain dcrouter hosts is delivered into that local mailbox rather than relayed — authentication grants permission to relay, it does not make a locally addressed message outbound.
816
818
 
817
819
  On upgrade, a migration converts legacy plaintext `emailConfig.auth.users` entries into hashed account documents and strips the plaintext from the persisted settings. Migrated accounts are unrestricted and generate no relay routes, so authentication and relay behave byte-identically to the legacy users — relay authorization is provably not widened (or narrowed) by the migration. Scoping a migrated account is an explicit post-migration operator action through the SMTP accounts UI/API. The legacy routes themselves stay in place until an operator retires them explicitly with `removeLegacyEmailRoute { routeName }`, which edits the persisted settings through the standard restart path, refuses unknown names, and refuses generated managed route names.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '32.8.0',
6
+ version: '33.0.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -2135,9 +2135,12 @@ export class DcRouter {
2135
2135
  : { accepted: true };
2136
2136
  },
2137
2137
  onMessageData: async (context) => {
2138
- const managedSenderDecision = await this.workAppMailManager.enforceManagedSmtpSender(context);
2139
- if (managedSenderDecision) {
2140
- return managedSenderDecision;
2138
+ // Sender authorization already happened in SmartMTA's scope
2139
+ // checker; this only refuses a WorkApp credential that stopped
2140
+ // being active or outbound-ready after it authenticated.
2141
+ const managedCredentialDecision = await this.workAppMailManager.enforceManagedSmtpCredentialReady(context);
2142
+ if (managedCredentialDecision) {
2143
+ return managedCredentialDecision;
2141
2144
  }
2142
2145
  const configuredDecision = configuredMessageDataHook
2143
2146
  ? await configuredMessageDataHook(context)
@@ -19,6 +19,7 @@ import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces
19
19
  import { createDnsProvider } from './providers/factory.js';
20
20
  import { buildDnsProviderRecordLink } from './provider-record-key.js';
21
21
  import type {
22
+ IDnsRecord,
22
23
  TDnsRecordType,
23
24
  TDnsRecordSource,
24
25
  } from '../../ts_interfaces/data/dns-record.js';
@@ -1961,22 +1962,13 @@ export class DnsManager {
1961
1962
  }
1962
1963
 
1963
1964
  /**
1964
- * Convert a DnsRecordDoc to its plain interface representation.
1965
+ * Convert a DnsRecordDoc to its plain interface representation. The
1966
+ * reconciler ownership fields are present exactly when the record is
1967
+ * reconciler-owned; internal bookkeeping (`managedRevision`,
1968
+ * `managedClientRecordKey`, `providerManagementMarker`, `providerRecordKey`)
1969
+ * is not part of the interface and stays out.
1965
1970
  */
1966
- public toPublicRecord(doc: DnsRecordDoc): {
1967
- id: string;
1968
- domainId: string;
1969
- name: string;
1970
- type: TDnsRecordType;
1971
- value: string;
1972
- ttl: number;
1973
- proxied?: boolean;
1974
- source: TDnsRecordSource;
1975
- providerRecordId?: string;
1976
- createdAt: number;
1977
- updatedAt: number;
1978
- createdBy: string;
1979
- } {
1971
+ public toPublicRecord(doc: DnsRecordDoc): IDnsRecord {
1980
1972
  return {
1981
1973
  id: doc.id,
1982
1974
  domainId: doc.domainId,
@@ -1990,6 +1982,9 @@ export class DnsManager {
1990
1982
  createdAt: doc.createdAt,
1991
1983
  updatedAt: doc.updatedAt,
1992
1984
  createdBy: doc.createdBy,
1985
+ ...(doc.managedBy ? { managedBy: doc.managedBy } : {}),
1986
+ ...(doc.managedOwnerId ? { managedOwnerId: doc.managedOwnerId } : {}),
1987
+ ...(doc.managedRecordKey ? { managedRecordKey: doc.managedRecordKey } : {}),
1993
1988
  };
1994
1989
  }
1995
1990
  }
@@ -20,6 +20,7 @@ import type {
20
20
  } from '@push.rocks/smartmta';
21
21
  import type { DcRouter } from '../classes.dcrouter.js';
22
22
  import { evaluateInboundAcceptance } from './helpers.inbound-security.js';
23
+ import { getRawMessageHeaderSection, setRawMessageHeaderField } from './helpers.raw-message-header.js';
23
24
  export {
24
25
  evaluateInboundAcceptance,
25
26
  type IInboundAcceptanceEvaluation,
@@ -258,7 +259,7 @@ export class AcceptedEmailSpool {
258
259
  cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
259
260
  cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
260
261
  cachedEmail.subject = email.subject || '';
261
- const persistedRawMessage = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
262
+ const persistedRawMessage = this.setDcRouterCacheIdHeader(rawMessage, cachedEmail.id);
262
263
  await this.persistRawMessage(cachedEmail, persistedRawMessage);
263
264
  cachedEmail.status = 'pending';
264
265
  cachedEmail.nextAttempt = new Date();
@@ -317,16 +318,19 @@ export class AcceptedEmailSpool {
317
318
  throw new Error('DcRouterDb is not available for email acceptance');
318
319
  }
319
320
 
321
+ // A string message is composed by dcrouter itself and encoded once; a
322
+ // Buffer is kept as the bytes it is.
320
323
  const rawMessage = plugins.buffer.Buffer.isBuffer(optionsArg.rawMessage)
321
- ? optionsArg.rawMessage.toString('utf8')
322
- : optionsArg.rawMessage;
324
+ ? optionsArg.rawMessage
325
+ : plugins.buffer.Buffer.from(optionsArg.rawMessage, 'utf8');
326
+ const headerSection = getRawMessageHeaderSection(rawMessage).toString('utf8');
323
327
  const cachedEmail = CachedEmail.createNew();
324
- cachedEmail.messageId = optionsArg.messageId || this.extractHeader(rawMessage, 'message-id') || cachedEmail.id;
328
+ cachedEmail.messageId = optionsArg.messageId || this.extractHeader(headerSection, 'message-id') || cachedEmail.id;
325
329
  cachedEmail.from = optionsArg.envelope.mailFrom;
326
330
  cachedEmail.to = optionsArg.envelope.rcptTo;
327
331
  cachedEmail.cc = [];
328
332
  cachedEmail.bcc = [];
329
- cachedEmail.subject = optionsArg.subject || this.extractHeader(rawMessage, 'subject') || '';
333
+ cachedEmail.subject = optionsArg.subject || this.extractHeader(headerSection, 'subject') || '';
330
334
  cachedEmail.submissionCredentialId = optionsArg.submissionCredentialId;
331
335
  cachedEmail.submissionIdempotencyKey = optionsArg.submissionIdempotencyKey;
332
336
  cachedEmail.submissionDigest = optionsArg.submissionDigest;
@@ -435,10 +439,7 @@ export class AcceptedEmailSpool {
435
439
  }
436
440
  const startedAtMs = Date.now();
437
441
 
438
- const persistedRawMessage = this.setDcRouterCacheIdHeader(
439
- context.rawMessage.toString('utf8'),
440
- cachedEmail.id,
441
- );
442
+ const persistedRawMessage = this.setDcRouterCacheIdHeader(context.rawMessage, cachedEmail.id);
442
443
  await this.persistRawMessage(cachedEmail, persistedRawMessage);
443
444
  cachedEmail.acceptedAt = startedAtMs;
444
445
 
@@ -585,7 +586,7 @@ export class AcceptedEmailSpool {
585
586
  */
586
587
  private async dispatchEnvelopeRecipients(
587
588
  cachedEmailArg: CachedEmail,
588
- rawMessageArg: string,
589
+ rawMessageArg: plugins.buffer.Buffer,
589
590
  emailServerArg: UnifiedEmailServer,
590
591
  ): Promise<void> {
591
592
  const routeData = this.parseCachedEmailRouteData(cachedEmailArg);
@@ -601,7 +602,7 @@ export class AcceptedEmailSpool {
601
602
  }>;
602
603
  try {
603
604
  const dispatchResult = await emailServerArg.dispatchAcceptedEnvelope(
604
- plugins.buffer.Buffer.from(rawMessageArg, 'utf8'),
605
+ rawMessageArg,
605
606
  envelopeDispatch.plan,
606
607
  envelopeDispatch.metadata,
607
608
  );
@@ -709,11 +710,7 @@ export class AcceptedEmailSpool {
709
710
 
710
711
  if (envelopeDispatch?.pendingRecipients?.length && !envelopeDispatch.abandonedAt) {
711
712
  const rawMessage = await this.readRawMessage(cachedEmailArg);
712
- await this.dispatchEnvelopeRecipients(
713
- cachedEmailArg,
714
- rawMessage.toString('utf8'),
715
- emailServerArg,
716
- );
713
+ await this.dispatchEnvelopeRecipients(cachedEmailArg, rawMessage, emailServerArg);
717
714
  return true;
718
715
  }
719
716
 
@@ -1245,9 +1242,9 @@ export class AcceptedEmailSpool {
1245
1242
  }
1246
1243
  }
1247
1244
 
1248
- private extractHeader(rawMessageArg: string, headerNameArg: string): string | undefined {
1245
+ private extractHeader(headerSectionArg: string, headerNameArg: string): string | undefined {
1249
1246
  const lowerName = `${headerNameArg.toLowerCase()}:`;
1250
- for (const line of rawMessageArg.split(/\r?\n/)) {
1247
+ for (const line of headerSectionArg.split(/\r?\n/)) {
1251
1248
  if (!line) return undefined;
1252
1249
  if (line.toLowerCase().startsWith(lowerName)) {
1253
1250
  return line.slice(lowerName.length).trim();
@@ -1336,10 +1333,16 @@ export class AcceptedEmailSpool {
1336
1333
  }
1337
1334
  }
1338
1335
 
1339
- private setDcRouterCacheIdHeader(rawContent: string, cachedEmailId: string): string {
1340
- const headerRegex = new RegExp(`^${DCROUTER_CACHE_ID_HEADER}:.*(?:\r?\n[\t ].*)*\r?\n?`, 'gim');
1341
- const sanitizedContent = rawContent.replace(headerRegex, '');
1342
- return `${DCROUTER_CACHE_ID_HEADER}: ${cachedEmailId}\r\n${sanitizedContent}`;
1336
+ /**
1337
+ * The stored copy: the received bytes with exactly one cache-id field at the
1338
+ * top of the header section. A cache-id field the client sent is replaced;
1339
+ * the same text in the body stays.
1340
+ */
1341
+ private setDcRouterCacheIdHeader(
1342
+ rawMessageArg: plugins.buffer.Buffer,
1343
+ cachedEmailIdArg: string,
1344
+ ): plugins.buffer.Buffer {
1345
+ return setRawMessageHeaderField(rawMessageArg, DCROUTER_CACHE_ID_HEADER, cachedEmailIdArg);
1343
1346
  }
1344
1347
 
1345
1348
  private isCachedEmailTerminal(cachedEmail: CachedEmail): boolean {
@@ -1478,17 +1481,16 @@ export class AcceptedEmailSpool {
1478
1481
  }
1479
1482
  }
1480
1483
 
1481
- private async persistRawMessage(cachedEmailArg: CachedEmail, rawMessageArg: string): Promise<void> {
1484
+ private async persistRawMessage(cachedEmailArg: CachedEmail, rawMessageArg: plugins.buffer.Buffer): Promise<void> {
1482
1485
  const blobStorage = this.dcRouterRef.getSmartMtaBlobStorageManager();
1483
1486
  if (!blobStorage) {
1484
1487
  throw new Error('SmartBucket storage is required before accepting SMTP messages');
1485
1488
  }
1486
1489
  const objectKey = `/email/messages/${cachedEmailArg.id}.eml`;
1487
- const rawMessage = plugins.buffer.Buffer.from(rawMessageArg, 'utf8');
1488
- await blobStorage.set(objectKey, rawMessage);
1490
+ await blobStorage.set(objectKey, rawMessageArg);
1489
1491
  cachedEmailArg.rawContent = undefined;
1490
1492
  cachedEmailArg.rawContentObjectKey = objectKey;
1491
- cachedEmailArg.rawContentSize = rawMessage.length;
1493
+ cachedEmailArg.rawContentSize = rawMessageArg.length;
1492
1494
  }
1493
1495
 
1494
1496
  private runCachedEmailUpdate(
@@ -76,11 +76,6 @@ export class SmtpAccountManager {
76
76
  private started = false;
77
77
  private mutationChain: Promise<unknown> = Promise.resolve();
78
78
 
79
- /** Explicit upstream capability marker; absence is deliberately fail-closed. */
80
- private get authAccountsCapability(): boolean {
81
- return (plugins.smartmta as any).smartMtaCapabilities?.smtpAuthAccountsAndScopes === true;
82
- }
83
-
84
79
  constructor(private dcRouterRef: DcRouter) {}
85
80
 
86
81
  public async start(): Promise<void> {
@@ -114,6 +109,10 @@ export class SmtpAccountManager {
114
109
  * the workapp identity contribution plus DB-backed SMTP accounts and their
115
110
  * generated relay routes. Idempotent — previously generated entries are
116
111
  * stripped by their reserved name prefixes before re-adding.
112
+ *
113
+ * WorkApp identities only ever enter as scoped `auth.accounts`, never as
114
+ * plain `auth.users`: an unscoped user could put any address in MAIL FROM
115
+ * or the header From.
117
116
  */
118
117
  public async composeEmailConfig<TConfig extends IUnifiedEmailServerOptions>(
119
118
  emailConfig: TConfig,
@@ -122,32 +121,6 @@ export class SmtpAccountManager {
122
121
  const contribution = workappContribution
123
122
  ?? await this.dcRouterRef.workAppMailManager.getStoredIdentityContribution(emailConfig);
124
123
 
125
- if (!this.authAccountsCapability) {
126
- // Fail closed: without the smartmta capability the accounts field would
127
- // be silently ignored (or rejected), so never pretend they are active —
128
- // any pre-existing accounts entry is dropped from the composed auth too.
129
- if (this.accounts.size > 0) {
130
- logger.log('error', `SmartMTA is missing the smtpAuthAccountsAndScopes capability — ${this.accounts.size} stored SMTP account(s) are NOT active`);
131
- }
132
- const authWithoutAccounts = { ...(emailConfig.auth || {}) } as NonNullable<IUnifiedEmailServerOptions['auth']>;
133
- delete authWithoutAccounts.accounts;
134
- return {
135
- ...emailConfig,
136
- routes: [
137
- ...(emailConfig.routes || [])
138
- .filter((route) => !isWorkAppManagedMailRouteName(route.name) && !isSmtpAccountRouteName(route.name)),
139
- ...contribution.routes,
140
- ],
141
- auth: {
142
- ...authWithoutAccounts,
143
- users: [
144
- ...(emailConfig.auth?.users || []).filter((user) => !isWorkAppManagedSmtpUsername(user.username)),
145
- ...contribution.users,
146
- ],
147
- },
148
- };
149
- }
150
-
151
124
  const accountDocs = [...this.accounts.values()]
152
125
  .sort((a, b) => a.username.localeCompare(b.username));
153
126
  const smtpAccounts: ISmtpAuthAccount[] = [];
@@ -182,8 +155,8 @@ export class SmtpAccountManager {
182
155
  routes: [...configuredRoutes, ...contribution.routes, ...accountRoutes],
183
156
  auth: {
184
157
  ...(emailConfig.auth || {}),
185
- users: [...configuredUsers, ...contribution.users],
186
- accounts: smtpAccounts,
158
+ users: configuredUsers,
159
+ accounts: [...contribution.accounts, ...smtpAccounts],
187
160
  },
188
161
  };
189
162
  }
@@ -324,9 +297,6 @@ export class SmtpAccountManager {
324
297
 
325
298
  public async createAccount(options: ISmtpAccountCreateOptions): Promise<ISmtpAccountSecretResult> {
326
299
  return await this.runMutationExclusive(async () => {
327
- if (!this.authAccountsCapability) {
328
- throw new Error('SmartMTA runtime does not support SMTP auth accounts (smtpAuthAccountsAndScopes capability missing)');
329
- }
330
300
  const username = this.normalizeUsername(options.username);
331
301
  const senderScope = this.normalizeSenderScope(options.senderScope);
332
302
  const recipientScope = this.normalizeRecipientScope(options.recipientScope);
@@ -3,6 +3,7 @@ import type {
3
3
  IExtendedSmtpSession,
4
4
  IMessageAcceptanceContext,
5
5
  IMessageAcceptanceDecision,
6
+ ISmtpAuthAccount,
6
7
  IUnifiedEmailServerOptions,
7
8
  } from '@push.rocks/smartmta';
8
9
  import type { Buffer } from 'node:buffer';
@@ -83,13 +84,17 @@ class MailSubmissionValidationError extends Error {
83
84
  }
84
85
 
85
86
  /**
86
- * WorkApp-generated routes and SMTP users contributed to the composed email
87
+ * WorkApp-generated routes and SMTP accounts contributed to the composed email
87
88
  * runtime config. Consumed by SmtpAccountManager, the single owner of the
88
89
  * final `auth` block and route-list composition.
90
+ *
91
+ * Every account carries a sender scope naming exactly its identity address:
92
+ * SmartMTA's scope checker is the only sender authorization, for MAIL FROM and
93
+ * for every header From mailbox alike.
89
94
  */
90
95
  export interface IWorkAppMailRuntimeContribution {
91
96
  routes: IEmailRoute[];
92
- users: Array<{ username: string; password: string }>;
97
+ accounts: ISmtpAuthAccount[];
93
98
  }
94
99
 
95
100
  /** Route names generated (and owned wholesale) by WorkAppMailManager. */
@@ -368,7 +373,16 @@ export class WorkAppMailManager {
368
373
  };
369
374
  }
370
375
 
371
- public async enforceManagedSmtpSender(
376
+ /**
377
+ * Live readiness of a WorkApp SMTP credential at DATA time.
378
+ *
379
+ * Sender authorization is not decided here: SmartMTA checks MAIL FROM and
380
+ * every header From mailbox against the account's sender scope before this
381
+ * hook runs. What SmartMTA cannot know is whether the identity is still
382
+ * active and its domain still outbound-ready now, after the session
383
+ * authenticated.
384
+ */
385
+ public async enforceManagedSmtpCredentialReady(
372
386
  context: IMessageAcceptanceContext,
373
387
  ): Promise<IMessageAcceptanceDecision | undefined> {
374
388
  const username = context.session.user?.username;
@@ -386,18 +400,6 @@ export class WorkAppMailManager {
386
400
  } catch (error: unknown) {
387
401
  return this.rejectSenderAuthorization((error as Error).message);
388
402
  }
389
-
390
- const envelopeFrom = this.normalizeMailboxAddress(
391
- context.session.envelope?.mailFrom?.address || context.session.mailFrom || '',
392
- );
393
- const headerFrom = this.extractSingleHeaderFromAddress(context.rawMessage, context.email.from);
394
- if (!envelopeFrom || !headerFrom) {
395
- return this.rejectSenderAuthorization('Managed SMTP sender must use exactly one From address');
396
- }
397
- if (envelopeFrom !== identity.address || headerFrom !== identity.address) {
398
- return this.rejectSenderAuthorization('Managed SMTP sender is not authorized for this From address');
399
- }
400
-
401
403
  return undefined;
402
404
  }
403
405
 
@@ -749,15 +751,16 @@ export class WorkAppMailManager {
749
751
  const generatedOutboundRoutes = nextIdentities
750
752
  .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
751
753
  .map((identity) => this.buildOutboundRoute(identity, emailConfig));
752
- const generatedUsers = nextIdentities
754
+ const generatedAccounts = nextIdentities
753
755
  .filter((identity) => identity.enabled && identity.smtp.enabled && readyDomains.has(identity.domain))
754
- .map((identity) => ({
756
+ .map((identity): ISmtpAuthAccount => ({
755
757
  username: identity.smtp.username,
756
- password: identity.smtpPassword,
758
+ credential: { password: identity.smtpPassword },
759
+ scope: { senders: [identity.address] },
757
760
  }));
758
761
  return {
759
762
  routes: [...generatedRoutes, ...generatedOutboundRoutes],
760
- users: generatedUsers,
763
+ accounts: generatedAccounts,
761
764
  };
762
765
  }
763
766
 
@@ -859,6 +862,7 @@ export class WorkAppMailManager {
859
862
  priority: 900,
860
863
  match: {
861
864
  authenticated: true,
865
+ authenticatedUser: identity.smtp.username,
862
866
  senders: identity.address,
863
867
  },
864
868
  action: {
@@ -969,45 +973,6 @@ export class WorkAppMailManager {
969
973
  return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
970
974
  }
971
975
 
972
- private normalizeMailboxAddress(addressArg: string): string | undefined {
973
- const trimmed = addressArg?.trim();
974
- if (!trimmed || trimmed === '<>') return undefined;
975
- const angleMatch = trimmed.match(/<([^<>]+)>/);
976
- const candidate = (angleMatch ? angleMatch[1] : trimmed).replace(/^mailto:/i, '').trim();
977
- try {
978
- return this.normalizeAddress(candidate);
979
- } catch {
980
- return undefined;
981
- }
982
- }
983
-
984
- private extractSingleHeaderFromAddress(rawMessageArg: Buffer, parsedFromArg: string): string | undefined {
985
- const headerSlice = rawMessageArg.subarray(0, 64 * 1024);
986
- const crlfHeaderEnd = headerSlice.indexOf('\r\n\r\n');
987
- const lfHeaderEnd = headerSlice.indexOf('\n\n');
988
- const headerEnd = crlfHeaderEnd >= 0
989
- ? crlfHeaderEnd
990
- : lfHeaderEnd >= 0
991
- ? lfHeaderEnd
992
- : headerSlice.length;
993
- const headersText = headerSlice.subarray(0, headerEnd).toString('utf8');
994
- const unfoldedLines: string[] = [];
995
- for (const line of headersText.split(/\r?\n/)) {
996
- if (/^[\t ]/.test(line) && unfoldedLines.length > 0) {
997
- unfoldedLines[unfoldedLines.length - 1] += ` ${line.trim()}`;
998
- } else {
999
- unfoldedLines.push(line);
1000
- }
1001
- }
1002
- const fromHeaders = unfoldedLines.filter((line) => /^from\s*:/i.test(line));
1003
- if (fromHeaders.length > 1) return undefined;
1004
- const headerValue = fromHeaders.length === 1
1005
- ? fromHeaders[0].replace(/^from\s*:/i, '').trim()
1006
- : parsedFromArg;
1007
- if (!headerValue || headerValue.includes(',')) return undefined;
1008
- return this.normalizeMailboxAddress(headerValue);
1009
- }
1010
-
1011
976
  private rejectSenderAuthorization(messageArg: string): IMessageAcceptanceDecision {
1012
977
  return {
1013
978
  accepted: false,
@@ -0,0 +1,106 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * Byte-level header edits on a raw RFC 5322 message.
5
+ *
6
+ * A received message is kept as the bytes the client sent: its body may be
7
+ * 8-bit text in any charset or binary, so it is never decoded to a string and
8
+ * encoded again. Only the header section is looked at, and only to find field
9
+ * lines; every byte outside the edited field lines is copied unchanged.
10
+ *
11
+ * Lines end at LF, with or without a preceding CR. The header section is every
12
+ * line before the first empty line (CRLF or a bare LF alone). A message without
13
+ * an empty line is all header section and has no body.
14
+ */
15
+
16
+ const LF = 0x0a;
17
+ const CR = 0x0d;
18
+ const SP = 0x20;
19
+ const HTAB = 0x09;
20
+ const COLON = 0x3a;
21
+
22
+ /** Length in bytes of the header section, up to (not including) the empty line that ends it. */
23
+ export const getRawMessageHeaderSectionLength = (rawMessageArg: plugins.buffer.Buffer): number => {
24
+ let lineStart = 0;
25
+ while (lineStart < rawMessageArg.length) {
26
+ const lineFeed = rawMessageArg.indexOf(LF, lineStart);
27
+ if (lineFeed === -1) {
28
+ // A last line without a terminator is a header line: there is no body.
29
+ return rawMessageArg.length;
30
+ }
31
+ const contentEnd = lineFeed > lineStart && rawMessageArg[lineFeed - 1] === CR ? lineFeed - 1 : lineFeed;
32
+ if (contentEnd === lineStart) {
33
+ return lineStart;
34
+ }
35
+ lineStart = lineFeed + 1;
36
+ }
37
+ return lineStart;
38
+ };
39
+
40
+ /** The header section's bytes (a view, not a copy). */
41
+ export const getRawMessageHeaderSection = (rawMessageArg: plugins.buffer.Buffer): plugins.buffer.Buffer => {
42
+ return rawMessageArg.subarray(0, getRawMessageHeaderSectionLength(rawMessageArg));
43
+ };
44
+
45
+ /** Whether a field line names `lowerCaseNameArg` (case-insensitive, optional WSP before the colon). */
46
+ const isFieldLineNamed = (lineArg: plugins.buffer.Buffer, lowerCaseNameArg: string): boolean => {
47
+ const colon = lineArg.indexOf(COLON);
48
+ if (colon === -1) return false;
49
+ let nameEnd = colon;
50
+ while (nameEnd > 0 && (lineArg[nameEnd - 1] === SP || lineArg[nameEnd - 1] === HTAB)) {
51
+ nameEnd--;
52
+ }
53
+ // latin1 maps each byte to one character, so a non-ASCII byte can never
54
+ // compare equal to the ASCII field name.
55
+ return lineArg.subarray(0, nameEnd).toString('latin1').toLowerCase() === lowerCaseNameArg;
56
+ };
57
+
58
+ /**
59
+ * Put exactly one `name: value` field at the top of the header section.
60
+ *
61
+ * Every field of that name in the header section is removed together with its
62
+ * folded continuation lines; the same text in the body is left alone. The new
63
+ * field line ends like the message's first line (CRLF, or a bare LF for a
64
+ * message whose first line ends in a bare LF), so the message keeps one line
65
+ * ending style; a message without any line terminator gets CRLF.
66
+ */
67
+ export const setRawMessageHeaderField = (
68
+ rawMessageArg: plugins.buffer.Buffer,
69
+ fieldNameArg: string,
70
+ valueArg: string,
71
+ ): plugins.buffer.Buffer => {
72
+ if (!/^[\x21-\x39\x3b-\x7e]+$/.test(fieldNameArg)) {
73
+ throw new Error(`Invalid header field name: ${JSON.stringify(fieldNameArg)}`);
74
+ }
75
+ if (/[\r\n]/.test(valueArg)) {
76
+ throw new Error(`Header field ${fieldNameArg} value must not contain CR or LF`);
77
+ }
78
+
79
+ const headerSectionLength = getRawMessageHeaderSectionLength(rawMessageArg);
80
+ const lowerCaseName = fieldNameArg.toLowerCase();
81
+ const parts: plugins.buffer.Buffer[] = [];
82
+ let removing = false;
83
+ let lineStart = 0;
84
+ while (lineStart < headerSectionLength) {
85
+ const lineFeed = rawMessageArg.indexOf(LF, lineStart);
86
+ const lineEnd = lineFeed === -1 ? headerSectionLength : lineFeed + 1;
87
+ const line = rawMessageArg.subarray(lineStart, lineEnd);
88
+ if (line[0] !== SP && line[0] !== HTAB) {
89
+ removing = isFieldLineNamed(line, lowerCaseName);
90
+ }
91
+ if (!removing) {
92
+ parts.push(line);
93
+ }
94
+ lineStart = lineEnd;
95
+ }
96
+
97
+ const firstLineFeed = rawMessageArg.indexOf(LF);
98
+ const lineEnding = firstLineFeed === -1 || (firstLineFeed > 0 && rawMessageArg[firstLineFeed - 1] === CR)
99
+ ? '\r\n'
100
+ : '\n';
101
+ return plugins.buffer.Buffer.concat([
102
+ plugins.buffer.Buffer.from(`${fieldNameArg}: ${valueArg}${lineEnding}`, 'utf8'),
103
+ ...parts,
104
+ rawMessageArg.subarray(headerSectionLength),
105
+ ]);
106
+ };
@@ -17,6 +17,12 @@ interface IDnsRecordSummary {
17
17
  source: interfaces.data.TDnsRecordSource;
18
18
  createdAt: number;
19
19
  updatedAt: number;
20
+ /**
21
+ * Reconciler ownership. `managedRecordKey` is left out: smartmcp redacts
22
+ * every key whose name contains `key`, so it would only read `[redacted]`.
23
+ */
24
+ managedBy?: string;
25
+ managedOwnerId?: string;
20
26
  }
21
27
 
22
28
  export class McpManager {
@@ -320,6 +326,8 @@ export class McpManager {
320
326
  source: recordArg.source,
321
327
  createdAt: recordArg.createdAt,
322
328
  updatedAt: recordArg.updatedAt,
329
+ ...(recordArg.managedBy ? { managedBy: recordArg.managedBy } : {}),
330
+ ...(recordArg.managedOwnerId ? { managedOwnerId: recordArg.managedOwnerId } : {}),
323
331
  };
324
332
  }
325
333
 
@@ -1389,8 +1389,21 @@ export class GatewayClientHandler {
1389
1389
  }
1390
1390
  for (const recordDoc of matchingRecords) {
1391
1391
  const record = dnsManager.toPublicRecord(recordDoc);
1392
+ // IGatewayDnsRecord declares no reconciler ownership fields, so the
1393
+ // record is mapped field by field instead of spread.
1392
1394
  records.push({
1393
- ...record,
1395
+ id: record.id,
1396
+ domainId: record.domainId,
1397
+ name: record.name,
1398
+ type: record.type,
1399
+ value: record.value,
1400
+ ttl: record.ttl,
1401
+ proxied: record.proxied,
1402
+ source: record.source,
1403
+ providerRecordId: record.providerRecordId,
1404
+ createdAt: record.createdAt,
1405
+ updatedAt: record.updatedAt,
1406
+ createdBy: record.createdBy,
1394
1407
  domainName: domainDoc?.name,
1395
1408
  status: 'active',
1396
1409
  gatewayClientType,
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '32.8.0',
6
+ version: '33.0.1',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }