chore: new feture for quato
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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: "<div><strong>John Doe</strong><br>Software Engineer<br>Company Inc.<br><a href='mailto:john@company.com'>john@company.com</a></div>",
|
||||
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;
|
||||
}
|
||||
@@ -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: "<div><strong>John Doe</strong><br>Software Engineer<br>Company Inc.<br><a href='mailto:john@company.com'>john@company.com</a></div>",
|
||||
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;
|
||||
}
|
||||
@@ -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<EmailSignature> {
|
||||
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<EmailSignature[]> {
|
||||
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<EmailSignature[]> {
|
||||
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<EmailSignature | null> {
|
||||
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<EmailSignature> {
|
||||
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<EmailSignature> {
|
||||
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<EmailSignature> {
|
||||
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<EmailSignature> {
|
||||
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<EmailSignature> {
|
||||
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<void> {
|
||||
return this.emailSignaturesService.remove(id, user.id);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
|
||||
import { EmailSignature } from "../entities/email-signature.entity";
|
||||
|
||||
export class EmailSignatureRepository extends EntityRepository<EmailSignature> {
|
||||
async findByUserId(userId: string): Promise<EmailSignature[]> {
|
||||
return this.find({ user: userId }, { orderBy: { isDefault: "DESC", createdAt: "DESC" } });
|
||||
}
|
||||
|
||||
async findActiveByUserId(userId: string): Promise<EmailSignature[]> {
|
||||
return this.find({ user: userId, isActive: true }, { orderBy: { isDefault: "DESC", createdAt: "DESC" } });
|
||||
}
|
||||
|
||||
async findDefaultByUserId(userId: string): Promise<EmailSignature | null> {
|
||||
return this.findOne({ user: userId, isDefault: true, isActive: true });
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<EmailSignature | null> {
|
||||
return this.findOne({ id, user: userId });
|
||||
}
|
||||
|
||||
async updateDefaultSignature(userId: string, newDefaultId: string): Promise<void> {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<EmailSignature> {
|
||||
// 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<EmailSignature[]> {
|
||||
return this.emailSignatureRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
async findActive(userId: string): Promise<EmailSignature[]> {
|
||||
return this.emailSignatureRepository.findActiveByUserId(userId);
|
||||
}
|
||||
|
||||
async findDefault(userId: string): Promise<EmailSignature | null> {
|
||||
return this.emailSignatureRepository.findDefaultByUserId(userId);
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<EmailSignature> {
|
||||
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<EmailSignature> {
|
||||
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<EmailSignature> {
|
||||
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<void> {
|
||||
const signature = await this.findOne(id, userId);
|
||||
await this.em.removeAndFlush(signature);
|
||||
}
|
||||
|
||||
async activate(id: string, userId: string): Promise<EmailSignature> {
|
||||
const signature = await this.findOne(id, userId);
|
||||
signature.isActive = true;
|
||||
await this.em.flush();
|
||||
return signature;
|
||||
}
|
||||
|
||||
async deactivate(id: string, userId: string): Promise<EmailSignature> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
internalData?: Record<string, any>;
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<QuotaInfo> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RefreshToken>(this);
|
||||
|
||||
|
||||
@@ -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<User> {}
|
||||
export class UserRepository extends EntityRepository<User> {
|
||||
async getUserListForAdmin(businessId: string, queryDto: UserListQueryDto) {
|
||||
const { limit, skip } = PaginationUtils(queryDto);
|
||||
|
||||
const whereClause: FilterQuery<User> = {
|
||||
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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<User> = {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user