From 6f1b13b6dce5c7b031d98d9838ad0d0cd4dd53f9 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 1 Sep 2026 22:00:04 +0330 Subject: [PATCH] Add Email DNS upsert for Stalwart DKIM, MX, and SPF. Co-authored-by: Cursor --- docs/PROJECT_CONTEXT.md | 1 + src/arvan-dns/arvan-dns.service.ts | 356 ++++++++++++++++++- src/cloudflare-dns/cloudflare-dns.service.ts | 334 +++++++++++++++++ src/domain-admin/domain-admin.controller.ts | 11 + src/domain-admin/domain-admin.service.ts | 68 +++- src/domain-admin/dto/apply-mail-dns.dto.ts | 17 + src/mail-dns/mail-dns.ts | 56 +++ 7 files changed, 841 insertions(+), 2 deletions(-) create mode 100644 src/domain-admin/dto/apply-mail-dns.dto.ts create mode 100644 src/mail-dns/mail-dns.ts diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 60b5b93..3e958ff 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -643,6 +643,7 @@ See `.env.example` for the full list. Key groups: - Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile) - Super admin: users, businesses, domains, system business categories - Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only) +- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare - Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images) - Business team management - Media upload (S3 + Sharp) diff --git a/src/arvan-dns/arvan-dns.service.ts b/src/arvan-dns/arvan-dns.service.ts index 8a8e497..f4fd3ac 100644 --- a/src/arvan-dns/arvan-dns.service.ts +++ b/src/arvan-dns/arvan-dns.service.ts @@ -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; }; +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 { + 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 { + 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(); } diff --git a/src/cloudflare-dns/cloudflare-dns.service.ts b/src/cloudflare-dns/cloudflare-dns.service.ts index 9d82318..c309517 100644 --- a/src/cloudflare-dns/cloudflare-dns.service.ts +++ b/src/cloudflare-dns/cloudflare-dns.service.ts @@ -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 = { + 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(); diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts index 3488868..32057a8 100644 --- a/src/domain-admin/domain-admin.controller.ts +++ b/src/domain-admin/domain-admin.controller.ts @@ -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( diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index 853d990..8471f3a 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -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, diff --git a/src/domain-admin/dto/apply-mail-dns.dto.ts b/src/domain-admin/dto/apply-mail-dns.dto.ts new file mode 100644 index 0000000..56cdad1 --- /dev/null +++ b/src/domain-admin/dto/apply-mail-dns.dto.ts @@ -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; +} diff --git a/src/mail-dns/mail-dns.ts b/src/mail-dns/mail-dns.ts new file mode 100644 index 0000000..533e047 --- /dev/null +++ b/src/mail-dns/mail-dns.ts @@ -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, + }; +}