From a37317e22c6ea64e1acbc127e02139aec5332c1a Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Tue, 15 Jul 2025 13:08:00 +0330 Subject: [PATCH] chore: add assign template to user for email sending and template --- src/common/enums/message.enum.ts | 3 + src/modules/email/DTO/send-email.dto.ts | 113 +++++----- src/modules/email/enums/email-header.enum.ts | 34 +++ .../interfaces/email-header.interface.ts | 27 +++ .../email/services/email-headers.service.ts | 179 +++++++-------- src/modules/email/services/email.service.ts | 208 +++++++++--------- .../services/template-processor.service.ts | 20 +- src/modules/templates/templates.module.ts | 3 +- .../users/DTO/create-email-user.dto.ts | 6 + .../users/DTO/update-email-user.dto.ts | 8 +- src/modules/users/services/users.service.ts | 31 ++- 11 files changed, 360 insertions(+), 272 deletions(-) create mode 100644 src/modules/email/enums/email-header.enum.ts create mode 100644 src/modules/email/interfaces/email-header.interface.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index f3c4c56..efc7003 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -100,6 +100,8 @@ export const enum UserMessage { EMAIL_USER_CREATED_SUCCESSFULLY = "ایمیل با موفقیت ایجاد شد", EMAIL_USER_UPDATED_SUCCESSFULLY = "ایمیل کاربر با موفقیت به‌روزرسانی شد", STATUS_UPDATED = "وضعیت کاربر با موفقیت تغییر کرد", + TEMPLATE_UUID = "شناسه قالب باید یک یو یو آی دی باشد", + TEMPLATE_NOT_EMPTY = "شناسه قالب نمیتواند خالی باشد", } export const enum CommonMessage { @@ -212,6 +214,7 @@ export const enum EmailMessage { BULK_ACTION_PARTIALLY_COMPLETED = "عملیات گروهی تا حدی انجام شد", BULK_ACTION_FAILED = "عملیات گروهی با خطا مواجه شد", USER_NOT_FOUND = "کاربر یافت نشد", + PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW = "Priority must be one of: high, normal, low", } 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 c2e5faa..77654fc 100644 --- a/src/modules/email/DTO/send-email.dto.ts +++ b/src/modules/email/DTO/send-email.dto.ts @@ -3,6 +3,7 @@ import { Type } from "class-transformer"; import { IsArray, IsBoolean, IsDateString, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator"; import { EmailMessage } from "../../../common/enums/message.enum"; +// import { Priority } from "../enums/email-header.enum"; //######################################################## export enum EmailType { @@ -107,62 +108,6 @@ 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 }) - @ApiPropertyOptional({ description: "ID of the Mailbox (optional)", example: "507f1f77bcf86cd799439012" }) - mailbox?: string; - @IsNotEmpty({ message: EmailMessage.FROM_ADDRESS_REQUIRED }) @ValidateNested() @Type(() => EmailRecipientDto) @@ -272,6 +217,62 @@ export class SendEmailDto { @Type(() => EmailEnvelopeDto) @ApiPropertyOptional({ description: "Optional envelope (optional)" }) envelope?: EmailEnvelopeDto; + + // @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(Priority, { message: EmailMessage.PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW }) + // @ApiPropertyOptional({ description: "Email priority (optional)", enum: Priority, default: Priority.Normal }) + // priority?: Priority; + + // @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 }) + // @ApiPropertyOptional({ description: "ID of the Mailbox (optional)", example: "507f1f77bcf86cd799439012" }) + // mailbox?: string; } //######################################################## diff --git a/src/modules/email/enums/email-header.enum.ts b/src/modules/email/enums/email-header.enum.ts new file mode 100644 index 0000000..431825f --- /dev/null +++ b/src/modules/email/enums/email-header.enum.ts @@ -0,0 +1,34 @@ +export const enum EmailHeaderKey { + MessageId = "Message-ID", + References = "References", + ThreadIndex = "Thread-Index", + XMailer = "X-Mailer", + XPriority = "X-Priority", + XMSMailPriority = "X-MSMail-Priority", + MIMEVersion = "MIME-Version", + AuthenticationResults = "Authentication-Results", + XAutoResponseSuppress = "X-Auto-Response-Suppress", + XEntityID = "X-Entity-ID", + Precedence = "Precedence", + ListUnsubscribePost = "List-Unsubscribe-Post", + ListUnsubscribe = "List-Unsubscribe", + ListId = "List-Id", + XSpamStatus = "X-Spam-Status", + XSpamScore = "X-Spam-Score", + XFeedbackID = "X-Feedback-ID", + XEntityRefID = "X-Entity-Ref-ID", + InReplyTo = "In-Reply-To", + XGmailThreadBreak = "X-Gmail-Thread-Break", + ThreadTopic = "Thread-Topic", + XTransactionID = "X-Transaction-ID", + XCategory = "X-Category", + Importance = "Importance", + XCampaignID = "X-Campaign-ID", + XMarketingEmail = "X-Marketing-Email", +} + +export enum Priority { + High = "high", + Normal = "normal", + Low = "low", +} diff --git a/src/modules/email/interfaces/email-header.interface.ts b/src/modules/email/interfaces/email-header.interface.ts new file mode 100644 index 0000000..de1dd7b --- /dev/null +++ b/src/modules/email/interfaces/email-header.interface.ts @@ -0,0 +1,27 @@ +import { Priority } from "../enums/email-header.enum"; + +export interface IGenerateAntiThreadingHeadersOptions { + messageId?: string; + listId?: string; + listUnsubscribe?: string; + businessName?: string; + businessDomain: string; + isTransactional?: boolean; + isMarketing?: boolean; + priority?: Priority; +} + +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; +} diff --git a/src/modules/email/services/email-headers.service.ts b/src/modules/email/services/email-headers.service.ts index d9165b2..65353b3 100644 --- a/src/modules/email/services/email-headers.service.ts +++ b/src/modules/email/services/email-headers.service.ts @@ -2,88 +2,65 @@ 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; -} +import { EmailHeaderKey } from "../enums/email-header.enum"; +import { + IGenerateAntiThreadingHeadersOptions, + IGenerateGmailAntiThreadingHeadersOptions, + IGenerateMarketingHeadersOptions, + IGenerateTransactionalHeadersOptions, +} from "../interfaces/email-header.interface"; @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", + key: EmailHeaderKey.MessageId, value: messageId, }); // Add References header with unique value to break threading headers.push({ - key: "References", + key: EmailHeaderKey.References, value: `<${uuidv4()}@${options.businessDomain || "localhost"}>`, }); // Add unique Thread-Index to prevent Outlook threading headers.push({ - key: "Thread-Index", + key: EmailHeaderKey.ThreadIndex, value: this.generateThreadIndex(), }); // Anti-spam headers headers.push({ - key: "X-Mailer", + key: EmailHeaderKey.XMailer, value: `${options.businessName || "Email Service"} v1.0`, }); headers.push({ - key: "X-Priority", + key: EmailHeaderKey.XPriority, value: this.getPriorityValue(options.priority || "normal"), }); headers.push({ - key: "X-MSMail-Priority", + key: EmailHeaderKey.XMSMailPriority, value: options.priority || "Normal", }); // Add MIME version headers.push({ - key: "MIME-Version", + key: EmailHeaderKey.MIMEVersion, value: "1.0", }); // Add authentication headers if (options.businessDomain) { headers.push({ - key: "Authentication-Results", + key: EmailHeaderKey.AuthenticationResults, value: `${options.businessDomain}; dkim=pass; spf=pass; dmarc=pass`, }); } @@ -91,32 +68,32 @@ export class EmailHeadersService { // Content classification headers if (options.isTransactional) { headers.push({ - key: "X-Auto-Response-Suppress", + key: EmailHeaderKey.XAutoResponseSuppress, value: "All", }); headers.push({ - key: "X-Entity-ID", + key: EmailHeaderKey.XEntityID, value: "transactional", }); headers.push({ - key: "Precedence", + key: EmailHeaderKey.Precedence, value: "bulk", }); } if (options.isMarketing) { headers.push({ - key: "X-Entity-ID", + key: EmailHeaderKey.XEntityID, value: "marketing", }); headers.push({ - key: "List-Unsubscribe-Post", + key: EmailHeaderKey.ListUnsubscribePost, value: "List-Unsubscribe=One-Click", }); if (options.listUnsubscribe) { headers.push({ - key: "List-Unsubscribe", + key: EmailHeaderKey.ListUnsubscribe, value: options.listUnsubscribe, }); } @@ -125,7 +102,7 @@ export class EmailHeadersService { // Add List-ID if provided if (options.listId) { headers.push({ - key: "List-Id", + key: EmailHeaderKey.ListId, value: options.listId, }); } @@ -137,66 +114,68 @@ export class EmailHeadersService { // }); headers.push({ - key: "X-Spam-Status", + key: EmailHeaderKey.XSpamStatus, value: "No", }); headers.push({ - key: "X-Spam-Score", + key: EmailHeaderKey.XSpamScore, value: "0.0", }); // Add feedback loop headers headers.push({ - key: "X-Feedback-ID", + key: EmailHeaderKey.XFeedbackID, 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); + // Add entity reference ID 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", + key: EmailHeaderKey.XEntityRefID, value: uuidv4(), }); return headers; } - /** - * Generate headers for transactional emails (receipts, notifications, etc.) - */ + //****************************************** */ + 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: EmailHeaderKey.MessageId, + value: uniqueMessageId, + }); + + // Clear any potential threading references + headers.push({ + key: EmailHeaderKey.References, + value: `<${uuidv4()}@${options.businessDomain}>`, + }); + + headers.push({ + key: EmailHeaderKey.InReplyTo, + value: `<${uuidv4()}@${options.businessDomain}>`, + }); + + // Gmail-specific thread breaking + headers.push({ + key: EmailHeaderKey.XGmailThreadBreak, + value: options.conversationBreaker || uuidv4(), + }); + + // Additional thread prevention + headers.push({ + key: EmailHeaderKey.ThreadTopic, + value: uuidv4(), + }); + + return headers; + } + + //****************************************** */ generateTransactionalHeaders(options: IGenerateTransactionalHeadersOptions): EmailHeaderDto[] { const headers: EmailHeaderDto[] = []; @@ -211,34 +190,32 @@ export class EmailHeadersService { // Transactional-specific headers headers.push({ - key: "X-Transaction-ID", + key: EmailHeaderKey.XTransactionID, value: options.transactionId || uuidv4(), }); if (options.category) { headers.push({ - key: "X-Category", + key: EmailHeaderKey.XCategory, value: options.category, }); } // Mark as important transactional email headers.push({ - key: "Importance", + key: EmailHeaderKey.Importance, value: "high", }); headers.push({ - key: "X-Auto-Response-Suppress", + key: EmailHeaderKey.XAutoResponseSuppress, value: "All", }); return headers; } - /** - * Generate headers for marketing emails - */ + //****************************************** */ generateMarketingHeaders(options: IGenerateMarketingHeadersOptions): EmailHeaderDto[] { const headers: EmailHeaderDto[] = []; @@ -256,38 +233,40 @@ export class EmailHeadersService { // Marketing-specific headers if (options.campaignId) { headers.push({ - key: "X-Campaign-ID", + key: EmailHeaderKey.XCampaignID, value: options.campaignId, }); } headers.push({ - key: "X-Marketing-Email", + key: EmailHeaderKey.XMarketingEmail, value: "true", }); // Required for bulk marketing emails headers.push({ - key: "Precedence", + key: EmailHeaderKey.Precedence, value: "bulk", }); return headers; } + //****************************************** */ - private generateMessageId(domain?: string, extraUnique = false): string { + 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"}>`; + return `<${timestamp}.${random}${uuid}@${domain}>`; } - + //****************************************** */ 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) { diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts index c185d76..3bbc0c3 100644 --- a/src/modules/email/services/email.service.ts +++ b/src/modules/email/services/email.service.ts @@ -13,6 +13,7 @@ import { BulkActionDto, BulkActionType } from "../DTO/bulk-actions.dto"; import { MessageListQueryDto, SearchMessagesQueryDto } from "../DTO/email-query.dto"; import { EmailType, SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto"; import { EmailGateway } from "../email.gateway"; +import { Priority } from "../enums/email-header.enum"; import { EmailDeletePayload, EmailMovePayload, EmailStatusUpdatePayload } from "../interfaces/email-events.interface"; @Injectable() @@ -30,9 +31,7 @@ export class EmailService { //######################################################## async sendEmail(userEmailId: string, sendEmailDto: SendEmailDto) { - this.logger.log( - `Sending ${sendEmailDto.emailType || EmailType.GENERAL} email from user ${userEmailId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`, - ); + this.logger.log(`Sending ${EmailType.GENERAL} email from user ${userEmailId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`); try { const user = await this.userRepository.findOne({ wildduckUserId: userEmailId, deletedAt: null }, { populate: ["business"] }); @@ -42,7 +41,7 @@ export class EmailService { this.logger.log(`Processing email through business template for business: ${user.business.id}`); // Process email content through business template - const processedTemplate = await this.templateProcessorService.processEmailContent(user.business.id, { + const processedTemplate = await this.templateProcessorService.processEmailContent(user.business.id, user.id, { text: sendEmailDto.text, html: sendEmailDto.html, subject: sendEmailDto.subject, @@ -67,7 +66,7 @@ export class EmailService { }; // Generate headers based on email type - const headers = this.generateHeadersForEmailType(sendEmailDto, user); + const headers = this.generateHeadersForEmailType(user); // Merge with existing headers if any sendEmailDto.headers = [...(sendEmailDto.headers || []), ...headers]; @@ -91,64 +90,6 @@ 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}`); @@ -234,42 +175,6 @@ export class EmailService { } } - //######################################################## - - private async findMessageMailbox(userId: string, messageId: number): Promise<{ mailboxId: string; mailboxName: string } | null> { - try { - const mailboxIds = await this.mailboxResolverService.getUserMailboxIds(userId); - - const mailboxesToSearch = [ - { id: mailboxIds.inbox, name: MailboxEnum.INBOX }, - { id: mailboxIds.sent, name: MailboxEnum.SENT }, - { id: mailboxIds.drafts, name: MailboxEnum.DRAFTS }, - { id: mailboxIds.trash, name: MailboxEnum.TRASH }, - { id: mailboxIds.archive, name: MailboxEnum.ARCHIVE }, - { id: mailboxIds.favorite, name: MailboxEnum.FAVORITE }, - { id: mailboxIds.junk, name: MailboxEnum.Junk }, - ]; - - for (const mailbox of mailboxesToSearch) { - if (!mailbox.id) continue; - - try { - await firstValueFrom(this.mailServerService.messages.getMessage(userId, mailbox.id, messageId)); - this.logger.log(`Message ${messageId} found in ${mailbox.name} mailbox`); - return { mailboxId: mailbox.id, mailboxName: mailbox.name }; - } catch (_error) { - continue; - } - } - - this.logger.warn(`Message ${messageId} not found in any mailbox for user ${userId}`); - return null; - } catch (error) { - this.logger.error(`Failed to find message ${messageId} for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`); - throw error; - } - } - //######################################################## async deleteMessage(userId: string, messageId: number) { try { @@ -459,7 +364,7 @@ export class EmailService { const existingDraft = await firstValueFrom(this.mailServerService.messages.getMessage(userEmailId, draftsMailboxId, messageId)); const draftData: SendEmailDto = { - mailbox: updateDraftDto.mailbox, + // mailbox: updateDraftDto.mailbox, from: updateDraftDto.from || { address: existingDraft.from?.address || "" }, to: updateDraftDto.to || [], cc: updateDraftDto.cc, @@ -486,7 +391,7 @@ export class EmailService { const user = await this.userRepository.findOne({ wildduckUserId: userEmailId, deletedAt: null }, { populate: ["business"] }); if (user && user.business && (updateDraftDto.text || updateDraftDto.html)) { - const processedTemplate = await this.templateProcessorService.processEmailContent(user.business.id, { + const processedTemplate = await this.templateProcessorService.processEmailContent(user.business.id, user.id, { text: updateDraftDto.text, html: updateDraftDto.html, subject: updateDraftDto.subject, @@ -955,4 +860,105 @@ export class EmailService { }, }; } + + //######################################################## + + private async findMessageMailbox(userId: string, messageId: number): Promise<{ mailboxId: string; mailboxName: string } | null> { + try { + const mailboxIds = await this.mailboxResolverService.getUserMailboxIds(userId); + + const mailboxesToSearch = [ + { id: mailboxIds.inbox, name: MailboxEnum.INBOX }, + { id: mailboxIds.sent, name: MailboxEnum.SENT }, + { id: mailboxIds.drafts, name: MailboxEnum.DRAFTS }, + { id: mailboxIds.trash, name: MailboxEnum.TRASH }, + { id: mailboxIds.archive, name: MailboxEnum.ARCHIVE }, + { id: mailboxIds.favorite, name: MailboxEnum.FAVORITE }, + { id: mailboxIds.junk, name: MailboxEnum.Junk }, + ]; + + for (const mailbox of mailboxesToSearch) { + if (!mailbox.id) continue; + + try { + await firstValueFrom(this.mailServerService.messages.getMessage(userId, mailbox.id, messageId)); + this.logger.log(`Message ${messageId} found in ${mailbox.name} mailbox`); + return { mailboxId: mailbox.id, mailboxName: mailbox.name }; + } catch (_error) { + continue; + } + } + + this.logger.warn(`Message ${messageId} not found in any mailbox for user ${userId}`); + return null; + } catch (error) { + this.logger.error(`Failed to find message ${messageId} for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + //######################################################## + private generateHeadersForEmailType(user: User) { + // const emailType = sendEmailDto.emailType || EmailType.GENERAL; + const businessName = user.business.name; + const businessDomain = user.emailAddress.split("@")[1]; + + return this.emailHeadersService.generateAntiThreadingHeaders({ + businessName, + businessDomain, + isTransactional: true, + priority: Priority.Normal, + }); + + // 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 as 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: Priority.Normal, + // }); + // } + } } diff --git a/src/modules/templates/services/template-processor.service.ts b/src/modules/templates/services/template-processor.service.ts index 8a9c93e..f1c0a95 100644 --- a/src/modules/templates/services/template-processor.service.ts +++ b/src/modules/templates/services/template-processor.service.ts @@ -1,19 +1,24 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { TemplatesService } from "./templates.service"; +import { UserMessage } from "../../../common/enums/message.enum"; +import { UserRepository } from "../../users/repositories/user.repository"; import { EmailContent, ProcessedEmailResult } from "../interfaces/structure.interface"; @Injectable() export class TemplateProcessorService { private readonly logger = new Logger(TemplateProcessorService.name); - constructor(private readonly templatesService: TemplatesService) {} + constructor( + private readonly templatesService: TemplatesService, + private readonly userRepository: UserRepository, + ) {} //**************************************************** */ - async processEmailContent(businessId: string, emailContent: EmailContent): Promise { + async processEmailContent(businessId: string, userId: string, emailContent: EmailContent): Promise { try { // Get selected template for the business - const selectedTemplate = await this.getSelectedTemplate(businessId); + const selectedTemplate = await this.getSelectedTemplate(businessId, userId); if (!selectedTemplate) { // No template - return original text content @@ -45,7 +50,12 @@ export class TemplateProcessorService { } //**************************************************** */ - private async getSelectedTemplate(businessId: string) { + private async getSelectedTemplate(businessId: string, userId: string) { + const user = await this.userRepository.findOne({ id: userId, business: { id: businessId } }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + if (user.template) return user.template; + const templates = await this.templatesService.getTemplatesByBusinessId(businessId); return templates.find((template) => template.selected) || null; } diff --git a/src/modules/templates/templates.module.ts b/src/modules/templates/templates.module.ts index 99a50a7..e7d8df1 100644 --- a/src/modules/templates/templates.module.ts +++ b/src/modules/templates/templates.module.ts @@ -7,9 +7,10 @@ import { TemplatesService } from "./services/templates.service"; import { TemplatesController } from "./templates.controller"; import { BusinessesModule } from "../businesses/businesses.module"; import { Business } from "../businesses/entities/business.entity"; +import { User } from "../users/entities/user.entity"; @Module({ - imports: [MikroOrmModule.forFeature([Template, Business]), BusinessesModule], + imports: [MikroOrmModule.forFeature([Template, Business, User]), BusinessesModule], providers: [TemplatesService, TemplateProcessorService], controllers: [TemplatesController], exports: [TemplatesService, TemplateProcessorService], diff --git a/src/modules/users/DTO/create-email-user.dto.ts b/src/modules/users/DTO/create-email-user.dto.ts index effa711..72c8ead 100644 --- a/src/modules/users/DTO/create-email-user.dto.ts +++ b/src/modules/users/DTO/create-email-user.dto.ts @@ -47,4 +47,10 @@ export class CreateEmailUserDto { @Transform(({ value }) => value?.trim()) @ApiProperty({ description: "User role/title", example: "Marketing Manager" }) title: string; + + @IsOptional() + @IsNotEmpty({ message: UserMessage.TEMPLATE_NOT_EMPTY }) + @IsUUID("7", { message: UserMessage.TEMPLATE_UUID }) + @ApiPropertyOptional({ description: "Template ID", example: "713e5000-0000-0000-0000-000000000000" }) + templateId?: string; } diff --git a/src/modules/users/DTO/update-email-user.dto.ts b/src/modules/users/DTO/update-email-user.dto.ts index 8e1a7ad..5351933 100644 --- a/src/modules/users/DTO/update-email-user.dto.ts +++ b/src/modules/users/DTO/update-email-user.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; -import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, Matches, MinLength } from "class-validator"; +import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Matches, MinLength } from "class-validator"; import { UserMessage } from "../../../common/enums/message.enum"; @@ -38,4 +38,10 @@ export class UpdateEmailUserDto { @Transform(({ value }) => value?.trim()) @ApiPropertyOptional({ description: "User role/title", example: "Senior Marketing Manager" }) title?: string; + + @IsOptional() + @IsNotEmpty({ message: UserMessage.TEMPLATE_NOT_EMPTY }) + @IsUUID("7", { message: UserMessage.TEMPLATE_UUID }) + @ApiPropertyOptional({ description: "Template ID", example: "713e5000-0000-0000-0000-000000000000" }) + templateId?: string; } diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index 6ae2630..d67fbaf 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -1,8 +1,8 @@ import { EntityManager } from "@mikro-orm/postgresql"; -import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; -import { BusinessMessage, DomainMessage, MailServerMessage, RoleMessage, UserMessage } from "../../../common/enums/message.enum"; +import { BusinessMessage, DomainMessage, MailServerMessage, RoleMessage, TemplateMessage, UserMessage } from "../../../common/enums/message.enum"; import { QUOTA_CONSTANTS } from "../../businesses/constant"; import { Business } from "../../businesses/entities/business.entity"; import { DomainStatus } from "../../domains/enums/domain-status.enum"; @@ -10,6 +10,7 @@ import { DomainAutomationService } from "../../domains/services/domain-automatio import { DomainsService } from "../../domains/services/domains.service"; import { UpdateUserDto } from "../../mail-server/DTO"; import { MailServerService } from "../../mail-server/services/mail-server.service"; +import { Template } from "../../templates/entities/template.entity"; import { PasswordService } from "../../utils/services/password.service"; import { CreateEmailUserDto } from "../DTO/create-email-user.dto"; import { UpdateEmailUserDto } from "../DTO/update-email-user.dto"; @@ -80,7 +81,7 @@ export class UsersService { /*******************************/ async createEmailUser(createEmailUserDto: CreateEmailUserDto, businessId: string) { - const { username, password, domainId, displayName, quota, aliases, title } = createEmailUserDto; + const { username, password, domainId, displayName, quota, aliases, title, templateId } = createEmailUserDto; const business = await this.em.findOne(Business, { id: businessId }); if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); @@ -145,6 +146,12 @@ export class UsersService { await this.em.persistAndFlush([user, business]); + if (templateId) { + const template = await this.em.findOne(Template, { id: templateId, business: { id: businessId } }); + if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND); + user.template = template; + } + if (aliases && aliases.length > 0) { for (const alias of aliases) { try { @@ -160,6 +167,8 @@ export class UsersService { } } + await this.em.flush(); + this.logger.log(`Email user created: ${emailAddress} for business ${businessId}`); await this.domainAutomationService.createDefaultMailboxes(mailServerUser.id); @@ -199,7 +208,7 @@ export class UsersService { emailEnabled: true, }); - if (!user) throw new NotFoundException(UserMessage.EMAIL_USER_NOT_FOUND); + if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); try { if (user.wildduckUserId) { @@ -224,7 +233,7 @@ export class UsersService { /*******************************/ async updateEmailUser(userId: string, businessId: string, updateEmailUserDto: UpdateEmailUserDto) { - const { password, displayName, quota, aliases, title } = updateEmailUserDto; + const { password, displayName, quota, aliases, title, templateId } = updateEmailUserDto; const user = await this.userRepository.findOne( { @@ -235,7 +244,13 @@ export class UsersService { { populate: ["business"] }, ); - if (!user) throw new NotFoundException(UserMessage.EMAIL_USER_NOT_FOUND); + if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); + + if (templateId) { + const template = await this.em.findOne(Template, { id: templateId, business: { id: businessId } }); + if (!template) throw new BadRequestException(TemplateMessage.TEMPLATE_NOT_FOUND); + user.template = template; + } // Validate business quota if quota is being updated if (quota) { @@ -335,7 +350,7 @@ export class UsersService { { populate: ["business"] }, ); - if (!user) throw new NotFoundException(UserMessage.EMAIL_USER_NOT_FOUND); + if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); // Validate business quota before updating const currentQuota = user.emailQuota || 0; @@ -370,7 +385,7 @@ export class UsersService { async updateEmailUserStatus(userId: string, businessId: string) { const user = await this.userRepository.findOne({ id: userId, business: { id: businessId }, emailEnabled: true }); - if (!user) throw new NotFoundException(UserMessage.EMAIL_USER_NOT_FOUND); + if (!user) throw new BadRequestException(UserMessage.EMAIL_USER_NOT_FOUND); user.isActive = !user.isActive; await this.em.flush();