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