Accept value-only DKIM TXT pastes for Email DNS

Allow v=DKIM1… pastes without a zone name by taking an optional
selector hint so Arvan/Cloudflare still get the right _domainkey record.
This commit is contained in:
Alireza Hassani
2026-09-18 10:38:06 +03:30
parent b7ca306097
commit 95d851f9dd
3 changed files with 38 additions and 8 deletions
+2 -2
View File
@@ -939,8 +939,8 @@ export class DomainAdminService {
let ed25519; let ed25519;
let rsa; let rsa;
try { try {
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519'); ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector);
rsa = parseDkimPaste(dto.rsaKey, 'rsa'); rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector);
} catch (err) { } catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key'); throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
} }
+13 -1
View File
@@ -1,4 +1,4 @@
import { IsIn, IsString, MaxLength, MinLength } from 'class-validator'; import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { PARKED_DNS_PROVIDERS } from '../parked-host.util'; import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
export class ApplyMailDnsDto { export class ApplyMailDnsDto {
@@ -14,4 +14,16 @@ export class ApplyMailDnsDto {
@MinLength(20) @MinLength(20)
@MaxLength(8000) @MaxLength(8000)
rsaKey!: string; rsaKey!: string;
/** Required when ed25519Key is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
@IsOptional()
@IsString()
@MaxLength(253)
ed25519Selector?: string;
/** Required when rsaKey is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
@IsOptional()
@IsString()
@MaxLength(253)
rsaSelector?: string;
} }
+23 -5
View File
@@ -11,13 +11,29 @@ export type MailDkimKey = {
text: string; text: string;
}; };
/** Bare selector, or `name._domainkey` / `name._domainkey.example.com.` */
export function normalizeDkimSelector(raw: string | undefined | null): string | null {
if (raw == null) return null;
let input = raw.trim().replace(/^"+|"+$/g, '').trim();
if (!input) return null;
const hostMatch = input.match(/^([a-z0-9][a-z0-9-]*)\._domainkey(?:\.|$)/i);
if (hostMatch) return hostMatch[1].toLowerCase();
if (/^[a-z0-9][a-z0-9-]*$/i.test(input)) return input.toLowerCase();
return null;
}
export function parseDkimPaste( export function parseDkimPaste(
raw: string, raw: string,
expectedKind: 'ed25519' | 'rsa', expectedKind: 'ed25519' | 'rsa',
selectorHint?: string,
): MailDkimKey { ): MailDkimKey {
const input = raw.trim(); const input = raw.trim();
if (!input) { if (!input) {
throw new Error(`Paste the ${expectedKind} DKIM line from Stalwart.`); throw new Error(
`Paste the ${expectedKind} DKIM TXT value (v=DKIM1; …) or the full Stalwart zone line.`,
);
} }
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join(''); const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
@@ -29,7 +45,7 @@ export function parseDkimPaste(
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim(); text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
if (!/^v=DKIM1/i.test(text)) { if (!/^v=DKIM1/i.test(text)) {
throw new Error( throw new Error(
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the Stalwart zone line.`, `Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the TXT value or Stalwart zone line.`,
); );
} }
@@ -45,14 +61,16 @@ export function parseDkimPaste(
} }
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i); const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
if (!nameMatch) { const selector =
(nameMatch ? nameMatch[1].toLowerCase() : null) || normalizeDkimSelector(selectorHint);
if (!selector) {
throw new Error( throw new Error(
'Include the selector name (for example v1-ed25519-20260831._domainkey) from the Stalwart zone file.', 'Could not find a DKIM selector. Enter the selector (for example v1-ed25519-20260901), or paste a zone line that includes …._domainkey.',
); );
} }
return { return {
selector: nameMatch[1].toLowerCase(), selector,
text, text,
}; };
} }