@serve.zone/dcrouter 17.0.0 → 17.1.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/.smartconfig.json +7 -4
- package/deno.json +1 -1
- package/dist_serve/bundle.js +1316 -1151
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.d.ts +3 -1
- package/dist_ts/classes.dcrouter.js +113 -52
- package/dist_ts/db/classes.cache.cleaner.d.ts +3 -0
- package/dist_ts/db/classes.cache.cleaner.js +7 -3
- package/dist_ts/db/classes.dcrouter-db.js +1 -2
- package/dist_ts/db/documents/classes.cached.email.d.ts +41 -4
- package/dist_ts/db/documents/classes.cached.email.js +36 -5
- package/dist_ts/db/documents/classes.dns-record.doc.js +3 -1
- package/dist_ts/dns/classes.dns-server-runtime.d.ts +0 -1
- package/dist_ts/dns/classes.dns-server-runtime.js +4 -36
- package/dist_ts/dns/index.d.ts +1 -0
- package/dist_ts/dns/index.js +2 -1
- package/dist_ts/dns/manager.dns.d.ts +1 -0
- package/dist_ts/dns/manager.dns.js +49 -14
- package/dist_ts/dns/providers/cloudflare.provider.js +19 -4
- package/dist_ts/dns/txt-record-presentation.d.ts +20 -0
- package/dist_ts/dns/txt-record-presentation.js +131 -0
- package/dist_ts/email/classes.accepted-email-spool.d.ts +32 -3
- package/dist_ts/email/classes.accepted-email-spool.js +174 -13
- package/dist_ts/email/classes.email-domain.manager.d.ts +14 -1
- package/dist_ts/email/classes.email-domain.manager.js +151 -8
- package/dist_ts/email/classes.email-settings.manager.js +2 -2
- package/dist_ts/email/classes.mail-dns-sync.d.ts +31 -5
- package/dist_ts/email/classes.mail-dns-sync.js +508 -117
- package/dist_ts/email/classes.smartmta-blob-storage-manager.d.ts +2 -2
- package/dist_ts/email/classes.smartmta-blob-storage-manager.js +4 -3
- package/dist_ts/email/classes.workapp-mail-manager.d.ts +1 -0
- package/dist_ts/email/classes.workapp-mail-manager.js +26 -2
- package/dist_ts/email/index.d.ts +2 -0
- package/dist_ts/email/index.js +3 -1
- package/dist_ts/email/mail-dns-txt-policy.d.ts +18 -0
- package/dist_ts/email/mail-dns-txt-policy.js +466 -0
- package/dist_ts/email/reconcile-mail-dns-singleton.d.ts +47 -0
- package/dist_ts/email/reconcile-mail-dns-singleton.js +159 -0
- package/dist_ts/opsserver/classes.opsserver.d.ts +8 -0
- package/dist_ts/opsserver/classes.opsserver.js +34 -1
- package/dist_ts/opsserver/handlers/certificate.handler.js +25 -4
- package/dist_ts/opsserver/handlers/config.handler.js +9 -5
- package/dist_ts/opsserver/handlers/dns-provider.handler.js +19 -2
- package/dist_ts/opsserver/handlers/dns-record.handler.js +25 -4
- package/dist_ts/opsserver/handlers/domain.handler.js +37 -3
- package/dist_ts/opsserver/handlers/email-domain.handler.js +5 -7
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/email-ops.handler.js +26 -7
- package/dist_ts/opsserver/handlers/index.d.ts +1 -0
- package/dist_ts/opsserver/handlers/index.js +2 -1
- package/dist_ts/opsserver/handlers/logs.handler.d.ts +1 -1
- package/dist_ts/opsserver/handlers/logs.handler.js +3 -3
- package/dist_ts/opsserver/handlers/realtime.handler.d.ts +37 -0
- package/dist_ts/opsserver/handlers/realtime.handler.js +154 -0
- package/dist_ts/opsserver/handlers/remoteingress.handler.js +22 -1
- package/dist_ts/opsserver/handlers/route-management.handler.js +47 -5
- package/dist_ts/opsserver/handlers/stats.handler.js +5 -1
- package/dist_ts/paths.d.ts +0 -11
- package/dist_ts/paths.js +1 -22
- package/dist_ts/plugins.d.ts +0 -34
- package/dist_ts/plugins.js +1 -62
- package/dist_ts_interfaces/data/email-domain.d.ts +14 -0
- package/dist_ts_interfaces/requests/config.d.ts +1 -1
- package/dist_ts_interfaces/requests/email-ops.d.ts +34 -1
- package/dist_ts_interfaces/requests/index.d.ts +1 -0
- package/dist_ts_interfaces/requests/index.js +2 -1
- package/dist_ts_interfaces/requests/realtime.d.ts +38 -0
- package/dist_ts_interfaces/requests/realtime.js +11 -0
- package/dist_ts_migrations/index.d.ts +6 -0
- package/dist_ts_migrations/index.js +208 -9
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/certificates.js +9 -2
- package/dist_ts_web/appstate/domains.js +16 -5
- package/dist_ts_web/appstate/email-domains.d.ts +3 -0
- package/dist_ts_web/appstate/email-domains.js +47 -14
- package/dist_ts_web/appstate/email-ops.d.ts +1 -0
- package/dist_ts_web/appstate/email-ops.js +20 -2
- package/dist_ts_web/appstate/realtime.d.ts +27 -0
- package/dist_ts_web/appstate/realtime.js +71 -0
- package/dist_ts_web/appstate/remoteingress.js +9 -3
- package/dist_ts_web/appstate/routes.js +16 -5
- package/dist_ts_web/appstate/runtime.js +135 -7
- package/dist_ts_web/appstate.d.ts +1 -0
- package/dist_ts_web/appstate.js +2 -1
- package/dist_ts_web/elements/email/email-domain-creation-progress.d.ts +33 -0
- package/dist_ts_web/elements/email/email-domain-creation-progress.js +257 -0
- package/dist_ts_web/elements/email/ops-view-email-domains.js +233 -68
- package/dist_ts_web/elements/email/ops-view-emails.d.ts +4 -2
- package/dist_ts_web/elements/email/ops-view-emails.js +25 -13
- package/dist_ts_web/elements/overview/ops-view-config.js +5 -5
- package/package.json +22 -19
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +124 -57
- package/ts/db/classes.cache.cleaner.ts +18 -3
- package/ts/db/classes.dcrouter-db.ts +0 -2
- package/ts/db/documents/classes.cached.email.ts +78 -4
- package/ts/db/documents/classes.dns-record.doc.ts +1 -0
- package/ts/dns/classes.dns-server-runtime.ts +9 -45
- package/ts/dns/index.ts +1 -0
- package/ts/dns/manager.dns.ts +50 -16
- package/ts/dns/providers/cloudflare.provider.ts +21 -3
- package/ts/dns/txt-record-presentation.ts +134 -0
- package/ts/email/classes.accepted-email-spool.ts +220 -14
- package/ts/email/classes.email-domain.manager.ts +201 -7
- package/ts/email/classes.email-settings.manager.ts +1 -1
- package/ts/email/classes.mail-dns-sync.ts +588 -122
- package/ts/email/classes.smartmta-blob-storage-manager.ts +3 -2
- package/ts/email/classes.workapp-mail-manager.ts +34 -1
- package/ts/email/index.ts +2 -0
- package/ts/email/mail-dns-txt-policy.ts +505 -0
- package/ts/email/reconcile-mail-dns-singleton.ts +224 -0
- package/ts/opsserver/classes.opsserver.ts +42 -0
- package/ts/opsserver/handlers/certificate.handler.ts +24 -3
- package/ts/opsserver/handlers/config.handler.ts +9 -5
- package/ts/opsserver/handlers/dns-provider.handler.ts +18 -1
- package/ts/opsserver/handlers/dns-record.handler.ts +24 -3
- package/ts/opsserver/handlers/domain.handler.ts +36 -2
- package/ts/opsserver/handlers/email-domain.handler.ts +4 -5
- package/ts/opsserver/handlers/email-ops.handler.ts +29 -6
- package/ts/opsserver/handlers/index.ts +1 -0
- package/ts/opsserver/handlers/logs.handler.ts +2 -2
- package/ts/opsserver/handlers/realtime.handler.ts +222 -0
- package/ts/opsserver/handlers/remoteingress.handler.ts +21 -0
- package/ts/opsserver/handlers/route-management.handler.ts +46 -4
- package/ts/opsserver/handlers/stats.handler.ts +4 -0
- package/ts/paths.ts +0 -25
- package/ts/plugins.ts +0 -68
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/certificates.ts +6 -1
- package/ts_web/appstate/domains.ts +11 -4
- package/ts_web/appstate/email-domains.ts +68 -14
- package/ts_web/appstate/email-ops.ts +20 -1
- package/ts_web/appstate/realtime.ts +109 -0
- package/ts_web/appstate/remoteingress.ts +6 -2
- package/ts_web/appstate/routes.ts +11 -4
- package/ts_web/appstate/runtime.ts +176 -7
- package/ts_web/appstate.ts +1 -0
- package/ts_web/elements/email/email-domain-creation-progress.ts +340 -0
- package/ts_web/elements/email/ops-view-email-domains.ts +273 -74
- package/ts_web/elements/email/ops-view-emails.ts +23 -16
- package/ts_web/elements/overview/ops-view-config.ts +4 -4
|
@@ -4,6 +4,7 @@ import type { IBlobStorageManager } from '@push.rocks/smartmta';
|
|
|
4
4
|
const SMARTMTA_BLOB_NAMESPACES = [
|
|
5
5
|
'/email/queue/',
|
|
6
6
|
'/email/attachments/',
|
|
7
|
+
'/email/messages/',
|
|
7
8
|
] as const;
|
|
8
9
|
const DEFAULT_OBJECT_PREFIX = 'dcrouter/smartmta/';
|
|
9
10
|
const MAX_STORAGE_KEY_BYTES = 1024;
|
|
@@ -59,8 +60,8 @@ function normalizeBlobKey(keyArg: string, allowNamespaceRoot = false): string {
|
|
|
59
60
|
/**
|
|
60
61
|
* SmartMTA blob persistence backed by a SmartBucket bucket.
|
|
61
62
|
*
|
|
62
|
-
* The adapter accepts only SmartMTA queue
|
|
63
|
-
*
|
|
63
|
+
* The adapter accepts only SmartMTA queue, attachment, and retained-message
|
|
64
|
+
* namespaces. Every key remains a SmartBucket object key.
|
|
64
65
|
*/
|
|
65
66
|
export class SmartMtaBlobStorageManager implements IBlobStorageManager {
|
|
66
67
|
private readonly objectPrefix: string;
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import type { Buffer } from 'node:buffer';
|
|
10
10
|
import * as plugins from '../plugins.js';
|
|
11
11
|
import { CachedEmail } from '../db/index.js';
|
|
12
|
+
import type { ICachedEmailSmtpTransaction } from '../db/documents/classes.cached.email.js';
|
|
12
13
|
import type { DcRouter } from '../classes.dcrouter.js';
|
|
13
14
|
import type * as interfaces from '../../ts_interfaces/index.js';
|
|
14
15
|
|
|
@@ -43,6 +44,7 @@ type TSmartMtaStatusQueueItem = {
|
|
|
43
44
|
attempts?: number;
|
|
44
45
|
nextAttempt?: Date;
|
|
45
46
|
lastError?: string;
|
|
47
|
+
smtpTransactions?: ICachedEmailSmtpTransaction[];
|
|
46
48
|
processingResult?: {
|
|
47
49
|
headers?: Record<string, string>;
|
|
48
50
|
email?: {
|
|
@@ -1054,10 +1056,41 @@ export class WorkAppMailManager {
|
|
|
1054
1056
|
createdAt,
|
|
1055
1057
|
updatedAt,
|
|
1056
1058
|
},
|
|
1057
|
-
attempts:
|
|
1059
|
+
attempts: this.mapDeliveryAttempts(
|
|
1060
|
+
queueItem?.smtpTransactions || cachedEmailArg.smtpTransactions || [],
|
|
1061
|
+
cachedEmailArg.id,
|
|
1062
|
+
),
|
|
1058
1063
|
};
|
|
1059
1064
|
}
|
|
1060
1065
|
|
|
1066
|
+
private mapDeliveryAttempts(
|
|
1067
|
+
transactionsArg: ICachedEmailSmtpTransaction[],
|
|
1068
|
+
spoolItemIdArg: string,
|
|
1069
|
+
): plugins.servezoneInterfaces.data.IMailDeliveryAttempt[] {
|
|
1070
|
+
return transactionsArg.map((transaction) => ({
|
|
1071
|
+
id: transaction.id,
|
|
1072
|
+
spoolItemId: spoolItemIdArg,
|
|
1073
|
+
target: {
|
|
1074
|
+
type: 'mtaRelay',
|
|
1075
|
+
label: transaction.recipientDomain || `${transaction.targetHost}:${transaction.targetPort}`,
|
|
1076
|
+
host: transaction.targetHost,
|
|
1077
|
+
port: transaction.targetPort,
|
|
1078
|
+
},
|
|
1079
|
+
status: transaction.outcome === 'succeeded'
|
|
1080
|
+
? 'delivered'
|
|
1081
|
+
: transaction.retryable
|
|
1082
|
+
? 'deferred'
|
|
1083
|
+
: 'failed',
|
|
1084
|
+
startedAt: this.toTimestamp(transaction.startedAt) || 0,
|
|
1085
|
+
completedAt: this.toTimestamp(transaction.completedAt),
|
|
1086
|
+
smtpCode: transaction.smtpCode,
|
|
1087
|
+
response: transaction.recipientResults?.map((result) => {
|
|
1088
|
+
return `${result.recipient}: ${result.responseCode} ${result.accepted ? 'accepted' : 'rejected'}`;
|
|
1089
|
+
}).join('; '),
|
|
1090
|
+
error: transaction.error,
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1061
1094
|
private findQueueItemForCachedEmail(cachedEmailIdArg: string): TSmartMtaStatusQueueItem | undefined {
|
|
1062
1095
|
const emailServer = this.dcRouterRef.emailServer;
|
|
1063
1096
|
if (!emailServer?.getQueueItems) {
|
package/ts/email/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export * from './classes.accepted-email-spool.js';
|
|
|
2
2
|
export * from './classes.email-domain.manager.js';
|
|
3
3
|
export * from './classes.email-route-builder.js';
|
|
4
4
|
export * from './classes.mail-dns-sync.js';
|
|
5
|
+
export * from './mail-dns-txt-policy.js';
|
|
6
|
+
export * from './reconcile-mail-dns-singleton.js';
|
|
5
7
|
export * from './classes.mail-egress-coordinator.js';
|
|
6
8
|
export * from './classes.mail-edge-eligibility.js';
|
|
7
9
|
export * from './classes.email-settings.manager.js';
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import type { IEmailDnsRecordIntent, TEmailDnsVerificationOutcome } from '../../ts_interfaces/data/email-domain.js';
|
|
2
|
+
import * as plugins from '../plugins.js';
|
|
3
|
+
import { decodeTxtRecordContent } from '../dns/txt-record-presentation.js';
|
|
4
|
+
|
|
5
|
+
export interface IMailDnsTxtPolicyResult {
|
|
6
|
+
outcome: TEmailDnsVerificationOutcome;
|
|
7
|
+
reason?: string;
|
|
8
|
+
effectiveValue?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface IMailDnsTxtPolicyOptions {
|
|
12
|
+
resolveDns?: (name: string, type: 'A' | 'AAAA') => Promise<string[]>;
|
|
13
|
+
lookupTimeoutMs?: number;
|
|
14
|
+
maxDnsLookups?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IMailTxtRecordClassification {
|
|
18
|
+
relevant: boolean;
|
|
19
|
+
canonicalValue?: string;
|
|
20
|
+
malformed?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type TMailTxtKind = 'spf' | 'dmarc' | 'dkim' | 'exact';
|
|
24
|
+
type TSpfQualifier = '+' | '-' | '~' | '?';
|
|
25
|
+
|
|
26
|
+
interface IIpNetwork {
|
|
27
|
+
family: 4 | 6;
|
|
28
|
+
bytes: number[];
|
|
29
|
+
prefixLength: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ISpfAMechanism {
|
|
33
|
+
domain?: string;
|
|
34
|
+
ipv4PrefixLength: number;
|
|
35
|
+
ipv6PrefixLength: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type TSpfMechanism =
|
|
39
|
+
| { kind: 'ip'; qualifier: TSpfQualifier; network: IIpNetwork }
|
|
40
|
+
| { kind: 'a'; qualifier: TSpfQualifier; mechanism: ISpfAMechanism }
|
|
41
|
+
| { kind: 'all'; qualifier: TSpfQualifier }
|
|
42
|
+
| { kind: 'unsupported'; name: string };
|
|
43
|
+
|
|
44
|
+
interface IParsedSpfRecord {
|
|
45
|
+
mechanisms: TSpfMechanism[];
|
|
46
|
+
hasRedirect: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function intentKind(intent: IEmailDnsRecordIntent): TMailTxtKind {
|
|
50
|
+
const desired = intent.value.trim().toLowerCase();
|
|
51
|
+
if (desired.startsWith('v=spf1')) return 'spf';
|
|
52
|
+
if (desired.startsWith('v=dmarc1')) return 'dmarc';
|
|
53
|
+
if (desired.startsWith('v=dkim1')) return 'dkim';
|
|
54
|
+
return 'exact';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function markerForKind(kind: TMailTxtKind): string | undefined {
|
|
58
|
+
if (kind === 'spf') return 'v=spf1';
|
|
59
|
+
if (kind === 'dmarc') return 'v=dmarc1';
|
|
60
|
+
if (kind === 'dkim') return 'v=dkim1';
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function classifyMailTxtRecord(
|
|
65
|
+
intent: IEmailDnsRecordIntent,
|
|
66
|
+
rawValue: string,
|
|
67
|
+
): IMailTxtRecordClassification {
|
|
68
|
+
const kind = intentKind(intent);
|
|
69
|
+
const decoded = decodeTxtRecordContent(rawValue);
|
|
70
|
+
if (!decoded.ok) {
|
|
71
|
+
const marker = markerForKind(kind);
|
|
72
|
+
const rawNormalized = rawValue.trim().toLowerCase();
|
|
73
|
+
const plausiblyRelevant = kind === 'dkim'
|
|
74
|
+
? rawNormalized.length > 0
|
|
75
|
+
: kind === 'dmarc'
|
|
76
|
+
? /(?:^|;)\s*v=dmarc1(?:;|$)/.test(rawNormalized.replace(/^"/, ''))
|
|
77
|
+
: marker
|
|
78
|
+
? rawNormalized.slice(0, marker.length + 4).includes(marker)
|
|
79
|
+
: rawValue.trim().startsWith('"');
|
|
80
|
+
return plausiblyRelevant
|
|
81
|
+
? { relevant: true, malformed: decoded.error }
|
|
82
|
+
: { relevant: false };
|
|
83
|
+
}
|
|
84
|
+
const canonicalValue = decoded.value.trim();
|
|
85
|
+
const marker = markerForKind(kind);
|
|
86
|
+
const normalizedValue = canonicalValue.toLowerCase();
|
|
87
|
+
return {
|
|
88
|
+
relevant: kind === 'dkim'
|
|
89
|
+
? canonicalValue.length > 0
|
|
90
|
+
: kind === 'dmarc'
|
|
91
|
+
? /(?:^|;)\s*v=dmarc1(?:;|$)/.test(normalizedValue)
|
|
92
|
+
: marker
|
|
93
|
+
? normalizedValue.startsWith(marker)
|
|
94
|
+
: canonicalValue === intent.value.trim(),
|
|
95
|
+
canonicalValue,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ipv4Bytes(address: string): number[] | undefined {
|
|
100
|
+
const parts = address.split('.');
|
|
101
|
+
if (parts.length !== 4) return undefined;
|
|
102
|
+
const bytes = parts.map((part) => Number(part));
|
|
103
|
+
return bytes.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
|
|
104
|
+
? bytes
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ipv6Bytes(address: string): number[] | undefined {
|
|
109
|
+
let normalized = address.toLowerCase();
|
|
110
|
+
if (normalized.includes('.')) {
|
|
111
|
+
const lastColon = normalized.lastIndexOf(':');
|
|
112
|
+
const ipv4 = ipv4Bytes(normalized.slice(lastColon + 1));
|
|
113
|
+
if (lastColon < 0 || !ipv4) return undefined;
|
|
114
|
+
normalized = `${normalized.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
|
|
115
|
+
}
|
|
116
|
+
const halves = normalized.split('::');
|
|
117
|
+
if (halves.length > 2) return undefined;
|
|
118
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
119
|
+
const right = halves[1] ? halves[1].split(':') : [];
|
|
120
|
+
const missing = 8 - left.length - right.length;
|
|
121
|
+
if ((halves.length === 1 && missing !== 0) || missing < 0) return undefined;
|
|
122
|
+
const groups = [...left, ...Array.from({ length: missing }, () => '0'), ...right];
|
|
123
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return groups.flatMap((group) => {
|
|
127
|
+
const value = Number.parseInt(group, 16);
|
|
128
|
+
return [value >> 8, value & 0xff];
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseIpNetwork(value: string, family: 4 | 6): IIpNetwork | undefined {
|
|
133
|
+
const slashIndex = value.lastIndexOf('/');
|
|
134
|
+
const address = slashIndex >= 0 ? value.slice(0, slashIndex) : value;
|
|
135
|
+
const maximumPrefix = family === 4 ? 32 : 128;
|
|
136
|
+
const rawPrefix = slashIndex >= 0 ? value.slice(slashIndex + 1) : String(maximumPrefix);
|
|
137
|
+
if (!/^\d{1,3}$/.test(rawPrefix)) return undefined;
|
|
138
|
+
const prefixLength = Number.parseInt(rawPrefix, 10);
|
|
139
|
+
if (prefixLength < 0 || prefixLength > maximumPrefix) return undefined;
|
|
140
|
+
const bytes = family === 4 ? ipv4Bytes(address) : ipv6Bytes(address);
|
|
141
|
+
return bytes ? { family, bytes, prefixLength } : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function networkContains(network: IIpNetwork, address: IIpNetwork): boolean {
|
|
145
|
+
if (network.family !== address.family) return false;
|
|
146
|
+
const wholeBytes = Math.floor(network.prefixLength / 8);
|
|
147
|
+
const remainingBits = network.prefixLength % 8;
|
|
148
|
+
for (let index = 0; index < wholeBytes; index++) {
|
|
149
|
+
if (network.bytes[index] !== address.bytes[index]) return false;
|
|
150
|
+
}
|
|
151
|
+
if (remainingBits === 0) return true;
|
|
152
|
+
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
153
|
+
return (network.bytes[wholeBytes] & mask) === (address.bytes[wholeBytes] & mask);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseAMechanism(body: string): ISpfAMechanism | undefined {
|
|
157
|
+
let remaining = body.slice(1);
|
|
158
|
+
let ipv6PrefixLength = 128;
|
|
159
|
+
const doubleSlashIndex = remaining.lastIndexOf('//');
|
|
160
|
+
if (doubleSlashIndex >= 0) {
|
|
161
|
+
const value = remaining.slice(doubleSlashIndex + 2);
|
|
162
|
+
if (!/^\d{1,3}$/.test(value)) return undefined;
|
|
163
|
+
ipv6PrefixLength = Number.parseInt(value, 10);
|
|
164
|
+
remaining = remaining.slice(0, doubleSlashIndex);
|
|
165
|
+
}
|
|
166
|
+
let ipv4PrefixLength = 32;
|
|
167
|
+
const slashIndex = remaining.lastIndexOf('/');
|
|
168
|
+
if (slashIndex >= 0) {
|
|
169
|
+
const value = remaining.slice(slashIndex + 1);
|
|
170
|
+
if (!/^\d{1,2}$/.test(value)) return undefined;
|
|
171
|
+
ipv4PrefixLength = Number.parseInt(value, 10);
|
|
172
|
+
remaining = remaining.slice(0, slashIndex);
|
|
173
|
+
}
|
|
174
|
+
if (ipv4PrefixLength > 32 || ipv6PrefixLength > 128) return undefined;
|
|
175
|
+
if (remaining && !remaining.startsWith(':')) return undefined;
|
|
176
|
+
const domain = remaining.slice(1).trim().toLowerCase().replace(/\.$/, '');
|
|
177
|
+
if (domain.includes('%') || (domain && !/^[a-z0-9_.-]+$/.test(domain))) return undefined;
|
|
178
|
+
return {
|
|
179
|
+
...(domain ? { domain } : {}),
|
|
180
|
+
ipv4PrefixLength,
|
|
181
|
+
ipv6PrefixLength,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseSpfRecord(value: string): { parsed?: IParsedSpfRecord; error?: string } {
|
|
186
|
+
const terms = value.trim().split(/\s+/);
|
|
187
|
+
if (terms.shift()?.toLowerCase() !== 'v=spf1') {
|
|
188
|
+
return { error: 'SPF record must start with v=spf1' };
|
|
189
|
+
}
|
|
190
|
+
const parsed: IParsedSpfRecord = { mechanisms: [], hasRedirect: false };
|
|
191
|
+
const modifiers = new Set<string>();
|
|
192
|
+
for (const rawTerm of terms) {
|
|
193
|
+
if (!rawTerm) continue;
|
|
194
|
+
let term = rawTerm;
|
|
195
|
+
let qualifier: TSpfQualifier = '+';
|
|
196
|
+
let hadQualifier = false;
|
|
197
|
+
if (/^[+\-~?]/.test(term)) {
|
|
198
|
+
qualifier = term[0] as TSpfQualifier;
|
|
199
|
+
term = term.slice(1);
|
|
200
|
+
hadQualifier = true;
|
|
201
|
+
}
|
|
202
|
+
if (!term) return { error: 'SPF contains an empty mechanism' };
|
|
203
|
+
const normalized = term.toLowerCase();
|
|
204
|
+
if (normalized.includes('=')) {
|
|
205
|
+
const match = term.match(/^([a-z][a-z0-9_.-]*)=(\S+)$/i);
|
|
206
|
+
if (hadQualifier || !match) return { error: `SPF contains an invalid modifier: ${rawTerm}` };
|
|
207
|
+
const name = match[1].toLowerCase();
|
|
208
|
+
if (modifiers.has(name)) return { error: `SPF contains duplicate ${name} modifiers` };
|
|
209
|
+
modifiers.add(name);
|
|
210
|
+
if (name === 'redirect') parsed.hasRedirect = true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (normalized.startsWith('ip4:') || normalized.startsWith('ip6:')) {
|
|
214
|
+
const family = normalized.startsWith('ip4:') ? 4 : 6;
|
|
215
|
+
const network = parseIpNetwork(term.slice(4), family);
|
|
216
|
+
if (!network) return { error: `SPF contains an invalid ${family === 4 ? 'ip4' : 'ip6'} mechanism: ${rawTerm}` };
|
|
217
|
+
parsed.mechanisms.push({ kind: 'ip', qualifier, network });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (normalized === 'a' || normalized.startsWith('a:') || normalized.startsWith('a/')) {
|
|
221
|
+
const mechanism = parseAMechanism(term);
|
|
222
|
+
if (!mechanism) return { error: `SPF contains an invalid a mechanism: ${rawTerm}` };
|
|
223
|
+
parsed.mechanisms.push({ kind: 'a', qualifier, mechanism });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (normalized === 'all') {
|
|
227
|
+
parsed.mechanisms.push({ kind: 'all', qualifier });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
normalized === 'mx'
|
|
232
|
+
|| normalized.startsWith('mx:')
|
|
233
|
+
|| normalized.startsWith('mx/')
|
|
234
|
+
|| normalized.startsWith('include:')
|
|
235
|
+
|| normalized.startsWith('exists:')
|
|
236
|
+
|| normalized === 'ptr'
|
|
237
|
+
|| normalized.startsWith('ptr:')
|
|
238
|
+
) {
|
|
239
|
+
parsed.mechanisms.push({ kind: 'unsupported', name: rawTerm });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
return { error: `SPF contains an unknown mechanism: ${rawTerm}` };
|
|
243
|
+
}
|
|
244
|
+
return { parsed };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function desiredSpfAddresses(value: string): { addresses?: IIpNetwork[]; error?: string } {
|
|
248
|
+
const parsed = parseSpfRecord(value);
|
|
249
|
+
if (!parsed.parsed) return { error: parsed.error };
|
|
250
|
+
const addresses: IIpNetwork[] = [];
|
|
251
|
+
for (const mechanism of parsed.parsed.mechanisms) {
|
|
252
|
+
if (mechanism.kind === 'all' && mechanism.qualifier === '-') continue;
|
|
253
|
+
if (
|
|
254
|
+
mechanism.kind !== 'ip'
|
|
255
|
+
|| mechanism.qualifier !== '+'
|
|
256
|
+
|| mechanism.network.prefixLength !== (mechanism.network.family === 4 ? 32 : 128)
|
|
257
|
+
) {
|
|
258
|
+
return { error: 'Desired SPF intent does not contain only concrete sender addresses' };
|
|
259
|
+
}
|
|
260
|
+
addresses.push(mechanism.network);
|
|
261
|
+
}
|
|
262
|
+
return addresses.length > 0
|
|
263
|
+
? { addresses }
|
|
264
|
+
: { error: 'Desired SPF intent does not contain concrete sender addresses' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
268
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
269
|
+
try {
|
|
270
|
+
return await Promise.race([
|
|
271
|
+
promise,
|
|
272
|
+
new Promise<T>((_resolve, reject) => {
|
|
273
|
+
timer = setTimeout(() => reject(new Error(`DNS lookup timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
274
|
+
}),
|
|
275
|
+
]);
|
|
276
|
+
} finally {
|
|
277
|
+
if (timer) clearTimeout(timer);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function evaluateSpf(
|
|
282
|
+
intent: IEmailDnsRecordIntent,
|
|
283
|
+
actualValue: string,
|
|
284
|
+
options: IMailDnsTxtPolicyOptions,
|
|
285
|
+
): Promise<IMailDnsTxtPolicyResult> {
|
|
286
|
+
const desired = desiredSpfAddresses(intent.value);
|
|
287
|
+
if (!desired.addresses) return { outcome: 'invalid', reason: desired.error };
|
|
288
|
+
const actual = parseSpfRecord(actualValue);
|
|
289
|
+
if (!actual.parsed) return { outcome: 'invalid', reason: actual.error };
|
|
290
|
+
|
|
291
|
+
const timeoutMs = Math.max(1, options.lookupTimeoutMs ?? 2_000);
|
|
292
|
+
const maxDnsLookups = Math.max(1, options.maxDnsLookups ?? 10);
|
|
293
|
+
let lookupCount = 0;
|
|
294
|
+
const cache = new Map<string, Promise<string[]>>();
|
|
295
|
+
const resolveBounded = async (name: string, type: 'A' | 'AAAA'): Promise<string[]> => {
|
|
296
|
+
const key = `${type}:${name}`;
|
|
297
|
+
const cached = cache.get(key);
|
|
298
|
+
if (cached) return await cached;
|
|
299
|
+
if (!options.resolveDns) throw new Error('SPF a mechanism requires a DNS resolver');
|
|
300
|
+
if (lookupCount >= maxDnsLookups) {
|
|
301
|
+
throw new Error(`SPF DNS lookup budget of ${maxDnsLookups} was exceeded`);
|
|
302
|
+
}
|
|
303
|
+
lookupCount++;
|
|
304
|
+
const lookup = withTimeout(options.resolveDns(name, type), timeoutMs);
|
|
305
|
+
cache.set(key, lookup);
|
|
306
|
+
return await lookup;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
for (const address of desired.addresses) {
|
|
311
|
+
let authorized = false;
|
|
312
|
+
for (const mechanism of actual.parsed.mechanisms) {
|
|
313
|
+
if (mechanism.kind === 'unsupported') {
|
|
314
|
+
return {
|
|
315
|
+
outcome: 'conflict',
|
|
316
|
+
reason: `SPF authorization is indeterminate before a proven match: ${mechanism.name}`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
let matches = false;
|
|
320
|
+
if (mechanism.kind === 'ip') {
|
|
321
|
+
matches = networkContains(mechanism.network, address);
|
|
322
|
+
} else if (mechanism.kind === 'a') {
|
|
323
|
+
const domain = mechanism.mechanism.domain || intent.name.toLowerCase().replace(/\.$/, '');
|
|
324
|
+
const type = address.family === 4 ? 'A' : 'AAAA';
|
|
325
|
+
const prefixLength = address.family === 4
|
|
326
|
+
? mechanism.mechanism.ipv4PrefixLength
|
|
327
|
+
: mechanism.mechanism.ipv6PrefixLength;
|
|
328
|
+
for (const answer of await resolveBounded(domain, type)) {
|
|
329
|
+
const network = parseIpNetwork(`${answer}/${prefixLength}`, address.family);
|
|
330
|
+
if (network && networkContains(network, address)) {
|
|
331
|
+
matches = true;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
matches = true;
|
|
337
|
+
}
|
|
338
|
+
if (!matches) continue;
|
|
339
|
+
if (mechanism.qualifier !== '+') {
|
|
340
|
+
return {
|
|
341
|
+
outcome: 'conflict',
|
|
342
|
+
reason: `SPF first match for a desired edge has qualifier ${mechanism.qualifier}`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
authorized = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
if (!authorized) {
|
|
349
|
+
return {
|
|
350
|
+
outcome: 'conflict',
|
|
351
|
+
reason: actual.parsed.hasRedirect
|
|
352
|
+
? 'SPF redirect is unsupported and authorization cannot be proven'
|
|
353
|
+
: 'SPF record does not authorize every desired mail edge address',
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (error: unknown) {
|
|
358
|
+
return { outcome: 'lookup-error', reason: `SPF DNS lookup failed: ${(error as Error).message}` };
|
|
359
|
+
}
|
|
360
|
+
return { outcome: 'valid', effectiveValue: actualValue };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
interface IDmarcPolicy {
|
|
364
|
+
policy: 'none' | 'quarantine' | 'reject';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseDmarc(value: string): { policy?: IDmarcPolicy; error?: string } {
|
|
368
|
+
const orderedTags: Array<{ key: string; value: string }> = [];
|
|
369
|
+
const seen = new Set<string>();
|
|
370
|
+
for (const segment of value.split(';')) {
|
|
371
|
+
const trimmed = segment.trim();
|
|
372
|
+
if (!trimmed) continue;
|
|
373
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
374
|
+
if (equalsIndex <= 0) return { error: `Malformed DMARC tag: ${trimmed}` };
|
|
375
|
+
const key = trimmed.slice(0, equalsIndex).trim().toLowerCase();
|
|
376
|
+
const tagValue = trimmed.slice(equalsIndex + 1).trim();
|
|
377
|
+
if (!key || !tagValue || seen.has(key)) return { error: `Invalid or duplicate DMARC tag: ${key}` };
|
|
378
|
+
seen.add(key);
|
|
379
|
+
orderedTags.push({ key, value: tagValue });
|
|
380
|
+
}
|
|
381
|
+
if (orderedTags[0]?.key !== 'v' || orderedTags[0].value !== 'DMARC1') {
|
|
382
|
+
return { error: 'DMARC v=DMARC1 must be the first tag' };
|
|
383
|
+
}
|
|
384
|
+
const tags = new Map(orderedTags.map((tag) => [tag.key, tag.value]));
|
|
385
|
+
const rawPolicy = tags.get('p')?.toLowerCase();
|
|
386
|
+
// Malformed optional tag values are ignored and locally defaulted. A missing
|
|
387
|
+
// or malformed p therefore has effective policy "none". pct and unknown
|
|
388
|
+
// extension tags do not change the policy-strength comparison.
|
|
389
|
+
const policy = rawPolicy === 'quarantine' || rawPolicy === 'reject' || rawPolicy === 'none'
|
|
390
|
+
? rawPolicy
|
|
391
|
+
: 'none';
|
|
392
|
+
return { policy: { policy } };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function evaluateDmarc(intent: IEmailDnsRecordIntent, actualValue: string): IMailDnsTxtPolicyResult {
|
|
396
|
+
const desired = parseDmarc(intent.value);
|
|
397
|
+
const actual = parseDmarc(actualValue);
|
|
398
|
+
if (!desired.policy) return { outcome: 'invalid', reason: desired.error };
|
|
399
|
+
if (!actual.policy) return { outcome: 'invalid', reason: actual.error };
|
|
400
|
+
const strength: Record<IDmarcPolicy['policy'], number> = { none: 0, quarantine: 1, reject: 2 };
|
|
401
|
+
if (strength[actual.policy.policy] < strength[desired.policy.policy]) {
|
|
402
|
+
return { outcome: 'conflict', reason: 'Operator DMARC policy is weaker than the desired policy' };
|
|
403
|
+
}
|
|
404
|
+
return { outcome: 'valid', effectiveValue: actualValue };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
interface IDkimPolicy {
|
|
408
|
+
publicKey: Uint8Array;
|
|
409
|
+
keyType: string;
|
|
410
|
+
hashes: string[];
|
|
411
|
+
services: string[];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function decodeDkimPublicKey(value: string): Uint8Array | undefined {
|
|
415
|
+
const normalized = value.replace(/\s+/g, '');
|
|
416
|
+
if (!normalized || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const decoded = plugins.buffer.Buffer.from(normalized, 'base64');
|
|
420
|
+
return decoded.toString('base64').replace(/=+$/, '') === normalized.replace(/=+$/, '')
|
|
421
|
+
? decoded
|
|
422
|
+
: undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseDkim(value: string): { policy?: IDkimPolicy; error?: string } {
|
|
426
|
+
const tags = new Map<string, string>();
|
|
427
|
+
for (const segment of value.split(';')) {
|
|
428
|
+
const trimmed = segment.trim();
|
|
429
|
+
if (!trimmed) continue;
|
|
430
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
431
|
+
if (equalsIndex <= 0) return { error: `Malformed DKIM tag: ${trimmed}` };
|
|
432
|
+
const key = trimmed.slice(0, equalsIndex).trim().toLowerCase();
|
|
433
|
+
const tagValue = trimmed.slice(equalsIndex + 1).trim();
|
|
434
|
+
if (!/^[a-z][a-z0-9]*$/.test(key) || tags.has(key)) {
|
|
435
|
+
return { error: `Invalid or duplicate DKIM tag: ${key}` };
|
|
436
|
+
}
|
|
437
|
+
tags.set(key, tagValue);
|
|
438
|
+
}
|
|
439
|
+
if (tags.has('v') && tags.get('v') !== 'DKIM1') {
|
|
440
|
+
return { error: 'DKIM v must be DKIM1 when present' };
|
|
441
|
+
}
|
|
442
|
+
const publicKey = decodeDkimPublicKey(tags.get('p') || '');
|
|
443
|
+
if (!publicKey) return { error: 'DKIM p must contain a valid non-empty base64 public key' };
|
|
444
|
+
const keyType = (tags.get('k') || 'rsa').toLowerCase();
|
|
445
|
+
const hashes = (tags.get('h') || 'sha256')
|
|
446
|
+
.split(':').map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
447
|
+
const services = (tags.get('s') || '*')
|
|
448
|
+
.split(':').map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
449
|
+
return { policy: { publicKey, keyType, hashes, services } };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
453
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function evaluateDkim(intent: IEmailDnsRecordIntent, actualValue: string): IMailDnsTxtPolicyResult {
|
|
457
|
+
const desired = parseDkim(intent.value);
|
|
458
|
+
const actual = parseDkim(actualValue);
|
|
459
|
+
if (!desired.policy) return { outcome: 'invalid', reason: desired.error };
|
|
460
|
+
if (!actual.policy) return { outcome: 'invalid', reason: actual.error };
|
|
461
|
+
if (actual.policy.keyType !== 'rsa') {
|
|
462
|
+
return { outcome: 'conflict', reason: 'DKIM k does not permit the required RSA key' };
|
|
463
|
+
}
|
|
464
|
+
if (!actual.policy.hashes.includes('sha256')) {
|
|
465
|
+
return { outcome: 'conflict', reason: 'DKIM h does not permit sha256' };
|
|
466
|
+
}
|
|
467
|
+
if (!actual.policy.services.some((service) => service === '*' || service === 'email')) {
|
|
468
|
+
return { outcome: 'conflict', reason: 'DKIM s does not permit email service' };
|
|
469
|
+
}
|
|
470
|
+
if (!equalBytes(actual.policy.publicKey, desired.policy.publicKey)) {
|
|
471
|
+
return { outcome: 'conflict', reason: `DKIM record at ${intent.name} contains a different public key` };
|
|
472
|
+
}
|
|
473
|
+
return { outcome: 'valid', effectiveValue: actualValue };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function evaluateMailTxtIntent(
|
|
477
|
+
intent: IEmailDnsRecordIntent,
|
|
478
|
+
rawValues: string[],
|
|
479
|
+
options: IMailDnsTxtPolicyOptions = {},
|
|
480
|
+
): Promise<IMailDnsTxtPolicyResult> {
|
|
481
|
+
const classified = rawValues
|
|
482
|
+
.map((value) => classifyMailTxtRecord(intent, value))
|
|
483
|
+
.filter((entry) => entry.relevant);
|
|
484
|
+
if (classified.length === 0) {
|
|
485
|
+
return { outcome: 'missing', reason: `${intent.type} ${intent.name} is missing` };
|
|
486
|
+
}
|
|
487
|
+
if (classified.length > 1) {
|
|
488
|
+
return { outcome: 'duplicate', reason: `${intent.name} has multiple relevant TXT records` };
|
|
489
|
+
}
|
|
490
|
+
const record = classified[0];
|
|
491
|
+
if (record.malformed || record.canonicalValue === undefined) {
|
|
492
|
+
return { outcome: 'invalid', reason: record.malformed || 'TXT record is malformed' };
|
|
493
|
+
}
|
|
494
|
+
const kind = intentKind(intent);
|
|
495
|
+
if (kind === 'spf') return await evaluateSpf(intent, record.canonicalValue, options);
|
|
496
|
+
if (kind === 'dmarc') return evaluateDmarc(intent, record.canonicalValue);
|
|
497
|
+
if (kind === 'dkim') return evaluateDkim(intent, record.canonicalValue);
|
|
498
|
+
if (record.canonicalValue === intent.value.trim()) {
|
|
499
|
+
return { outcome: 'valid', effectiveValue: record.canonicalValue };
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
outcome: 'conflict',
|
|
503
|
+
reason: `TXT record at ${intent.name} does not exactly match the desired value`,
|
|
504
|
+
};
|
|
505
|
+
}
|