diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 5618c45..f3c4c56 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -211,6 +211,7 @@ export const enum EmailMessage { BULK_ACTION_COMPLETED_SUCCESSFULLY = "عملیات گروهی با موفقیت انجام شد", BULK_ACTION_PARTIALLY_COMPLETED = "عملیات گروهی تا حدی انجام شد", BULK_ACTION_FAILED = "عملیات گروهی با خطا مواجه شد", + USER_NOT_FOUND = "کاربر یافت نشد", } export const enum WebSocketMessage { diff --git a/src/modules/email/DTO/send-email.dto.ts b/src/modules/email/DTO/send-email.dto.ts index 8d9fec9..c2e5faa 100644 --- a/src/modules/email/DTO/send-email.dto.ts +++ b/src/modules/email/DTO/send-email.dto.ts @@ -4,6 +4,14 @@ import { IsArray, IsBoolean, IsDateString, IsEmail, IsEnum, IsNotEmpty, IsNumber import { EmailMessage } from "../../../common/enums/message.enum"; +//######################################################## +export enum EmailType { + GENERAL = "general", + TRANSACTIONAL = "transactional", + MARKETING = "marketing", + ANTI_THREADING = "anti-threading", +} + //######################################################## export class EmailRecipientDto { @IsOptional() @@ -99,6 +107,56 @@ export class EmailEnvelopeDto { //######################################################## export class SendEmailDto { + @IsOptional() + @IsEnum(EmailType, { message: "Email type must be one of: general, transactional, marketing, anti-threading" }) + @ApiPropertyOptional({ + description: "Email type for appropriate header configuration", + enum: EmailType, + default: EmailType.GENERAL, + example: EmailType.TRANSACTIONAL, + }) + emailType?: EmailType; + + @IsOptional() + @IsString({ message: EmailMessage.HEADER_VALUE_MUST_BE_STRING }) + @ApiPropertyOptional({ description: "Transaction ID for transactional emails (optional)", example: "ORDER-123456" }) + transactionId?: string; + + @IsOptional() + @IsString({ message: EmailMessage.HEADER_VALUE_MUST_BE_STRING }) + @ApiPropertyOptional({ description: "Category for transactional emails (optional)", example: "order-confirmation" }) + category?: string; + + @IsOptional() + @IsString({ message: EmailMessage.HEADER_VALUE_MUST_BE_STRING }) + @ApiPropertyOptional({ description: "List ID for marketing emails (optional)", example: "newsletter-subscribers" }) + listId?: string; + + @IsOptional() + @IsString({ message: EmailMessage.HEADER_VALUE_MUST_BE_STRING }) + @ApiPropertyOptional({ description: "Unsubscribe URL for marketing emails (optional)", example: "https://yourdomain.com/unsubscribe?token=xyz" }) + unsubscribeUrl?: string; + + @IsOptional() + @IsString({ message: EmailMessage.HEADER_VALUE_MUST_BE_STRING }) + @ApiPropertyOptional({ description: "Campaign ID for marketing emails (optional)", example: "CAMPAIGN-2024-001" }) + campaignId?: string; + + @IsOptional() + @IsEnum(["high", "normal", "low"], { message: "Priority must be one of: high, normal, low" }) + @ApiPropertyOptional({ description: "Email priority (optional)", enum: ["high", "normal", "low"], default: "normal" }) + priority?: "high" | "normal" | "low"; + + @IsOptional() + @IsBoolean({ message: EmailMessage.IS_DRAFT_MUST_BE_BOOLEAN }) + @ApiPropertyOptional({ description: "Force Gmail thread break for anti-threading emails (optional)", default: false }) + forceGmailBreak?: boolean; + + @IsOptional() + @IsString({ message: EmailMessage.HEADER_VALUE_MUST_BE_STRING }) + @ApiPropertyOptional({ description: "Conversation breaker for anti-threading emails (optional)" }) + conversationBreaker?: string; + @IsOptional() @IsNotEmpty() @IsString({ message: EmailMessage.MAILBOX_MUST_BE_STRING }) diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts index 0ac171d..0ef5535 100644 --- a/src/modules/email/email.controller.ts +++ b/src/modules/email/email.controller.ts @@ -16,9 +16,13 @@ export class EmailController { constructor(private readonly emailService: EmailService) {} @Post("send") - @ApiOperation({ summary: "Send an email" }) + @ApiOperation({ + summary: "Send an email with configurable headers based on type", + description: + "Send emails with automatic anti-threading and anti-spam headers. Supports different email types: general, transactional, marketing, anti-threading. Marketing emails require listId and unsubscribeUrl.", + }) @ApiResponse({ status: 201, description: "Email sent successfully" }) - @ApiResponse({ status: 400, description: "Invalid email data" }) + @ApiResponse({ status: 400, description: "Invalid email data or missing required fields for email type" }) @ApiResponse({ status: 403, description: "Not authorized to send email" }) sendEmail(@UserDec("wildduckUserId") userEmailId: string, @Body() sendEmailDto: SendEmailDto) { return this.emailService.sendEmail(userEmailId, sendEmailDto); diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts index e971ed3..f899b2e 100644 --- a/src/modules/email/email.module.ts +++ b/src/modules/email/email.module.ts @@ -4,6 +4,7 @@ import { JwtModule } from "@nestjs/jwt"; import { EmailController } from "./email.controller"; import { EmailGateway } from "./email.gateway"; +import { EmailHeadersService } from "./services/email-headers.service"; import { EmailNotificationService } from "./services/email-notification.service"; import { EmailService } from "./services/email.service"; import { MailboxResolverService } from "./services/mailbox-resolver.service"; @@ -17,7 +18,7 @@ import { UsersModule } from "../users/users.module"; @Module({ imports: [MailServerModule, UsersModule, TemplatesModule, JwtModule.registerAsync(jwtConfig()), MikroOrmModule.forFeature([User])], controllers: [EmailController], - providers: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService], - exports: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService], + providers: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService, EmailHeadersService], + exports: [EmailService, MailboxResolverService, EmailGateway, EmailNotificationService, WebSocketAuthService, EmailHeadersService], }) export class EmailModule {} diff --git a/src/modules/email/services/email-headers.service.ts b/src/modules/email/services/email-headers.service.ts new file mode 100644 index 0000000..d9165b2 --- /dev/null +++ b/src/modules/email/services/email-headers.service.ts @@ -0,0 +1,308 @@ +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { EmailHeaderDto } from "../DTO/send-email.dto"; + +export const EMAIL_HEADERS_SERVICE = Symbol("EMAIL_HEADERS_SERVICE"); + +export interface IGenerateAntiThreadingHeadersOptions { + messageId?: string; + listId?: string; + listUnsubscribe?: string; + businessName?: string; + businessDomain?: string; + isTransactional?: boolean; + isMarketing?: boolean; + priority?: "high" | "normal" | "low"; +} + +export interface IGenerateGmailAntiThreadingHeadersOptions extends Pick { + conversationBreaker?: string; +} + +export interface IGenerateTransactionalHeadersOptions extends Pick { + transactionId?: string; + category?: string; +} + +export interface IGenerateMarketingHeadersOptions + extends Pick { + campaignId?: string; + unsubscribeUrl: string; +} + +@Injectable() +export class EmailHeadersService { + /** + * Generate headers to prevent Gmail threading and reduce spam likelihood + */ + generateAntiThreadingHeaders(options: IGenerateAntiThreadingHeadersOptions): EmailHeaderDto[] { + const headers: EmailHeaderDto[] = []; + + // Generate unique Message-ID to prevent threading + const messageId = options.messageId || this.generateMessageId(options.businessDomain); + headers.push({ + key: "Message-ID", + value: messageId, + }); + + // Add References header with unique value to break threading + headers.push({ + key: "References", + value: `<${uuidv4()}@${options.businessDomain || "localhost"}>`, + }); + + // Add unique Thread-Index to prevent Outlook threading + headers.push({ + key: "Thread-Index", + value: this.generateThreadIndex(), + }); + + // Anti-spam headers + headers.push({ + key: "X-Mailer", + value: `${options.businessName || "Email Service"} v1.0`, + }); + + headers.push({ + key: "X-Priority", + value: this.getPriorityValue(options.priority || "normal"), + }); + + headers.push({ + key: "X-MSMail-Priority", + value: options.priority || "Normal", + }); + + // Add MIME version + headers.push({ + key: "MIME-Version", + value: "1.0", + }); + + // Add authentication headers + if (options.businessDomain) { + headers.push({ + key: "Authentication-Results", + value: `${options.businessDomain}; dkim=pass; spf=pass; dmarc=pass`, + }); + } + + // Content classification headers + if (options.isTransactional) { + headers.push({ + key: "X-Auto-Response-Suppress", + value: "All", + }); + headers.push({ + key: "X-Entity-ID", + value: "transactional", + }); + headers.push({ + key: "Precedence", + value: "bulk", + }); + } + + if (options.isMarketing) { + headers.push({ + key: "X-Entity-ID", + value: "marketing", + }); + headers.push({ + key: "List-Unsubscribe-Post", + value: "List-Unsubscribe=One-Click", + }); + + if (options.listUnsubscribe) { + headers.push({ + key: "List-Unsubscribe", + value: options.listUnsubscribe, + }); + } + } + + // Add List-ID if provided + if (options.listId) { + headers.push({ + key: "List-Id", + value: options.listId, + }); + } + + // Add custom headers to help with deliverability + // headers.push({ + // key: "X-Originating-IP", + // value: "[" + this.getServerIP() + "]", + // }); + + headers.push({ + key: "X-Spam-Status", + value: "No", + }); + + headers.push({ + key: "X-Spam-Score", + value: "0.0", + }); + + // Add feedback loop headers + headers.push({ + key: "X-Feedback-ID", + value: `${uuidv4()}:${options.businessDomain || "localhost"}`, + }); + + return headers; + } + + /** + * Generate headers specifically for preventing Gmail conversation threading + */ + generateGmailAntiThreadingHeaders(options: IGenerateGmailAntiThreadingHeadersOptions): EmailHeaderDto[] { + const headers: EmailHeaderDto[] = []; + + // Generate completely unique Message-ID for each email + const uniqueMessageId = this.generateMessageId(options.businessDomain, true); + headers.push({ + key: "Message-ID", + value: uniqueMessageId, + }); + + // Clear any potential threading references + headers.push({ + key: "References", + value: `<${uuidv4()}@${options.businessDomain || "localhost"}>`, + }); + + headers.push({ + key: "In-Reply-To", + value: `<${uuidv4()}@${options.businessDomain || "localhost"}>`, + }); + + // Gmail-specific thread breaking + headers.push({ + key: "X-Gmail-Thread-Break", + value: options.conversationBreaker || uuidv4(), + }); + + // Additional thread prevention + headers.push({ + key: "Thread-Topic", + value: uuidv4(), + }); + + return headers; + } + + /** + * Generate headers for transactional emails (receipts, notifications, etc.) + */ + generateTransactionalHeaders(options: IGenerateTransactionalHeadersOptions): EmailHeaderDto[] { + const headers: EmailHeaderDto[] = []; + + // Base anti-threading headers + headers.push( + ...this.generateAntiThreadingHeaders({ + businessName: options.businessName, + businessDomain: options.businessDomain, + isTransactional: true, + }), + ); + + // Transactional-specific headers + headers.push({ + key: "X-Transaction-ID", + value: options.transactionId || uuidv4(), + }); + + if (options.category) { + headers.push({ + key: "X-Category", + value: options.category, + }); + } + + // Mark as important transactional email + headers.push({ + key: "Importance", + value: "high", + }); + + headers.push({ + key: "X-Auto-Response-Suppress", + value: "All", + }); + + return headers; + } + + /** + * Generate headers for marketing emails + */ + generateMarketingHeaders(options: IGenerateMarketingHeadersOptions): EmailHeaderDto[] { + const headers: EmailHeaderDto[] = []; + + // Base anti-threading headers + headers.push( + ...this.generateAntiThreadingHeaders({ + businessName: options.businessName, + businessDomain: options.businessDomain, + isMarketing: true, + listId: options.listId, + listUnsubscribe: `<${options.unsubscribeUrl}>`, + }), + ); + + // Marketing-specific headers + if (options.campaignId) { + headers.push({ + key: "X-Campaign-ID", + value: options.campaignId, + }); + } + + headers.push({ + key: "X-Marketing-Email", + value: "true", + }); + + // Required for bulk marketing emails + headers.push({ + key: "Precedence", + value: "bulk", + }); + + return headers; + } + + private generateMessageId(domain?: string, extraUnique = false): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 15); + const uuid = extraUnique ? uuidv4() : ""; + return `<${timestamp}.${random}${uuid}@${domain || "localhost"}>`; + } + + private generateThreadIndex(): string { + // Generate a unique thread index for Outlook + const timestamp = Date.now().toString(16); + const random = Math.random().toString(16).substring(2, 10); + return `${timestamp}${random}`.toUpperCase(); + } + + private getPriorityValue(priority: "high" | "normal" | "low"): string { + switch (priority) { + case "high": + return "1"; + case "low": + return "5"; + default: + return "3"; + } + } + + // private getServerIP(): string { + // // In a real implementation, you might want to get the actual server IP + // // For now, returning a placeholder + // return "127.0.0.1"; + // } +} diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts index d63b3ac..c185d76 100644 --- a/src/modules/email/services/email.service.ts +++ b/src/modules/email/services/email.service.ts @@ -1,15 +1,17 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; +import { EmailHeadersService } from "./email-headers.service"; import { MailboxResolverService } from "./mailbox-resolver.service"; import { EmailMessage } from "../../../common/enums/message.enum"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { MailboxEnum } from "../../mailbox/enums/mailbox.enum"; import { TemplateProcessorService } from "../../templates/services/template-processor.service"; +import { User } from "../../users/entities/user.entity"; import { UserRepository } from "../../users/repositories/user.repository"; import { BulkActionDto, BulkActionType } from "../DTO/bulk-actions.dto"; import { MessageListQueryDto, SearchMessagesQueryDto } from "../DTO/email-query.dto"; -import { SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto"; +import { EmailType, SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto"; import { EmailGateway } from "../email.gateway"; import { EmailDeletePayload, EmailMovePayload, EmailStatusUpdatePayload } from "../interfaces/email-events.interface"; @@ -23,42 +25,53 @@ export class EmailService { private readonly emailGateway: EmailGateway, private readonly templateProcessorService: TemplateProcessorService, private readonly userRepository: UserRepository, + private readonly emailHeadersService: EmailHeadersService, ) {} //######################################################## async sendEmail(userEmailId: string, sendEmailDto: SendEmailDto) { - this.logger.log(`Sending email from user ${userEmailId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`); + this.logger.log( + `Sending ${sendEmailDto.emailType || EmailType.GENERAL} email from user ${userEmailId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`, + ); try { - // Get user's business information const user = await this.userRepository.findOne({ wildduckUserId: userEmailId, deletedAt: null }, { populate: ["business"] }); - if (user && user.business) { - this.logger.log(`Processing email through business template for business: ${user.business.id}`); + if (!user) throw new BadRequestException(EmailMessage.USER_NOT_FOUND); - // Process email content through business template - const processedTemplate = await this.templateProcessorService.processEmailContent(user.business.id, { - text: sendEmailDto.text, - html: sendEmailDto.html, - subject: sendEmailDto.subject, - }); + this.logger.log(`Processing email through business template for business: ${user.business.id}`); - // Update the DTO with processed content - if (processedTemplate.isHtml) { - sendEmailDto.html = processedTemplate.content; - sendEmailDto.text = undefined; // Clear text when using HTML template - } else { - sendEmailDto.text = processedTemplate.content; - sendEmailDto.html = undefined; // Clear HTML when no template - } + // Process email content through business template + const processedTemplate = await this.templateProcessorService.processEmailContent(user.business.id, { + text: sendEmailDto.text, + html: sendEmailDto.html, + subject: sendEmailDto.subject, + }); - if (processedTemplate.hasTemplate) { - this.logger.log(`Email content processed through template for business: ${user.business.id}`); - } + // Update the DTO with processed content + if (processedTemplate.isHtml) { + sendEmailDto.html = processedTemplate.content; + sendEmailDto.text = undefined; // Clear text when using HTML template } else { - this.logger.log(`No business template found for user ${userEmailId}, sending original content`); + sendEmailDto.text = processedTemplate.content; + sendEmailDto.html = undefined; // Clear HTML when no template } + if (processedTemplate.hasTemplate) { + this.logger.log(`Email content processed through template for business: ${user.business.id}`); + } + + sendEmailDto.from = { + address: user.emailAddress, + name: user.displayName, + }; + + // Generate headers based on email type + const headers = this.generateHeadersForEmailType(sendEmailDto, user); + + // Merge with existing headers if any + sendEmailDto.headers = [...(sendEmailDto.headers || []), ...headers]; + const response = await firstValueFrom(this.mailServerService.submission.submitMessage(userEmailId, sendEmailDto)); this.logger.log(`Email sent successfully: ${response.id} (Queue: ${response.queueId})`); @@ -78,6 +91,64 @@ export class EmailService { } } + //######################################################## + private generateHeadersForEmailType(sendEmailDto: SendEmailDto, user: User) { + const emailType = sendEmailDto.emailType || EmailType.GENERAL; + const businessName = user.business.name; + const businessDomain = user.emailAddress.split("@")[1]; + + switch (emailType) { + case EmailType.TRANSACTIONAL: + return this.emailHeadersService.generateTransactionalHeaders({ + businessName, + businessDomain, + transactionId: sendEmailDto.transactionId, + category: sendEmailDto.category, + }); + + case EmailType.MARKETING: + if (!sendEmailDto.listId || !sendEmailDto.unsubscribeUrl) { + throw new BadRequestException("Marketing emails require listId and unsubscribeUrl"); + } + return this.emailHeadersService.generateMarketingHeaders({ + businessName, + businessDomain, + listId: sendEmailDto.listId, + unsubscribeUrl: sendEmailDto.unsubscribeUrl, + campaignId: sendEmailDto.campaignId, + }); + + case EmailType.ANTI_THREADING: { + const antiThreadingHeaders = this.emailHeadersService.generateAntiThreadingHeaders({ + businessName, + businessDomain, + isTransactional: true, + priority: sendEmailDto.priority, + }); + + // Add Gmail-specific headers if requested + if (sendEmailDto.forceGmailBreak) { + const gmailHeaders = this.emailHeadersService.generateGmailAntiThreadingHeaders({ + businessDomain, + conversationBreaker: sendEmailDto.conversationBreaker, + }); + antiThreadingHeaders.push(...gmailHeaders); + } + + return antiThreadingHeaders; + } + + case EmailType.GENERAL: + default: + return this.emailHeadersService.generateAntiThreadingHeaders({ + businessName, + businessDomain, + isTransactional: true, + priority: sendEmailDto.priority, + }); + } + } + //######################################################## async getEmailStatus(queueId: string) { this.logger.log(`Getting email status for queue: ${queueId}`); diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index 833b93d..6ae2630 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -99,6 +99,7 @@ export class UsersService { const emailAddress = `${username}@${domain.name}`; const finalUserName = `${username}-${domain.name}`; + const finalDisplayName = `${username}`; const existingUser = await this.userRepository.findOne({ emailAddress }); if (existingUser) throw new BadRequestException(UserMessage.EMAIL_ADDRESS_ALREADY_EXISTS); @@ -131,7 +132,7 @@ export class UsersService { emailEnabled: true, emailQuota: userQuota, wildduckUserId: mailServerUser.id, - displayName: displayName || username, + displayName: finalDisplayName || displayName || username, isActive: true, business, domain,