chore: complete the mail domain setup

This commit is contained in:
mahyargdz
2025-07-03 12:25:31 +03:30
parent ccbf743ecb
commit 9ae6f260a8
6 changed files with 147 additions and 150 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
RUN npm install -g corepack@latest RUN npm install -g corepack@latest
RUN corepack enable && corepack prepare pnpm --activate RUN corepack enable && corepack prepare pnpm@9 --activate
# Install tzdata to support timezone settings # Install tzdata to support timezone settings
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
+1 -1
View File
@@ -129,5 +129,5 @@
"git add ." "git add ."
] ]
}, },
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" "packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531"
} }
+11 -21
View File
@@ -45,34 +45,24 @@ export class DomainsController {
return this.domainsService.deleteDomain(paramDto.id); return this.domainsService.deleteDomain(paramDto.id);
} }
@Get(":id/setup") @Get("setup")
@ApiOperation({ summary: "Get domain setup instructions" }) @ApiOperation({ summary: "Get domain setup instructions" })
@ApiResponse({ status: 200, description: "Domain setup instructions retrieved" }) @ApiResponse({ status: 200, description: "Domain setup instructions retrieved" })
getDomainSetupInstructions(@Param() paramDto: ParamDto) { getDomainSetupInstructions(@BusinessDec("id") businessId: string) {
return this.domainsService.getDomainSetupInstructions(paramDto.id); return this.domainsService.getDomainSetupInstructions(businessId);
} }
@Get(":id/dns-records") @Get("dns-records")
@ApiOperation({ summary: "Get DNS records for domain" }) @ApiOperation({ summary: "Get DNS records for domain" })
@ApiResponse({ status: 200, description: "DNS records retrieved successfully" }) @ApiResponse({ status: 200, description: "DNS records retrieved successfully" })
getDomainDnsRecords(@Param() paramDto: ParamDto) { getDomainDnsRecords(@BusinessDec("id") businessId: string) {
return this.domainsService.getDomainDnsRecords(paramDto.id); return this.domainsService.getDomainDnsRecords(businessId);
} }
@Get(":id/verification-status") @Get("verify-dns")
@ApiOperation({ summary: "Check domain verification status" }) @ApiOperation({ summary: "Check DNS records, verify domain configuration and return verification status" })
@ApiResponse({ status: 200, description: "Domain verification status checked" }) @ApiResponse({ status: 200, description: "DNS records verified and status returned" })
checkDomainVerificationStatus(@Param() paramDto: ParamDto) { verifyDnsRecords(@BusinessDec("id") businessId: string) {
return this.domainsService.checkDomainVerificationStatus(paramDto.id); return this.domainsService.checkDomainVerificationStatus(businessId);
}
@Post(":id/check-dns")
@ApiOperation({ summary: "Check DNS records and update verification status" })
@ApiResponse({ status: 200, description: "DNS records checked and updated" })
async checkDnsRecords(@Param() paramDto: ParamDto) {
// Trigger DNS verification
await this.domainsService.verifyDomain(paramDto.id);
// Return updated status
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
} }
} }
+10 -4
View File
@@ -20,8 +20,9 @@ export class DnsService {
private readonly logger = new Logger(DnsService.name); private readonly logger = new Logger(DnsService.name);
private readonly mailServerDomain: string = "mail.danakcorp.com"; private readonly mailServerDomain: string = "mail.danakcorp.com";
private readonly spfRecord: string = `v=spf1 include:${this.mailServerDomain} ~all`; private readonly serverDomain: string = "danakcorp.com";
private readonly dmarcRecord: string = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${this.mailServerDomain}; ruf=mailto:dmarc@${this.mailServerDomain}`; private readonly spfRecord: string = `v=spf1 mx include:${this.serverDomain} ~all`;
// private readonly dmarcRecord: string = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${this.serverDomain}; ruf=mailto:dmarc@${this.serverDomain}`;
private readonly resolveTxt = promisify(dns.resolveTxt); private readonly resolveTxt = promisify(dns.resolveTxt);
private readonly resolveCname = promisify(dns.resolveCname); private readonly resolveCname = promisify(dns.resolveCname);
@@ -107,10 +108,11 @@ export class DnsService {
}); });
// DMARC Record // DMARC Record
const dmarcRecord = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain.name}; ruf=mailto:dmarc@${domain.name}`;
requiredRecords.push({ requiredRecords.push({
name: `_dmarc.${domain.name}`, name: `_dmarc.${domain.name}`,
type: DNSRecordType.DMARC, type: DNSRecordType.DMARC,
value: this.dmarcRecord, value: dmarcRecord,
isRequired: true, isRequired: true,
description: "DMARC policy record", description: "DMARC policy record",
}); });
@@ -269,7 +271,11 @@ export class DnsService {
const txtRecords = await this.resolveTxt(dnsRecord.name); const txtRecords = await this.resolveTxt(dnsRecord.name);
const flatRecords = txtRecords.flat(); const flatRecords = txtRecords.flat();
return flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value); // Join all TXT record parts together (handles multi-part TXT records like DKIM)
const joinedRecord = flatRecords.join("");
// Check if the expected value matches either the joined record or any individual part
return joinedRecord === dnsRecord.value || flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value);
} catch (error) { } catch (error) {
this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error"); this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
return false; return false;
+115 -111
View File
@@ -56,26 +56,10 @@ export class DomainsService {
await this.dnsService.generateRequiredDnsRecords(domain); await this.dnsService.generateRequiredDnsRecords(domain);
// Generate and create DKIM record // Generate and create DKIM record
try { await this.setupDomainDkim(domain, "default");
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, "default");
// Update domain with DKIM keys
domain.dkimPrivateKey = dkimKeys.privateKey;
domain.dkimPublicKey = dkimKeys.publicKey;
// Create DKIM DNS record
await this.dnsService.createDKIMRecord(domain, "default", dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
await this.em.persistAndFlush(domain); await this.em.persistAndFlush(domain);
this.logger.log(`DKIM automatically enabled for domain ${name}`);
} catch (error) {
this.logger.warn(`Failed to create DKIM for domain ${name}:`, error);
// Continue with domain creation even if DKIM fails
domain.dkimEnabled = false;
await this.em.persistAndFlush(domain);
}
// Create initial verification // Create initial verification
this.logger.log(`Domain ${name} created for business ${businessId} with all required records`); this.logger.log(`Domain ${name} created for business ${businessId} with all required records`);
@@ -98,16 +82,6 @@ export class DomainsService {
return domain; return domain;
} }
/**
* Get domain by name
*/
async getDomainByName(name: string) {
const domain = await this.domainRepository.findByName(name);
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
return { domain };
}
/** /**
* Get domains for a business * Get domains for a business
*/ */
@@ -173,13 +147,40 @@ export class DomainsService {
return { domain }; return { domain };
} }
/**
* Setup DKIM for domain
*/
private async setupDomainDkim(domain: Domain, selector: string) {
try {
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, selector);
// Update domain with DKIM keys
domain.dkimPrivateKey = dkimKeys.privateKey;
domain.dkimPublicKey = dkimKeys.publicKey;
// Create DKIM DNS record
await this.dnsService.createDKIMRecord(domain, selector, dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
this.logger.log(`DKIM automatically enabled for domain ${domain.name}`);
} catch (error) {
this.logger.warn(`Failed to create DKIM for domain ${domain.name}:`, error);
// Continue with domain creation even if DKIM fails
domain.dkimEnabled = false;
domain.dkimPrivateKey = undefined;
domain.dkimPublicKey = undefined;
domain.dkimSelector = undefined;
}
}
/** /**
* Setup domain in mail server * Setup domain in mail server
*/ */
private async setupDomainInMailServer(domain: Domain) { private async setupDomainInMailServer(domain: Domain) {
try { try {
// Create DKIM keys if enabled // Only create DKIM keys if enabled and not already created
if (domain.dkimEnabled && domain.dkimSelector) { if (domain.dkimEnabled && domain.dkimSelector && !domain.dkimPrivateKey) {
this.logger.log(`Creating DKIM keys for domain ${domain.name} in mail server`);
const dkimKey = await firstValueFrom( const dkimKey = await firstValueFrom(
this.mailServerService.dkim.createDKIMKey({ this.mailServerService.dkim.createDKIMKey({
domain: domain.name, domain: domain.name,
@@ -189,6 +190,8 @@ export class DomainsService {
); );
if (!dkimKey.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_DKIM_KEY); if (!dkimKey.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_DKIM_KEY);
} else if (domain.dkimEnabled && domain.dkimPrivateKey) {
this.logger.log(`DKIM already exists for domain ${domain.name}, skipping creation`);
} }
this.logger.log(`Mail server setup completed for domain ${domain.name}`); this.logger.log(`Mail server setup completed for domain ${domain.name}`);
@@ -198,56 +201,16 @@ export class DomainsService {
} }
} }
/**
* Enable DKIM for domain
*/
async enableDKIM(id: string, selector?: string) {
const domain = await this.getDomainById(id);
const dkimSelector = selector || "default";
// Generate DKIM keys
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, dkimSelector);
domain.dkimEnabled = true;
domain.dkimSelector = dkimSelector;
domain.dkimPrivateKey = dkimKeys.privateKey;
domain.dkimPublicKey = dkimKeys.publicKey;
// Create DKIM DNS record
await this.dnsService.createDKIMRecord(domain, dkimSelector, dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
await this.em.persistAndFlush(domain);
this.logger.log(`DKIM enabled for domain ${domain.name}`);
return { domain };
}
/**
* Get domain statistics
*/
async getDomainStats(businessId: string) {
return this.domainRepository.getBusinessDomainStats(businessId);
}
/**
* Check all domains that need verification
*/
async checkDomainsNeedingVerification() {
const domain = await this.domainRepository.findNeedingCheck();
if (!domain) return { message: DomainMessage.NO_DOMAIN_FOUND };
await this.verifyDomain(domain.id);
return { message: DomainMessage.DOMAIN_CHECKED, domain: domain };
}
/** /**
* Get domain setup instructions * Get domain setup instructions
*/ */
async getDomainSetupInstructions(domainId: string) { async getDomainSetupInstructions(businessId: string) {
const domain = await this.getDomainById(domainId); const businessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null });
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId); if (!businessDomain) throw new NotFoundException(DomainMessage.DOMAIN_NOT_FOUND);
const domain = await this.getDomainById(businessDomain.id);
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(businessDomain.id);
return this.formatDomainSetupResponse(domain, dnsRecords); return this.formatDomainSetupResponse(domain, dnsRecords);
} }
@@ -255,19 +218,57 @@ export class DomainsService {
/** /**
* Get DNS records for domain * Get DNS records for domain
*/ */
async getDomainDnsRecords(domainId: string) { async getDomainDnsRecords(businessId: string) {
// Verify domain exists const businessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null });
await this.getDomainById(domainId); if (!businessDomain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
return this.dnsService.getDomainDnsRecords(domainId);
const domain = await this.getDomainById(businessDomain.id);
const { overallStatus, recommendations } = await this.checkDomainVerificationStatus(businessId);
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
return {
dnsRecords,
overallStatus,
recommendations,
};
} }
/** /**
* Check domain verification status * Check domain verification status
*/ */
async checkDomainVerificationStatus(domainId: string) { async checkDomainVerificationStatus(businessId: string) {
await this.getDomainById(domainId); const businessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null });
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId); if (!businessDomain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
const domain = await this.getDomainById(businessDomain.id);
// Trigger DNS verification
await this.verifyDomain(domain.id);
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
// Process DNS records for verification status
const { verificationStatuses, statusCounts, recommendations } = await this.processDnsRecordsForVerification(dnsRecords);
const isVerified = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0;
return {
dnsRecords: verificationStatuses,
overallStatus: {
isVerified,
...statusCounts,
total: dnsRecords.length,
lastChecked: new Date(),
},
recommendations,
};
}
/**
* Process DNS records for verification status
*/
private async processDnsRecordsForVerification(dnsRecords: DnsRecord[]) {
const verificationStatuses = []; const verificationStatuses = [];
let verified = 0; let verified = 0;
let pending = 0; let pending = 0;
@@ -316,22 +317,24 @@ export class DomainsService {
} }
} }
const isVerified = failed === 0 && pending === 0 && verified > 0;
return { return {
dnsRecords: verificationStatuses, verificationStatuses,
overallStatus: { statusCounts: { verified, pending, failed },
isVerified,
verified,
pending,
failed,
total: dnsRecords.length,
lastChecked: new Date(),
},
recommendations, recommendations,
}; };
} }
/**
* Get DNS record status counts
*/
private getDnsRecordStatusCounts(dnsRecords: DnsRecord[]) {
const verified = dnsRecords.filter((r) => r.status === VerificationStatus.VERIFIED).length;
const pending = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING).length;
const failed = dnsRecords.filter((r) => r.status === VerificationStatus.FAILED).length;
return { verified, pending, failed };
}
/** /**
* Format domain setup response with instructions * Format domain setup response with instructions
*/ */
@@ -353,20 +356,15 @@ export class DomainsService {
instructions: this.generateRecordInstructions(record), instructions: this.generateRecordInstructions(record),
})); }));
const verified = dnsRecords.filter((r) => r.status === VerificationStatus.VERIFIED).length; const statusCounts = this.getDnsRecordStatusCounts(dnsRecords);
const pending = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING).length;
const failed = dnsRecords.filter((r) => r.status === VerificationStatus.FAILED).length;
const nextSteps = this.generateNextSteps(domain, dnsRecords); const nextSteps = this.generateNextSteps(domain, dnsRecords);
return { return {
domain, domain,
dnsRecords: dnsInstructions, dnsRecords: dnsInstructions,
setupStatus: { setupStatus: {
isComplete: failed === 0 && pending === 0 && verified > 0, isComplete: statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0,
verified, ...statusCounts,
pending,
failed,
total: dnsRecords.length, total: dnsRecords.length,
}, },
nextSteps, nextSteps,
@@ -420,19 +418,25 @@ export class DomainsService {
*/ */
private generateNextSteps(domain: Domain, dnsRecords: DnsRecord[]): string[] { private generateNextSteps(domain: Domain, dnsRecords: DnsRecord[]): string[] {
const steps: string[] = []; const steps: string[] = [];
const statusCounts = this.getDnsRecordStatusCounts(dnsRecords);
if (domain.status === DomainStatus.PENDING) { if (domain.status === DomainStatus.PENDING) {
steps.push(`1. ${DomainMessage.STEP_LOGIN_DNS_PANEL}`); const setupSteps = [
steps.push(`2. ${DomainMessage.STEP_ADD_ALL_RECORDS}`); DomainMessage.STEP_LOGIN_DNS_PANEL,
steps.push(`3. ${DomainMessage.STEP_INCLUDE_ALL_TYPES}`); DomainMessage.STEP_ADD_ALL_RECORDS,
steps.push(`4. ${DomainMessage.STEP_WAIT_PROPAGATION}`); DomainMessage.STEP_INCLUDE_ALL_TYPES,
steps.push(`5. ${DomainMessage.STEP_USE_CHECK_ENDPOINT}`); DomainMessage.STEP_WAIT_PROPAGATION,
steps.push(`6. ${DomainMessage.STEP_DOMAIN_READY}`); DomainMessage.STEP_USE_CHECK_ENDPOINT,
DomainMessage.STEP_DOMAIN_READY,
];
setupSteps.forEach((step, index) => {
steps.push(`${index + 1}. ${step}`);
});
} }
const pendingRecords = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING); if (statusCounts.pending > 0) {
if (pendingRecords.length > 0) { steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: statusCounts.pending })}`);
steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: pendingRecords.length })}`);
} }
const requiredRecords = dnsRecords.filter((r) => r.isRequired); const requiredRecords = dnsRecords.filter((r) => r.isRequired);
+8 -11
View File
@@ -1,11 +1,11 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from "@nestjs/common";
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from "@nestjs/testing";
import * as request from 'supertest'; import * as request from "supertest";
import { App } from 'supertest/types'; import { App } from "supertest/types";
import { AppModule } from './../src/app.module'; import { AppModule } from "./../src/app.module";
describe('AppController (e2e)', () => { describe("AppController (e2e)", () => {
let app: INestApplication<App>; let app: INestApplication<App>;
beforeEach(async () => { beforeEach(async () => {
@@ -17,10 +17,7 @@ describe('AppController (e2e)', () => {
await app.init(); await app.init();
}); });
it('/ (GET)', () => { it("/ (GET)", () => {
return request(app.getHttpServer()) return request(app.getHttpServer()).get("/").expect(200).expect("Hello World!");
.get('/')
.expect(200)
.expect('Hello World!');
}); });
}); });