From 9ae6f260a856c038129f6ef98dc3967d9f45a27d Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Thu, 3 Jul 2025 12:25:31 +0330 Subject: [PATCH] chore: complete the mail domain setup --- Dockerfile | 2 +- package.json | 2 +- src/modules/domains/domains.controller.ts | 32 +-- src/modules/domains/services/dns.service.ts | 14 +- .../domains/services/domains.service.ts | 228 +++++++++--------- test/app.e2e-spec.ts | 19 +- 6 files changed, 147 insertions(+), 150 deletions(-) diff --git a/Dockerfile b/Dockerfile index faa5503..5f45ad5 100755 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine AS base 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 RUN apk add --no-cache tzdata diff --git a/package.json b/package.json index 2bf9bdd..d043f16 100644 --- a/package.json +++ b/package.json @@ -129,5 +129,5 @@ "git add ." ] }, - "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac" + "packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531" } diff --git a/src/modules/domains/domains.controller.ts b/src/modules/domains/domains.controller.ts index 3611b81..a141538 100644 --- a/src/modules/domains/domains.controller.ts +++ b/src/modules/domains/domains.controller.ts @@ -45,34 +45,24 @@ export class DomainsController { return this.domainsService.deleteDomain(paramDto.id); } - @Get(":id/setup") + @Get("setup") @ApiOperation({ summary: "Get domain setup instructions" }) @ApiResponse({ status: 200, description: "Domain setup instructions retrieved" }) - getDomainSetupInstructions(@Param() paramDto: ParamDto) { - return this.domainsService.getDomainSetupInstructions(paramDto.id); + getDomainSetupInstructions(@BusinessDec("id") businessId: string) { + return this.domainsService.getDomainSetupInstructions(businessId); } - @Get(":id/dns-records") + @Get("dns-records") @ApiOperation({ summary: "Get DNS records for domain" }) @ApiResponse({ status: 200, description: "DNS records retrieved successfully" }) - getDomainDnsRecords(@Param() paramDto: ParamDto) { - return this.domainsService.getDomainDnsRecords(paramDto.id); + getDomainDnsRecords(@BusinessDec("id") businessId: string) { + return this.domainsService.getDomainDnsRecords(businessId); } - @Get(":id/verification-status") - @ApiOperation({ summary: "Check domain verification status" }) - @ApiResponse({ status: 200, description: "Domain verification status checked" }) - checkDomainVerificationStatus(@Param() paramDto: ParamDto) { - return this.domainsService.checkDomainVerificationStatus(paramDto.id); - } - - @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); + @Get("verify-dns") + @ApiOperation({ summary: "Check DNS records, verify domain configuration and return verification status" }) + @ApiResponse({ status: 200, description: "DNS records verified and status returned" }) + verifyDnsRecords(@BusinessDec("id") businessId: string) { + return this.domainsService.checkDomainVerificationStatus(businessId); } } diff --git a/src/modules/domains/services/dns.service.ts b/src/modules/domains/services/dns.service.ts index 21c5cc0..eed6afe 100644 --- a/src/modules/domains/services/dns.service.ts +++ b/src/modules/domains/services/dns.service.ts @@ -20,8 +20,9 @@ export class DnsService { private readonly logger = new Logger(DnsService.name); private readonly mailServerDomain: string = "mail.danakcorp.com"; - private readonly spfRecord: string = `v=spf1 include:${this.mailServerDomain} ~all`; - private readonly dmarcRecord: string = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${this.mailServerDomain}; ruf=mailto:dmarc@${this.mailServerDomain}`; + private readonly serverDomain: string = "danakcorp.com"; + 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 resolveCname = promisify(dns.resolveCname); @@ -107,10 +108,11 @@ export class DnsService { }); // DMARC Record + const dmarcRecord = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${domain.name}; ruf=mailto:dmarc@${domain.name}`; requiredRecords.push({ name: `_dmarc.${domain.name}`, type: DNSRecordType.DMARC, - value: this.dmarcRecord, + value: dmarcRecord, isRequired: true, description: "DMARC policy record", }); @@ -269,7 +271,11 @@ export class DnsService { const txtRecords = await this.resolveTxt(dnsRecord.name); 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) { this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error"); return false; diff --git a/src/modules/domains/services/domains.service.ts b/src/modules/domains/services/domains.service.ts index 1901bd9..4ead5ea 100644 --- a/src/modules/domains/services/domains.service.ts +++ b/src/modules/domains/services/domains.service.ts @@ -56,25 +56,9 @@ export class DomainsService { await this.dnsService.generateRequiredDnsRecords(domain); // Generate and create DKIM record - try { - const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, "default"); + await this.setupDomainDkim(domain, "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); - - 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); - } + await this.em.persistAndFlush(domain); // Create initial verification @@ -98,16 +82,6 @@ export class DomainsService { 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 */ @@ -173,13 +147,40 @@ export class DomainsService { 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 */ private async setupDomainInMailServer(domain: Domain) { try { - // Create DKIM keys if enabled - if (domain.dkimEnabled && domain.dkimSelector) { + // Only create DKIM keys if enabled and not already created + if (domain.dkimEnabled && domain.dkimSelector && !domain.dkimPrivateKey) { + this.logger.log(`Creating DKIM keys for domain ${domain.name} in mail server`); + const dkimKey = await firstValueFrom( this.mailServerService.dkim.createDKIMKey({ domain: domain.name, @@ -189,6 +190,8 @@ export class DomainsService { ); 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}`); @@ -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 */ - async getDomainSetupInstructions(domainId: string) { - const domain = await this.getDomainById(domainId); - const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId); + async getDomainSetupInstructions(businessId: string) { + const businessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null }); + 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); } @@ -255,19 +218,57 @@ export class DomainsService { /** * Get DNS records for domain */ - async getDomainDnsRecords(domainId: string) { - // Verify domain exists - await this.getDomainById(domainId); - return this.dnsService.getDomainDnsRecords(domainId); + async getDomainDnsRecords(businessId: string) { + const businessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null }); + if (!businessDomain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND); + + 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 */ - async checkDomainVerificationStatus(domainId: string) { - await this.getDomainById(domainId); - const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId); + async checkDomainVerificationStatus(businessId: string) { + const businessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null }); + 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 = []; let verified = 0; let pending = 0; @@ -316,22 +317,24 @@ export class DomainsService { } } - const isVerified = failed === 0 && pending === 0 && verified > 0; - return { - dnsRecords: verificationStatuses, - overallStatus: { - isVerified, - verified, - pending, - failed, - total: dnsRecords.length, - lastChecked: new Date(), - }, + verificationStatuses, + statusCounts: { verified, pending, failed }, 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 */ @@ -353,20 +356,15 @@ export class DomainsService { instructions: this.generateRecordInstructions(record), })); - 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; - + const statusCounts = this.getDnsRecordStatusCounts(dnsRecords); const nextSteps = this.generateNextSteps(domain, dnsRecords); return { domain, dnsRecords: dnsInstructions, setupStatus: { - isComplete: failed === 0 && pending === 0 && verified > 0, - verified, - pending, - failed, + isComplete: statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0, + ...statusCounts, total: dnsRecords.length, }, nextSteps, @@ -420,19 +418,25 @@ export class DomainsService { */ private generateNextSteps(domain: Domain, dnsRecords: DnsRecord[]): string[] { const steps: string[] = []; + const statusCounts = this.getDnsRecordStatusCounts(dnsRecords); if (domain.status === DomainStatus.PENDING) { - steps.push(`1. ${DomainMessage.STEP_LOGIN_DNS_PANEL}`); - steps.push(`2. ${DomainMessage.STEP_ADD_ALL_RECORDS}`); - steps.push(`3. ${DomainMessage.STEP_INCLUDE_ALL_TYPES}`); - steps.push(`4. ${DomainMessage.STEP_WAIT_PROPAGATION}`); - steps.push(`5. ${DomainMessage.STEP_USE_CHECK_ENDPOINT}`); - steps.push(`6. ${DomainMessage.STEP_DOMAIN_READY}`); + const setupSteps = [ + DomainMessage.STEP_LOGIN_DNS_PANEL, + DomainMessage.STEP_ADD_ALL_RECORDS, + DomainMessage.STEP_INCLUDE_ALL_TYPES, + DomainMessage.STEP_WAIT_PROPAGATION, + 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 (pendingRecords.length > 0) { - steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: pendingRecords.length })}`); + if (statusCounts.pending > 0) { + steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: statusCounts.pending })}`); } const requiredRecords = dnsRecords.filter((r) => r.isRequired); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 78b1a56..5141f32 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,11 +1,11 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; -import { App } from 'supertest/types'; +import { INestApplication } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import * as request from "supertest"; +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; beforeEach(async () => { @@ -17,10 +17,7 @@ describe('AppController (e2e)', () => { await app.init(); }); - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + it("/ (GET)", () => { + return request(app.getHttpServer()).get("/").expect(200).expect("Hello World!"); }); });