@serve.zone/dcrouter 15.3.1 → 15.4.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/deno.json +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +5 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +1 -0
- package/dist_ts/dns/classes.dns-server-runtime.js +11 -3
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +34 -2
- package/dist_ts/email/classes.workapp-mail-manager.js +215 -22
- package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +5 -0
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +100 -16
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +2 -2
- package/readme.md +5 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +4 -0
- package/ts/dns/classes.dns-server-runtime.ts +16 -1
- package/ts/email/classes.workapp-mail-manager.ts +277 -23
- package/ts/opsserver/handlers/gatewayclient.handler.ts +130 -15
- package/ts/readme.md +2 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
IEmailRoute,
|
|
3
|
+
IMessageAcceptanceContext,
|
|
4
|
+
IMessageAcceptanceDecision,
|
|
3
5
|
IUnifiedEmailServerOptions,
|
|
4
6
|
} from '@push.rocks/smartmta';
|
|
7
|
+
import type { Buffer } from 'node:buffer';
|
|
5
8
|
import * as plugins from '../plugins.js';
|
|
6
9
|
import type * as interfaces from '../../ts_interfaces/index.js';
|
|
7
10
|
|
|
@@ -21,10 +24,28 @@ type TMailAddressBinding = plugins.servezoneInterfaces.data.IMailAddressBinding;
|
|
|
21
24
|
type TMailAddressBindingSync = plugins.servezoneInterfaces.requests.mail.TMailAddressBindingSync;
|
|
22
25
|
type TMailAddressBindingSyncResponse = plugins.servezoneInterfaces.requests.mail.IReq_SyncMailAddressBinding['response'];
|
|
23
26
|
type TMailAddressBindingDeleteResponse = plugins.servezoneInterfaces.requests.mail.IReq_DeleteMailAddressBinding['response'];
|
|
27
|
+
type TMailCredentialRotateResponse = plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential['response'];
|
|
24
28
|
type TWorkAppMailBinding = plugins.servezoneInterfaces.data.IWorkAppMailBinding;
|
|
25
29
|
|
|
26
|
-
interface IStoredWorkAppMailIdentity
|
|
30
|
+
interface IStoredWorkAppMailIdentity {
|
|
31
|
+
id: string;
|
|
32
|
+
externalKey: string;
|
|
33
|
+
ownership: interfaces.data.IWorkAppMailOwnership;
|
|
34
|
+
address: string;
|
|
35
|
+
localPart: string;
|
|
36
|
+
domain: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
displayName?: string;
|
|
39
|
+
inbound?: interfaces.data.IWorkAppMailInboundRoute;
|
|
40
|
+
smtp: {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
username: string;
|
|
43
|
+
};
|
|
44
|
+
createdAt: number;
|
|
45
|
+
updatedAt: number;
|
|
46
|
+
createdBy: string;
|
|
27
47
|
smtpPassword: string;
|
|
48
|
+
smtpLastRotatedAt?: number;
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
interface IStoredWorkAppMailState {
|
|
@@ -34,6 +55,7 @@ interface IStoredWorkAppMailState {
|
|
|
34
55
|
|
|
35
56
|
export class WorkAppMailManager {
|
|
36
57
|
private readonly storageKey = '/workhosters/mail-identities.json';
|
|
58
|
+
private identityMutationPromise: Promise<unknown> = Promise.resolve();
|
|
37
59
|
|
|
38
60
|
constructor(private dcRouterRef: any) {}
|
|
39
61
|
|
|
@@ -49,6 +71,21 @@ export class WorkAppMailManager {
|
|
|
49
71
|
public async syncMailIdentity(
|
|
50
72
|
request: TSyncRequest,
|
|
51
73
|
createdBy: string,
|
|
74
|
+
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
|
|
75
|
+
return await this.runIdentityMutationExclusive(async () => {
|
|
76
|
+
return await this.syncMailIdentityInner(request, createdBy);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async runIdentityMutationExclusive<T>(actionArg: () => Promise<T>): Promise<T> {
|
|
81
|
+
const actionPromise = this.identityMutationPromise.catch(() => undefined).then(actionArg);
|
|
82
|
+
this.identityMutationPromise = actionPromise.then(() => undefined, () => undefined);
|
|
83
|
+
return await actionPromise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async syncMailIdentityInner(
|
|
87
|
+
request: TSyncRequest,
|
|
88
|
+
createdBy: string,
|
|
52
89
|
): Promise<interfaces.data.IWorkAppMailIdentitySyncResult> {
|
|
53
90
|
if (!this.dcRouterRef.options.emailConfig) {
|
|
54
91
|
return { success: false, message: 'Email server is not configured' };
|
|
@@ -80,9 +117,10 @@ export class WorkAppMailManager {
|
|
|
80
117
|
|
|
81
118
|
const existingIdentity = existingIndex >= 0 ? identities[existingIndex] : undefined;
|
|
82
119
|
const now = Date.now();
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
const shouldRotateSmtpPassword = !existingIdentity || request.resetSmtpPassword;
|
|
121
|
+
const smtpPassword = shouldRotateSmtpPassword
|
|
122
|
+
? this.generateSmtpPassword()
|
|
123
|
+
: existingIdentity.smtpPassword;
|
|
86
124
|
const identity: IStoredWorkAppMailIdentity = {
|
|
87
125
|
id: existingIdentity?.id || plugins.smartunique.shortId(),
|
|
88
126
|
externalKey,
|
|
@@ -101,6 +139,9 @@ export class WorkAppMailManager {
|
|
|
101
139
|
updatedAt: now,
|
|
102
140
|
createdBy: existingIdentity?.createdBy || createdBy,
|
|
103
141
|
smtpPassword,
|
|
142
|
+
smtpLastRotatedAt: shouldRotateSmtpPassword
|
|
143
|
+
? now
|
|
144
|
+
: existingIdentity?.smtpLastRotatedAt || existingIdentity?.createdAt || now,
|
|
104
145
|
};
|
|
105
146
|
|
|
106
147
|
if (existingIndex >= 0) {
|
|
@@ -162,32 +203,135 @@ export class WorkAppMailManager {
|
|
|
162
203
|
binding: TMailAddressBindingSync,
|
|
163
204
|
createdBy: string,
|
|
164
205
|
): Promise<TMailAddressBindingSyncResponse> {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
206
|
+
return await this.runIdentityMutationExclusive(async () => {
|
|
207
|
+
const ownership = this.normalizeMailResourceOwner(binding.owner);
|
|
208
|
+
const { localPart, domain } = this.normalizeMailAddressParts(binding);
|
|
209
|
+
const syncRequest: TSyncRequest = {
|
|
210
|
+
ownership,
|
|
211
|
+
localPart,
|
|
212
|
+
domain,
|
|
213
|
+
inbound: this.toLegacyInboundRoute(binding.inboundTarget),
|
|
214
|
+
enabled: binding.enabled,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (binding.outboundEnabled !== undefined) {
|
|
218
|
+
syncRequest.smtpEnabled = binding.outboundEnabled;
|
|
219
|
+
} else if (binding.outboundIdentityId !== undefined) {
|
|
220
|
+
syncRequest.smtpEnabled = Boolean(binding.outboundIdentityId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const result = await this.syncMailIdentityInner(syncRequest, createdBy);
|
|
224
|
+
const storedIdentity = result.identity
|
|
225
|
+
? (await this.readStoredIdentities()).find((identity) => identity.id === result.identity!.id)
|
|
226
|
+
: undefined;
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
success: result.success,
|
|
230
|
+
binding: storedIdentity ? this.toMailAddressBinding(storedIdentity) : undefined,
|
|
231
|
+
message: result.message,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public async rotateMailCredential(
|
|
237
|
+
credentialId: string,
|
|
238
|
+
createdBy: string,
|
|
239
|
+
owner?: Partial<TMailResourceOwner>,
|
|
240
|
+
): Promise<TMailCredentialRotateResponse> {
|
|
241
|
+
return await this.runIdentityMutationExclusive(async () => {
|
|
242
|
+
return await this.rotateMailCredentialInner(credentialId, createdBy, owner);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async rotateMailCredentialInner(
|
|
247
|
+
credentialId: string,
|
|
248
|
+
createdBy: string,
|
|
249
|
+
owner?: Partial<TMailResourceOwner>,
|
|
250
|
+
): Promise<TMailCredentialRotateResponse> {
|
|
251
|
+
if (!this.dcRouterRef.options.emailConfig) {
|
|
252
|
+
return { success: false, message: 'Email server is not configured' };
|
|
253
|
+
}
|
|
174
254
|
|
|
175
|
-
|
|
176
|
-
|
|
255
|
+
const normalizedCredentialId = credentialId?.trim();
|
|
256
|
+
if (!normalizedCredentialId) {
|
|
257
|
+
return { success: false, message: 'credentialId is required' };
|
|
177
258
|
}
|
|
178
259
|
|
|
179
|
-
const
|
|
260
|
+
const identities = await this.readStoredIdentities();
|
|
261
|
+
const identityIndex = identities.findIndex((identityArg) => {
|
|
262
|
+
return this.matchesMailOwner(this.toMailOwner(identityArg.ownership), owner)
|
|
263
|
+
&& (
|
|
264
|
+
identityArg.id === normalizedCredentialId
|
|
265
|
+
|| identityArg.externalKey === normalizedCredentialId
|
|
266
|
+
|| identityArg.smtp.username === normalizedCredentialId
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
if (identityIndex < 0) {
|
|
270
|
+
return { success: false, message: 'Mail credential not found' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const identity = identities[identityIndex];
|
|
274
|
+
if (!identity.enabled || !identity.smtp.enabled) {
|
|
275
|
+
return { success: false, message: 'Mail credential is disabled' };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
identities[identityIndex] = {
|
|
280
|
+
...identity,
|
|
281
|
+
smtpPassword: this.generateSmtpPassword(),
|
|
282
|
+
smtpLastRotatedAt: now,
|
|
283
|
+
updatedAt: now,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
await this.writeStoredIdentities(identities);
|
|
287
|
+
await this.applyStoredIdentitiesToRuntime(identities);
|
|
180
288
|
|
|
181
289
|
return {
|
|
182
|
-
success:
|
|
183
|
-
|
|
184
|
-
message: result.message,
|
|
290
|
+
success: true,
|
|
291
|
+
credential: this.toMailCredentialSecret(identities[identityIndex]),
|
|
185
292
|
};
|
|
186
293
|
}
|
|
187
294
|
|
|
295
|
+
public async enforceManagedSmtpSender(
|
|
296
|
+
context: IMessageAcceptanceContext,
|
|
297
|
+
): Promise<IMessageAcceptanceDecision | undefined> {
|
|
298
|
+
const username = context.session.user?.username;
|
|
299
|
+
if (!username || !this.isManagedSmtpUsername(username)) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const identities = await this.readStoredIdentities();
|
|
304
|
+
const identity = identities.find((identityArg) => identityArg.smtp.username === username);
|
|
305
|
+
if (!identity || !identity.enabled || !identity.smtp.enabled) {
|
|
306
|
+
return this.rejectSenderAuthorization('Managed SMTP credential is not active');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const envelopeFrom = this.normalizeMailboxAddress(
|
|
310
|
+
context.session.envelope?.mailFrom?.address || context.session.mailFrom || '',
|
|
311
|
+
);
|
|
312
|
+
const headerFrom = this.extractSingleHeaderFromAddress(context.rawMessage, context.email.from);
|
|
313
|
+
if (!envelopeFrom || !headerFrom) {
|
|
314
|
+
return this.rejectSenderAuthorization('Managed SMTP sender must use exactly one From address');
|
|
315
|
+
}
|
|
316
|
+
if (envelopeFrom !== identity.address || headerFrom !== identity.address) {
|
|
317
|
+
return this.rejectSenderAuthorization('Managed SMTP sender is not authorized for this From address');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
188
323
|
public async deleteMailAddressBinding(
|
|
189
324
|
id: string,
|
|
190
325
|
createdBy: string,
|
|
326
|
+
): Promise<TMailAddressBindingDeleteResponse> {
|
|
327
|
+
return await this.runIdentityMutationExclusive(async () => {
|
|
328
|
+
return await this.deleteMailAddressBindingInner(id, createdBy);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async deleteMailAddressBindingInner(
|
|
333
|
+
id: string,
|
|
334
|
+
createdBy: string,
|
|
191
335
|
): Promise<TMailAddressBindingDeleteResponse> {
|
|
192
336
|
const identities = await this.readStoredIdentities();
|
|
193
337
|
const identity = identities.find((storedIdentity) => storedIdentity.id === id || storedIdentity.externalKey === id);
|
|
@@ -195,7 +339,7 @@ export class WorkAppMailManager {
|
|
|
195
339
|
return { success: true };
|
|
196
340
|
}
|
|
197
341
|
|
|
198
|
-
const result = await this.
|
|
342
|
+
const result = await this.syncMailIdentityInner({
|
|
199
343
|
ownership: identity.ownership,
|
|
200
344
|
localPart: identity.localPart,
|
|
201
345
|
domain: identity.domain,
|
|
@@ -255,6 +399,9 @@ export class WorkAppMailManager {
|
|
|
255
399
|
const generatedRoutes = identities
|
|
256
400
|
.filter((identity) => identity.enabled && identity.inbound?.enabled)
|
|
257
401
|
.map((identity) => this.buildInboundRoute(identity));
|
|
402
|
+
const generatedOutboundRoutes = identities
|
|
403
|
+
.filter((identity) => identity.enabled && identity.smtp.enabled)
|
|
404
|
+
.map((identity) => this.buildOutboundRoute(identity));
|
|
258
405
|
const configuredRoutes = (emailConfig.routes || [])
|
|
259
406
|
.filter((route) => !this.isManagedMailRouteName(route.name));
|
|
260
407
|
const generatedUsers = identities
|
|
@@ -268,7 +415,7 @@ export class WorkAppMailManager {
|
|
|
268
415
|
|
|
269
416
|
return {
|
|
270
417
|
...emailConfig,
|
|
271
|
-
routes: [...configuredRoutes, ...generatedRoutes],
|
|
418
|
+
routes: [...configuredRoutes, ...generatedRoutes, ...generatedOutboundRoutes],
|
|
272
419
|
auth: {
|
|
273
420
|
...(emailConfig.auth || {}),
|
|
274
421
|
users: [...configuredUsers, ...generatedUsers],
|
|
@@ -301,6 +448,34 @@ export class WorkAppMailManager {
|
|
|
301
448
|
};
|
|
302
449
|
}
|
|
303
450
|
|
|
451
|
+
private buildOutboundRoute(identity: IStoredWorkAppMailIdentity): IEmailRoute {
|
|
452
|
+
return {
|
|
453
|
+
name: this.buildOutboundRouteName(identity.externalKey),
|
|
454
|
+
priority: 900,
|
|
455
|
+
match: {
|
|
456
|
+
authenticated: true,
|
|
457
|
+
senders: identity.address,
|
|
458
|
+
},
|
|
459
|
+
action: {
|
|
460
|
+
type: 'process',
|
|
461
|
+
allowRelay: true,
|
|
462
|
+
process: {
|
|
463
|
+
dkim: true,
|
|
464
|
+
queue: 'normal',
|
|
465
|
+
},
|
|
466
|
+
options: {
|
|
467
|
+
mtaOptions: {
|
|
468
|
+
dkimSign: true,
|
|
469
|
+
dkimOptions: {
|
|
470
|
+
domainName: identity.domain,
|
|
471
|
+
keySelector: 'default',
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
304
479
|
private async ensureEmailDomainConfigured(domain: string): Promise<void> {
|
|
305
480
|
const emailConfig = this.dcRouterRef.options.emailConfig as IUnifiedEmailServerOptions | undefined;
|
|
306
481
|
if (emailConfig?.domains?.some((domainConfig) => domainConfig.domain.toLowerCase() === domain)) {
|
|
@@ -359,6 +534,53 @@ export class WorkAppMailManager {
|
|
|
359
534
|
return `${this.normalizeLocalPart(localPart)}@${this.normalizeDomain(domain)}`;
|
|
360
535
|
}
|
|
361
536
|
|
|
537
|
+
private normalizeMailboxAddress(addressArg: string): string | undefined {
|
|
538
|
+
const trimmed = addressArg?.trim();
|
|
539
|
+
if (!trimmed || trimmed === '<>') return undefined;
|
|
540
|
+
const angleMatch = trimmed.match(/<([^<>]+)>/);
|
|
541
|
+
const candidate = (angleMatch ? angleMatch[1] : trimmed).replace(/^mailto:/i, '').trim();
|
|
542
|
+
try {
|
|
543
|
+
return this.normalizeAddress(candidate);
|
|
544
|
+
} catch {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private extractSingleHeaderFromAddress(rawMessageArg: Buffer, parsedFromArg: string): string | undefined {
|
|
550
|
+
const headerSlice = rawMessageArg.subarray(0, 64 * 1024);
|
|
551
|
+
const crlfHeaderEnd = headerSlice.indexOf('\r\n\r\n');
|
|
552
|
+
const lfHeaderEnd = headerSlice.indexOf('\n\n');
|
|
553
|
+
const headerEnd = crlfHeaderEnd >= 0
|
|
554
|
+
? crlfHeaderEnd
|
|
555
|
+
: lfHeaderEnd >= 0
|
|
556
|
+
? lfHeaderEnd
|
|
557
|
+
: headerSlice.length;
|
|
558
|
+
const headersText = headerSlice.subarray(0, headerEnd).toString('utf8');
|
|
559
|
+
const unfoldedLines: string[] = [];
|
|
560
|
+
for (const line of headersText.split(/\r?\n/)) {
|
|
561
|
+
if (/^[\t ]/.test(line) && unfoldedLines.length > 0) {
|
|
562
|
+
unfoldedLines[unfoldedLines.length - 1] += ` ${line.trim()}`;
|
|
563
|
+
} else {
|
|
564
|
+
unfoldedLines.push(line);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const fromHeaders = unfoldedLines.filter((line) => /^from\s*:/i.test(line));
|
|
568
|
+
if (fromHeaders.length > 1) return undefined;
|
|
569
|
+
const headerValue = fromHeaders.length === 1
|
|
570
|
+
? fromHeaders[0].replace(/^from\s*:/i, '').trim()
|
|
571
|
+
: parsedFromArg;
|
|
572
|
+
if (!headerValue || headerValue.includes(',')) return undefined;
|
|
573
|
+
return this.normalizeMailboxAddress(headerValue);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private rejectSenderAuthorization(messageArg: string): IMessageAcceptanceDecision {
|
|
577
|
+
return {
|
|
578
|
+
accepted: false,
|
|
579
|
+
smtpCode: 553,
|
|
580
|
+
smtpMessage: messageArg,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
362
584
|
private normalizeMailResourceOwner(owner: TMailResourceOwner): interfaces.data.IWorkAppMailOwnership {
|
|
363
585
|
const gatewayClientType = owner.gatewayClientType;
|
|
364
586
|
const gatewayClientId = owner.gatewayClientId?.trim();
|
|
@@ -477,6 +699,10 @@ export class WorkAppMailManager {
|
|
|
477
699
|
return `workapp-mail-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
|
478
700
|
}
|
|
479
701
|
|
|
702
|
+
private buildOutboundRouteName(externalKey: string): string {
|
|
703
|
+
return `workapp-mail-outbound-${this.hashExternalKey(externalKey).slice(0, 32)}`;
|
|
704
|
+
}
|
|
705
|
+
|
|
480
706
|
private hashExternalKey(externalKey: string): string {
|
|
481
707
|
return plugins.crypto.createHash('sha256').update(externalKey).digest('hex');
|
|
482
708
|
}
|
|
@@ -509,6 +735,31 @@ export class WorkAppMailManager {
|
|
|
509
735
|
};
|
|
510
736
|
}
|
|
511
737
|
|
|
738
|
+
private toMailCredentialPublic(
|
|
739
|
+
identity: IStoredWorkAppMailIdentity,
|
|
740
|
+
): plugins.servezoneInterfaces.data.IMailCredentialPublic {
|
|
741
|
+
return {
|
|
742
|
+
id: identity.smtp.username,
|
|
743
|
+
type: 'smtp',
|
|
744
|
+
status: identity.enabled && identity.smtp.enabled ? 'active' : 'disabled',
|
|
745
|
+
username: identity.smtp.username,
|
|
746
|
+
scopes: [`from:${identity.address}`],
|
|
747
|
+
createdAt: identity.createdAt,
|
|
748
|
+
updatedAt: identity.updatedAt,
|
|
749
|
+
lastRotatedAt: identity.smtpLastRotatedAt || identity.createdAt,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private toMailCredentialSecret(
|
|
754
|
+
identity: IStoredWorkAppMailIdentity,
|
|
755
|
+
): plugins.servezoneInterfaces.data.IMailCredentialOneTimeSecret {
|
|
756
|
+
return {
|
|
757
|
+
credential: this.toMailCredentialPublic(identity),
|
|
758
|
+
secret: identity.smtpPassword,
|
|
759
|
+
secretShownOnce: true,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
512
763
|
private toMailOwner(ownership: interfaces.data.IWorkAppMailOwnership): TMailResourceOwner & { appInstanceId: string } {
|
|
513
764
|
return {
|
|
514
765
|
gatewayClientType: ownership.workHosterType,
|
|
@@ -545,6 +796,9 @@ export class WorkAppMailManager {
|
|
|
545
796
|
status: identity.enabled ? 'active' : 'disabled',
|
|
546
797
|
inboundTarget: this.toMailInboundTarget(identity.inbound),
|
|
547
798
|
outboundIdentityId: identity.smtp.enabled ? identity.smtp.username : undefined,
|
|
799
|
+
...(identity.smtp.enabled ? {
|
|
800
|
+
outboundCredential: this.toMailCredentialPublic(identity as IStoredWorkAppMailIdentity),
|
|
801
|
+
} : {}),
|
|
548
802
|
recipientPolicy: {
|
|
549
803
|
mode: 'staticList',
|
|
550
804
|
staticRecipients: [identity.address],
|
|
@@ -552,7 +806,7 @@ export class WorkAppMailManager {
|
|
|
552
806
|
createdAt: identity.createdAt,
|
|
553
807
|
updatedAt: identity.updatedAt,
|
|
554
808
|
createdBy: identity.createdBy,
|
|
555
|
-
};
|
|
809
|
+
} as TMailAddressBinding;
|
|
556
810
|
}
|
|
557
811
|
|
|
558
812
|
private toWorkAppMailBinding(
|
|
@@ -581,7 +835,7 @@ export class WorkAppMailManager {
|
|
|
581
835
|
private toPublicIdentity(
|
|
582
836
|
identity: IStoredWorkAppMailIdentity,
|
|
583
837
|
): interfaces.data.IWorkAppMailIdentity {
|
|
584
|
-
const { smtpPassword, ...publicIdentity } = identity;
|
|
838
|
+
const { smtpPassword, smtpLastRotatedAt, ...publicIdentity } = identity;
|
|
585
839
|
return publicIdentity;
|
|
586
840
|
}
|
|
587
841
|
}
|
|
@@ -211,12 +211,13 @@ export class GatewayClientHandler {
|
|
|
211
211
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
|
|
212
212
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
213
213
|
if (!manager) return { bindings: [] };
|
|
214
|
+
const bindings = await manager.listMailAddressBindings({
|
|
215
|
+
owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
|
|
216
|
+
domain: dataArg.domain,
|
|
217
|
+
address: dataArg.address,
|
|
218
|
+
});
|
|
214
219
|
return {
|
|
215
|
-
bindings:
|
|
216
|
-
owner: this.resolveMailOwnerFilter(auth, dataArg.owner),
|
|
217
|
-
domain: dataArg.domain,
|
|
218
|
-
address: dataArg.address,
|
|
219
|
-
}),
|
|
220
|
+
bindings: this.filterMailBindingsAllowed(auth, bindings),
|
|
220
221
|
};
|
|
221
222
|
},
|
|
222
223
|
),
|
|
@@ -237,6 +238,7 @@ export class GatewayClientHandler {
|
|
|
237
238
|
...dataArg.binding,
|
|
238
239
|
owner: this.resolveMailOwner(auth, dataArg.binding.owner),
|
|
239
240
|
};
|
|
241
|
+
this.assertMailAddressAllowed(auth, binding.address, binding.domain);
|
|
240
242
|
this.assertMailForwardTargetAllowed(auth, binding.inboundTarget);
|
|
241
243
|
return await manager.syncMailAddressBinding(binding, auth.userId);
|
|
242
244
|
} catch (error) {
|
|
@@ -257,9 +259,9 @@ export class GatewayClientHandler {
|
|
|
257
259
|
return { success: false, message: 'WorkApp mail manager not initialized' };
|
|
258
260
|
}
|
|
259
261
|
if (auth.token?.policy?.role === 'gatewayClient') {
|
|
260
|
-
const bindings = await manager.listMailAddressBindings({
|
|
262
|
+
const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({
|
|
261
263
|
owner: this.resolveMailOwnerFilter(auth),
|
|
262
|
-
});
|
|
264
|
+
}));
|
|
263
265
|
const binding = bindings.find((candidate) => candidate.id === dataArg.id);
|
|
264
266
|
if (!binding) return { success: true };
|
|
265
267
|
return await manager.deleteMailAddressBinding(binding.id, auth.userId);
|
|
@@ -276,7 +278,39 @@ export class GatewayClientHandler {
|
|
|
276
278
|
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:read');
|
|
277
279
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
278
280
|
if (!manager) return { bindings: [] };
|
|
279
|
-
|
|
281
|
+
const owner = this.resolveMailOwnerFilter(auth, dataArg.owner);
|
|
282
|
+
const bindings = await manager.listWorkAppMailBindings(owner);
|
|
283
|
+
return { bindings: await this.filterWorkAppMailBindingsAllowed(auth, manager, owner, bindings) };
|
|
284
|
+
},
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
this.typedrouter.addTypedHandler(
|
|
289
|
+
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.mail.IReq_RotateMailCredential>(
|
|
290
|
+
'rotateMailCredential',
|
|
291
|
+
async (dataArg) => {
|
|
292
|
+
const auth = await this.requireAuth(dataArg.auth || {}, 'gateway-clients:write');
|
|
293
|
+
this.assertCapability(auth, 'syncRoutes');
|
|
294
|
+
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
|
295
|
+
if (!manager) {
|
|
296
|
+
return { success: false, message: 'WorkApp mail manager not initialized' };
|
|
297
|
+
}
|
|
298
|
+
const owner = this.resolveMailOwnerFilter(auth);
|
|
299
|
+
if (auth.token?.policy?.role === 'gatewayClient') {
|
|
300
|
+
const credentialId = dataArg.credentialId?.trim();
|
|
301
|
+
const bindings = this.filterMailBindingsAllowed(auth, await manager.listMailAddressBindings({ owner }));
|
|
302
|
+
const binding = bindings.find((bindingArg) => bindingArg.outboundIdentityId === credentialId
|
|
303
|
+
|| bindingArg.outboundCredential?.id === credentialId
|
|
304
|
+
|| bindingArg.outboundCredential?.username === credentialId);
|
|
305
|
+
if (!binding) {
|
|
306
|
+
return { success: false, message: 'Mail credential not found' };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return await manager.rotateMailCredential(
|
|
310
|
+
dataArg.credentialId,
|
|
311
|
+
auth.userId,
|
|
312
|
+
owner,
|
|
313
|
+
);
|
|
280
314
|
},
|
|
281
315
|
),
|
|
282
316
|
);
|
|
@@ -456,20 +490,101 @@ export class GatewayClientHandler {
|
|
|
456
490
|
auth: TAuthContext,
|
|
457
491
|
target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
|
|
458
492
|
): void {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
493
|
+
if (this.isMailForwardTargetAllowed(auth, target)) return;
|
|
494
|
+
const targetDescription = target?.smtpForward
|
|
495
|
+
? `${target.smtpForward.host.trim().toLowerCase()}:${Number(target.smtpForward.port)}`
|
|
496
|
+
: 'unknown';
|
|
497
|
+
if ((auth.token?.policy?.allowedRouteTargets || []).length === 0) {
|
|
463
498
|
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
|
|
464
499
|
}
|
|
500
|
+
throw new plugins.typedrequest.TypedResponseError(`mail target is outside token policy: ${targetDescription}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private isMailForwardTargetAllowed(
|
|
504
|
+
auth: TAuthContext,
|
|
505
|
+
target?: plugins.servezoneInterfaces.data.IMailInboundTarget,
|
|
506
|
+
): boolean {
|
|
507
|
+
const policy = auth.token?.policy;
|
|
508
|
+
if (!policy || policy.role !== 'gatewayClient' || !target?.smtpForward) return true;
|
|
509
|
+
const allowedTargets = policy.allowedRouteTargets || [];
|
|
510
|
+
if (allowedTargets.length === 0) return false;
|
|
465
511
|
const host = target.smtpForward.host.trim().toLowerCase();
|
|
466
512
|
const port = Number(target.smtpForward.port);
|
|
467
|
-
|
|
513
|
+
return allowedTargets.some((allowedTarget) => {
|
|
468
514
|
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
|
|
469
515
|
});
|
|
470
|
-
|
|
471
|
-
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private assertMailAddressAllowed(
|
|
519
|
+
auth: TAuthContext,
|
|
520
|
+
addressArg: string,
|
|
521
|
+
domainArg: string,
|
|
522
|
+
): void {
|
|
523
|
+
if (this.isMailAddressAllowed(auth, addressArg, domainArg)) return;
|
|
524
|
+
throw new plugins.typedrequest.TypedResponseError('mail domain is outside token policy');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private filterMailBindingsAllowed(
|
|
528
|
+
auth: TAuthContext,
|
|
529
|
+
bindingsArg: plugins.servezoneInterfaces.data.IMailAddressBinding[],
|
|
530
|
+
): plugins.servezoneInterfaces.data.IMailAddressBinding[] {
|
|
531
|
+
return bindingsArg.filter((bindingArg) => {
|
|
532
|
+
try {
|
|
533
|
+
return this.isMailAddressAllowed(auth, bindingArg.address, bindingArg.domain)
|
|
534
|
+
&& this.isMailForwardTargetAllowed(auth, bindingArg.inboundTarget);
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private async filterWorkAppMailBindingsAllowed(
|
|
542
|
+
auth: TAuthContext,
|
|
543
|
+
managerArg: any,
|
|
544
|
+
ownerArg: Partial<plugins.servezoneInterfaces.data.IMailResourceOwner> | undefined,
|
|
545
|
+
bindingsArg: plugins.servezoneInterfaces.data.IWorkAppMailBinding[],
|
|
546
|
+
): Promise<plugins.servezoneInterfaces.data.IWorkAppMailBinding[]> {
|
|
547
|
+
if (auth.token?.policy?.role !== 'gatewayClient') return bindingsArg;
|
|
548
|
+
const allowedAddressBindings = this.filterMailBindingsAllowed(
|
|
549
|
+
auth,
|
|
550
|
+
await managerArg.listMailAddressBindings({ owner: ownerArg }),
|
|
551
|
+
);
|
|
552
|
+
const allowedAddressIds = new Set(allowedAddressBindings.map((bindingArg) => bindingArg.id));
|
|
553
|
+
return bindingsArg
|
|
554
|
+
.map((bindingArg) => {
|
|
555
|
+
const addressBindingIds = (bindingArg.addressBindingIds || []).filter((idArg) => allowedAddressIds.has(idArg));
|
|
556
|
+
const allowedForBinding = allowedAddressBindings.filter((addressBindingArg) => addressBindingIds.includes(addressBindingArg.id));
|
|
557
|
+
const allowedOutboundIds = new Set(allowedForBinding
|
|
558
|
+
.map((addressBindingArg) => addressBindingArg.outboundIdentityId)
|
|
559
|
+
.filter((identityIdArg): identityIdArg is string => Boolean(identityIdArg)));
|
|
560
|
+
const defaultFrom = allowedForBinding.find((addressBindingArg) => allowedOutboundIds.has(addressBindingArg.outboundIdentityId || ''))?.address
|
|
561
|
+
|| allowedForBinding[0]?.address;
|
|
562
|
+
return {
|
|
563
|
+
...bindingArg,
|
|
564
|
+
addressBindingIds,
|
|
565
|
+
outboundIdentityIds: (bindingArg.outboundIdentityIds || []).filter((idArg) => allowedOutboundIds.has(idArg)),
|
|
566
|
+
defaultFrom,
|
|
567
|
+
inboundTarget: undefined,
|
|
568
|
+
};
|
|
569
|
+
})
|
|
570
|
+
.filter((bindingArg) => (bindingArg.addressBindingIds || []).length > 0);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private isMailAddressAllowed(
|
|
574
|
+
auth: TAuthContext,
|
|
575
|
+
addressArg: string,
|
|
576
|
+
domainArg: string,
|
|
577
|
+
): boolean {
|
|
578
|
+
const policy = auth.token?.policy;
|
|
579
|
+
if (!policy || policy.role !== 'gatewayClient') return true;
|
|
580
|
+
const normalizedDomain = domainArg.trim().toLowerCase();
|
|
581
|
+
const normalizedAddress = addressArg.trim().toLowerCase();
|
|
582
|
+
if (!normalizedAddress.endsWith(`@${normalizedDomain}`)) {
|
|
583
|
+
throw new plugins.typedrequest.TypedResponseError('mail address does not match domain');
|
|
472
584
|
}
|
|
585
|
+
const patterns = policy.hostnamePatterns || [];
|
|
586
|
+
return this.matchesHostnamePatterns(normalizedDomain, patterns)
|
|
587
|
+
|| patterns.some((patternArg) => patternArg.trim().toLowerCase() === `*.${normalizedDomain}`);
|
|
473
588
|
}
|
|
474
589
|
|
|
475
590
|
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
|
package/ts/readme.md
CHANGED
|
@@ -56,7 +56,7 @@ await router.start();
|
|
|
56
56
|
## What `DcRouter` Manages
|
|
57
57
|
|
|
58
58
|
- SmartProxy for HTTP/HTTPS/TCP routes
|
|
59
|
-
- `UnifiedEmailServer` for SMTP ingress and
|
|
59
|
+
- `UnifiedEmailServer` for SMTP ingress, delivery, managed app address bindings, and outbound SMTP submission when `emailConfig` is present
|
|
60
60
|
- DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
|
|
61
61
|
- embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
|
|
62
62
|
- VPN, RADIUS, and remote ingress services when their config blocks are enabled
|
|
@@ -68,6 +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
72
|
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
|
|
72
73
|
- The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
|
|
73
74
|
|