diff --git a/package.json b/package.json index 4e3994e..6ebb831 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-fastify": "^11.1.3", + "@nestjs/schedule": "^6.0.0", "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.4.0", "axios": "^1.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6034d1..6d86fa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@nestjs/platform-fastify': specifier: ^11.1.3 version: 11.1.3(@fastify/static@8.2.0)(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/schedule': + specifier: ^6.0.0 + version: 6.0.0(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/swagger': specifier: ^11.2.0 version: 11.2.0(@fastify/static@8.2.0)(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) @@ -1263,6 +1266,12 @@ packages: '@fastify/view': optional: true + '@nestjs/schedule@6.0.0': + resolution: {integrity: sha512-aQySMw6tw2nhitELXd3EiRacQRgzUKD9mFcUZVOJ7jPLqIBvXOyvRWLsK9SdurGA+jjziAlMef7iB5ZEFFoQpw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.5': resolution: {integrity: sha512-T50SCNyqCZ/fDssaOD7meBKLZ87ebRLaJqZTJPvJKjlib1VYhMOCwXYsr7bjMPmuPgiQHOwvppz77xN/m6GM7A==} peerDependencies: @@ -1563,6 +1572,9 @@ packages: '@types/jsonwebtoken@9.0.7': resolution: {integrity: sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==} + '@types/luxon@3.6.2': + resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -2462,6 +2474,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron@4.3.0: + resolution: {integrity: sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==} + engines: {node: '>=18.x'} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -7068,6 +7084,12 @@ snapshots: optionalDependencies: '@fastify/static': 8.2.0 + '@nestjs/schedule@6.0.0(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.3(@nestjs/common@11.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.3.0 + '@nestjs/schematics@11.0.5(chokidar@4.0.3)(typescript@5.8.3)': dependencies: '@angular-devkit/core': 19.2.6(chokidar@4.0.3) @@ -7367,6 +7389,8 @@ snapshots: dependencies: '@types/node': 22.16.0 + '@types/luxon@3.6.2': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -8406,6 +8430,11 @@ snapshots: dependencies: luxon: 3.6.1 + cron@4.3.0: + dependencies: + '@types/luxon': 3.6.2 + luxon: 3.6.1 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 diff --git a/src/app.module.ts b/src/app.module.ts index 2148e18..7063fd7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { BullModule } from "@nestjs/bullmq"; import { CacheModule } from "@nestjs/cache-manager"; import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; import { ThrottlerModule } from "@nestjs/throttler"; import { bullMqConfig } from "./configs/bullmq.config"; @@ -14,12 +15,14 @@ import { rateLimitConfig } from "./configs/rateLimit.config"; import { AuthModule } from "./modules/auth/auth.module"; import { DomainsModule } from "./modules/domains/domains.module"; import { MailServerModule } from "./modules/mail-server/mail-server.module"; +import { QuotaSyncModule } from "./modules/quota-sync/quota-sync.module"; import { UsersModule } from "./modules/users/users.module"; import { UtilsModule } from "./modules/utils/utils.module"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true }), + ScheduleModule.forRoot(), BullModule.forRootAsync(bullMqConfig()), CacheModule.registerAsync(cacheConfig()), HttpModule.registerAsync(httpConfig()), @@ -30,6 +33,7 @@ import { UtilsModule } from "./modules/utils/utils.module"; UsersModule, DomainsModule, MailServerModule, + QuotaSyncModule, ], }) export class AppModule {} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 39d338b..285dd3c 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -248,3 +248,12 @@ export const enum DnsRecordMessage { export const enum RoleMessage { NOT_FOUND = "نقش یافت نشد", } + +export const enum SignatureMessage { + NAME_NOT_EMPTY = "نام علامت توضیحی نمیتواند خالی باشد", + NAME_MAX_LENGTH = "NAME_MAX_LENGTH", + NAME_STRING = "NAME_STRING", + TEXT_CONTENT_NOT_EMPTY = "TEXT_CONTENT_NOT_EMPTY", + TEXT_CONTENT_STRING = "TEXT_CONTENT_STRING", + TEXT_CONTENT_MAX_LENGTH = "TEXT_CONTENT_MAX_LENGTH", +} diff --git a/src/modules/email-signatures/DTO/create-email-signature.dto.ts b/src/modules/email-signatures/DTO/create-email-signature.dto.ts new file mode 100644 index 0000000..82a705f --- /dev/null +++ b/src/modules/email-signatures/DTO/create-email-signature.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; + +import { SignatureMessage } from "../../../common/enums/message.enum"; + +export class CreateEmailSignatureDto { + @IsNotEmpty({ message: SignatureMessage.NAME_NOT_EMPTY }) + @IsString({ message: SignatureMessage.NAME_STRING }) + @MaxLength(100, { message: SignatureMessage.NAME_MAX_LENGTH }) + @ApiProperty({ description: "Name for the email signature", example: "Business Signature", maxLength: 100 }) + name: string; + + @IsOptional() + @IsNotEmpty({ message: SignatureMessage.TEXT_CONTENT_NOT_EMPTY }) + @IsString({ message: SignatureMessage.TEXT_CONTENT_STRING }) + @MaxLength(5000, { message: SignatureMessage.TEXT_CONTENT_MAX_LENGTH }) + @ApiPropertyOptional({ + description: "Plain text content of the signature", + example: "John Doe\nSoftware Engineer\nCompany Inc.\njohn@company.com", + maxLength: 5000, + }) + textContent?: string; + + @IsOptional() + @IsString() + @MaxLength(10000) + @ApiPropertyOptional({ + description: "HTML content of the signature", + example: "
John Doe
Software Engineer
Company Inc.
john@company.com
", + maxLength: 10000, + }) + htmlContent?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ + description: "Whether this signature is active", + default: true, + }) + isActive?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ + description: "Whether this is the default signature for the user", + default: false, + }) + isDefault?: boolean; +} diff --git a/src/modules/email-signatures/DTO/update-email-signature.dto.ts b/src/modules/email-signatures/DTO/update-email-signature.dto.ts new file mode 100644 index 0000000..32a22af --- /dev/null +++ b/src/modules/email-signatures/DTO/update-email-signature.dto.ts @@ -0,0 +1,50 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator"; + +export class UpdateEmailSignatureDto { + @IsOptional() + @IsString() + @MaxLength(100) + @ApiPropertyOptional({ + description: "Name for the email signature", + example: "Business Signature", + maxLength: 100, + }) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(5000) + @ApiPropertyOptional({ + description: "Plain text content of the signature", + example: "John Doe\nSoftware Engineer\nCompany Inc.\njohn@company.com", + maxLength: 5000, + }) + textContent?: string; + + @IsOptional() + @IsString() + @MaxLength(10000) + @ApiPropertyOptional({ + description: "HTML content of the signature", + example: "
John Doe
Software Engineer
Company Inc.
john@company.com
", + maxLength: 10000, + }) + htmlContent?: string; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ + description: "Whether this signature is active", + default: true, + }) + isActive?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ + description: "Whether this is the default signature for the user", + default: false, + }) + isDefault?: boolean; +} diff --git a/src/modules/email-signatures/email-signatures.controller.ts b/src/modules/email-signatures/email-signatures.controller.ts new file mode 100644 index 0000000..5c28c0b --- /dev/null +++ b/src/modules/email-signatures/email-signatures.controller.ts @@ -0,0 +1,97 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; + +import { CreateEmailSignatureDto } from "./DTO/create-email-signature.dto"; +import { UpdateEmailSignatureDto } from "./DTO/update-email-signature.dto"; +import { EmailSignature } from "./entities/email-signature.entity"; +import { EmailSignaturesService } from "./services/email-signatures.service"; +import { UserDec } from "../../common/decorators/user.decorator"; +import { JwtAuthGuard } from "../auth/guards/auth.guard"; +import { User } from "../users/entities/user.entity"; + +@ApiTags("Email Signatures") +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller("email-signatures") +export class EmailSignaturesController { + constructor(private readonly emailSignaturesService: EmailSignaturesService) {} + + @Post() + @ApiOperation({ summary: "Create a new email signature" }) + @ApiResponse({ status: 201, description: "Email signature created successfully", type: EmailSignature }) + @ApiResponse({ status: 400, description: "Bad request" }) + async create(@UserDec() user: User, @Body() createEmailSignatureDto: CreateEmailSignatureDto): Promise { + return this.emailSignaturesService.create(user.id, createEmailSignatureDto); + } + + @Get() + @ApiOperation({ summary: "Get all email signatures for the user" }) + @ApiResponse({ status: 200, description: "Email signatures retrieved successfully", type: [EmailSignature] }) + async findAll(@UserDec() user: User): Promise { + return this.emailSignaturesService.findAll(user.id); + } + + @Get("active") + @ApiOperation({ summary: "Get all active email signatures for the user" }) + @ApiResponse({ status: 200, description: "Active email signatures retrieved successfully", type: [EmailSignature] }) + async findActive(@UserDec() user: User): Promise { + return this.emailSignaturesService.findActive(user.id); + } + + @Get("default") + @ApiOperation({ summary: "Get the default email signature for the user" }) + @ApiResponse({ status: 200, description: "Default email signature retrieved successfully", type: EmailSignature }) + @ApiResponse({ status: 404, description: "No default signature found" }) + async findDefault(@UserDec() user: User): Promise { + return this.emailSignaturesService.findDefault(user.id); + } + + @Get(":id") + @ApiOperation({ summary: "Get a specific email signature" }) + @ApiResponse({ status: 200, description: "Email signature retrieved successfully", type: EmailSignature }) + @ApiResponse({ status: 404, description: "Email signature not found" }) + async findOne(@Param("id") id: string, @UserDec() user: User): Promise { + return this.emailSignaturesService.findOne(id, user.id); + } + + @Patch(":id") + @ApiOperation({ summary: "Update an email signature" }) + @ApiResponse({ status: 200, description: "Email signature updated successfully", type: EmailSignature }) + @ApiResponse({ status: 404, description: "Email signature not found" }) + async update(@Param("id") id: string, @UserDec() user: User, @Body() updateEmailSignatureDto: UpdateEmailSignatureDto): Promise { + return this.emailSignaturesService.update(id, user.id, updateEmailSignatureDto); + } + + @Put(":id/set-default") + @ApiOperation({ summary: "Set an email signature as default" }) + @ApiResponse({ status: 200, description: "Email signature set as default successfully", type: EmailSignature }) + @ApiResponse({ status: 400, description: "Cannot set inactive signature as default" }) + @ApiResponse({ status: 404, description: "Email signature not found" }) + async setAsDefault(@Param("id") id: string, @UserDec() user: User): Promise { + return this.emailSignaturesService.setAsDefault(id, user.id); + } + + @Put(":id/activate") + @ApiOperation({ summary: "Activate an email signature" }) + @ApiResponse({ status: 200, description: "Email signature activated successfully", type: EmailSignature }) + @ApiResponse({ status: 404, description: "Email signature not found" }) + async activate(@Param("id") id: string, @UserDec() user: User): Promise { + return this.emailSignaturesService.activate(id, user.id); + } + + @Put(":id/deactivate") + @ApiOperation({ summary: "Deactivate an email signature" }) + @ApiResponse({ status: 200, description: "Email signature deactivated successfully", type: EmailSignature }) + @ApiResponse({ status: 404, description: "Email signature not found" }) + async deactivate(@Param("id") id: string, @UserDec() user: User): Promise { + return this.emailSignaturesService.deactivate(id, user.id); + } + + @Delete(":id") + @ApiOperation({ summary: "Delete an email signature" }) + @ApiResponse({ status: 200, description: "Email signature deleted successfully" }) + @ApiResponse({ status: 404, description: "Email signature not found" }) + async remove(@Param("id") id: string, @UserDec() user: User): Promise { + return this.emailSignaturesService.remove(id, user.id); + } +} diff --git a/src/modules/email-signatures/email-signatures.module.ts b/src/modules/email-signatures/email-signatures.module.ts new file mode 100644 index 0000000..25fa469 --- /dev/null +++ b/src/modules/email-signatures/email-signatures.module.ts @@ -0,0 +1,15 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { Module } from "@nestjs/common"; + +import { EmailSignaturesController } from "./email-signatures.controller"; +import { EmailSignature } from "./entities/email-signature.entity"; +import { EmailSignatureRepository } from "./repositories/email-signature.repository"; +import { EmailSignaturesService } from "./services/email-signatures.service"; + +@Module({ + imports: [MikroOrmModule.forFeature([EmailSignature])], + controllers: [EmailSignaturesController], + providers: [EmailSignaturesService, EmailSignatureRepository], + exports: [EmailSignaturesService], +}) +export class EmailSignaturesModule {} diff --git a/src/modules/email-signatures/entities/email-signature.entity.ts b/src/modules/email-signatures/entities/email-signature.entity.ts new file mode 100644 index 0000000..f9cdad1 --- /dev/null +++ b/src/modules/email-signatures/entities/email-signature.entity.ts @@ -0,0 +1,29 @@ +import { Entity, EntityRepositoryType, Index, OneToOne, Opt, Property } from "@mikro-orm/core"; + +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; +import { EmailSignatureRepository } from "../repositories/email-signature.repository"; + +@Entity({ repository: () => EmailSignatureRepository }) +@Index({ properties: ["user", "isActive"] }) +export class EmailSignature extends BaseEntity { + @OneToOne(() => User, (user) => user.emailSignature, { owner: true, deleteRule: "cascade" }) + user!: User; + + @Property({ type: "varchar", length: 100 }) + name!: string; + + @Property({ type: "text", nullable: true }) + textContent?: string; + + // @Property({ type: "text", nullable: true }) + // htmlContent?: string; + + @Property({ type: "boolean", default: true }) + isActive!: boolean & Opt; + + @Property({ type: "boolean", default: false }) + isDefault!: boolean & Opt; + + [EntityRepositoryType]?: EmailSignatureRepository; +} diff --git a/src/modules/email-signatures/repositories/email-signature.repository.ts b/src/modules/email-signatures/repositories/email-signature.repository.ts new file mode 100644 index 0000000..ab758a6 --- /dev/null +++ b/src/modules/email-signatures/repositories/email-signature.repository.ts @@ -0,0 +1,28 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { EmailSignature } from "../entities/email-signature.entity"; + +export class EmailSignatureRepository extends EntityRepository { + async findByUserId(userId: string): Promise { + return this.find({ user: userId }, { orderBy: { isDefault: "DESC", createdAt: "DESC" } }); + } + + async findActiveByUserId(userId: string): Promise { + return this.find({ user: userId, isActive: true }, { orderBy: { isDefault: "DESC", createdAt: "DESC" } }); + } + + async findDefaultByUserId(userId: string): Promise { + return this.findOne({ user: userId, isDefault: true, isActive: true }); + } + + async findByIdAndUserId(id: string, userId: string): Promise { + return this.findOne({ id, user: userId }); + } + + async updateDefaultSignature(userId: string, newDefaultId: string): Promise { + // remove default from all signatures for this user + await this.nativeUpdate({ user: userId }, { isDefault: false }); + // set the new default + await this.nativeUpdate({ id: newDefaultId, user: userId }, { isDefault: true }); + } +} diff --git a/src/modules/email-signatures/services/email-signatures.service.ts b/src/modules/email-signatures/services/email-signatures.service.ts new file mode 100644 index 0000000..1b563cd --- /dev/null +++ b/src/modules/email-signatures/services/email-signatures.service.ts @@ -0,0 +1,106 @@ +import { EntityManager } from "@mikro-orm/core"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; + +import { User } from "../../users/entities/user.entity"; +import { CreateEmailSignatureDto } from "../DTO/create-email-signature.dto"; +import { UpdateEmailSignatureDto } from "../DTO/update-email-signature.dto"; +import { EmailSignature } from "../entities/email-signature.entity"; +import { EmailSignatureRepository } from "../repositories/email-signature.repository"; + +@Injectable() +export class EmailSignaturesService { + constructor( + private readonly emailSignatureRepository: EmailSignatureRepository, + private readonly em: EntityManager, + ) {} + + async create(userId: string, createDto: CreateEmailSignatureDto): Promise { + // Validate that at least one content type is provided + if (!createDto.textContent && !createDto.htmlContent) { + throw new BadRequestException("At least one content type (text or HTML) must be provided"); + } + + if (createDto.isDefault) await this.emailSignatureRepository.updateDefaultSignature(userId, ""); + + const signature = this.emailSignatureRepository.create({ + ...createDto, + user: { id: userId } as User, + }); + + await this.em.persistAndFlush(signature); + return signature; + } + + async findAll(userId: string): Promise { + return this.emailSignatureRepository.findByUserId(userId); + } + + async findActive(userId: string): Promise { + return this.emailSignatureRepository.findActiveByUserId(userId); + } + + async findDefault(userId: string): Promise { + return this.emailSignatureRepository.findDefaultByUserId(userId); + } + + async findOne(id: string, userId: string): Promise { + const signature = await this.emailSignatureRepository.findByIdAndUserId(id, userId); + if (!signature) { + throw new NotFoundException("Email signature not found"); + } + return signature; + } + + async update(id: string, userId: string, updateDto: UpdateEmailSignatureDto): Promise { + const signature = await this.findOne(id, userId); + + // If updating to default, remove default from other signatures + if (updateDto.isDefault && !signature.isDefault) { + await this.emailSignatureRepository.updateDefaultSignature(userId, id); + } + + // Update the signature + Object.assign(signature, updateDto); + await this.em.flush(); + + return signature; + } + + async setAsDefault(id: string, userId: string): Promise { + const signature = await this.findOne(id, userId); + + if (!signature.isActive) { + throw new BadRequestException("Cannot set inactive signature as default"); + } + + await this.emailSignatureRepository.updateDefaultSignature(userId, id); + signature.isDefault = true; + + return signature; + } + + async remove(id: string, userId: string): Promise { + const signature = await this.findOne(id, userId); + await this.em.removeAndFlush(signature); + } + + async activate(id: string, userId: string): Promise { + const signature = await this.findOne(id, userId); + signature.isActive = true; + await this.em.flush(); + return signature; + } + + async deactivate(id: string, userId: string): Promise { + const signature = await this.findOne(id, userId); + + // If this is the default signature, we need to handle it + if (signature.isDefault) { + signature.isDefault = false; + } + + signature.isActive = false; + await this.em.flush(); + return signature; + } +} diff --git a/src/modules/mail-server/interfaces/users-response.interface.ts b/src/modules/mail-server/interfaces/users-response.interface.ts index 3ab332b..896d979 100644 --- a/src/modules/mail-server/interfaces/users-response.interface.ts +++ b/src/modules/mail-server/interfaces/users-response.interface.ts @@ -33,90 +33,76 @@ export interface UserStorageInfo { quota: number; } +export interface UserKeyInfo { + name: string; + address: string; + fingerprint: string; +} + +export interface UserQuotaLimits { + allowed: number; + used: number; +} + +export interface UserTimeLimits { + allowed: number; + used: number; + ttl: number; +} + +export interface UserConnectionLimits { + allowed: number; + used: number; +} + export interface UserLimits { - quota: { - allowed: number; - used: number; - }; - recipients: { - allowed: number; - used: number; - ttl: number | false; - }; - forwards: { - allowed: number; - used: number; - ttl: number | false; - }; - received: { - allowed: number; - used: number; - ttl: number | false; - }; - imapUpload: { - allowed: number; - used: number; - ttl: number | false; - }; - imapDownload: { - allowed: number; - used: number; - ttl: number | false; - }; - pop3Download: { - allowed: number; - used: number; - ttl: number | false; - }; - imapMaxConnections: { + quota: UserQuotaLimits; + recipients: UserTimeLimits; + forwards: UserTimeLimits; + received: UserTimeLimits; + imapUpload: UserTimeLimits; + imapDownload: UserTimeLimits; + pop3Download: UserTimeLimits; + imapMaxConnections: UserConnectionLimits; + filters: { allowed: number; used: number; }; } +export interface UserTarget { + id: string; + type: "relay" | "webhook" | "http"; + value: string; +} + export interface UserProfile { + success: boolean; id: string; username: string; - name?: string; + name: string; address: string; - language?: string; retention?: number; - enabled2fa?: string[]; - autoreply?: boolean; - encryptMessages?: boolean; - encryptForwarded?: boolean; - pubKey?: string; - metaData?: Record; - internalData?: Record; + enabled2fa: string[]; + autoreply: boolean; + encryptMessages: boolean; + encryptForwarded: boolean; + pubKey: string; + keyInfo: UserKeyInfo; + metaData: any; + internalData: object; + targets: string[]; + mtaRelay?: string; + spamLevel: number; + limits: UserLimits; + quota: UserQuotaLimits; + tags: string[]; + fromWhitelist?: string[]; + disabledScopes: ("imap" | "pop3" | "smtp")[]; hasPasswordSet: boolean; activated: boolean; disabled: boolean; suspended: boolean; - tags: string[]; - fromWhitelist?: string[]; - disabledScopes?: string[]; - lastLogin?: { - time: string; - event: string; - }; - previousPassword?: { - date: string; - hash: string; - }; - tempPassword?: { - created: string; - validUntil: string; - }; - quota: { - allowed: number; - used: number; - }; - limits: UserLimits; - targets?: Array<{ - id: string; - type: "relay" | "webhook" | "http"; - value: string; - }>; } export interface UserListResponse { diff --git a/src/modules/quota-sync/DTO/quota-statistics.dto.ts b/src/modules/quota-sync/DTO/quota-statistics.dto.ts new file mode 100644 index 0000000..af11fb1 --- /dev/null +++ b/src/modules/quota-sync/DTO/quota-statistics.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +export class QuotaStatisticsDto { + @ApiProperty({ description: "Total number of email users", example: 150 }) + totalUsers: number; + + @ApiProperty({ description: "Total quota used by all users in bytes", example: 5368709120 }) + totalQuotaUsed: number; + + @ApiProperty({ description: "Total quota allowed for all users in bytes", example: 21474836480 }) + totalQuotaAllowed: number; + + @ApiProperty({ description: "Average quota usage percentage", example: 25.0 }) + averageUsagePercent: number; + + @ApiPropertyOptional({ + description: "Last time quota was synced", + example: "2024-01-15T10:30:00Z", + type: String, + format: "date-time", + }) + lastSyncTime: Date | null; +} + +export class QuotaInfoDto { + @ApiProperty({ description: "Quota used in bytes", example: 1073741824 }) + used: number; + + @ApiProperty({ description: "Quota allowed in bytes", example: 5368709120 }) + allowed: number; +} + +export class SyncResultDto { + @ApiProperty({ description: "Number of successfully synced users", example: 145 }) + success: number; + + @ApiProperty({ description: "Number of users with sync errors", example: 5 }) + errors: number; +} diff --git a/src/modules/quota-sync/quota-sync.module.ts b/src/modules/quota-sync/quota-sync.module.ts new file mode 100644 index 0000000..2fb0d47 --- /dev/null +++ b/src/modules/quota-sync/quota-sync.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; + +import { MailServerModule } from "../mail-server/mail-server.module"; +import { QuotaSyncService } from "./services/quota-sync.service"; + +@Module({ + imports: [MailServerModule], + providers: [QuotaSyncService], + exports: [QuotaSyncService], +}) +export class QuotaSyncModule {} diff --git a/src/modules/quota-sync/services/quota-sync.service.ts b/src/modules/quota-sync/services/quota-sync.service.ts new file mode 100644 index 0000000..4d30a51 --- /dev/null +++ b/src/modules/quota-sync/services/quota-sync.service.ts @@ -0,0 +1,206 @@ +import { EntityManager } from "@mikro-orm/core"; +import { Injectable, Logger } from "@nestjs/common"; +import { Cron, CronExpression } from "@nestjs/schedule"; +import { firstValueFrom } from "rxjs"; + +import { WildDuckUsersService } from "../../mail-server/services/wildduck-users.service"; +import { User } from "../../users/entities/user.entity"; + +interface QuotaInfo { + used: number; + allowed: number; +} + +@Injectable() +export class QuotaSyncService { + private readonly logger = new Logger(QuotaSyncService.name); + + constructor( + private readonly wildDuckUsersService: WildDuckUsersService, + private readonly em: EntityManager, + ) {} + + /** + * Sync quota usage every 15 minutes + */ + @Cron(CronExpression.EVERY_2_HOURS) + // @Cron("0 */15 * * * *") + async syncAllUsersQuota(): Promise { + const em = this.em.fork(); + this.logger.log("Starting quota synchronization for all users"); + + try { + const users = await em.find(User, { + emailEnabled: true, + deletedAt: null, + wildduckUserId: { $ne: null }, + }); + + this.logger.log(`Found ${users.length} users to sync quota for`); + + let successCount = 0; + let errorCount = 0; + + for (const user of users) { + try { + await this.syncUserQuotaWithEntityManager(user, em); + successCount++; + } catch (error) { + errorCount++; + this.logger.error(`Failed to sync quota for user ${user.id}: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + + // Flush all changes at once + await em.flush(); + + this.logger.log(`Quota sync completed: ${successCount} success, ${errorCount} errors`); + } catch (error) { + this.logger.error("Failed to sync quota for users", `${error instanceof Error ? error.message : "Unknown error"}`); + } + } + + /** + * Sync quota for a specific user with provided entity manager + */ + private async syncUserQuotaWithEntityManager(user: User, _em: EntityManager): Promise { + if (!user.wildduckUserId) { + this.logger.warn(`User ${user.id} has no WildDuck user ID, skipping quota sync`); + return; + } + + try { + const wildduckUser = await firstValueFrom(this.wildDuckUsersService.getUser(user.wildduckUserId)); + + if (!wildduckUser || !wildduckUser.success) { + this.logger.warn(`Failed to fetch WildDuck user data for user ${user.id}`); + return; + } + + const quotaInfo: QuotaInfo = { + used: wildduckUser.limits?.quota?.used || 0, + allowed: wildduckUser.limits?.quota?.allowed || 0, + }; + + // Update local user quota info + user.emailQuotaUsed = quotaInfo.used; + user.emailQuota = quotaInfo.allowed; + user.lastQuotaSync = new Date(); + + this.logger.debug(`Synced quota for user ${user.id}: ${quotaInfo.used}/${quotaInfo.allowed} bytes`); + } catch (error) { + this.logger.error(`Error syncing quota for user ${user.id}:`, `${error instanceof Error ? error.message : "Unknown error"}`); + throw error; + } + } + + /** + * Sync quota for a specific user + */ + async syncUserQuota(user: User): Promise { + const em = this.em.fork(); + + // Re-fetch the user with the current entity manager + const managedUser = await em.findOne(User, { id: user.id }); + if (!managedUser) { + throw new Error(`User ${user.id} not found`); + } + + await this.syncUserQuotaWithEntityManager(managedUser, em); + await em.flush(); + } + + /** + * Manually trigger quota sync for a specific user + */ + async syncUserQuotaById(userId: string): Promise { + const em = this.em.fork(); + const user = await em.findOne(User, { + id: userId, + emailEnabled: true, + deletedAt: null, + }); + + if (!user) { + throw new Error(`User ${userId} not found or not email enabled`); + } + + await this.syncUserQuotaWithEntityManager(user, em); + await em.flush(); + + return { + used: user.emailQuotaUsed || 0, + allowed: user.emailQuota || 0, + }; + } + + /** + * Get quota statistics for all users + */ + async getQuotaStatistics(): Promise<{ + totalUsers: number; + totalQuotaUsed: number; + totalQuotaAllowed: number; + averageUsagePercent: number; + lastSyncTime: Date | null; + }> { + const em = this.em.fork(); + const users = await em.find(User, { + emailEnabled: true, + deletedAt: null, + emailQuotaUsed: { $ne: null }, + }); + + const totalQuotaUsed = users.reduce((sum, user) => sum + (user.emailQuotaUsed || 0), 0); + const totalQuotaAllowed = users.reduce((sum, user) => sum + (user.emailQuota || 0), 0); + const averageUsagePercent = totalQuotaAllowed > 0 ? (totalQuotaUsed / totalQuotaAllowed) * 100 : 0; + + // Find the most recent sync time + const lastSyncUser = await em.findOne( + User, + { + emailEnabled: true, + deletedAt: null, + lastQuotaSync: { $ne: null }, + }, + { + orderBy: { lastQuotaSync: "DESC" }, + }, + ); + + return { + totalUsers: users.length, + totalQuotaUsed, + totalQuotaAllowed, + averageUsagePercent: Math.round(averageUsagePercent * 100) / 100, + lastSyncTime: lastSyncUser?.lastQuotaSync || null, + }; + } + + /** + * Manual sync for all users (useful for admin endpoints) + */ + async forceSyncAllUsers(): Promise<{ success: number; errors: number }> { + this.logger.log("Manual quota sync triggered"); + await this.syncAllUsersQuota(); + + // Return basic statistics + const em = this.em.fork(); + const users = await em.find(User, { + emailEnabled: true, + deletedAt: null, + wildduckUserId: { $ne: null }, + }); + + const recentSyncs = await em.count(User, { + emailEnabled: true, + deletedAt: null, + lastQuotaSync: { $gte: new Date(Date.now() - 60000) }, // Last minute + }); + + return { + success: recentSyncs, + errors: users.length - recentSyncs, + }; + } +} diff --git a/src/modules/users/DTO/user-list-query.dto.ts b/src/modules/users/DTO/user-list-query.dto.ts new file mode 100644 index 0000000..5e3fd0a --- /dev/null +++ b/src/modules/users/DTO/user-list-query.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { CommonMessage } from "../../../common/enums/message.enum"; + +export class UserListQueryDto extends PaginationDto { + @IsOptional() + @IsString({ message: CommonMessage.SEARCH_QUERY_STRING }) + @ApiPropertyOptional({ description: "Search query", example: "search query" }) + q?: string; + + @IsOptional() + @Type(() => Number) + @IsIn([1, 0], { message: CommonMessage.IS_ACTIVE_SHOULD_BE_1_0 }) + @ApiPropertyOptional({ description: "User status", example: 1 }) + isActive?: number; +} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 18b7770..c713279 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,10 +1,11 @@ -import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, Property } from "@mikro-orm/core"; +import { Collection, Entity, EntityRepositoryType, ManyToOne, OneToMany, OneToOne, Property } from "@mikro-orm/core"; import { RefreshToken } from "./refresh-token.entity"; 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 { UserRepository } from "../repositories/user.repository"; @Entity({ repository: () => UserRepository }) @@ -27,18 +28,29 @@ export class User extends BaseEntity { @Property({ type: "bigint", nullable: true, default: 1073741824 }) // 1GB default emailQuota?: number; // Email quota in bytes + @Property({ type: "bigint", nullable: true, default: 0 }) + emailQuotaUsed?: number; // Email quota used in bytes + // Email account integration fields @Property({ type: "varchar", length: 255, nullable: true }) - emailAccountId?: string; // WildDuck user ID + wildduckUserId?: string; // WildDuck user ID @Property({ type: "varchar", length: 255, nullable: true }) displayName?: string; // Display name for email + @Property({ type: "timestamptz", nullable: true }) + lastQuotaSync?: Date; // Last time quota was synced with WildDuck + @Property({ default: true, nullable: false }) isActive: boolean = true; //========================= + @OneToOne(() => EmailSignature, (signature) => signature.user, { nullable: true, deleteRule: "cascade" }) + emailSignature?: EmailSignature; + + //========================= + @OneToMany(() => RefreshToken, (token) => token.user) refreshTokens = new Collection(this); diff --git a/src/modules/users/repositories/user.repository.ts b/src/modules/users/repositories/user.repository.ts index cf13c49..4cec384 100644 --- a/src/modules/users/repositories/user.repository.ts +++ b/src/modules/users/repositories/user.repository.ts @@ -1,5 +1,38 @@ -import { EntityRepository } from "@mikro-orm/postgresql"; +import { EntityRepository, FilterQuery } from "@mikro-orm/postgresql"; +import { PaginationUtils } from "../../utils/services/pagination.utils"; +import { UserListQueryDto } from "../DTO/user-list-query.dto"; import { User } from "../entities/user.entity"; -export class UserRepository extends EntityRepository {} +export class UserRepository extends EntityRepository { + async getUserListForAdmin(businessId: string, queryDto: UserListQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const whereClause: FilterQuery = { + business: { id: businessId }, + emailEnabled: true, + deletedAt: null, + }; + + if (queryDto.q) { + whereClause.$or = [ + { userName: { $like: `%${queryDto.q}%` } }, + { emailAddress: { $like: `%${queryDto.q}%` } }, + { displayName: { $like: `%${queryDto.q}%` } }, + ]; + } + + if (queryDto.isActive) { + console.log(queryDto.isActive); + + whereClause.isActive = queryDto.isActive === 1; + } + + return this.findAndCount(whereClause, { + populate: ["domain", "business"], + limit, + offset: skip, + orderBy: { createdAt: "DESC" }, + }); + } +} diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index 7d56286..a8ac987 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -1,4 +1,4 @@ -import { EntityManager, FilterQuery } from "@mikro-orm/postgresql"; +import { EntityManager } from "@mikro-orm/postgresql"; import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { firstValueFrom } from "rxjs"; @@ -17,6 +17,7 @@ import { DomainsService } from "../../domains/services/domains.service"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { PasswordService } from "../../utils/services/password.service"; import { CreateEmailUserDto } from "../DTO/create-email-user.dto"; +import { UserListQueryDto } from "../DTO/user-list-query.dto"; import { Role } from "../entities/role.entity"; import { User } from "../entities/user.entity"; import { RoleEnum } from "../enums/role.enum"; @@ -103,7 +104,7 @@ export class UsersService { emailAddress, emailEnabled: true, emailQuota: quota || 1073741824, - emailAccountId: mailServerUser.id, + wildduckUserId: mailServerUser.id, displayName: displayName || username, isActive: true, business, @@ -135,7 +136,7 @@ export class UsersService { id: user.id, emailAddress: user.emailAddress!, displayName: user.displayName!, - emailAccountId: user.emailAccountId!, + wildduckUserId: user.wildduckUserId!, emailQuota: user.emailQuota!, emailEnabled: user.emailEnabled, domain: domain.name, @@ -164,21 +165,10 @@ export class UsersService { } /*******************************/ - async getEmailUsers(businessId: string, domainId?: string) { - const whereClause: FilterQuery = { - business: { id: businessId }, - emailEnabled: true, - deletedAt: null, - }; + async getEmailUsers(businessId: string, queryDto: UserListQueryDto) { + const [users, count] = await this.userRepository.getUserListForAdmin(businessId, queryDto); - if (domainId) whereClause.domain = { id: domainId }; - - const users = await this.userRepository.find(whereClause, { - populate: ["domain", "business"], - orderBy: { createdAt: "DESC" }, - }); - - return { users }; + return { users, count, paginate: true }; } /*******************************/ @@ -192,8 +182,8 @@ export class UsersService { if (!user) throw new NotFoundException(UserMessage.EMAIL_USER_NOT_FOUND); try { - if (user.emailAccountId) { - await firstValueFrom(this.mailServerService.users.deleteUser(user.emailAccountId)); + if (user.wildduckUserId) { + await firstValueFrom(this.mailServerService.users.deleteUser(user.wildduckUserId)); } user.deletedAt = new Date(); @@ -224,9 +214,9 @@ export class UsersService { if (!user) throw new NotFoundException(UserMessage.EMAIL_USER_NOT_FOUND); try { - if (user.emailAccountId) { + if (user.wildduckUserId) { await firstValueFrom( - this.mailServerService.users.updateUser(user.emailAccountId, { + this.mailServerService.users.updateUser(user.wildduckUserId, { quota: newQuota, }), ); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 4785c2f..ece23bc 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,14 +1,17 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { CreateEmailUserDto } from "./DTO/create-email-user.dto"; +import { UserListQueryDto } from "./DTO/user-list-query.dto"; import { UsersService } from "./services/users.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { BusinessDec } from "../../common/decorators/business.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; +import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; @Controller("users") @ApiHeader({ name: "x-business-id", description: "Business ID" }) +@UseInterceptors(BusinessInterceptor) @AuthGuards() export class UsersController { constructor(private readonly usersService: UsersService) {} @@ -23,8 +26,8 @@ export class UsersController { @Get() @ApiOperation({ summary: "Get email users for business" }) @ApiResponse({ status: 200, description: "Email users retrieved successfully" }) - getEmailUsers(@BusinessDec("id") businessId: string, @Query("domainId") domainId?: string) { - return this.usersService.getEmailUsers(businessId, domainId); + getEmailUsers(@BusinessDec("id") businessId: string, @Query() queryDto: UserListQueryDto) { + return this.usersService.getEmailUsers(businessId, queryDto); } @Delete(":id") diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index bb65e68..7784a57 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -6,12 +6,13 @@ import { Role } from "./entities/role.entity"; import { User } from "./entities/user.entity"; import { UsersService } from "./services/users.service"; import { UsersController } from "./users.controller"; +import { BusinessesModule } from "../businesses/businesses.module"; import { DomainsModule } from "../domains/domains.module"; import { MailServerModule } from "../mail-server/mail-server.module"; import { UtilsModule } from "../utils/utils.module"; @Module({ - imports: [MikroOrmModule.forFeature([User, RefreshToken, Role]), UtilsModule, MailServerModule, DomainsModule], + imports: [MikroOrmModule.forFeature([User, RefreshToken, Role]), UtilsModule, MailServerModule, DomainsModule, BusinessesModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService],