diff --git a/src/app.module.ts b/src/app.module.ts index 7063fd7..d98eed5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,7 +14,9 @@ import { databaseConfig } from "./configs/mikro-orm.config"; import { rateLimitConfig } from "./configs/rateLimit.config"; import { AuthModule } from "./modules/auth/auth.module"; import { DomainsModule } from "./modules/domains/domains.module"; +import { EmailModule } from "./modules/email/email.module"; import { MailServerModule } from "./modules/mail-server/mail-server.module"; +import { MailboxModule } from "./modules/mailbox/mailbox.module"; import { QuotaSyncModule } from "./modules/quota-sync/quota-sync.module"; import { UsersModule } from "./modules/users/users.module"; import { UtilsModule } from "./modules/utils/utils.module"; @@ -32,6 +34,8 @@ import { UtilsModule } from "./modules/utils/utils.module"; AuthModule, UsersModule, DomainsModule, + EmailModule, + MailboxModule, MailServerModule, QuotaSyncModule, ], diff --git a/src/common/DTO/pagination.dto.ts b/src/common/DTO/pagination.dto.ts index 6dd4287..a0a707b 100755 --- a/src/common/DTO/pagination.dto.ts +++ b/src/common/DTO/pagination.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Type } from "class-transformer"; -import { IsNotEmpty, IsNumber, IsOptional, Max, Min } from "class-validator"; +import { Transform, Type } from "class-transformer"; +import { IsInt, IsNotEmpty, IsNumber, IsOptional, Max, Min } from "class-validator"; export class PaginationDto { @IsOptional() @@ -20,3 +20,19 @@ export class PaginationDto { @ApiPropertyOptional({ type: "number", required: false }) limit?: number; } + +export class PaginationQueryDto { + @ApiPropertyOptional({ description: "Number of items per page", example: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Transform(({ value }) => parseInt(value)) + @IsInt() + @Min(1) + limit?: number; + + @ApiPropertyOptional({ description: "Page number", example: 1, minimum: 1 }) + @IsOptional() + @Transform(({ value }) => parseInt(value)) + @IsInt() + @Min(1) + page?: number; +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 285dd3c..437ce49 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -131,6 +131,20 @@ export const enum EmailMessage { ANNOUNCEMENT = "اعلان", PAYMENT_REMINDER = "یادآوری پرداخت", PAYMENT_CANCELLATION = "لغو پرداخت", + EMAIL_SENDING_SUCCESS = "ارسال ایمیل با موفقیت انجام شد", + EMAIL_STATUS_RETRIEVED_SUCCESSFULLY = "وضعیت ایمیل با موفقیت دریافت شد", + MESSAGES_SEARCHED_SUCCESSFULLY = "جستجوی ایمیل با موفقیت انجام شد", + MESSAGES_LISTED_SUCCESSFULLY = "لیست ایمیل ها با موفقیت دریافت شد", + MESSAGE_RETRIEVED_SUCCESSFULLY = "ایمیل با موفقیت دریافت شد", + MESSAGE_DELETED_SUCCESSFULLY = "ایمیل با موفقیت حذف شد", + USER_ID_REQUIRED = "شناسه کاربری مورد نیاز است", + USER_ID_MUST_BE_STRING = "شناسه کاربری باید یک رشته باشد", + MAILBOX_ID_REQUIRED = "شناسه مستندک ایمیل مورد نیاز است", + MAILBOX_ID_MUST_BE_STRING = "شناسه مستندک ایمیل باید یک رشته باشد", + QUEUE_ID_REQUIRED = "شناسه پیش‌نویس ایمیل مورد نیاز است", + QUEUE_ID_MUST_BE_STRING = "شناسه پیش‌نویس ایمیل باید یک رشته باشد", + MESSAGE_ID_MUST_BE_NUMBER = "شناسه ایمیل باید یک عدد باشد", + FROM_ADDRESS_REQUIRED = "آدرس از مورد نیاز است", } export const enum SmsMessage { diff --git a/src/modules/domains/domains.module.ts b/src/modules/domains/domains.module.ts index d3507ce..40ab605 100644 --- a/src/modules/domains/domains.module.ts +++ b/src/modules/domains/domains.module.ts @@ -6,13 +6,15 @@ import { MailServerModule } from "../mail-server/mail-server.module"; import { DnsRecord } from "./entities/dns-record.entity"; import { Domain } from "./entities/domain.entity"; import { DnsService } from "./services/dns.service"; +import { DomainAutomationService } from "./services/domain-automation.service"; import { DomainsService } from "./services/domains.service"; import { BusinessesModule } from "../businesses/businesses.module"; +import { UtilsModule } from "../utils/utils.module"; @Module({ - imports: [MikroOrmModule.forFeature([Domain, DnsRecord]), MailServerModule, BusinessesModule], + imports: [MikroOrmModule.forFeature([Domain, DnsRecord]), MailServerModule, BusinessesModule, UtilsModule], controllers: [DomainsController], - providers: [DomainsService, DnsService], - exports: [DomainsService, DnsService], + providers: [DomainsService, DnsService, DomainAutomationService], + exports: [DomainsService, DnsService, DomainAutomationService], }) export class DomainsModule {} diff --git a/src/modules/domains/services/domain-automation.service.ts b/src/modules/domains/services/domain-automation.service.ts new file mode 100644 index 0000000..5fd6cd9 --- /dev/null +++ b/src/modules/domains/services/domain-automation.service.ts @@ -0,0 +1,137 @@ +import { EntityManager } from "@mikro-orm/core"; +import { Injectable, Logger } from "@nestjs/common"; +import { firstValueFrom } from "rxjs"; + +import { MailServerService } from "../../mail-server/services/mail-server.service"; +import { Role } from "../../users/entities/role.entity"; +import { User } from "../../users/entities/user.entity"; +import { RoleEnum } from "../../users/enums/role.enum"; +import { PasswordService } from "../../utils/services/password.service"; +import { Domain } from "../entities/domain.entity"; + +@Injectable() +export class DomainAutomationService { + private readonly logger = new Logger(DomainAutomationService.name); + + constructor( + private readonly em: EntityManager, + private readonly mailServerService: MailServerService, + private readonly passwordService: PasswordService, + ) {} + + /** + * Create info@domain.com address when domain is verified + */ + async createInfoAddress(domain: Domain): Promise { + const infoEmail = `info@${domain.name}`; + + try { + this.logger.log(`Creating info address for domain: ${domain.name}`); + + // Check if info address already exists in local database + const existingUser = await this.em.findOne(User, { + emailAddress: infoEmail, + business: { id: domain.business.id }, + }); + + if (existingUser) { + this.logger.warn(`Info address ${infoEmail} already exists, skipping creation`); + return; + } + + // Generate a secure password for the info account + const password = this.generateSecurePassword(); + const hashedPassword = await this.passwordService.hashPassword(password); + + // Get user role + const userRole = await this.em.findOne(Role, { name: RoleEnum.USER }); + if (!userRole) { + throw new Error("User role not found in database"); + } + + // Create user in mail server (WildDuck) + const mailServerUser = await firstValueFrom( + this.mailServerService.users.createUser({ + username: "info", + password, + address: infoEmail, + name: `Info - ${domain.business.name}`, + quota: 1073741824, // 1GB default quota + language: "fa", + }), + ); + + if (!mailServerUser.success) { + throw new Error(`Failed to create mail server user`); + } + + // Create user in local database + const user = this.em.create(User, { + title: `Info - ${domain.business.name}`, + userName: "info", + password: hashedPassword, + emailAddress: infoEmail, + emailEnabled: true, + emailQuota: 1073741824, // 1GB default + wildduckUserId: mailServerUser.id, + displayName: `Info - ${domain.business.name}`, + isActive: true, + business: domain.business, + domain: domain, + role: userRole, + }); + + await this.em.persistAndFlush(user); + + this.logger.log(`Successfully created info address: ${infoEmail} for business ${domain.business.name}`); + this.logger.log(`Info account credentials - Email: ${infoEmail}, Password: [GENERATED]`); + } catch (error) { + this.logger.error(`Failed to create info address for domain ${domain.name}:`, error); + // Don't throw the error to prevent domain verification from failing + // The domain should still be marked as verified even if info address creation fails + } + } + + /** + * Generate a secure password for the info account + */ + private generateSecurePassword(): string { + const length = 16; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; + let password = ""; + + for (let i = 0; i < length; i++) { + password += charset.charAt(Math.floor(Math.random() * charset.length)); + } + + return password; + } + + /** + * Check if info address exists for a domain + */ + async infoAddressExists(domain: Domain): Promise { + const infoEmail = `info@${domain.name}`; + const existingUser = await this.em.findOne(User, { + emailAddress: infoEmail, + business: { id: domain.business.id }, + }); + + return !!existingUser; + } + + /** + * Get info address details for a domain + */ + async getInfoAddressDetails(domain: Domain): Promise { + const infoEmail = `info@${domain.name}`; + return this.em.findOne( + User, + { + emailAddress: infoEmail, + business: { id: domain.business.id }, + }, + { populate: ["role", "business", "domain"] }, + ); + } +} diff --git a/src/modules/domains/services/domains.service.ts b/src/modules/domains/services/domains.service.ts index 4c7b9ae..3253014 100644 --- a/src/modules/domains/services/domains.service.ts +++ b/src/modules/domains/services/domains.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from "@nes import { firstValueFrom } from "rxjs"; import { DnsService } from "./dns.service"; +import { DomainAutomationService } from "./domain-automation.service"; import { BusinessMessage, DomainMessage, MailServerMessage } from "../../../common/enums/message.enum"; import { Business } from "../../businesses/entities/business.entity"; import { MailServerService } from "../../mail-server/services/mail-server.service"; @@ -23,6 +24,7 @@ export class DomainsService { private readonly em: EntityManager, private readonly dnsService: DnsService, private readonly mailServerService: MailServerService, + private readonly domainAutomationService: DomainAutomationService, ) {} /** @@ -122,34 +124,6 @@ export class DomainsService { return { message: DomainMessage.DOMAIN_DELETED_SUCCESSFULLY }; } - /** - * Verify domain ownership - */ - async verifyDomain(id: string, businessId: string) { - const domain = await this.getDomainById(id, businessId); - - // Check DNS records for verification - const isVerified = await this.dnsService.verifyDomainDNS(domain); - - if (isVerified) { - domain.status = DomainStatus.VERIFIED; - domain.isVerified = true; - domain.verifiedAt = new Date(); - - // Setup domain in mail server - await this.setupDomainInMailServer(domain); - - await this.em.persistAndFlush(domain); - this.logger.log(`Domain ${domain.name} verified successfully`); - } else { - domain.status = DomainStatus.FAILED; - await this.em.persistAndFlush(domain); - this.logger.warn(`Domain ${domain.name} verification failed`); - } - - return { domain }; - } - /** * Setup DKIM for domain */ @@ -246,8 +220,8 @@ export class DomainsService { const domain = await this.getDomainById(businessDomain.id, businessId); - // Trigger DNS verification - await this.verifyDomain(domain.id, businessId); + // // Check DNS records for verification + // await this.dnsService.verifyDomainDNS(domain); const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id); @@ -256,10 +230,35 @@ export class DomainsService { const isVerified = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0; + const isComplete = statusCounts.failed === 0 && statusCounts.pending === 0 && statusCounts.verified > 0; + + if (isVerified && isComplete && !domain.isVerified) { + // Update domain status + domain.status = DomainStatus.VERIFIED; + domain.isVerified = true; + domain.verifiedAt = new Date(); + + // Setup domain in mail server + await this.setupDomainInMailServer(domain); + + await this.em.persistAndFlush(domain); + + // Automatically create info@domain.com address + await this.domainAutomationService.createInfoAddress(domain); + + this.logger.log(`Domain ${domain.name} verified successfully`); + } else if (!isVerified && !isComplete && domain.status !== DomainStatus.FAILED) { + // Update domain status to failed if verification failed + domain.status = DomainStatus.FAILED; + await this.em.persistAndFlush(domain); + this.logger.warn(`Domain ${domain.name} verification failed`); + } + return { dnsRecords: verificationStatuses, overallStatus: { isVerified, + isComplete, ...statusCounts, total: dnsRecords.length, lastChecked: new Date(), diff --git a/src/modules/email/DTO/email-params.dto.ts b/src/modules/email/DTO/email-params.dto.ts new file mode 100644 index 0000000..81f7fcf --- /dev/null +++ b/src/modules/email/DTO/email-params.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsNotEmpty, IsNumberString, IsString } from "class-validator"; + +import { EmailMessage } from "../../../common/enums/message.enum"; + +export class UserIdParamDto { + @IsNotEmpty({ message: EmailMessage.USER_ID_REQUIRED }) + @IsString({ message: EmailMessage.USER_ID_MUST_BE_STRING }) + @ApiProperty({ description: "User ID", example: "507f1f77bcf86cd799439011" }) + userId: string; +} + +export class MailboxIdParamDto { + @ApiProperty({ description: "Mailbox ID", example: "507f1f77bcf86cd799439012" }) + @IsString({ message: EmailMessage.MAILBOX_ID_MUST_BE_STRING }) + @IsNotEmpty({ message: EmailMessage.MAILBOX_ID_REQUIRED }) + mailboxId: string; +} + +export class MessageIdParamDto { + @ApiProperty({ description: "Message ID", example: "12345" }) + @Transform(({ value }) => parseInt(value)) + @IsNumberString({ no_symbols: true }, { message: EmailMessage.MESSAGE_ID_MUST_BE_NUMBER }) + messageId: number; +} + +export class QueueIdParamDto { + @ApiProperty({ description: "Email queue ID", example: "16f2d5e0b9e5c8f0a1b2c3d4" }) + @IsString({ message: EmailMessage.QUEUE_ID_MUST_BE_STRING }) + @IsNotEmpty({ message: EmailMessage.QUEUE_ID_REQUIRED }) + queueId: string; +} + +export class UserMailboxParamDto extends UserIdParamDto { + @ApiProperty({ description: "Mailbox ID", example: "507f1f77bcf86cd799439012" }) + @IsString({ message: EmailMessage.MAILBOX_ID_MUST_BE_STRING }) + @IsNotEmpty({ message: EmailMessage.MAILBOX_ID_REQUIRED }) + mailboxId: string; +} + +export class UserMailboxMessageParamDto extends UserMailboxParamDto { + @ApiProperty({ description: "Message ID", example: "12345" }) + @Transform(({ value }) => parseInt(value)) + @IsNumberString({ no_symbols: true }, { message: EmailMessage.MESSAGE_ID_MUST_BE_NUMBER }) + messageId: number; +} diff --git a/src/modules/email/DTO/email-query.dto.ts b/src/modules/email/DTO/email-query.dto.ts new file mode 100644 index 0000000..6f1119d --- /dev/null +++ b/src/modules/email/DTO/email-query.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsOptional, IsString } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { CommonMessage } from "../../../common/enums/message.enum"; + +export class MessageListQueryDto extends PaginationDto { + @ApiPropertyOptional({ description: "Sort order", enum: ["asc", "desc"], example: "desc" }) + @IsOptional() + @IsEnum(["asc", "desc"]) + order?: "asc" | "desc"; +} + +export class SearchMessagesQueryDto extends PaginationDto { + @IsOptional() + @IsString({ message: CommonMessage.SEARCH_QUERY_STRING }) + @ApiPropertyOptional({ description: "Search query", example: "search query" }) + q?: string; +} diff --git a/src/modules/email/DTO/send-email.dto.ts b/src/modules/email/DTO/send-email.dto.ts new file mode 100644 index 0000000..450cda0 --- /dev/null +++ b/src/modules/email/DTO/send-email.dto.ts @@ -0,0 +1,207 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +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"; + +//######################################################## +export class EmailRecipientDto { + @ApiPropertyOptional({ description: "Display name", example: "John Doe" }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ description: "Email address", example: "john@example.com" }) + @IsEmail() + address: string; +} + +//######################################################## +export class EmailHeaderDto { + @ApiProperty({ description: "Header key", example: "X-Mailer" }) + @IsString() + key: string; + + @ApiProperty({ description: "Header value", example: "My Awesome Mailing Service" }) + @IsString() + value: string; +} + +//######################################################## +export class EmailAttachmentDto { + @ApiPropertyOptional({ description: "Filename", example: "document.pdf" }) + @IsOptional() + @IsString() + filename?: string; + + @ApiPropertyOptional({ description: "MIME type for the attachment file", example: "application/pdf" }) + @IsOptional() + @IsString() + contentType?: string; + + @ApiPropertyOptional({ description: "Encoding to use to store the attachments", example: "base64", default: "base64" }) + @IsOptional() + @IsString() + encoding?: string; + + @ApiPropertyOptional({ description: "Transfer encoding" }) + @IsOptional() + @IsString() + contentTransferEncoding?: string; + + @ApiPropertyOptional({ description: "Content Disposition", enum: ["inline", "attachment"] }) + @IsOptional() + @IsEnum(["inline", "attachment"]) + contentDisposition?: "inline" | "attachment"; + + @ApiProperty({ description: "Base64 encoded attachment content" }) + @IsString() + content: string; + + @ApiPropertyOptional({ description: "Content-ID value if you want to reference to this attachment from HTML formatted message" }) + @IsOptional() + @IsString() + cid?: string; +} + +//######################################################## +export class DraftReferenceDto { + @ApiProperty({ description: "ID of the Mailbox", example: "507f1f77bcf86cd799439012" }) + @IsString() + mailbox: string; + + @ApiProperty({ description: "Message ID", example: 12345 }) + @IsNumber() + id: number; +} + +//######################################################## +export class EmailEnvelopeDto { + @ApiProperty({ description: "From address for envelope" }) + @ValidateNested() + @Type(() => EmailRecipientDto) + from: EmailRecipientDto; + + @ApiProperty({ description: "To addresses for envelope", type: [EmailRecipientDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailRecipientDto) + to: EmailRecipientDto[]; +} + +//######################################################## +export class SendEmailDto { + @ApiPropertyOptional({ description: "ID of the Mailbox", example: "507f1f77bcf86cd799439012" }) + @IsOptional() + @IsString() + mailbox?: string; + + @IsNotEmpty({ message: EmailMessage.FROM_ADDRESS_REQUIRED }) + @ValidateNested() + @Type(() => EmailRecipientDto) + @ApiProperty({ description: "Address for the From: header" }) + from: EmailRecipientDto; + + @ApiPropertyOptional({ description: "Address for the Reply-To: header" }) + @IsOptional() + @ValidateNested() + @Type(() => EmailRecipientDto) + replyTo?: EmailRecipientDto; + + @ApiProperty({ description: "Addresses for the To: header", type: [EmailRecipientDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailRecipientDto) + to: EmailRecipientDto[]; + + @ApiPropertyOptional({ description: "Addresses for the Cc: header", type: [EmailRecipientDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailRecipientDto) + cc?: EmailRecipientDto[]; + + @ApiPropertyOptional({ description: "Addresses for the Bcc: header", type: [EmailRecipientDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailRecipientDto) + bcc?: EmailRecipientDto[]; + + @ApiPropertyOptional({ + description: "Custom headers for the message. If reference message is set then In-Reply-To and References headers are set automatically", + type: [EmailHeaderDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailHeaderDto) + headers?: EmailHeaderDto[]; + + @ApiPropertyOptional({ description: "Message subject. If not then resolved from Reference message", example: "Test email" }) + @IsOptional() + @IsString() + subject?: string; + + @ApiPropertyOptional({ description: "Plaintext message" }) + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional({ description: "HTML formatted message" }) + @IsOptional() + @IsString() + html?: string; + + @ApiPropertyOptional({ description: "Attachments for the message", type: [EmailAttachmentDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailAttachmentDto) + attachments?: EmailAttachmentDto[]; + + @ApiPropertyOptional({ description: "Optional metadata, must be an object or JSON formatted string" }) + @IsOptional() + meta?: any; + + @ApiPropertyOptional({ description: "Session identifier for the logs" }) + @IsOptional() + @IsString() + sess?: string; + + @ApiPropertyOptional({ description: "IP address for the logs" }) + @IsOptional() + @IsString() + ip?: string; + + @ApiPropertyOptional({ description: "Reference message data" }) + @IsOptional() + reference?: any; + + @ApiPropertyOptional({ description: "Is the message a draft or not", default: false }) + @IsOptional() + @IsBoolean() + isDraft?: boolean; + + @ApiPropertyOptional({ description: "Draft message to base this one on" }) + @IsOptional() + @ValidateNested() + @Type(() => DraftReferenceDto) + draft?: DraftReferenceDto; + + @ApiPropertyOptional({ description: "Send time", example: "2023-12-01T10:00:00Z" }) + @IsOptional() + @IsDateString() + sendTime?: string; + + @ApiPropertyOptional({ description: "If true only uploads the message but does not send it", default: false }) + @IsOptional() + @IsBoolean() + uploadOnly?: boolean; + + @ApiPropertyOptional({ description: "Optional envelope" }) + @IsOptional() + @ValidateNested() + @Type(() => EmailEnvelopeDto) + envelope?: EmailEnvelopeDto; +} diff --git a/src/modules/email/email.controller.ts b/src/modules/email/email.controller.ts new file mode 100644 index 0000000..a435c49 --- /dev/null +++ b/src/modules/email/email.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Delete, Get, Param, Post, Query } from "@nestjs/common"; +import { ApiOperation, ApiResponse } from "@nestjs/swagger"; + +import { QueueIdParamDto, UserIdParamDto, UserMailboxMessageParamDto, UserMailboxParamDto } from "./DTO/email-params.dto"; +import { MessageListQueryDto } from "./DTO/email-query.dto"; +import { SearchMessagesQueryDto } from "./DTO/email-query.dto"; +import { SendEmailDto } from "./DTO/send-email.dto"; +import { EmailService } from "./services/email.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; + +@Controller("email") +@AuthGuards() +export class EmailController { + constructor(private readonly emailService: EmailService) {} + + @Post(":userId/send") + @ApiOperation({ summary: "Send an email" }) + @ApiResponse({ status: 201, description: "Email sent successfully" }) + @ApiResponse({ status: 400, description: "Invalid email data" }) + @ApiResponse({ status: 403, description: "Not authorized to send email" }) + sendEmail(@Param() params: UserIdParamDto, @Body() sendEmailDto: SendEmailDto) { + return this.emailService.sendEmail(params.userId, sendEmailDto); + } + + @Get("status/:queueId") + @ApiOperation({ summary: "Get email delivery status" }) + @ApiResponse({ status: 200, description: "Email delivery status" }) + @ApiResponse({ status: 404, description: "Email not found" }) + getEmailStatus(@Param() params: QueueIdParamDto) { + return this.emailService.getEmailStatus(params.queueId); + } + + @Get(":userId/messages/:mailboxId") + @ApiOperation({ summary: "List messages in a mailbox" }) + @ApiResponse({ status: 200, description: "List of messages" }) + @ApiResponse({ status: 404, description: "Mailbox not found" }) + listMessages(@Param() params: UserMailboxParamDto, @Query() query: MessageListQueryDto) { + return this.emailService.listMessages(params.userId, params.mailboxId, query); + } + + @Get(":userId/messages/:mailboxId/:messageId") + @ApiOperation({ summary: "Get a specific message" }) + @ApiResponse({ status: 200, description: "Message details" }) + @ApiResponse({ status: 404, description: "Message not found" }) + getMessage(@Param() params: UserMailboxMessageParamDto) { + return this.emailService.getMessage(params.userId, params.mailboxId, params.messageId); + } + + @Delete(":userId/messages/:mailboxId/:messageId") + @ApiOperation({ summary: "Delete a message" }) + @ApiResponse({ status: 200, description: "Message deleted successfully" }) + @ApiResponse({ status: 404, description: "Message not found" }) + deleteMessage(@Param() params: UserMailboxMessageParamDto) { + return this.emailService.deleteMessage(params.userId, params.mailboxId, params.messageId); + } + + @Get(":userId/search") + @ApiOperation({ summary: "Search messages across all mailboxes" }) + @ApiResponse({ status: 200, description: "Search results" }) + searchMessages(@Param() params: UserIdParamDto, @Query() query: SearchMessagesQueryDto) { + return this.emailService.searchMessages(params.userId, query); + } +} diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts new file mode 100644 index 0000000..e97a2f8 --- /dev/null +++ b/src/modules/email/email.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; + +import { EmailController } from "./email.controller"; +import { EmailService } from "./services/email.service"; +import { MailServerModule } from "../mail-server/mail-server.module"; + +@Module({ + imports: [MailServerModule], + controllers: [EmailController], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts new file mode 100644 index 0000000..55d6f69 --- /dev/null +++ b/src/modules/email/services/email.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { firstValueFrom } from "rxjs"; + +import { EmailMessage } from "../../../common/enums/message.enum"; +import { MailServerService } from "../../mail-server/services/mail-server.service"; +import { MessageListQueryDto, SearchMessagesQueryDto } from "../DTO/email-query.dto"; +import { SendEmailDto } from "../DTO/send-email.dto"; + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + + constructor(private readonly mailServerService: MailServerService) {} + + //######################################################## + async sendEmail(userId: string, sendEmailDto: SendEmailDto) { + this.logger.log(`Sending email from user ${userId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`); + + try { + const response = await firstValueFrom(this.mailServerService.submission.submitMessage(userId, sendEmailDto)); + + this.logger.log(`Email sent successfully: ${response.id} (Queue: ${response.queueId})`); + + return { + message: EmailMessage.EMAIL_SENDING_SUCCESS, + messageId: response.id, + queueId: response.queueId, + from: response.from, + to: response.to, + subject: response.subject, + created: response.created, + }; + } catch (error) { + this.logger.error(`Failed to send email for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + //######################################################## + async getEmailStatus(queueId: string) { + this.logger.log(`Getting email status for queue: ${queueId}`); + + try { + const response = await firstValueFrom(this.mailServerService.submission.getQueueStatus(queueId)); + + this.logger.log(`Retrieved status for queue ${queueId}: ${response.status}`); + + return { + message: EmailMessage.EMAIL_STATUS_RETRIEVED_SUCCESSFULLY, + ...response, + }; + } catch (error) { + this.logger.error(`Failed to get email status for queue ${queueId}: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + //######################################################## + async searchMessages(userId: string, query: SearchMessagesQueryDto) { + this.logger.log(`Searching messages for user ${userId} with query: ${query.q}`); + + try { + const response = await firstValueFrom( + this.mailServerService.messages.searchMessages(userId, { query: query.q || "", limit: query.limit, page: query.page }), + ); + + this.logger.log(`Found ${response.results.length} messages matching search for user: ${userId}`); + + return { + message: EmailMessage.MESSAGES_SEARCHED_SUCCESSFULLY, + ...response, + }; + } catch (error) { + this.logger.error(`Failed to search messages for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + //######################################################## + async listMessages(userId: string, mailboxId: string, query: MessageListQueryDto) { + this.logger.log(`Listing messages in mailbox ${mailboxId} for user: ${userId}`); + + try { + const response = await firstValueFrom(this.mailServerService.messages.listMessages(userId, mailboxId, query)); + + this.logger.log(`Found ${response.results.length} messages in mailbox ${mailboxId} for user: ${userId}`); + + return { + message: EmailMessage.MESSAGES_LISTED_SUCCESSFULLY, + ...response, + }; + } catch (error) { + this.logger.error( + `Failed to list messages for mailbox ${mailboxId} and user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + throw error; + } + } + + //######################################################## + async getMessage(userId: string, mailboxId: string, messageId: number) { + this.logger.log(`Getting message ${messageId} from mailbox ${mailboxId} for user: ${userId}`); + + try { + const response = await firstValueFrom(this.mailServerService.messages.getMessage(userId, mailboxId, messageId)); + + this.logger.log(`Retrieved message ${messageId} for user: ${userId}`); + + return { + message: EmailMessage.MESSAGE_RETRIEVED_SUCCESSFULLY, + ...response, + }; + } catch (error) { + this.logger.error(`Failed to get message ${messageId} for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + //######################################################## + async deleteMessage(userId: string, mailboxId: string, messageId: number) { + this.logger.log(`Deleting message ${messageId} from mailbox ${mailboxId} for user: ${userId}`); + + try { + const response = await firstValueFrom(this.mailServerService.messages.deleteMessage(userId, mailboxId, messageId)); + + this.logger.log(`Deleted message ${messageId} for user: ${userId}`); + + return { + message: EmailMessage.MESSAGE_DELETED_SUCCESSFULLY, + ...response, + }; + } catch (error) { + this.logger.error(`Failed to delete message ${messageId} for user ${userId}: ${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } +} diff --git a/src/modules/mail-server/interfaces/submission.interface.ts b/src/modules/mail-server/interfaces/submission.interface.ts index 63ea259..875af71 100644 --- a/src/modules/mail-server/interfaces/submission.interface.ts +++ b/src/modules/mail-server/interfaces/submission.interface.ts @@ -1,31 +1,54 @@ +export interface EmailAddress { + name?: string; + address: string; +} + +export interface EmailHeader { + key: string; + value: string; +} + +export interface EmailAttachment { + filename?: string; + contentType?: string; + encoding?: string; + contentTransferEncoding?: string; + contentDisposition?: "inline" | "attachment"; + content: string; // base64 encoded + cid?: string; +} + +export interface DraftReference { + mailbox: string; + id: number; +} + +export interface EmailEnvelope { + from: EmailAddress; + to: EmailAddress[]; +} + export interface SubmissionData { - from: { - name?: string; - address: string; - }; - to: Array<{ - name?: string; - address: string; - }>; - cc?: Array<{ - name?: string; - address: string; - }>; - bcc?: Array<{ - name?: string; - address: string; - }>; - subject: string; + mailbox?: string; + from: EmailAddress; + replyTo?: EmailAddress; + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + headers?: EmailHeader[]; + subject?: string; text?: string; html?: string; - attachments?: Array<{ - filename?: string; - contentType?: string; - content: string; // base64 encoded - }>; - headers?: Record; + attachments?: EmailAttachment[]; + meta?: any; sess?: string; ip?: string; + reference?: any; + isDraft?: boolean; + draft?: DraftReference; + sendTime?: string; // ISO date string + uploadOnly?: boolean; + envelope?: EmailEnvelope; } export interface QueueStatus { diff --git a/src/modules/mailbox/DTO/mailbox.dto.ts b/src/modules/mailbox/DTO/mailbox.dto.ts new file mode 100644 index 0000000..b466c62 --- /dev/null +++ b/src/modules/mailbox/DTO/mailbox.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsNumber, IsOptional, IsString } from "class-validator"; + +export class CreateMailboxDto { + @ApiProperty({ + description: "Mailbox path with slashes as folder separators", + example: "Important/Work", + }) + @IsString() + path: string; + + @ApiPropertyOptional({ + description: "Retention time in milliseconds for messages in this mailbox", + example: 2592000000, + }) + @IsOptional() + @IsNumber() + retention?: number; +} + +export class UpdateMailboxDto { + @ApiPropertyOptional({ + description: "New mailbox path if renaming", + example: "Renamed Folder", + }) + @IsOptional() + @IsString() + path?: string; + + @ApiPropertyOptional({ + description: "New retention time in milliseconds", + example: 5184000000, + }) + @IsOptional() + @IsNumber() + retention?: number; +} + +export class MailboxQueryDto { + @ApiPropertyOptional({ + description: "Include message counters in results", + default: false, + }) + @IsOptional() + @IsBoolean() + counters?: boolean; +} diff --git a/src/modules/mailbox/mailbox.controller.ts b/src/modules/mailbox/mailbox.controller.ts new file mode 100644 index 0000000..05fc907 --- /dev/null +++ b/src/modules/mailbox/mailbox.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from "@nestjs/swagger"; +import { Observable } from "rxjs"; + +import { MailboxQueryDto } from "./DTO/mailbox.dto"; +import { MailboxService } from "./services/mailbox.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { CreateMailboxDto, UpdateMailboxDto } from "../mail-server/DTO"; + +@Controller("mailbox") +@AuthGuards() +export class MailboxController { + constructor(private readonly mailboxService: MailboxService) {} + + @Get(":userId/mailboxes") + @ApiOperation({ summary: "List mailboxes for a user" }) + @ApiParam({ name: "userId", description: "User ID" }) + @ApiQuery({ name: "counters", required: false, description: "Include message counters" }) + @ApiResponse({ status: 200, description: "List of mailboxes" }) + @ApiResponse({ status: 404, description: "User not found" }) + listMailboxes(@Param("userId") userId: string, @Query() query: MailboxQueryDto): Observable { + return this.mailboxService.listMailboxes(userId, query); + } + + @Get(":userId/mailboxes/:mailboxId") + @ApiOperation({ summary: "Get mailbox details" }) + @ApiParam({ name: "userId", description: "User ID" }) + @ApiParam({ name: "mailboxId", description: "Mailbox ID" }) + @ApiResponse({ status: 200, description: "Mailbox details" }) + @ApiResponse({ status: 404, description: "Mailbox not found" }) + getMailbox(@Param("userId") userId: string, @Param("mailboxId") mailboxId: string): Observable { + return this.mailboxService.getMailbox(userId, mailboxId); + } + + @Post(":userId/mailboxes") + @ApiOperation({ summary: "Create a new mailbox" }) + @ApiParam({ name: "userId", description: "User ID" }) + @ApiResponse({ status: 201, description: "Mailbox created successfully" }) + @ApiResponse({ status: 400, description: "Invalid mailbox data" }) + createMailbox(@Param("userId") userId: string, @Body() createMailboxDto: CreateMailboxDto): Observable { + return this.mailboxService.createMailbox(userId, createMailboxDto); + } + + @Patch(":userId/mailboxes/:mailboxId") + @ApiOperation({ summary: "Update mailbox" }) + @ApiParam({ name: "userId", description: "User ID" }) + @ApiParam({ name: "mailboxId", description: "Mailbox ID" }) + @ApiResponse({ status: 200, description: "Mailbox updated successfully" }) + @ApiResponse({ status: 404, description: "Mailbox not found" }) + updateMailbox(@Param("userId") userId: string, @Param("mailboxId") mailboxId: string, @Body() updateMailboxDto: UpdateMailboxDto): Observable { + return this.mailboxService.updateMailbox(userId, mailboxId, updateMailboxDto); + } + + @Delete(":userId/mailboxes/:mailboxId") + @ApiOperation({ summary: "Delete a mailbox" }) + @ApiParam({ name: "userId", description: "User ID" }) + @ApiParam({ name: "mailboxId", description: "Mailbox ID" }) + @ApiResponse({ status: 200, description: "Mailbox deleted successfully" }) + @ApiResponse({ status: 404, description: "Mailbox not found" }) + deleteMailbox(@Param("userId") userId: string, @Param("mailboxId") mailboxId: string): Observable { + return this.mailboxService.deleteMailbox(userId, mailboxId); + } +} diff --git a/src/modules/mailbox/mailbox.module.ts b/src/modules/mailbox/mailbox.module.ts new file mode 100644 index 0000000..c108504 --- /dev/null +++ b/src/modules/mailbox/mailbox.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; + +import { MailboxController } from "./mailbox.controller"; +import { MailboxService } from "./services/mailbox.service"; +import { MailServerModule } from "../mail-server/mail-server.module"; + +@Module({ + imports: [MailServerModule], + controllers: [MailboxController], + providers: [MailboxService], + exports: [MailboxService], +}) +export class MailboxModule {} diff --git a/src/modules/mailbox/services/mailbox.service.ts b/src/modules/mailbox/services/mailbox.service.ts new file mode 100644 index 0000000..12e8537 --- /dev/null +++ b/src/modules/mailbox/services/mailbox.service.ts @@ -0,0 +1,112 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { Observable, catchError, map, throwError } from "rxjs"; + +import { CreateMailboxDto, UpdateMailboxDto } from "../../mail-server/DTO"; +import { MailServerService } from "../../mail-server/services/mail-server.service"; +import { MailboxQueryDto } from "../DTO/mailbox.dto"; + +@Injectable() +export class MailboxService { + private readonly logger = new Logger(MailboxService.name); + + constructor(private readonly mailServerService: MailServerService) {} + + /** + * List all mailboxes for a user + */ + listMailboxes(userId: string, query?: MailboxQueryDto): Observable { + this.logger.log(`Listing mailboxes for user: ${userId}`); + + return this.mailServerService.mailboxes.listMailboxes(userId, query).pipe( + map((response) => { + this.logger.log(`Found ${response.results.length} mailboxes for user: ${userId}`); + return response.results; + }), + catchError((error) => { + this.logger.error(`Failed to list mailboxes for user ${userId}: ${error.message}`); + return throwError(() => error); + }), + ); + } + + /** + * Get a specific mailbox + */ + getMailbox(userId: string, mailboxId: string): Observable { + this.logger.log(`Getting mailbox ${mailboxId} for user: ${userId}`); + + return this.mailServerService.mailboxes.getMailbox(userId, mailboxId).pipe( + map((response) => { + this.logger.log(`Retrieved mailbox ${mailboxId} for user: ${userId}`); + return response; + }), + catchError((error) => { + this.logger.error(`Failed to get mailbox ${mailboxId} for user ${userId}: ${error.message}`); + if (error.status === 404) { + throw new NotFoundException(`Mailbox ${mailboxId} not found for user ${userId}`); + } + return throwError(() => error); + }), + ); + } + + /** + * Create a new mailbox + */ + createMailbox(userId: string, createMailboxDto: CreateMailboxDto): Observable { + this.logger.log(`Creating mailbox "${createMailboxDto.path}" for user: ${userId}`); + + return this.mailServerService.mailboxes.createMailbox(userId, createMailboxDto).pipe( + map((response) => { + this.logger.log(`Created mailbox ${response.id} for user: ${userId}`); + return response; + }), + catchError((error) => { + this.logger.error(`Failed to create mailbox for user ${userId}: ${error.message}`); + return throwError(() => error); + }), + ); + } + + /** + * Update a mailbox + */ + updateMailbox(userId: string, mailboxId: string, updateMailboxDto: UpdateMailboxDto): Observable { + this.logger.log(`Updating mailbox ${mailboxId} for user: ${userId}`); + + return this.mailServerService.mailboxes.updateMailbox(userId, mailboxId, updateMailboxDto).pipe( + map((response) => { + this.logger.log(`Updated mailbox ${mailboxId} for user: ${userId}`); + return response; + }), + catchError((error) => { + this.logger.error(`Failed to update mailbox ${mailboxId} for user ${userId}: ${error.message}`); + if (error.status === 404) { + throw new NotFoundException(`Mailbox ${mailboxId} not found for user ${userId}`); + } + return throwError(() => error); + }), + ); + } + + /** + * Delete a mailbox + */ + deleteMailbox(userId: string, mailboxId: string): Observable { + this.logger.log(`Deleting mailbox ${mailboxId} for user: ${userId}`); + + return this.mailServerService.mailboxes.deleteMailbox(userId, mailboxId).pipe( + map((response) => { + this.logger.log(`Deleted mailbox ${mailboxId} for user: ${userId}`); + return response; + }), + catchError((error) => { + this.logger.error(`Failed to delete mailbox ${mailboxId} for user ${userId}: ${error.message}`); + if (error.status === 404) { + throw new NotFoundException(`Mailbox ${mailboxId} not found for user ${userId}`); + } + return throwError(() => error); + }), + ); + } +} diff --git a/src/modules/email-signatures/DTO/create-email-signature.dto.ts b/src/modules/signatures/DTO/create-email-signature.dto.ts similarity index 100% rename from src/modules/email-signatures/DTO/create-email-signature.dto.ts rename to src/modules/signatures/DTO/create-email-signature.dto.ts diff --git a/src/modules/email-signatures/DTO/update-email-signature.dto.ts b/src/modules/signatures/DTO/update-email-signature.dto.ts similarity index 100% rename from src/modules/email-signatures/DTO/update-email-signature.dto.ts rename to src/modules/signatures/DTO/update-email-signature.dto.ts diff --git a/src/modules/email-signatures/email-signatures.controller.ts b/src/modules/signatures/email-signatures.controller.ts similarity index 100% rename from src/modules/email-signatures/email-signatures.controller.ts rename to src/modules/signatures/email-signatures.controller.ts diff --git a/src/modules/email-signatures/email-signatures.module.ts b/src/modules/signatures/email-signatures.module.ts similarity index 100% rename from src/modules/email-signatures/email-signatures.module.ts rename to src/modules/signatures/email-signatures.module.ts diff --git a/src/modules/email-signatures/entities/email-signature.entity.ts b/src/modules/signatures/entities/email-signature.entity.ts similarity index 100% rename from src/modules/email-signatures/entities/email-signature.entity.ts rename to src/modules/signatures/entities/email-signature.entity.ts diff --git a/src/modules/email-signatures/repositories/email-signature.repository.ts b/src/modules/signatures/repositories/email-signature.repository.ts similarity index 100% rename from src/modules/email-signatures/repositories/email-signature.repository.ts rename to src/modules/signatures/repositories/email-signature.repository.ts diff --git a/src/modules/email-signatures/services/email-signatures.service.ts b/src/modules/signatures/services/email-signatures.service.ts similarity index 100% rename from src/modules/email-signatures/services/email-signatures.service.ts rename to src/modules/signatures/services/email-signatures.service.ts diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index c713279..b51c6bc 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -5,7 +5,7 @@ import { Role } from "./role.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Business } from "../../businesses/entities/business.entity"; import { Domain } from "../../domains/entities/domain.entity"; -import { EmailSignature } from "../../email-signatures/entities/email-signature.entity"; +import { EmailSignature } from "../../signatures/entities/email-signature.entity"; import { UserRepository } from "../repositories/user.repository"; @Entity({ repository: () => UserRepository }) diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index a8ac987..b799beb 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -91,7 +91,7 @@ export class UsersService { address: emailAddress, name: displayName || username, quota: quota || 1073741824, - language: "fa", + // language: "fa", }), );