Add Email DNS upsert for Stalwart DKIM, MX, and SPF.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 22:00:04 +03:30
co-authored by Cursor
parent 6b8073f998
commit 6f1b13b6dc
7 changed files with 841 additions and 2 deletions
+355 -1
View File
@@ -1,10 +1,18 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MAIL_DMARC,
MAIL_HOSTNAME,
MAIL_IPV4,
MAIL_MX_PRIORITY,
MAIL_SPF,
type MailDkimKey,
} from '../mail-dns/mail-dns';
const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
const TTL = 120;
type ArvanRecordType = 'aname' | 'cname';
type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
type DesiredRecord = {
type: ArvanRecordType;
@@ -12,6 +20,13 @@ type DesiredRecord = {
value: Record<string, unknown>;
};
type DnsMutationResult = {
created: string[];
updated: string[];
skipped: string[];
removed: string[];
};
type ArvanDnsRecord = {
id: string;
type: string;
@@ -51,6 +66,89 @@ export class ArvanDnsService {
return this.ensureRecords(hostRaw, this.desiredParkedRecords);
}
/**
* Stalwart mail for a Meshkee website: mail A, MX, SPF, DKIM, autoconfig.
* DNS-only (not orange-cloud). Does not touch website ANAME/CNAME records.
*/
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<DnsMutationResult> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
throw new ServiceUnavailableException('Domain host is required for Arvan DNS');
}
const auth = this.authHeader();
if (!auth) {
throw new ServiceUnavailableException('Arvan DNS is not configured');
}
const zone = await this.request('GET', `/domains/${encodeURIComponent(host)}`, auth);
if (zone.status === 404) {
throw new ServiceUnavailableException(
`Arvan zone "${host}" was not found. Add the domain in Arvan first.`,
);
}
if (!zone.ok) {
throw new ServiceUnavailableException(
`Arvan could not load zone "${host}" (${zone.status})${zone.message ? `: ${zone.message}` : ''}`,
);
}
const existing = await this.listRecords(host, auth);
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const removed: string[] = [];
await this.replaceConflicting(
host,
auth,
existing,
'mail',
['cname', 'aname'],
removed,
);
await this.upsertTyped(host, auth, existing, {
type: 'a',
name: 'mail',
value: { ip: MAIL_IPV4 },
}, created, updated, skipped);
await this.replaceExtraMx(host, auth, existing, removed);
await this.upsertTyped(host, auth, existing, {
type: 'mx',
name: '@',
value: { host: `${MAIL_HOSTNAME}.`, priority: MAIL_MX_PRIORITY },
}, created, updated, skipped);
await this.upsertSpf(host, auth, existing, created, updated, skipped);
for (const key of keys) {
await this.upsertTyped(host, auth, existing, {
type: 'txt',
name: `${key.selector}._domainkey`,
value: { text: key.text },
}, created, updated, skipped);
}
await this.upsertTyped(host, auth, existing, {
type: 'cname',
name: 'autoconfig',
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
}, created, updated, skipped);
await this.upsertTyped(host, auth, existing, {
type: 'cname',
name: 'autodiscover',
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
}, created, updated, skipped);
await this.ensureDmarcIfMissing(host, auth, existing, created, skipped);
this.logger.log(
`Arvan mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
);
return { created, updated, skipped, removed };
}
private async ensureRecords(
hostRaw: string,
desiredFor: (apex: string) => DesiredRecord[],
@@ -198,14 +296,270 @@ export class ArvanDnsService {
];
}
private async upsertTyped(
host: string,
auth: string,
existing: ArvanDnsRecord[],
desired: DesiredRecord,
created: string[],
updated: string[],
skipped: string[],
) {
const match = existing.find(
(item) => this.relativeName(item.name, host) === desired.name && item.type === desired.type,
);
const label = `${desired.type.toUpperCase()} ${desired.name}`;
if (match && this.sameTarget(desired, match)) {
skipped.push(label);
return;
}
if (match) {
const put = await this.request(
'PUT',
`/domains/${encodeURIComponent(host)}/dns-records/${match.id}`,
auth,
{
type: desired.type,
name: desired.name,
ttl: match.ttl || TTL,
cloud: false,
value: desired.value,
},
);
if (!put.ok) {
throw new ServiceUnavailableException(
`Arvan failed to update ${label} (${put.status})${put.message ? `: ${put.message}` : ''}`,
);
}
match.value = desired.value;
updated.push(label);
return;
}
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
type: desired.type,
name: desired.name,
ttl: TTL,
cloud: false,
value: desired.value,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Arvan failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
const createdId =
post.body && typeof post.body === 'object' && 'data' in post.body
? String((post.body.data as { id?: string } | undefined)?.id ?? '')
: '';
existing.push({
id: createdId || `new-${desired.type}-${desired.name}`,
type: desired.type,
name: desired.name,
value: desired.value,
});
created.push(label);
}
private async upsertSpf(
host: string,
auth: string,
existing: ArvanDnsRecord[],
created: string[],
updated: string[],
skipped: string[],
) {
const match = existing.find(
(item) =>
this.relativeName(item.name, host) === '@' &&
item.type === 'txt' &&
this.txtValue(item).toLowerCase().startsWith('v=spf1'),
);
const desired: DesiredRecord = {
type: 'txt',
name: '@',
value: { text: MAIL_SPF },
};
if (match) {
if (this.sameTarget(desired, match)) {
skipped.push('TXT @ SPF');
return;
}
const put = await this.request(
'PUT',
`/domains/${encodeURIComponent(host)}/dns-records/${match.id}`,
auth,
{
type: 'txt',
name: '@',
ttl: match.ttl || TTL,
cloud: false,
value: desired.value,
},
);
if (!put.ok) {
throw new ServiceUnavailableException(
`Arvan failed to update TXT @ SPF (${put.status})${put.message ? `: ${put.message}` : ''}`,
);
}
match.value = desired.value;
updated.push('TXT @ SPF');
return;
}
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
type: 'txt',
name: '@',
ttl: TTL,
cloud: false,
value: desired.value,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Arvan failed to create TXT @ SPF (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
existing.push({
id: 'new-spf',
type: 'txt',
name: '@',
value: desired.value,
});
created.push('TXT @ SPF');
}
private async ensureDmarcIfMissing(
host: string,
auth: string,
existing: ArvanDnsRecord[],
created: string[],
skipped: string[],
) {
const match = existing.find(
(item) => this.relativeName(item.name, host) === '_dmarc' && item.type === 'txt',
);
if (match) {
skipped.push('TXT _dmarc');
return;
}
await this.upsertTyped(
host,
auth,
existing,
{ type: 'txt', name: '_dmarc', value: { text: MAIL_DMARC } },
created,
[],
skipped,
);
}
private async replaceExtraMx(
host: string,
auth: string,
existing: ArvanDnsRecord[],
removed: string[],
) {
const mx = existing.filter(
(item) => this.relativeName(item.name, host) === '@' && item.type === 'mx',
);
const keep = mx.find((item) => {
const current = item.value ?? {};
return this.fqdn(this.mxHost(current)) === MAIL_HOSTNAME;
});
for (const item of mx) {
if (keep && item.id === keep.id) continue;
const del = await this.request(
'DELETE',
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
auth,
);
if (!del.ok) {
throw new ServiceUnavailableException(
`Arvan failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`MX ${this.mxHost(item.value ?? {}) || item.id}`);
const index = existing.indexOf(item);
if (index >= 0) existing.splice(index, 1);
}
}
private async replaceConflicting(
host: string,
auth: string,
existing: ArvanDnsRecord[],
name: string,
types: string[],
removed: string[],
) {
const conflicts = existing.filter(
(item) =>
this.relativeName(item.name, host) === name && types.includes(item.type),
);
for (const item of conflicts) {
const del = await this.request(
'DELETE',
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
auth,
);
if (!del.ok) {
throw new ServiceUnavailableException(
`Arvan failed to replace ${item.type} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`${item.type.toUpperCase()} ${name}`);
const index = existing.indexOf(item);
if (index >= 0) existing.splice(index, 1);
}
}
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
const current = existing.value ?? {};
if (desired.type === 'aname') {
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
}
if (desired.type === 'a') {
return String(current.ip ?? '') === String(desired.value.ip ?? '');
}
if (desired.type === 'mx') {
return (
this.fqdn(this.mxHost(current)) === this.fqdn(String(desired.value.host ?? '')) &&
Number(current.priority ?? 0) === Number(desired.value.priority ?? 0)
);
}
if (desired.type === 'txt') {
return this.txtValue(existing).replace(/\s+/g, '') === String(desired.value.text ?? '').replace(/\s+/g, '');
}
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
}
private txtValue(record: ArvanDnsRecord): string {
const value = record.value ?? {};
if (typeof value.text === 'string') return value.text;
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
return String((value.txt as { text?: unknown }).text ?? '');
}
return '';
}
private mxHost(value: Record<string, unknown>): string {
if (typeof value.host === 'string') return value.host;
if (value.mx && typeof value.mx === 'object' && value.mx && 'host' in value.mx) {
return String((value.mx as { host?: unknown }).host ?? '');
}
return '';
}
private relativeName(name: string, zone: string): string {
const n = name.replace(/\.$/, '').toLowerCase();
const z = zone.replace(/\.$/, '').toLowerCase();
if (n === z || n === '@') return '@';
if (n.endsWith(`.${z}`)) return n.slice(0, -(z.length + 1));
return n;
}
private fqdn(value: string): string {
return value.trim().replace(/\.$/, '').toLowerCase();
}
@@ -1,5 +1,13 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MAIL_DMARC,
MAIL_HOSTNAME,
MAIL_IPV4,
MAIL_MX_PRIORITY,
MAIL_SPF,
type MailDkimKey,
} from '../mail-dns/mail-dns';
const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4';
const TTL = 120;
@@ -12,6 +20,7 @@ type CfRecord = {
content: string;
proxied?: boolean;
ttl?: number;
priority?: number;
};
@Injectable()
@@ -52,6 +61,139 @@ export class CloudflareDnsService {
]);
}
/**
* Stalwart mail for a Meshkee website. All records DNS-only (not proxied).
*/
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<{
created: string[];
updated: string[];
skipped: string[];
removed: string[];
}> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
throw new ServiceUnavailableException('Domain host is required for Cloudflare DNS');
}
const token = this.apiToken();
if (!token) {
throw new ServiceUnavailableException(
'Cloudflare DNS is not configured (CLOUDFLARE_API_TOKEN)',
);
}
const zoneId = await this.findZoneId(host, token);
const existing = await this.listRecords(zoneId, token);
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const removed: string[] = [];
await this.deleteRecordsOfTypes(zoneId, token, existing, host, 'mail', ['CNAME'], removed);
await this.upsertCf(
zoneId,
token,
existing,
host,
{ type: 'A', name: 'mail', content: MAIL_IPV4, proxied: false },
created,
updated,
skipped,
);
await this.replaceExtraMx(zoneId, token, existing, host, removed);
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'MX',
name: '@',
content: MAIL_HOSTNAME,
priority: MAIL_MX_PRIORITY,
proxied: false,
},
created,
updated,
skipped,
);
await this.upsertSpf(zoneId, token, existing, host, created, updated, skipped);
for (const key of keys) {
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'TXT',
name: `${key.selector}._domainkey`,
content: key.text,
proxied: false,
},
created,
updated,
skipped,
);
}
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'CNAME',
name: 'autoconfig',
content: MAIL_HOSTNAME,
proxied: false,
},
created,
updated,
skipped,
);
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'CNAME',
name: 'autodiscover',
content: MAIL_HOSTNAME,
proxied: false,
},
created,
updated,
skipped,
);
const dmarc = existing.find(
(rec) => rec.type === 'TXT' && this.relativeName(rec.name, host) === '_dmarc',
);
if (dmarc) {
skipped.push('TXT _dmarc');
} else {
await this.upsertCf(
zoneId,
token,
existing,
host,
{ type: 'TXT', name: '_dmarc', content: MAIL_DMARC, proxied: false },
created,
updated,
skipped,
);
}
this.logger.log(
`Cloudflare mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
);
return { created, updated, skipped, removed };
}
private async ensureCnameRecords(
hostRaw: string,
desiredFor: (apex: string) => Array<{ relative: string; content: string }>,
@@ -208,6 +350,198 @@ export class CloudflareDnsService {
}
}
private async upsertCf(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
desired: {
type: 'A' | 'MX' | 'TXT' | 'CNAME';
name: string;
content: string;
priority?: number;
proxied: boolean;
},
created: string[],
updated: string[],
skipped: string[],
) {
const label = `${desired.type} ${desired.name}`;
const match = existing.find(
(rec) => rec.type === desired.type && this.relativeName(rec.name, zone) === desired.name,
);
const same =
match &&
this.sameContent(match, desired.content) &&
(desired.priority === undefined || this.mxPriority(match) === desired.priority) &&
(desired.proxied === false ? match.proxied !== true : true);
if (match && same && match.proxied === false) {
skipped.push(label);
return;
}
const body: Record<string, unknown> = {
type: desired.type,
name: desired.name,
content: desired.content,
ttl: TTL,
proxied: desired.type === 'A' || desired.type === 'CNAME' ? desired.proxied : undefined,
};
if (desired.priority !== undefined) body.priority = desired.priority;
if (desired.type === 'MX' || desired.type === 'TXT') delete body.proxied;
if (match) {
const patch = await this.request(
'PATCH',
`/zones/${zoneId}/dns_records/${match.id}`,
token,
body,
);
if (!patch.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to update ${label} (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
);
}
match.content = desired.content;
match.proxied = desired.proxied;
updated.push(label);
return;
}
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, body);
if (!post.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
existing.push({
id: String((post.body.result as { id?: string } | undefined)?.id ?? `new-${label}`),
type: desired.type,
name: desired.name,
content: desired.content,
proxied: desired.proxied,
});
created.push(label);
}
private async upsertSpf(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
created: string[],
updated: string[],
skipped: string[],
) {
const match = existing.find(
(rec) =>
rec.type === 'TXT' &&
this.relativeName(rec.name, zone) === '@' &&
rec.content.replace(/^"|"$/g, '').toLowerCase().startsWith('v=spf1'),
);
if (match) {
if (this.sameContent(match, MAIL_SPF)) {
skipped.push('TXT @ SPF');
return;
}
const patch = await this.request(
'PATCH',
`/zones/${zoneId}/dns_records/${match.id}`,
token,
{ type: 'TXT', name: '@', content: MAIL_SPF, ttl: TTL },
);
if (!patch.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to update TXT @ SPF (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
);
}
match.content = MAIL_SPF;
updated.push('TXT @ SPF');
return;
}
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, {
type: 'TXT',
name: '@',
content: MAIL_SPF,
ttl: TTL,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to create TXT @ SPF (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
existing.push({
id: String((post.body.result as { id?: string } | undefined)?.id ?? 'new-spf'),
type: 'TXT',
name: '@',
content: MAIL_SPF,
});
created.push('TXT @ SPF');
}
private async replaceExtraMx(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
removed: string[],
) {
const mx = existing.filter(
(rec) => rec.type === 'MX' && this.relativeName(rec.name, zone) === '@',
);
const keep = mx.find(
(rec) => this.sameContent(rec, MAIL_HOSTNAME) && this.mxPriority(rec) === MAIL_MX_PRIORITY,
);
for (const rec of mx) {
if (keep && rec.id === keep.id) continue;
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
if (!del.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`MX ${rec.content}`);
const index = existing.indexOf(rec);
if (index >= 0) existing.splice(index, 1);
}
}
private async deleteRecordsOfTypes(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
name: string,
types: string[],
removed: string[],
) {
const conflicts = existing.filter(
(rec) => types.includes(rec.type) && this.relativeName(rec.name, zone) === name,
);
for (const rec of conflicts) {
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
if (!del.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to replace ${rec.type} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`${rec.type} ${name}`);
const index = existing.indexOf(rec);
if (index >= 0) existing.splice(index, 1);
}
}
private sameContent(rec: CfRecord, content: string): boolean {
return rec.content.replace(/\.$/, '').replace(/^"|"$/g, '').toLowerCase() ===
content.replace(/\.$/, '').replace(/^"|"$/g, '').toLowerCase();
}
private mxPriority(rec: CfRecord): number {
return Number(rec.priority ?? 0);
}
private relativeName(name: string, zone: string): string {
const n = name.replace(/\.$/, '').toLowerCase();
const z = zone.replace(/\.$/, '').toLowerCase();
@@ -19,6 +19,7 @@ import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
import { DomainAdminService } from './domain-admin.service';
@Controller('domains')
@@ -71,6 +72,16 @@ export class DomainAdminController {
return this.service.setParkedHosts(domainId, dto, user);
}
@Post(':domainId/mail-dns')
@UseGuards(JwtAuthGuard)
applyMailDns(
@Param('domainId') domainId: string,
@Body() dto: ApplyMailDnsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.applyMailDns(domainId, dto, user);
}
@Patch(':domainId')
@UseGuards(JwtAuthGuard)
update(
+67 -1
View File
@@ -30,7 +30,14 @@ import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { isValidParkedHost, parseParkedDnsMap, uniqueParkedAliases, uniqueParkedHosts } from './parked-host.util';
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
import { parseDkimPaste } from '../mail-dns/mail-dns';
import {
isValidParkedHost,
parseParkedDnsMap,
uniqueParkedAliases,
uniqueParkedHosts,
} from './parked-host.util';
import type { ParkedDnsProvider } from './parked-host.util';
type DomainRow = {
@@ -919,6 +926,65 @@ export class DomainAdminService {
};
}
async applyMailDns(domainIdRaw: string, dto: ApplyMailDnsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({
where: { id: domainId },
select: { id: true, host: true },
});
if (!domain) throw new NotFoundException('Website not found');
let ed25519;
let rsa;
try {
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519');
rsa = parseDkimPaste(dto.rsaKey, 'rsa');
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
}
if (ed25519.selector === rsa.selector) {
throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.');
}
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
const host = domain.host.trim().toLowerCase();
try {
const result =
provider === 'cloudflare'
? await this.cloudflareDns.ensureMailRecords(host, [ed25519, rsa])
: await this.arvanDns.ensureMailRecords(host, [ed25519, rsa]);
const parts = [
result.created.length ? `created ${result.created.join(', ')}` : '',
result.updated.length ? `updated ${result.updated.join(', ')}` : '',
result.removed.length ? `removed ${result.removed.join(', ')}` : '',
].filter(Boolean);
return {
host,
dnsProvider: provider,
created: result.created,
updated: result.updated,
skipped: result.skipped,
removed: result.removed,
message: parts.length
? `Mail DNS updated on ${provider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (${parts.join('; ')}).`
: `Mail DNS already up to date on ${provider === 'cloudflare' ? 'Cloudflare' : 'Arvan'}.`,
};
} catch (err) {
throw new ServiceUnavailableException(
this.httpErrorMessage(
err,
provider === 'cloudflare'
? 'Cloudflare mail DNS update failed'
: 'Arvan mail DNS update failed',
),
);
}
}
private async applyParkedDns(
host: string,
provider: ParkedDnsProvider,
@@ -0,0 +1,17 @@
import { IsIn, IsString, MaxLength, MinLength } from 'class-validator';
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
export class ApplyMailDnsDto {
@IsIn(PARKED_DNS_PROVIDERS)
dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number];
@IsString()
@MinLength(20)
@MaxLength(8000)
ed25519Key!: string;
@IsString()
@MinLength(20)
@MaxLength(8000)
rsaKey!: string;
}
+56
View File
@@ -0,0 +1,56 @@
export const MAIL_IPV4 = '185.214.101.41';
export const MAIL_HOSTNAME = 'mail.meshkee.com';
export const MAIL_MX_PRIORITY = 10;
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`;
export const MAIL_DMARC = 'v=DMARC1; p=none';
export type MailDkimKey = {
selector: string;
text: string;
};
export function parseDkimPaste(
raw: string,
expectedKind: 'ed25519' | 'rsa',
): MailDkimKey {
const input = raw.trim();
if (!input) {
throw new Error(`Paste the ${expectedKind} DKIM line from Stalwart.`);
}
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
const compact = input.replace(/[()\n\r\t]+/g, ' ').replace(/\s+/g, ' ').trim();
const blob = (quoted || compact).replace(/"\s+"/g, '');
const txtMatch = blob.match(/v=DKIM1[\s\S]*/i);
let text = (txtMatch ? txtMatch[0] : blob).trim();
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
if (!/^v=DKIM1/i.test(text)) {
throw new Error(
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the Stalwart zone line.`,
);
}
const kind = /k=ed25519/i.test(text)
? 'ed25519'
: /k=rsa/i.test(text)
? 'rsa'
: null;
if (kind && kind !== expectedKind) {
throw new Error(
`That paste is ${kind} DKIM. Put it in the ${kind === 'ed25519' ? 'Ed25519' : 'RSA'} field.`,
);
}
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
if (!nameMatch) {
throw new Error(
'Include the selector name (for example v1-ed25519-20260831._domainkey) from the Stalwart zone file.',
);
}
return {
selector: nameMatch[1].toLowerCase(),
text,
};
}