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
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
+1 -1
View File
@@ -129,5 +129,5 @@
"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);
}
@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);
}
}
+10 -4
View File
@@ -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;
+115 -111
View File
@@ -56,26 +56,10 @@ export class DomainsService {
await this.dnsService.generateRequiredDnsRecords(domain);
// Generate and create DKIM record
try {
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.setupDomainDkim(domain, "default");
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
this.logger.log(`Domain ${name} created for business ${businessId} with all required records`);
@@ -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);
+8 -11
View File
@@ -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<App>;
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!");
});
});