@serve.zone/dcrouter 32.8.0 → 33.0.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.
- package/deno.json +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +7 -4
- package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -0
- package/dist_ts/email/classes.accepted-email-spool.js +24 -18
- package/dist_ts/email/classes.smtp-account.manager.d.ts +4 -2
- package/dist_ts/email/classes.smtp-account.manager.js +7 -35
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +17 -9
- package/dist_ts/email/classes.workapp-mail-manager.js +16 -55
- package/dist_ts/email/helpers.raw-message-header.d.ts +15 -0
- package/dist_ts/email/helpers.raw-message-header.js +96 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +8 -8
- package/readme.md +3 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +6 -3
- package/ts/email/classes.accepted-email-spool.ts +28 -26
- package/ts/email/classes.smtp-account.manager.ts +6 -36
- package/ts/email/classes.workapp-mail-manager.ts +23 -58
- package/ts/email/helpers.raw-message-header.ts +106 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/dcrouter",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "33.0.0",
|
|
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.
|
|
19
|
+
"@git.zone/tsbundle": "^3.7.0",
|
|
20
20
|
"@git.zone/tsdeno": "^1.15.0",
|
|
21
|
-
"@git.zone/tsdocker": "^3.
|
|
21
|
+
"@git.zone/tsdocker": "^3.12.0",
|
|
22
22
|
"@git.zone/tsrun": "^3.0.3",
|
|
23
|
-
"@git.zone/tstest": "^
|
|
24
|
-
"@git.zone/tswatch": "^7.
|
|
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.
|
|
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": "^
|
|
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
|
|
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.
|
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/classes.dcrouter.ts
CHANGED
|
@@ -2135,9 +2135,12 @@ export class DcRouter {
|
|
|
2135
2135
|
: { accepted: true };
|
|
2136
2136
|
},
|
|
2137
2137
|
onMessageData: async (context) => {
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1245
|
+
private extractHeader(headerSectionArg: string, headerNameArg: string): string | undefined {
|
|
1249
1246
|
const lowerName = `${headerNameArg.toLowerCase()}:`;
|
|
1250
|
-
for (const line of
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|