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: "
",
+ 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: "",
+ 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],