chore: first commit

This commit is contained in:
mahyargdz
2025-06-24 11:26:06 +03:30
commit ccbf743ecb
186 changed files with 22258 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { HttpModule } from "@nestjs/axios";
import { BullModule } from "@nestjs/bullmq";
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ThrottlerModule } from "@nestjs/throttler";
import { bullMqConfig } from "./configs/bullmq.config";
import { cacheConfig } from "./configs/cache.config";
import { httpConfig } from "./configs/http.config";
import { databaseConfig } from "./configs/mikro-orm.config";
import { rateLimitConfig } from "./configs/rateLimit.config";
import { AuthModule } from "./modules/auth/auth.module";
import { DomainsModule } from "./modules/domains/domains.module";
import { MailServerModule } from "./modules/mail-server/mail-server.module";
import { UsersModule } from "./modules/users/users.module";
import { UtilsModule } from "./modules/utils/utils.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, cache: true }),
BullModule.forRootAsync(bullMqConfig()),
CacheModule.registerAsync(cacheConfig()),
HttpModule.registerAsync(httpConfig()),
ThrottlerModule.forRootAsync(rateLimitConfig()),
MikroOrmModule.forRootAsync(databaseConfig),
UtilsModule,
AuthModule,
UsersModule,
DomainsModule,
MailServerModule,
],
})
export class AppModule {}
+22
View File
@@ -0,0 +1,22 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmpty, IsNumber, IsOptional, Max, Min } from "class-validator";
export class PaginationDto {
@IsOptional()
@IsNotEmpty()
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiPropertyOptional({ type: "number", required: false })
page?: number;
@IsOptional()
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(50)
@Type(() => Number)
@ApiPropertyOptional({ type: "number", required: false })
limit?: number;
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiProperty, PartialType } from "@nestjs/swagger";
import { IsNotEmpty, IsUUID } from "class-validator";
import { CommonMessage } from "../enums/message.enum";
export class ParamDto {
@IsNotEmpty({ message: CommonMessage.ID_REQUIRED })
@IsUUID("7", { message: CommonMessage.ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Id of the entity", example: "8b1e8b1e-8b1e-8b1e-8b1e-8b1e8b1e8b1e" })
id: string;
}
export class OptionalParamDto extends PartialType(ParamDto) {}
export class ParamNumberIdDto {
@IsNotEmpty({ message: CommonMessage.ID_REQUIRED })
@ApiProperty({ description: "Id of the entity", example: "20" })
id: string;
}
+15
View File
@@ -0,0 +1,15 @@
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
export const AUTH_THROTTLE_TTL = 5 * 60 * 1000;
export const AUTH_THROTTLE_LIMIT = 5;
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
export const AUTH__REFRESH_THROTTLE_LIMIT = 10;
//
export const CONSOLE_JWT_STRATEGY_NAME = "console_jwt_strategy";
export const LOCAL_JWT_STRATEGY_NAME = "local_jwt_strategy";
export const DANAK_IMAPS_SERVER = "imaps.danakcorp.com";
export const DANAK_SMTPS_SERVER = "smtps.danakcorp.com";
export const DANAK_IMAPS_PORT = 993;
export const DANAK_SMTPS_PORT = 465;
export const DANAK_IMAPS_ENCRYPTION = "TLS";
export const DANAK_SMTPS_ENCRYPTION = "TLS";
+4
View File
@@ -0,0 +1,4 @@
export const ADMIN_ROUTE = "shouldAdmin";
import { SetMetadata } from "@nestjs/common";
export const AdminRoute = (isAdminRoute: boolean = true) => SetMetadata(ADMIN_ROUTE, isAdminRoute);
+8
View File
@@ -0,0 +1,8 @@
import { UseGuards, applyDecorators } from "@nestjs/common";
import { ApiBearerAuth } from "@nestjs/swagger";
import { JwtAuthGuard } from "../../modules/auth/guards/auth.guard";
export function AuthGuards() {
return applyDecorators(UseGuards(JwtAuthGuard), ApiBearerAuth("authorization"));
}
@@ -0,0 +1,17 @@
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
import { FastifyRequest } from "fastify";
/**
* Extracts the business ID from the request header
* @example
* ```typescript
* @Get()
* findAll(@BusinessIdHeader() businessId: string) {
* // Use businessId
* }
* ```
*/
export const BusinessIdHeader = createParamDecorator((headerName: string = "X-Business-Id", ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
return req.headers[headerName.toLocaleLowerCase()];
});
@@ -0,0 +1,11 @@
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { Business } from "../../modules/businesses/entities/business.entity";
export const BusinessDec = createParamDecorator((data: keyof Business | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
const business = req.business;
return data ? business?.[data] : business;
});
+35
View File
@@ -0,0 +1,35 @@
import { ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, registerDecorator } from "class-validator";
export function isValidIranianNationalCode(nationalCode: string): boolean {
if (!/^\d{10}$/.test(nationalCode)) return false;
const digits = nationalCode.split("").map(Number);
const controlDigit = digits[9];
const sum = digits.slice(0, 9).reduce((acc, digit, index) => acc + digit * (10 - index), 0);
const remainder = sum % 11;
return (remainder < 2 && controlDigit === remainder) || (remainder >= 2 && controlDigit === 11 - remainder);
}
@ValidatorConstraint({ async: false })
export class IsNationalCodeConstraint implements ValidatorConstraintInterface {
validate(value: unknown, _args: ValidationArguments) {
return typeof value === "string" && isValidIranianNationalCode(value);
}
defaultMessage(_args: ValidationArguments) {
return `Invalid Iranian national code :${_args.value}`;
}
}
export function IsNationalCode(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsNationalCodeConstraint,
});
};
}
+9
View File
@@ -0,0 +1,9 @@
import { applyDecorators } from "@nestjs/common";
import { ApiQuery } from "@nestjs/swagger";
export function Pagination() {
return applyDecorators(
ApiQuery({ name: "page", example: 1, required: false, type: "number" }),
ApiQuery({ name: "limit", example: 10, required: false, type: "number" }),
);
}
+6
View File
@@ -0,0 +1,6 @@
// import { SetMetadata } from "@nestjs/common";
// import { PermissionEnum } from "../../modules/users/enums/permission.enum";
// export const PERMISSION_KEY = "permissions";
// export const PermissionsDec = (...permissions: PermissionEnum[]) => SetMetadata(PERMISSION_KEY, permissions);
@@ -0,0 +1,12 @@
import { UseGuards, applyDecorators } from "@nestjs/common";
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
import { AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL } from "../constants";
export const RateLimit = (limit: number, ttl: number) => applyDecorators(Throttle({ default: { limit, ttl } }), UseGuards(ThrottlerGuard));
// Predefined rate limits for common scenarios
export const StrictRateLimit = () => RateLimit(5, 60000); // 5 requests per minute
export const StandardRateLimit = () => RateLimit(30, 60000); // 30 requests per minute
export const MailSendRateLimit = () => RateLimit(10, 60000); // 10 emails per minute
export const RefreshTokenRateLimit = () => RateLimit(AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL); // 10 emails per minute
+4
View File
@@ -0,0 +1,4 @@
// import { SetMetadata } from "@nestjs/common";
// export const ROLES_KEY = "roles";
// export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
+5
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from "@nestjs/common";
export const SKIP_AUTH_KEY = "skipAuth";
export const SkipAuth = () => SetMetadata(SKIP_AUTH_KEY, true);
+17
View File
@@ -0,0 +1,17 @@
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { ITokenPayload } from "../../modules/auth/interfaces/IToken-payload";
declare module "fastify" {
interface FastifyRequest {
user?: ITokenPayload;
}
}
export const UserDec = createParamDecorator((data: keyof ITokenPayload | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
const user = req.user;
return data ? user?.[data] : user;
});
+16
View File
@@ -0,0 +1,16 @@
import { Opt, PrimaryKey, Property, sql } from "@mikro-orm/core";
import { v7 } from "uuid";
export abstract class BaseEntity {
@PrimaryKey({ type: "uuid" })
id = v7();
@Property({ default: sql.now(), type: "timestamptz" })
createdAt: Date & Opt;
@Property({ default: sql.now(), type: "timestamptz" })
updatedAt: Date & Opt;
@Property({ nullable: true })
deletedAt?: Date;
}
+222
View File
@@ -0,0 +1,222 @@
export const enum AuthMessage {
UNAUTHORIZED_ACCESS = "شما دسترسی لازم برای این عملیات را ندارید.",
PHONE_REGISTERED = "شماره ثبت شد",
INVALID_PHONE_FORMAT = "فرمت شماره تلفن صحیح نیست",
OTP_SENT = "کد یکبار مصرف به شماره شما ارسال شد.",
INVALID_PHONE = "شماره موبایل اشتباه می باشد",
INVALID_CREDENTIAL = "شماره موبایل یا رمز عبور اشتباه می باشد",
OTP_FAILED = "کد یا شماره موبایل اشتباه است",
LOGIN_SUCCESS = "ورود با موفقیت انجام شد",
PASSWORD_LOGIN_SUCCESS = "با موفقیت وارد شدید",
FORGOT_PASSWORD_SUCCESS = "درخواست فراموشی رمز عبور با موفقیت ثبت شد",
PASSWORD_SET_SUCCESS = "پسورد با موفقیت تنظیم شد",
PASSWORD_UPDATE_SUCCESS = "رمز عبور شما تغییر کرد",
INVALID_OTP = "کد صحیح نمی‌باشد یا منقضی شده است",
PASSWORD_MISMATCH = "رمز عبور یکسان نیست",
INVALID_PASSWORD = "ایمیل یا رمز عبور اشتباه است",
INVALID_REPEAT_PASSWORD = "تکرار رمز عبور اشتباه است",
EMAIL_NOT_EMPTY = "ایمیل نمیتواند خالی باشد.",
INVALID_EMAIL_FORMAT = "فرمت ایمیل صحیح نیست",
PasswordNotEmpty = "پسورد نمیتواند خالی باشد.",
USER_NOT_FOUND = "کاربری با این شماره وجود ندارد",
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
INVALID_USER = "کاربری با این مشخصات یافت نشد",
PHONE_NOT_FOUND = "کاربری با این شماره یافت نشد",
TOKEN_EXPIRED = "توکن منقضی شده است",
TOKEN_INVALID = "توکن نامعتبر است",
BANNED = "در حال حاضر شما امکان دسترسی به این سرویس را ندارید",
PHONE_EXISTS = "شماره تلفن قبلا ثبت شده است",
ADMIN_CREATED = "ادمین با موفقیت ایجاد شد",
PERM_NOT_FOUND = "دسترسی یافت نشد",
INVALID_PASS_FORMAT = "فرمت رمز عبور صحیح نیست",
PHONE_NOT_EMPTY = "شماره تلفن نمیتواند خالی باشد",
PASSWORD_FORMAT_INVALID = "رمز عبور باید به صورت رشته باشد",
OTP_FORMAT_INVALID = "کد یکبار مصرف باید عددی و ۵ رقمی باشد",
PHONE_FORMAT_INVALID = "شماره تلفن باید معتبر و به فرمت ایران باشد",
TOKEN_NOT_EMPTY = "توکن نمی‌تواند خالی باشد",
PASSWORD_NOT_EMPTY = "رمز عبور نمی‌تواند خالی باشد",
PASSWORD_CHANGED_SUCCESSFULLY = "رمز عبور با موفقیت تغییر کرد",
PASSWORD_CONFIRMATION_NOT_EMPTY = "تایید رمز عبور نمی‌تواند خالی باشد",
PASSWORD_LENGTH = "رمز عبور باید حداقل ۸ کاراکتر باشد",
OTP_NOT_EMPTY = "کد یکبار مصرف نمی‌تواند خالی باشد",
LAST_PASSWORD_NOT_EMPTY = "رمز عبور قبلی نمی‌تواند خالی باشد",
FIRST_NAME_NOT_EMPTY = "نام نمی‌تواند خالی باشد",
LAST_NAME_NOT_EMPTY = "نام خانوادگی نمی‌تواند خالی باشد",
FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = "نام باید بین ۲ تا ۵۰ کاراکتر باشد",
LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = "نام خانوادگی باید بین ۲ تا ۵۰ کاراکتر باشد",
BIRTH_DATE_NOT_EMPTY = "تاریخ تولد نمی‌تواند خالی باشد",
NATIONAL_CODE_INCORRECT = "کد ملی باید ۱۰ رقمی باشد",
NATIONAL_NOT_EMPTY = "کد ملی نمی‌تواند خالی باشد",
OTP_ALREADY_SENT = "کد یکبار مصرف قبلا ارسال شده است",
TOO_MANY_REQUESTS = "تعداد درخواست های شما بیش از حد مجاز است",
NOT_ADMIN = "شما دسترسی به بخش ادمین ندارید",
PHONE_SHOULD_BE_11_DIGIT = "شماره تلفن باید ۱۱ رقم باشد",
TIMESTAMP_NOT_EMPTY = "زمان نمی‌تواند خالی باشد",
ADMIN_CAN_NOT_LOGIN = "ادمین نمی‌تواند وارد شود",
NATIONAL_CODE_INVALID = "کد ملی نامعتبر است",
INVALID_REFRESH_TOKEN = "توکن رفرش نامعتبر است",
REFRESH_TOKEN_EXPIRED = "توکن رفرش منقضی شده است",
LOGOUT_SUCCESS = "خروج با موفقیت انجام شد",
CURRENT_PASSWORD_INVALID = "رمز عبور فعلی نامعتبر است",
TOKEN_EXPIRED_OR_INVALID = "توکن منقضی شده یا نامعتبر است",
ACCESS_DENIED = "دسترسی شما محدود شده است",
USER_ACCOUNT_INACTIVE = "حساب کاربری شما غیر فعال است",
}
export const enum UserMessage {
USER_NOT_FOUND = "کاربری با این شماره وجود ندارد",
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
INVALID_USER = "کاربری با این مشخصات یافت نشد",
EMAIL_ADDRESS_ALREADY_EXISTS = "ایمیل قبلا ثبت شده است",
EMAIL_USER_NOT_FOUND = "ایمیل یافت نشد",
EMAIL_USER_DELETED_SUCCESSFULLY = "ایمیل با موفقیت حذف شد",
FAILED_TO_DELETE_EMAIL_USER = "خطا در حذف ایمیل",
EMAIL_USER_QUOTA_UPDATED_SUCCESSFULLY = "ایمیل با موفقیت به روز رسانی شد",
FAILED_TO_UPDATE_EMAIL_USER_QUOTA = "خطا در به روز رسانی حجم ایمیل",
}
export const enum CommonMessage {
VALIDITY_TYPE_REQUIRED = "نوع اعتبار سنجی مورد نیاز است",
THIS_FILED_IS_REQUIRED = "این فیلد الزامی است",
VALID_FOR_CHOOSE = "معتبر برای انتخاب",
UPDATE_SUCCESS = "با موفقیت به روز رسانی شد",
CREATED = "با موفقیت ایجاد شد",
DELETED = "با موفقیت حذف شد",
ID_REQUIRED = "شناسه مورد نیاز است",
ID_SHOULD_BE_UUID = "شناسه باید یک یو یو آی دی باشد",
PaymentTypeQueryNotEmpty = "نوع پرداخت نمی‌تواند خالی باشد",
SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد",
UPDATED = "با موفقیت آپدیت شد",
IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت باید یکی از مقادیر ۰ و ۱ باشد",
DATE_MUST_BE_DATE = "تاریخ باید یک تاریخ معتبر به صورت رشته باشد",
}
export const enum UploaderMessage {
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
}
export const enum EmailMessage {
EMAIL_VERIFICATION = "تایید ایمیل",
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
LOGIN = "ورود به سیستم",
INVOICE = "فاکتور",
WALLET = "اعلان کیف پول",
TICKET = "تیکت پشتیبانی",
ANNOUNCEMENT = "اعلان",
PAYMENT_REMINDER = "یادآوری پرداخت",
PAYMENT_CANCELLATION = "لغو پرداخت",
}
export const enum SmsMessage {
SEND_SMS_ERROR = "خطا در ارسال پیامک",
}
export const enum BusinessMessage {
NOT_FOUND = "کسب و کار یافت نشد",
BUSINESS_ID_REQUIRED = "شناسه کسب و کار مورد نیاز است",
SLUG_REQUIRED = "اسلاگ مورد نیاز است",
SLUG_MUST_BE_STRING = "اسلاگ باید یک رشته باشد",
DOMAIN_INVALID = "دامنه وارد شده معتبر نیست",
DOMAIN_UPDATED = "دامنه با موفقیت به‌روزرسانی شد",
DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد",
DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد",
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
DOMAIN_VERIFICATION_METHOD_INVALID = "روش تایید دامنه نامعتبر است",
DOMAIN_ALREADY_VERIFIED = "این دامنه قبلا تایید شده است",
DOMAIN_REQUIRED = "دامنه مورد نیاز است",
DOMAIN_MUST_BE_STRING = "دامنه باید یک رشته باشد",
STAFF_NOT_FOUND = "کاربر ادمین یافت نشد",
ZARINPAL_MERCHANT_ID_REQUIRED = "شناسه مرچنت زرین پال مورد نیاز است",
ZARINPAL_MERCHANT_ID_STRING = "شناسه مرچنت زرین پال باید یک رشته باشد",
LOGO_URL_REQUIRED = "آدرس تصویر لوگو مورد نیاز است",
LOGO_URL_STRING = "آدرس تصویر لوگو باید یک رشته باشد",
LOGO_URL_INVALID = "آدرس تصویر لوگو معتبر نیست",
BUSINESS_SETTINGS_UPDATED = "تنظیمات کسب و کار با موفقیت به روز رسانی شد",
NAME_REQUIRED = "نام کسب و کار مورد نیاز است",
NAME_STRING = "نام کسب و کار باید یک رشته باشد",
DOMAIN_CONNECTED = "دامنه با موفقیت متصل شد",
DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد",
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
}
export const enum DomainMessage {
// Domain Ownership & Verification
NOT_BELONG_TO_BUSINESS = "دامنه به این کسب و کار تعلق ندارد",
NOT_VERIFIED = "دامنه باید تایید شده باشد",
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
DOMAIN_ALREADY_EXISTS = "دامنه [name] قبلا ثبت شده است",
DOMAIN_DELETED_SUCCESSFULLY = "دامنه با موفقیت حذف شد",
VERIFICATION_NOT_FOUND = "تایید دامنه یافت نشد",
NO_DOMAIN_FOUND = "دامنه ای یافت نشد",
DOMAIN_CHECKED = "دامنه با موفقیت بررسی شد",
// Domain Creation & Setup
DOMAIN_CREATED_WITH_RECORDS = "دامنه [name] با موفقیت ایجاد شد و تمام رکوردهای مورد نیاز تولید گردید",
ALL_REQUIRED_RECORDS_GENERATED = "تمام رکوردهای مورد نیاز (MX, SPF, DMARC, DKIM) به طور خودکار برای شما تولید شده‌اند",
ESTIMATED_PROPAGATION_TIME = "تغییرات DNS معمولاً ۱۵ دقیقه تا ۴۸ ساعت در سراسر جهان منتشر می‌شود",
// DNS Records Instructions
ADD_MX_RECORD = "یک رکورد MX با نام [name] که به [value] با اولویت [priority] اشاره کند اضافه کنید",
ADD_TXT_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید",
ADD_DKIM_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (کلید عمومی DKIM)",
ADD_DMARC_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (سیاست DMARC)",
ADD_CNAME_RECORD = "یک رکورد CNAME با نام [name] که به [value] اشاره کند اضافه کنید",
ADD_GENERIC_RECORD = "یک رکورد [type] با نام [name] و مقدار [value] اضافه کنید",
// Domain Setup Steps
STEP_LOGIN_DNS_PANEL = "وارد پنل مدیریت DNS ثبت کننده دامنه خود شوید",
STEP_ADD_ALL_RECORDS = "تمام رکوردهای DNS مورد نیاز لیست شده در بالا را به زون DNS دامنه خود اضافه کنید",
STEP_INCLUDE_ALL_TYPES = "مطمئن شوید که رکوردهای MX، SPF، DMARC و DKIM را برای عملکرد کامل ایمیل شامل کرده‌اید",
STEP_WAIT_PROPAGATION = "منتظر انتشار DNS بمانید (معمولاً ۱۵ دقیقه تا ۴۸ ساعت)",
STEP_USE_CHECK_ENDPOINT = "از نقطه پایانی 'بررسی DNS' برای تایید تنظیم صحیح رکوردهای خود استفاده کنید",
STEP_DOMAIN_READY = "پس از تایید، دامنه شما برای سرویس‌های ایمیل آماده خواهد بود!",
// Status Messages
RECORDS_PENDING_VERIFICATION = "[count] رکورد DNS هنوز در انتظار تایید هستند",
ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED = "تمام [count] رکورد مورد نیاز باید برای کار درست ایمیل تنظیم شوند",
DOMAIN_FULLY_VERIFIED = "دامنه شما به طور کامل تایید شده و برای ایمیل آماده است!",
EMAIL_ACCOUNTS_READY = "اکنون می‌توانید حساب‌های ایمیل ایجاد کرده و شروع به ارسال/دریافت ایمیل کنید",
// Verification Status
DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد",
DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد",
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
DOMAIN_VERIFICATION_PENDING = "تایید دامنه در انتظار است",
// DNS Record Status
DNS_RECORD_VERIFIED = "رکورد DNS تایید شد",
DNS_RECORD_FAILED = "رکورد DNS تایید نشد",
DNS_RECORD_NOT_FOUND_OR_MISMATCH = "رکورد DNS یافت نشد یا مقدار مطابقت ندارد",
// DKIM Messages
DKIM_ENABLED_AUTOMATICALLY = "DKIM به طور خودکار برای دامنه [name] فعال شد",
DKIM_FAILED_TO_CREATE = "ایجاد DKIM برای دامنه [name] ناموفق بود",
DKIM_ENABLED_SUCCESSFULLY = "DKIM برای دامنه [name] با موفقیت فعال شد",
// Error Messages
FAILED_TO_VERIFY_DOMAIN = "تایید دامنه [name] ناموفق بود",
FAILED_TO_SETUP_MAIL_SERVER = "راه‌اندازی دامنه [name] در سرور ایمیل ناموفق بود",
DOMAIN_CREATION_FAILED = "ایجاد دامنه ناموفق بود",
DNS_VERIFICATION_ATTEMPTS_EXCEEDED = "تعداد تلاش‌های تایید DNS از حد مجاز بیشتر شد",
// Service Messages
DOMAIN_UPDATED_SUCCESSFULLY = "دامنه بروزرسانی شد",
VERIFICATION_TOKEN_PREFIX = "توکن تایید: [token]",
RECORD_NEEDS_FIXING = "رکورد [type] برای [name] نیاز به اصلاح دارد: [error]",
RECORD_PENDING_VERIFICATION_DETAIL = "رکورد [type] برای [name] هنوز در انتظار تایید است. لطفا مطمئن شوید که انتشار DNS کامل شده است.",
RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY = "رکورد [type] برای عملکرد ایمیل",
BUSINESS_ALREADY_HAS_DOMAIN = "این کسب و کار قبلا دامنه ای دارد",
}
export const enum MailServerMessage {
FAILED_TO_CREATE_ACCOUNT = "خطا در ایجاد ایمیل",
FAILED_TO_CREATE_DKIM_KEY = "FAILED_TO_CREATE_DKIM_KEY",
}
export const enum DnsRecordMessage {
DNS_RECORD_NOT_FOUND = "رکورد DNS یافت نشد",
DNS_RECORD_DELETED = "رکورد DNS با موفقیت حذف شد",
}
+9
View File
@@ -0,0 +1,9 @@
export interface ErrorResponse {
statusCode: number;
success: boolean;
error: {
message: string | string[];
timestamp?: string;
path?: string;
};
}
+19
View File
@@ -0,0 +1,19 @@
export interface IPageFormat {
page: number;
limit: number;
totalItems: number;
totalPages: number;
prevPage: string | boolean;
nextPage: string | boolean;
}
export interface PaginationQuery {
page?: string;
limit?: string;
}
export interface PaginatedResponse {
paginate?: boolean;
count?: number;
[key: string]: unknown;
}
+9
View File
@@ -0,0 +1,9 @@
import { ValueProvider } from "@nestjs/common";
import { Logger } from "@nestjs/common";
export const MIKRO_ORM_QUERY_LOGGER = "MikroOrmQueryLogger";
export const MikroOrmQueryLogger: ValueProvider = {
provide: MIKRO_ORM_QUERY_LOGGER,
useValue: new Logger("mikro-orm"),
};
+33
View File
@@ -0,0 +1,33 @@
import { OnWorkerEvent, WorkerHost } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
export abstract class WorkerProcessor extends WorkerHost {
protected readonly logger = new Logger(WorkerProcessor.name);
@OnWorkerEvent("completed")
onCompleted(job: Job) {
const { id, name, queueName, finishedOn, returnvalue } = job;
const completionTime = finishedOn ? new Date(finishedOn).toISOString() : "";
this.logger.log(`Job id: ${id}, name: ${name} completed in queue ${queueName} on ${completionTime}. Result: ${returnvalue}`);
}
@OnWorkerEvent("progress")
onProgress(job: Job) {
const { id, name, progress } = job;
this.logger.log(`Job id: ${id}, name: ${name} completes ${progress}%`);
}
@OnWorkerEvent("failed")
onFailed(job: Job) {
const { id, name, queueName, failedReason } = job;
this.logger.error(`Job id: ${id}, name: ${name} failed in queue ${queueName}. Failed reason: ${failedReason}`);
}
@OnWorkerEvent("active")
onActive(job: Job) {
const { id, name, queueName, timestamp } = job;
const startTime = timestamp ? new Date(timestamp).toISOString() : "";
this.logger.log(`Job id: ${id}, name: ${name} starts in queue ${queueName} on ${startTime}.`);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { SharedBullAsyncConfiguration } from "@nestjs/bullmq";
import { ConfigService } from "@nestjs/config";
export function bullMqConfig(): SharedBullAsyncConfiguration {
return {
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
connection: {
url: configService.getOrThrow<string>("REDIS_URI"),
},
prefix: "dmail",
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
attempts: 5,
},
}),
};
}
+16
View File
@@ -0,0 +1,16 @@
import KeyvRedis from "@keyv/redis";
import { CacheModuleAsyncOptions } from "@nestjs/cache-manager";
import { ConfigService } from "@nestjs/config";
export function cacheConfig(): CacheModuleAsyncOptions {
return {
inject: [ConfigService],
isGlobal: true,
useFactory: async (configService: ConfigService) => {
return {
ttl: configService.getOrThrow<string>("CACHE_TTL"),
stores: [new KeyvRedis(configService.getOrThrow<string>("REDIS_URI"), { namespace: "dmail:" })],
};
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { HttpModuleAsyncOptions } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
export function httpConfig(): HttpModuleAsyncOptions {
return {
inject: [ConfigService],
global: true,
useFactory: async (configService: ConfigService) => {
return {
timeout: configService.getOrThrow<number>("AXIOS_TIMEOUT"),
headers: { "Content-Type": "application/json" },
global: true,
};
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { ConfigService } from "@nestjs/config";
import { JwtModuleAsyncOptions } from "@nestjs/jwt";
export function jwtConfig(): JwtModuleAsyncOptions {
return {
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
return {
secret: configService.getOrThrow<string>("JWT_SECRET"),
signOptions: {
issuer: configService.getOrThrow<string>("JWT_ISSUER"),
},
};
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import { MikroOrmModuleAsyncOptions } from "@mikro-orm/nestjs";
import { PostgreSqlDriver } from "@mikro-orm/postgresql";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MIKRO_ORM_QUERY_LOGGER, MikroOrmQueryLogger } from "../common/providers/mikro-orm-logger";
export const databaseConfig: MikroOrmModuleAsyncOptions = {
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
providers: [MikroOrmQueryLogger],
useFactory: (configService: ConfigService, logger: Logger) => {
const DB_PASS = configService.getOrThrow<string>("DB_PASS");
const DB_USER = configService.getOrThrow<string>("DB_USER");
const DB_HOST = configService.getOrThrow<string>("DB_HOST");
const DB_PORT = configService.getOrThrow<number>("DB_PORT");
const encodedPassword = encodeURIComponent(DB_PASS);
return {
driver: PostgreSqlDriver,
autoLoadEntities: true,
dbName: configService.getOrThrow<string>("DB_NAME"),
debug: configService.get<string>("NODE_ENV") !== "production",
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
ensureDatabase: { forceCheck: true, create: true, schema: "update" },
forceUtcTimezone: true,
pool: {
min: 2,
max: 15,
idleTimeoutMillis: 10000,
acquireTimeoutMillis: 10000,
reapIntervalMillis: 1000,
createTimeoutMillis: 3000,
destroyTimeoutMillis: 5000,
},
logger: (message) => logger.debug(message),
schemaGenerator: {
createForeignKey: true,
disableForeignKeys: false,
createIndex: true,
},
migrations: {
path: "./database/migrations",
pathTs: "./database/migrations",
tableName: "migrations",
transactional: true,
allOrNothing: true,
dropTables: false,
safe: true,
emit: "ts",
},
};
},
};
+21
View File
@@ -0,0 +1,21 @@
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { ConfigService } from "@nestjs/config";
import { ThrottlerAsyncOptions } from "@nestjs/throttler";
import { AuthMessage } from "../common/enums/message.enum";
export function rateLimitConfig(): ThrottlerAsyncOptions {
return {
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
throttlers: [
{
ttl: configService.getOrThrow("THROTTLE_TTL"),
limit: configService.getOrThrow("THROTTLE_LIMIT"),
},
],
errorMessage: AuthMessage.TOO_MANY_REQUESTS,
storage: new ThrottlerStorageRedisService(configService.getOrThrow("REDIS_URI")),
}),
};
}
+26
View File
@@ -0,0 +1,26 @@
import { ConfigService } from "@nestjs/config";
export function S3Configs() {
return {
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
accessKeyId: configService.getOrThrow<string>("BUCKET_ACCESS_KEY"),
secretAccessKey: configService.getOrThrow<string>("BUCKET_SECRET_KEY"),
endpoint: configService.getOrThrow<string>("BUCKET_URL"),
region: configService.getOrThrow<string>("BUCKET_REGION"),
bucket: configService.getOrThrow<string>("BUCKET_NAME"),
url: configService.getOrThrow<string>("BUCKET_UPLOAD_URL"),
};
},
};
}
export interface IS3Configs {
accessKeyId: string;
secretAccessKey: string;
endpoint: string;
region: string;
bucket: string;
url: string;
}
+49
View File
@@ -0,0 +1,49 @@
import { ConfigService } from "@nestjs/config";
export function smsConfigs() {
return {
inject: [ConfigService],
useFactory(configService: ConfigService) {
return {
API_URL: configService.getOrThrow<string>("SMS_API_URL"),
API_KEY: configService.getOrThrow<string>("SMS_API_KEY"),
SMS_PATTERN_OTP: configService.getOrThrow<string>("SMS_PATTERN_OTP"),
SMS_PATTERN_INVOICE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE"),
SMS_PATTERN_LOGIN: configService.getOrThrow<string>("SMS_PATTERN_LOGIN"),
SMS_PATTERN_INVOICE_CREATED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_CREATED"),
SMS_PATTERN_ANNOUNCEMENT: configService.getOrThrow<string>("SMS_PATTERN_ANNOUNCEMENT"),
SMS_PATTERN_TICKET_CREATED: configService.getOrThrow<string>("SMS_PATTERN_TICKET_CREATED"),
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"),
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
SMS_PATTERN_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_REMINDER"),
SMS_PATTERN_PAYMENT_CANCELLATION: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_CANCELLATION"),
};
},
};
}
export interface ISmsConfigs {
API_URL: string;
API_KEY: string;
SMS_PATTERN_OTP: string;
SMS_PATTERN_INVOICE: string;
SMS_PATTERN_LOGIN: string;
SMS_PATTERN_INVOICE_CREATED: string;
SMS_PATTERN_ANNOUNCEMENT: string;
SMS_PATTERN_TICKET_CREATED: string;
SMS_PATTERN_TICKET_ANSWERED: string;
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: string;
SMS_PATTERN_INVOICE_APPROVED: string;
SMS_PATTERN_INVOICE_PAID: string;
SMS_PATTERN_RECURRING_INVOICE_DRAFT: string;
SMS_PATTERN_SUBSCRIPTION_CANCELLED: string;
SMS_PATTERN_INVOICE_REMINDER: string;
SMS_PATTERN_INVOICE_OVERDUE: string;
SMS_PATTERN_PAYMENT_REMINDER: string;
SMS_PATTERN_PAYMENT_CANCELLATION: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { NestFastifyApplication } from "@nestjs/platform-fastify";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
export function getSwaggerDocument(app: NestFastifyApplication) {
const swaggerConfig = new DocumentBuilder()
.setTitle("The dmail api document")
.setDescription("The dmail API description")
.addBearerAuth(
{
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
name: "authorization",
in: "header",
},
"authorization",
)
.setVersion("1.0.0")
.build();
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup("api-docs", app, swaggerDocument);
}
+11
View File
@@ -0,0 +1,11 @@
import { ConfigService } from "@nestjs/config";
export const wildduckConfig = () => ({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
baseUrl: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
accessToken: configService.getOrThrow<string>("WILDDUCK_ACCESS_TOKEN"),
timeout: configService.getOrThrow<number>("WILDDUCK_TIMEOUT"),
retries: configService.getOrThrow<number>("WILDDUCK_RETRIES"),
}),
});
+18
View File
@@ -0,0 +1,18 @@
import { ConfigService } from "@nestjs/config";
export function zarinpalConfig() {
return {
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
gatewayApiUrl: configService.getOrThrow<string>("ZARINPAL_API_URL"),
callBackUrl: configService.getOrThrow<string>("CALLBACK_URL"),
ipgType: configService.getOrThrow<string>("IPG_TYPE"),
}),
};
}
export interface IZarinpalConfig {
gatewayApiUrl: string;
callBackUrl: string;
ipgType: string;
}
@@ -0,0 +1,49 @@
import { HttpException, HttpStatus } from "@nestjs/common";
export class MailServerException extends HttpException {
constructor(message: string, status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR) {
super(message, status);
}
}
export class UserNotFoundException extends MailServerException {
constructor(userId: string) {
super(`User with ID '${userId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class MailboxNotFoundException extends MailServerException {
constructor(mailboxId: string) {
super(`Mailbox with ID '${mailboxId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class MessageNotFoundException extends MailServerException {
constructor(messageId: number) {
super(`Message with ID '${messageId}' not found`, HttpStatus.NOT_FOUND);
}
}
export class UserAlreadyExistsException extends MailServerException {
constructor(username: string) {
super(`User '${username}' already exists`, HttpStatus.CONFLICT);
}
}
export class AddressAlreadyExistsException extends MailServerException {
constructor(address: string) {
super(`Address '${address}' already exists`, HttpStatus.CONFLICT);
}
}
export class MailServerConnectionException extends MailServerException {
constructor(details?: string) {
super(`Mail server connection failed${details ? `: ${details}` : ""}`, HttpStatus.SERVICE_UNAVAILABLE);
}
}
export class InvalidMailServerResponseException extends MailServerException {
constructor() {
super("Invalid response from mail server", HttpStatus.BAD_GATEWAY);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
import { FastifyReply, FastifyRequest } from "fastify";
import { ErrorResponse } from "../../common/interfaces/IError-response";
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const reply = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const status = exception.getStatus() || HttpStatus.INTERNAL_SERVER_ERROR;
const exceptionResponse = exception.getResponse();
const message = this.extractMessage(exceptionResponse);
const response: ErrorResponse = {
statusCode: status,
success: false,
error: {
message,
path: request.url,
},
};
reply.status(status).send(response);
}
private extractMessage(response: string | Record<string, any>): string[] {
if (typeof response === "string") {
return [response];
}
if (typeof response === "object" && response !== null) {
if ("message" in response) {
const message = response.message;
return Array.isArray(message) ? message : [message];
}
if ("error" in response) {
return [response.error];
}
}
return ["An unexpected error occurred"];
}
}
@@ -0,0 +1,41 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { FastifyReply, FastifyRequest } from "fastify";
import { MailServerException } from "../exceptions/mail-server.exceptions";
@Catch(MailServerException)
export class MailServerExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(MailServerExceptionFilter.name);
catch(exception: MailServerException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const status = exception.getStatus();
const message = exception.message;
// Log the error for monitoring
this.logger.error({
message: "Mail server exception occurred",
url: request.url,
method: request.method,
status,
error: message,
userId: (request.params as any)?.userId || (request.params as any)?.id,
userAgent: request.headers["user-agent"],
ip: request.ip,
});
// Return a consistent error response
response.status(status).send({
success: false,
error: {
code: exception.constructor.name,
message,
timestamp: new Date().toISOString(),
path: request.url,
},
});
}
}
@@ -0,0 +1,42 @@
import { BadRequestException, CallHandler, ExecutionContext, ForbiddenException, Injectable, Logger, NestInterceptor } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { Observable } from "rxjs";
import { BusinessMessage } from "../../common/enums/message.enum";
import { Business } from "../../modules/businesses/entities/business.entity";
import { BusinessesService } from "../../modules/businesses/services/businesses.service";
declare module "fastify" {
interface FastifyRequest {
business?: Business;
}
}
@Injectable()
export class BusinessInterceptor implements NestInterceptor {
private readonly headerName = "x-business-id";
private readonly logger = new Logger(BusinessInterceptor.name);
constructor(private readonly businessesService: BusinessesService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const businessId = request.headers[this.headerName.toLocaleLowerCase()] as string;
if (businessId) {
try {
const business = await this.businessesService.getBusinessByDanakSubscriptionId(businessId);
request.business = business;
} catch (error) {
this.logger.error(error);
throw new BadRequestException(BusinessMessage.NOT_FOUND);
}
} else {
throw new ForbiddenException(BusinessMessage.BUSINESS_ID_REQUIRED);
}
return next.handle();
}
}
@@ -0,0 +1,79 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
import { catchError, tap } from "rxjs/operators";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const { method, url, body, query, params, headers } = request;
const startTime = Date.now();
const requestId = this.generateRequestId();
// Log request
this.logger.log({
message: "Incoming request",
requestId,
method,
url,
query,
params,
userAgent: headers["user-agent"],
ip: request.ip,
// Don't log sensitive data
body: this.sanitizeRequestBody(body),
});
return next.handle().pipe(
tap((data) => {
const duration = Date.now() - startTime;
this.logger.log({
message: "Request completed",
requestId,
method,
url,
statusCode: response.statusCode,
duration: `${duration}ms`,
responseSize: JSON.stringify(data).length,
});
}),
catchError((error) => {
const duration = Date.now() - startTime;
this.logger.error({
message: "Request failed",
requestId,
method,
url,
statusCode: response.statusCode,
duration: `${duration}ms`,
error: error.message,
stack: error.stack,
});
throw error;
}),
);
}
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private sanitizeRequestBody(body: any): any {
if (!body || typeof body !== "object") return body;
const sanitized = { ...body };
const sensitiveFields = ["password", "token", "authorization", "secret"];
for (const field of sensitiveFields) {
if (sanitized[field]) {
sanitized[field] = "***REDACTED***";
}
}
return sanitized;
}
}
@@ -0,0 +1,84 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
interface RequestMetrics {
method: string;
url: string;
statusCode: number;
responseTime: number;
timestamp: string;
userAgent?: string;
ip?: string;
}
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
private readonly logger = new Logger(MetricsInterceptor.name);
private static metrics: Map<string, any> = new Map();
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const startTime = Date.now();
return next.handle().pipe(
tap(() => {
const responseTime = Date.now() - startTime;
const metrics: RequestMetrics = {
method: request.method,
url: request.url,
statusCode: response.statusCode,
responseTime,
timestamp: new Date().toISOString(),
userAgent: request.headers["user-agent"],
ip: request.ip,
};
this.collectMetrics(metrics);
// Log slow requests
if (responseTime > 1000) {
this.logger.warn(`Slow request detected: ${request.method} ${request.url} - ${responseTime}ms`);
}
}),
);
}
private collectMetrics(metrics: RequestMetrics): void {
const key = `${metrics.method}:${metrics.url}`;
if (!MetricsInterceptor.metrics.has(key)) {
MetricsInterceptor.metrics.set(key, {
count: 0,
totalResponseTime: 0,
avgResponseTime: 0,
maxResponseTime: 0,
minResponseTime: Infinity,
errorCount: 0,
});
}
const existing = MetricsInterceptor.metrics.get(key);
existing.count++;
existing.totalResponseTime += metrics.responseTime;
existing.avgResponseTime = existing.totalResponseTime / existing.count;
existing.maxResponseTime = Math.max(existing.maxResponseTime, metrics.responseTime);
existing.minResponseTime = Math.min(existing.minResponseTime, metrics.responseTime);
if (metrics.statusCode >= 400) {
existing.errorCount++;
}
MetricsInterceptor.metrics.set(key, existing);
}
static getMetrics(): Map<string, any> {
return MetricsInterceptor.metrics;
}
static resetMetrics(): void {
MetricsInterceptor.metrics.clear();
}
}
+97
View File
@@ -0,0 +1,97 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { Observable, map } from "rxjs";
import { IPageFormat, PaginatedResponse, PaginationQuery } from "../../common/interfaces/IPagination";
@Injectable()
export class PaginationInterceptor implements NestInterceptor {
private readonly logger = new Logger(PaginationInterceptor.name);
private readonly DEFAULT_PAGE = 1;
private readonly DEFAULT_LIMIT = 10;
private readonly MAX_LIMIT = 100;
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const { page, limit } = this.extractPaginationParams(request.query as PaginationQuery);
const { className, handlerName } = this.getContextInfo(context);
return next.handle().pipe(
map((data: PaginatedResponse) => {
if (!this.isPaginatedResponse(data)) {
return data;
}
this.logger.log(`Paginating response from ${className}.${handlerName}`);
const { count, paginate, ...response } = data;
const pager = this.formatPage(page, limit, count, request);
return {
pager,
...response,
};
}),
);
}
//******* Extract Pagination Params *******//
private extractPaginationParams(query: PaginationQuery): { page: number; limit: number } {
const page = this.validateNumber(query.page, this.DEFAULT_PAGE);
const limit = this.validateNumber(query.limit, this.DEFAULT_LIMIT);
return {
page: Math.max(1, page),
limit: Math.min(Math.max(1, limit), this.MAX_LIMIT),
};
}
//******* Validate Number *******//
private validateNumber(value: string | undefined, defaultValue: number): number {
const parsed = parseInt(value || "", 10);
return isNaN(parsed) ? defaultValue : parsed;
}
//******* Get Context Info *******//
private getContextInfo(context: ExecutionContext): { className: string; handlerName: string } {
return {
className: context.getClass().name,
handlerName: context.getHandler().name,
};
}
//******* Check if Response is Paginated *******//
private isPaginatedResponse(data: PaginatedResponse): data is PaginatedResponse {
return data && (data.paginate || typeof data.count === "number");
}
//******* Format Page *******//
private formatPage(page: number, limit: number, totalItems: number | undefined, request: FastifyRequest): IPageFormat {
const count = totalItems || 0;
const totalPages = Math.ceil(count / limit);
const prevPage = page > 1 ? page - 1 : false;
const nextPage = page < totalPages ? page + 1 : false;
return {
page,
limit,
totalItems: count,
totalPages,
prevPage: this.generatePageLink(prevPage, limit, request),
nextPage: this.generatePageLink(nextPage, limit, request),
};
}
//******* Generate Page Link *******//
private generatePageLink(page: number | boolean, limit: number, request: FastifyRequest): string | boolean {
if (!page) return false;
const { protocol, hostname, url } = request;
const baseUrl = url.split("?")[0];
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
return `${protocol}://${hostname}${baseUrl}?${queryParams.toString()}`;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { FastifyReply } from "fastify";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
//
intercept(context: ExecutionContext, next: CallHandler<Record<string, unknown>>): Observable<unknown> {
const ctx = context.switchToHttp().getResponse<FastifyReply>();
const statusCode = ctx.statusCode;
return next.handle().pipe(
map((data) => {
if (data && data.data !== undefined) {
return {
statusCode,
success: true,
data: data.data,
};
}
return {
statusCode,
success: true,
data,
};
}),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
import { FastifyReply, FastifyRequest } from "fastify";
@Injectable()
export class HTTPLogger implements NestMiddleware {
private readonly logger = new Logger(HTTPLogger.name);
use(req: FastifyRequest, rep: FastifyReply["raw"], next: () => void): void {
const startAt = process.hrtime();
const { method, originalUrl } = req;
const ip = req.ip || req.socket.remoteAddress || "-";
const userAgent = req.headers["user-agent"] || "-";
const referer = req.headers.referer || "-";
rep.on("finish", () => {
const { statusCode } = rep;
const contentLength = rep.getHeader("content-length") || 0;
const dif = process.hrtime(startAt);
const responseTime = dif[0] * 1e3 + dif[1] * 1e-6;
const timestamp = new Date().toISOString();
// Apache-like format: IP - - [timestamp] "METHOD URL" STATUS SIZE "REFERER" "USER-AGENT" RESPONSE_TIME
this.logger.log(
`${ip} - - [${timestamp}] "${method} ${originalUrl}" ${statusCode} ${contentLength} "${referer}" "${userAgent}" ${responseTime.toFixed(2)}ms`,
);
});
next();
}
}
+64
View File
@@ -0,0 +1,64 @@
import { MikroORM } from "@mikro-orm/core";
import { Logger, ValidationPipe } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import { AppModule } from "./app.module";
import { getSwaggerDocument } from "./configs/swagger.config";
import { HttpExceptionFilter } from "./core/filters/http-exception.filters";
import { MailServerExceptionFilter } from "./core/filters/mail-server-exception.filter";
import { PaginationInterceptor } from "./core/interceptors/pagination.interceptor";
import { ResponseInterceptor } from "./core/interceptors/response.interceptor";
async function bootstrap() {
const logger = new Logger("APP");
const fastifyAdapter = new FastifyAdapter({
bodyLimit: 10 * 1024 * 1024,
trustProxy: true,
});
fastifyAdapter.getInstance().addContentTypeParser("multipart/form-data", (_request, _payload, done) => {
done(null);
});
fastifyAdapter.getInstance().addContentTypeParser("text/plain", (_request, _payload, done) => {
done(null);
});
const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
app.useGlobalInterceptors(new ResponseInterceptor(), new PaginationInterceptor());
app.useGlobalFilters(new MailServerExceptionFilter(), new HttpExceptionFilter());
app.enableCors({
origin: true,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
credentials: true,
optionsSuccessStatus: 204,
});
const configService = app.get<ConfigService>(ConfigService);
getSwaggerDocument(app);
const orm = app.get(MikroORM);
if (process.env.NODE_ENV !== "production") {
await app.get(MikroORM).getSchemaGenerator().ensureDatabase();
await orm.getSchemaGenerator().updateSchema();
}
const PORT = configService.getOrThrow<number>("PORT");
await app.listen(PORT, "0.0.0.0", () => {
logger.log(`Application is running on: http://localhost:${PORT}`);
logger.log(`Swagger documentation: http://localhost:${PORT}/api-docs`);
});
}
bootstrap().catch((error) => {
const logger = new Logger("Bootstrap");
logger.error("Failed to start application", error);
process.exit(1);
});
+16
View File
@@ -0,0 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
import { AuthMessage } from "../../../common/enums/message.enum";
export class LoginDto {
@IsNotEmpty({ message: AuthMessage.EMAIL_NOT_EMPTY })
@IsEmail({}, { message: AuthMessage.INVALID_EMAIL_FORMAT })
@ApiProperty({ description: "User's email address", example: "john.doe@example.com" })
email: string;
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
@IsString({ message: AuthMessage.INVALID_PASS_FORMAT })
@ApiProperty({ description: "User's password", example: "SecurePassword123!" })
password: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
export class RefreshTokenDto {
@ApiProperty({ description: "Refresh token" })
@IsString({ message: "Refresh token must be a string" })
@IsNotEmpty({ message: "Refresh token is required" })
refreshToken: string;
}
+41
View File
@@ -0,0 +1,41 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger";
import { LoginDto } from "./DTO/login.dto";
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
import { AuthService } from "./services/auth.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { RefreshTokenRateLimit } from "../../common/decorators/rate-limit.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
@ApiTags("Authentication")
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("login")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Login user" })
@ApiResponse({ status: HttpStatus.OK, description: "User successfully logged in" })
@ApiUnauthorizedResponse({ description: "Invalid credentials" })
@ApiBadRequestResponse({ description: "Invalid input data" })
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@RefreshTokenRateLimit()
@ApiOperation({ summary: "refresh the user access token / refresh token" })
@HttpCode(HttpStatus.OK)
@Post("refresh")
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}
@AuthGuards()
@ApiOperation({ summary: "logout the user" })
@HttpCode(HttpStatus.OK)
@Post("logout")
logout(@UserDec("id") userId: string) {
return this.authService.logout(userId);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { AuthController } from "./auth.controller";
import { jwtConfig } from "../../configs/jwt.config";
import { UtilsModule } from "../utils/utils.module";
import { AuthService } from "./services/auth.service";
import { TokensService } from "./services/tokens.service";
import { UsersModule } from "../users/users.module";
import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy";
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
import { BusinessesModule } from "../businesses/businesses.module";
import { User } from "../users/entities/user.entity";
@Module({
imports: [MikroOrmModule.forFeature([User]), UtilsModule, UsersModule, PassportModule, JwtModule.registerAsync(jwtConfig()), BusinessesModule],
controllers: [AuthController],
providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+25
View File
@@ -0,0 +1,25 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { FastifyRequest } from "fastify";
import { ADMIN_ROUTE } from "../../../common/decorators/admin.decorator";
import { AuthMessage } from "../../../common/enums/message.enum";
@Injectable()
export class AdminRouteGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredAdmin = this.reflector.getAllAndOverride<boolean>(ADMIN_ROUTE, [context.getHandler(), context.getClass()]);
if (!requiredAdmin) return true;
const req = context.switchToHttp().getRequest<FastifyRequest>();
const user = req.user;
if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
if (!user.role) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
return true;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs";
import { CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
import { SKIP_AUTH_KEY } from "../../../common/decorators/skip-auth.decorator";
import { AuthMessage } from "../../../common/enums/message.enum";
@Injectable()
export class JwtAuthGuard extends AuthGuard([CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME]) {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const skipAuth = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH_KEY, [context.getHandler(), context.getClass()]);
if (skipAuth) return true;
return super.canActivate(context);
}
handleRequest(err: unknown, user: any, _info: unknown) {
if (err || !user) throw new UnauthorizedException(AuthMessage.TOKEN_EXPIRED_OR_INVALID);
return user;
}
}
+27
View File
@@ -0,0 +1,27 @@
// import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
// import { Reflector } from "@nestjs/core";
// import { FastifyRequest } from "fastify";
// import { ROLES_KEY } from "../../../common/decorators/roles.decorator";
// import { AuthMessage } from "../../../common/enums/message.enum";
// import { RoleEnum } from "../../users/enums/role.enum";
// @Injectable()
// export class RoleGuard implements CanActivate {
// constructor(private reflector: Reflector) {}
// canActivate(context: ExecutionContext): boolean {
// const requiredRole = this.reflector.getAllAndOverride<RoleEnum[]>(ROLES_KEY, [context.getHandler(), context.getClass()]);
// if (!requiredRole) return true;
// const req = context.switchToHttp().getRequest<FastifyRequest>();
// const user = req.user;
// if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
// const hasRequiredRole = requiredRole.some((role) => user.roles.includes(role));
// if (!hasRequiredRole) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
// return true;
// }
// }
+14
View File
@@ -0,0 +1,14 @@
import { RoleEnum } from "../../users/enums/role.enum";
export interface ILocalTokenPayload {
id: string;
role: RoleEnum;
}
export interface IConsoleTokenPayload {
id: string;
permissions?: string[];
isAdmin?: boolean;
}
export interface ITokenPayload extends ILocalTokenPayload, IConsoleTokenPayload {}
+58
View File
@@ -0,0 +1,58 @@
import { EntityRepository } from "@mikro-orm/core";
import { InjectRepository } from "@mikro-orm/nestjs";
import { BadRequestException, Injectable } from "@nestjs/common";
import { TokensService } from "./tokens.service";
import { AuthMessage } from "../../../common/enums/message.enum";
import { User } from "../../users/entities/user.entity";
import { PasswordService } from "../../utils/services/password.service";
import { LoginDto } from "../DTO/login.dto";
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: EntityRepository<User>,
private readonly tokensService: TokensService,
private readonly passwordService: PasswordService,
) {}
//==============================================
//==============================================
async login(loginDto: LoginDto) {
const { email, password } = loginDto;
const user = await this.checkUserLoginCredentialWithEmail(email, password);
const tokens = await this.tokensService.generateTokens(user);
return {
message: AuthMessage.PASSWORD_LOGIN_SUCCESS,
...tokens,
};
}
//==============================================
//==============================================
async refreshToken(oldRefreshToken: string) {
return this.tokensService.refreshToken(oldRefreshToken);
}
//==============================================
//==============================================
async logout(userId: string) {
await this.tokensService.invalidateRefreshToken(userId);
return {
message: AuthMessage.LOGOUT_SUCCESS,
};
}
//==============================================
private async checkUserLoginCredentialWithEmail(email: string, password: string) {
const user = await this.userRepository.findOne({ emailAddress: email, isActive: true });
if (!user) throw new BadRequestException(AuthMessage.INVALID_PASSWORD);
const passCompare = await this.passwordService.comparePasswords(password, user.password);
if (!passCompare) throw new BadRequestException(AuthMessage.INVALID_PASSWORD);
return user;
}
}
+141
View File
@@ -0,0 +1,141 @@
import { randomBytes } from "node:crypto";
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import dayjs from "dayjs";
import { AuthMessage, UserMessage } from "../../../common/enums/message.enum";
import { RefreshToken } from "../../users/entities/refresh-token.entity";
import { User } from "../../users/entities/user.entity";
import { ITokenPayload } from "../interfaces/IToken-payload";
@Injectable()
export class TokensService {
private readonly logger = new Logger(TokensService.name);
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly em: EntityManager,
) {}
async generateTokens(user: User, em?: EntityManager) {
return this.generateAccessAndRefreshToken(
{
id: user.id,
role: user.role.name,
},
em,
);
}
private async generateAccessAndRefreshToken(payload: ITokenPayload, em?: EntityManager) {
const accessExpire = this.configService.getOrThrow<number>("ACCESS_TOKEN_EXPIRE");
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: `${accessExpire}m` });
const refreshToken = await this.generateRefreshToken();
await this.storeRefreshToken(payload.id, refreshToken, em);
return {
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, "minute").valueOf() },
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, "day").valueOf() },
};
}
async storeRefreshToken(userId: string, refreshToken: string, em?: EntityManager) {
const entityManager = em || this.em.fork();
let transactionStarted = false;
try {
if (!entityManager.isInTransaction()) {
await entityManager.begin();
transactionStarted = true;
}
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
const expiresAt = dayjs().add(refreshExpire, "day").toDate();
const user = await entityManager.findOne(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
const token = entityManager.create(RefreshToken, {
token: refreshToken,
user,
expiresAt,
});
await entityManager.persistAndFlush(token);
if (transactionStarted) await entityManager.commit();
} catch (error) {
this.logger.error(error);
if (transactionStarted) await entityManager.rollback();
throw error;
}
}
async refreshToken(oldRefreshToken: string) {
const entityManager = this.em.fork();
let transactionStarted = false;
try {
await entityManager.begin();
transactionStarted = true;
const token = await entityManager.findOne(
RefreshToken,
{ token: oldRefreshToken },
{
populate: ["user", "user.role"],
},
);
if (!token) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
if (dayjs(token.expiresAt).isBefore(dayjs())) {
await entityManager.removeAndFlush(token);
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
}
await entityManager.removeAndFlush(token);
const tokens = await this.generateTokens(token.user, entityManager);
await entityManager.commit();
return tokens;
} catch (error) {
this.logger.error(error);
if (transactionStarted) {
await entityManager.rollback();
}
throw error;
}
}
async invalidateRefreshToken(userId: string) {
const entityManager = this.em.fork();
let transactionStarted = false;
try {
await entityManager.begin();
transactionStarted = true;
await entityManager.nativeDelete(RefreshToken, { user: { id: userId } });
this.logger.log(`Successfully deleted all refresh tokens for user ${userId}`);
await entityManager.commit();
} catch (error) {
this.logger.error(error);
if (transactionStarted) {
await entityManager.rollback();
}
throw error;
}
}
private async generateRefreshToken() {
return randomBytes(32).toString("hex");
}
}
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { CONSOLE_JWT_STRATEGY_NAME } from "../../../common/constants";
import { IConsoleTokenPayload } from "../interfaces/IToken-payload";
@Injectable()
export class ConsoleJwtStrategy extends PassportStrategy(Strategy, CONSOLE_JWT_STRATEGY_NAME) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: readFileSync(`${process.cwd()}/keys/public.pem`, "utf8"),
algorithms: ["RS256"],
});
}
async validate(payload: IConsoleTokenPayload) {
return { id: payload.id, permissions: payload.permissions, isAdmin: payload.isAdmin };
}
}
@@ -0,0 +1,23 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
import { ITokenPayload } from "../interfaces/IToken-payload";
@Injectable()
export class LocalJwtStrategy extends PassportStrategy(Strategy, LOCAL_JWT_STRATEGY_NAME) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"),
issuer: configService.getOrThrow<string>("JWT_ISSUER"),
});
}
async validate(payload: ITokenPayload) {
return { id: payload.id, role: payload.role };
}
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
import { BusinessMessage } from "../../../common/enums/message.enum";
export class BusinessSlugParamDto {
@IsNotEmpty({ message: BusinessMessage.SLUG_REQUIRED })
@IsString({ message: BusinessMessage.SLUG_MUST_BE_STRING })
@ApiProperty({ description: "The slug of the business", example: "example-business" })
slug: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";
import { BusinessMessage } from "../../../common/enums/message.enum";
export class UpdateBusinessSettingsDto {
@IsOptional()
@IsNotEmpty({ message: BusinessMessage.ZARINPAL_MERCHANT_ID_REQUIRED })
@IsString({ message: BusinessMessage.ZARINPAL_MERCHANT_ID_STRING })
@ApiProperty({ description: "Zarinpal merchant id", example: "1234567890" })
zarinpalMerchantId?: string;
@IsOptional()
@IsNotEmpty({ message: BusinessMessage.LOGO_URL_REQUIRED })
@IsString({ message: BusinessMessage.LOGO_URL_STRING })
@IsUrl({ protocols: ["https"] }, { message: BusinessMessage.LOGO_URL_INVALID })
@ApiProperty({ description: "Logo url", example: "https://example.com/logo.png" })
logoUrl?: string;
// @IsOptional()
// @IsNotEmpty({ message: BusinessMessage.NAME_REQUIRED })
// @IsString({ message: BusinessMessage.NAME_STRING })
// @ApiProperty({ description: "Name", example: "Example" })
// name?: string;
}
@@ -0,0 +1,37 @@
import { Body, Controller, Get, Param, Patch, UseInterceptors } from "@nestjs/common";
import { ApiHeader, ApiOperation } from "@nestjs/swagger";
import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto";
import { BusinessesService } from "./services/businesses.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
@Controller("business")
@ApiHeader({ name: "x-business-id", description: "Business ID" })
export class BusinessesController {
constructor(private readonly businessesService: BusinessesService) {}
@Get("slug/:slug")
@ApiOperation({ summary: "Get business by slug" })
getBusinessBySlug(@Param() params: BusinessSlugParamDto) {
return this.businessesService.getBusinessBySlug(params.slug);
}
@Patch("settings")
@UseInterceptors(BusinessInterceptor)
@ApiOperation({ summary: "Update business settings" })
@AuthGuards()
updateBusinessSettings(@BusinessDec("id") businessId: string, @Body() settingsDto: UpdateBusinessSettingsDto) {
return this.businessesService.updateBusinessSettings(businessId, settingsDto);
}
@Get("settings")
@UseInterceptors(BusinessInterceptor)
@ApiOperation({ summary: "Get business settings" })
@AuthGuards()
getBusinessSettings(@BusinessDec("id") businessId: string) {
return this.businessesService.getBusinessSettings(businessId);
}
}
@@ -0,0 +1,23 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { BusinessesController } from "./businesses.controller";
import { SUBSCRIPTIONS } from "./constant";
import { Business } from "./entities/business.entity";
import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor";
import { BusinessesService } from "./services/businesses.service";
@Module({
imports: [
MikroOrmModule.forFeature([Business]),
BullModule.registerQueue({
name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME,
prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX,
}),
],
providers: [BusinessesService, BusinessProvisioningProcessor],
exports: [BusinessesService],
controllers: [BusinessesController],
})
export class BusinessesModule {}
+9
View File
@@ -0,0 +1,9 @@
export const SUBSCRIPTIONS = Object.freeze({
PROVISIONING_QUEUE_PREFIX: "provisioning",
PROVISIONING_QUEUE_NAME: "provisioning",
PROVISIONING_JOB_NAME: "business.created",
//
SERVICE_SLUG: "Dmail",
SERVICE_ID: "e51afdc3-ea0b-49cf-8f49-2a6f131b024e",
});
@@ -0,0 +1,25 @@
import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core";
import { Business } from "./business.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { BusinessStaffRepository } from "../repositories/business-staff.repository";
@Entity({ repository: () => BusinessStaffRepository })
export class BusinessStaff extends BaseEntity {
@Property({ type: "uuid", nullable: false })
danakUserId: string;
@Property({ type: "varchar", length: 255, nullable: false })
fullName: string;
@Property({ type: "varchar", length: 255, nullable: true })
email?: string;
@Property({ type: "varchar", length: 255, nullable: false })
phone: string;
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
business: Business;
[EntityRepositoryType]?: BusinessStaffRepository;
}
@@ -0,0 +1,37 @@
import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, OneToOne, Property, Unique } from "@mikro-orm/core";
import { BusinessStaff } from "./business-staff.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Domain } from "../../domains/entities/domain.entity";
import { BusinessRepository } from "../repositories/business.repository";
@Entity({ repository: () => BusinessRepository })
export class Business extends BaseEntity {
@Unique()
@Property({ type: "uuid", nullable: false })
danakSubscriptionId!: string;
@Property({ type: "varchar", length: 255, nullable: false })
name!: string;
@Property({ type: "varchar", length: 255, nullable: false })
slug!: string;
@Property({ type: "varchar", length: 255, nullable: true })
logoUrl?: string;
//=========================
@OneToOne(() => Domain, (domain) => domain.business, { nullable: true, cascade: [Cascade.ALL] })
domain?: Domain;
//=========================
// @OneToMany(() => User, (user) => user.business)
// users = new Collection<User>(this);
@OneToMany(() => BusinessStaff, (businessStaff) => businessStaff.business)
businessStaffs = new Collection<BusinessStaff>(this);
[EntityRepositoryType]?: BusinessRepository;
}
@@ -0,0 +1,11 @@
export interface IBusinessProvisioningJob {
subscriptionId: string;
serviceId: string;
serviceName: string;
businessName: string;
userId: string;
slug: string;
phone: string;
email: string;
fullName: string;
}
@@ -0,0 +1,70 @@
// src/business/queue/business-provisioning.processor.ts
import { EntityManager } from "@mikro-orm/postgresql";
import { Processor } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { WorkerProcessor } from "../../../common/queues/worker.processor";
import { SUBSCRIPTIONS } from "../constant";
import { BusinessStaff } from "../entities/business-staff.entity";
import { Business } from "../entities/business.entity";
import { IBusinessProvisioningJob } from "../interfaces/IBusiness";
@Processor(SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME)
export class BusinessProvisioningProcessor extends WorkerProcessor {
protected readonly logger = new Logger(BusinessProvisioningProcessor.name);
constructor(private readonly em: EntityManager) {
super();
}
async process(job: Job) {
switch (job.name) {
case SUBSCRIPTIONS.PROVISIONING_JOB_NAME:
await this.handleBusinessCreated(job);
break;
default:
this.logger.warn(`Unknown job name: ${job.name}`);
}
}
private async handleBusinessCreated(job: Job<IBusinessProvisioningJob>) {
const em = this.em.fork();
const { subscriptionId, serviceId, serviceName, businessName, slug, userId, fullName, phone, email } = job.data;
// Only act if the event is for this service
if (serviceId !== SUBSCRIPTIONS.SERVICE_ID) {
this.logger.debug(`Skipping job for service: ${serviceName}`);
return;
}
// Check if already exists
let business = await em.findOne(Business, { danakSubscriptionId: subscriptionId });
if (!business) {
business = em.create(Business, {
danakSubscriptionId: subscriptionId,
name: businessName,
slug: slug,
});
await em.persistAndFlush(business);
this.logger.log(`Created business for subscription ${subscriptionId}`);
} else {
this.logger.debug(`Business already exists for subscription ${subscriptionId}`);
}
const user = await em.findOne(BusinessStaff, { danakUserId: userId });
if (!user) {
const user = em.create(BusinessStaff, {
danakUserId: userId,
fullName,
email,
phone,
business,
});
await em.persistAndFlush(user);
this.logger.log(`Created business staff for subscription ${subscriptionId}`);
} else {
this.logger.debug(`Business staff already exists for subscription ${subscriptionId}`);
}
}
}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/core";
import { BusinessStaff } from "../entities/business-staff.entity";
export class BusinessStaffRepository extends EntityRepository<BusinessStaff> {}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { Business } from "../entities/business.entity";
export class BusinessRepository extends EntityRepository<Business> {}
@@ -0,0 +1,88 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { BadRequestException, Injectable } from "@nestjs/common";
import { BusinessMessage } from "../../../common/enums/message.enum";
import { UpdateBusinessSettingsDto } from "../DTO/update-business-settings.dto";
import { BusinessStaff } from "../entities/business-staff.entity";
import { BusinessRepository } from "../repositories/business.repository";
@Injectable()
export class BusinessesService {
constructor(
private readonly businessRepository: BusinessRepository,
private readonly em: EntityManager,
) {}
async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) {
const business = await this.businessRepository.findOne({ danakSubscriptionId, deletedAt: null });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
return business;
}
//************************************************** */
async getBusinessBySlug(slug: string) {
const business = await this.businessRepository.findOne({ slug, deletedAt: null });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
return { business };
}
//************************************************** */
async updateBusinessSettings(businessId: string, settingsDto: UpdateBusinessSettingsDto) {
const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
this.businessRepository.assign(business, settingsDto);
await this.em.flush();
return {
message: BusinessMessage.BUSINESS_SETTINGS_UPDATED,
business,
};
}
//************************************************** */
async getBusinessSettings(businessId: string) {
const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null });
console.log("business", business);
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
return { business };
}
//************************************************** */
async getBusinessById(id: string) {
const business = await this.businessRepository.findOne({ id, deletedAt: null });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
return business;
}
//************************************************** */
async findAdminWithLeastTickets() {
// First query to get the ID of the business staff with the least tickets
const result = await this.em.getConnection().execute(
`
SELECT bs.id
FROM business_staff bs
WHERE bs.deleted_at IS NULL
ORDER BY (
SELECT COUNT(*) FROM ticket t WHERE t.assigned_to_id = bs.id
) ASC
LIMIT 1
`,
);
if (result.length === 0) return null;
return this.em.findOne(BusinessStaff, { id: result[0].id });
}
/*******************************/
async findOneByIdWithEntityManager(staffId: string, em: EntityManager) {
const staff = await em.findOne(BusinessStaff, { id: staffId });
if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND);
return staff;
}
}
@@ -0,0 +1,50 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
import { DNSRecordType } from "../enums/domain-status.enum";
export class CreateDnsRecordDto {
@IsNotEmpty()
@IsString()
@MaxLength(255)
@ApiProperty({ description: "The name of the DNS record", example: "mail" })
name!: string;
@IsEnum(DNSRecordType)
@ApiProperty({ description: "The type of the DNS record", example: "MX" })
type!: DNSRecordType;
@IsNotEmpty()
@IsString()
@ApiProperty({ description: "The value of the DNS record", example: "10 mail.example.com" })
value!: string;
@IsNumber()
@IsOptional()
@Min(60)
@Max(86400)
@ApiProperty({ description: "The TTL of the DNS record", example: 300 })
ttl?: number = 300;
@IsNumber()
@IsOptional()
@Min(0)
@Max(65535)
@ApiProperty({ description: "The priority of the DNS record", example: 10 })
priority?: number;
@IsUUID()
@ApiProperty({ description: "The ID of the domain", example: "123e4567-e89b-12d3-a456-426614174000" })
domainId!: string;
@IsBoolean()
@IsOptional()
@ApiProperty({ description: "Whether the DNS record is required", example: true })
isRequired?: boolean = true;
@IsString()
@IsOptional()
@MaxLength(500)
@ApiProperty({ description: "The description of the DNS record", example: "This is a test DNS record" })
description?: string;
}
@@ -0,0 +1,38 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
import { VerificationType } from "../enums/domain-status.enum";
export class CreateDomainVerificationDto {
@IsEnum(VerificationType)
@ApiProperty({ description: "The type of the domain verification", example: "DNS_TXT" })
type!: VerificationType;
@IsUUID()
@ApiProperty({ description: "The ID of the domain", example: "123e4567-e89b-12d3-a456-426614174000" })
domainId!: string;
@IsString()
@IsOptional()
@MaxLength(255)
@ApiProperty({ description: "The name of the record", example: "mail" })
recordName?: string;
@IsString()
@IsOptional()
@ApiProperty({ description: "The value of the record", example: "10 mail.example.com" })
recordValue?: string;
@IsString()
@IsOptional()
@MaxLength(1000)
@ApiProperty({ description: "The instructions for the domain verification", example: "This is a test domain verification" })
instructions?: string;
}
export class VerifyDomainDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ description: "The token for the domain verification", example: "123e4567-e89b-12d3-a456-426614174000" })
token!: string;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from "class-validator";
export class CreateDomainDto {
@IsNotEmpty()
@IsString()
@MaxLength(255)
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/, { message: "Domain name must be a valid domain format" })
@ApiProperty({ description: "The name of the domain", example: "example.com" })
name!: string;
@IsString()
@IsOptional()
@MaxLength(1000)
@ApiProperty({ description: "The notes of the domain", example: "This is a test domain" })
notes?: string;
}
@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateDomainDto } from "./create-domain.dto";
export class UpdateDomainDto extends PartialType(CreateDomainDto) {}
+78
View File
@@ -0,0 +1,78 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { CreateDomainDto } from "./DTO/create-domain.dto";
import { UpdateDomainDto } from "./DTO/update-domain.dto";
import { DomainsService } from "./services/domains.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator";
import { PaginationDto } from "../../common/DTO/pagination.dto";
import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
@Controller("domains")
@ApiHeader({ name: "x-business-id", description: "Business ID" })
@UseInterceptors(BusinessInterceptor)
@AuthGuards()
export class DomainsController {
constructor(private readonly domainsService: DomainsService) {}
@Post()
@ApiOperation({ summary: "Create a new domain and get setup instructions" })
@ApiResponse({ status: 201, description: "Domain created with setup instructions" })
createDomain(@Body() createDomainDto: CreateDomainDto, @BusinessDec("id") businessId: string) {
return this.domainsService.createDomain(createDomainDto, businessId);
}
@Get()
@ApiOperation({ summary: "Get domain for business" })
@ApiResponse({ status: 200, description: "Domains retrieved successfully" })
getBusinessDomain(@BusinessDec("id") businessId: string, @Query() _query: PaginationDto) {
return this.domainsService.getBusinessDomain(businessId);
}
@Patch(":id")
@ApiOperation({ summary: "Update domain" })
@ApiResponse({ status: 200, description: "Domain updated successfully" })
updateDomain(@Param() paramDto: ParamDto, @Body() updateDomainDto: UpdateDomainDto) {
return this.domainsService.updateDomain(paramDto.id, updateDomainDto);
}
@Delete(":id")
@ApiOperation({ summary: "Delete domain" })
@ApiResponse({ status: 200, description: "Domain deleted successfully" })
deleteDomain(@Param() paramDto: ParamDto) {
return this.domainsService.deleteDomain(paramDto.id);
}
@Get(":id/setup")
@ApiOperation({ summary: "Get domain setup instructions" })
@ApiResponse({ status: 200, description: "Domain setup instructions retrieved" })
getDomainSetupInstructions(@Param() paramDto: ParamDto) {
return this.domainsService.getDomainSetupInstructions(paramDto.id);
}
@Get(":id/dns-records")
@ApiOperation({ summary: "Get DNS records for domain" })
@ApiResponse({ status: 200, description: "DNS records retrieved successfully" })
getDomainDnsRecords(@Param() paramDto: ParamDto) {
return this.domainsService.getDomainDnsRecords(paramDto.id);
}
@Get(":id/verification-status")
@ApiOperation({ summary: "Check domain verification status" })
@ApiResponse({ status: 200, description: "Domain verification status checked" })
checkDomainVerificationStatus(@Param() paramDto: ParamDto) {
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
}
@Post(":id/check-dns")
@ApiOperation({ summary: "Check DNS records and update verification status" })
@ApiResponse({ status: 200, description: "DNS records checked and updated" })
async checkDnsRecords(@Param() paramDto: ParamDto) {
// Trigger DNS verification
await this.domainsService.verifyDomain(paramDto.id);
// Return updated status
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { MikroOrmModule } from "@mikro-orm/nestjs";
import { Module } from "@nestjs/common";
import { DomainsController } from "./domains.controller";
import { MailServerModule } from "../mail-server/mail-server.module";
import { DnsRecord } from "./entities/dns-record.entity";
import { Domain } from "./entities/domain.entity";
import { DnsService } from "./services/dns.service";
import { DomainsService } from "./services/domains.service";
import { BusinessesModule } from "../businesses/businesses.module";
@Module({
imports: [MikroOrmModule.forFeature([Domain, DnsRecord]), MailServerModule, BusinessesModule],
controllers: [DomainsController],
providers: [DomainsService, DnsService],
exports: [DomainsService, DnsService],
})
export class DomainsModule {}
@@ -0,0 +1,60 @@
import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core";
import { Domain } from "./domain.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
import { DnsRecordRepository } from "../repositories/dns-record.repository";
@Entity({ repository: () => DnsRecordRepository })
export class DnsRecord extends BaseEntity {
@Property({ type: "varchar", length: 255, nullable: true })
wildduckId?: string;
@Property({ type: "varchar", length: 255, nullable: false })
name!: string;
@Enum({ items: () => DNSRecordType, nativeEnumName: "dns_record_type_enum" })
type!: DNSRecordType;
@Property({ type: "text", nullable: false })
value!: string;
@Property({ type: "integer", default: 300 })
ttl: number & Opt = 300;
@Property({ type: "integer", nullable: true })
priority?: number;
@Enum({ items: () => VerificationStatus, nativeEnumName: "verification_status_enum", default: VerificationStatus.PENDING })
status: VerificationStatus & Opt;
@Property({ type: "boolean", default: true })
isRequired: boolean & Opt;
@Property({ type: "boolean", default: true })
isActive: boolean & Opt;
@Property({ type: "datetime", nullable: true })
lastVerifiedAt?: Date;
@Property({ type: "datetime", nullable: true })
lastCheckedAt?: Date;
@Property({ type: "text", nullable: true })
description?: string;
@Property({ type: "text", nullable: true })
errorMessage?: string;
@Property({ type: "integer", default: 0 })
verificationAttempts: number & Opt;
@Property({ type: "datetime", nullable: true })
nextVerificationAt?: Date;
//==================================
@ManyToOne(() => Domain, { nullable: false })
domain!: Domain;
[EntityRepositoryType]?: DnsRecordRepository;
}
@@ -0,0 +1,70 @@
import { Collection, Entity, EntityRepositoryType, Enum, OneToMany, OneToOne, Opt, Property, Unique } from "@mikro-orm/core";
import { DnsRecord } from "./dns-record.entity";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Business } from "../../businesses/entities/business.entity";
import { DomainStatus } from "../enums/domain-status.enum";
import { DomainRepository } from "../repositories/domain.repository";
@Entity({ repository: () => DomainRepository })
export class Domain extends BaseEntity {
@Unique()
@Property({ type: "varchar", length: 255, nullable: false })
name!: string;
@Enum({ items: () => DomainStatus, nativeEnumName: "domain_status_enum", default: DomainStatus.PENDING })
status: DomainStatus & Opt;
@Property({ type: "boolean", default: false })
isVerified: boolean & Opt;
@Property({ type: "boolean", default: true })
isActive: boolean & Opt;
@Property({ type: "datetime", nullable: true })
verifiedAt?: Date;
@Property({ type: "datetime", nullable: true })
lastCheckedAt?: Date;
@Property({ type: "text", nullable: true })
notes?: string;
// DKIM Configuration
@Property({ type: "boolean", default: false })
dkimEnabled: boolean & Opt;
@Property({ type: "varchar", length: 255, nullable: true })
dkimSelector?: string;
@Property({ type: "text", nullable: true })
dkimPrivateKey?: string;
@Property({ type: "text", nullable: true })
dkimPublicKey?: string;
// SPF Configuration
@Property({ type: "boolean", default: false })
spfEnabled: boolean & Opt;
@Property({ type: "text", nullable: true })
spfRecord?: string;
// DMARC Configuration
@Property({ type: "boolean", default: false })
dmarcEnabled: boolean & Opt;
@Property({ type: "text", nullable: true })
dmarcPolicy?: string;
//==================================
@OneToOne(() => Business, (business) => business.domain, { owner: true, orphanRemoval: true })
business!: Business;
//==================================
@OneToMany(() => DnsRecord, (dnsRecord) => dnsRecord.domain)
dnsRecords = new Collection<DnsRecord>(this);
[EntityRepositoryType]?: DomainRepository;
}
@@ -0,0 +1,29 @@
export enum DomainStatus {
PENDING = "PENDING",
VERIFIED = "VERIFIED",
FAILED = "FAILED",
SUSPENDED = "SUSPENDED",
}
export enum DNSRecordType {
A = "A",
AAAA = "AAAA",
CNAME = "CNAME",
MX = "MX",
TXT = "TXT",
SPF = "SPF",
DKIM = "DKIM",
DMARC = "DMARC",
SRV = "SRV",
}
export enum VerificationStatus {
PENDING = "PENDING",
VERIFIED = "VERIFIED",
FAILED = "FAILED",
EXPIRED = "EXPIRED",
}
export enum VerificationType {
DNS_TXT = "DNS_TXT",
DNS_CNAME = "DNS_CNAME",
}
@@ -0,0 +1,112 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { DnsRecord } from "../entities/dns-record.entity";
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
export class DnsRecordRepository extends EntityRepository<DnsRecord> {
/**
* Find DNS records by domain ID
*/
async findByDomainId(domainId: string): Promise<DnsRecord[]> {
return this.find({ domain: domainId, deletedAt: null });
}
/**
* Find DNS records by type
*/
async findByType(domainId: string, type: DNSRecordType): Promise<DnsRecord[]> {
return this.find({ domain: domainId, type });
}
/**
* Find required DNS records that are not verified
*/
async findUnverifiedRequired(domainId: string): Promise<DnsRecord[]> {
return this.find({
domain: domainId,
isRequired: true,
status: { $ne: VerificationStatus.VERIFIED },
isActive: true,
});
}
/**
* Find DNS records that need verification
*/
async findNeedingVerification(): Promise<DnsRecord[]> {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
return this.find(
{
$and: [
{
$or: [{ lastCheckedAt: null }, { lastCheckedAt: { $lt: oneHourAgo } }],
},
{
$or: [{ status: VerificationStatus.PENDING }, { status: VerificationStatus.FAILED }],
},
{ isActive: true },
{ verificationAttempts: { $lt: 5 } },
],
},
{ populate: ["domain"] },
);
}
/**
* Get DNS record statistics for a domain
*/
async getDomainDnsStats(domainId: string): Promise<{
total: number;
verified: number;
pending: number;
failed: number;
required: number;
}> {
const records = await this.find({ domain: domainId, isActive: true });
return {
total: records.length,
verified: records.filter((r) => r.status === VerificationStatus.VERIFIED).length,
pending: records.filter((r) => r.status === VerificationStatus.PENDING).length,
failed: records.filter((r) => r.status === VerificationStatus.FAILED).length,
required: records.filter((r) => r.isRequired).length,
};
}
/**
* Find MX records for a domain
*/
async findMXRecords(domainId: string): Promise<DnsRecord[]> {
return this.find(
{
domain: domainId,
type: DNSRecordType.MX,
isActive: true,
},
{ orderBy: { priority: "ASC" } },
);
}
/**
* Find SPF record for a domain
*/
async findSPFRecord(domainId: string): Promise<DnsRecord | null> {
return this.findOne({
domain: domainId,
type: DNSRecordType.SPF,
isActive: true,
});
}
/**
* Find DKIM records for a domain
*/
async findDKIMRecords(domainId: string): Promise<DnsRecord[]> {
return this.find({
domain: domainId,
type: DNSRecordType.DKIM,
isActive: true,
});
}
}
@@ -0,0 +1,66 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import { Domain } from "../entities/domain.entity";
import { DomainStatus } from "../enums/domain-status.enum";
export class DomainRepository extends EntityRepository<Domain> {
/**
* Find domain by name
*/
async findByName(name: string): Promise<Domain | null> {
return this.findOne({ name, deletedAt: null }, { populate: ["business", "dnsRecords"] });
}
/**
* Find domains by business ID
*/
async findByBusinessId(businessId: string): Promise<Domain | null> {
return this.findOne({ business: businessId, deletedAt: null }, { populate: ["dnsRecords"] });
}
/**
* Find verified domains
*/
async findVerified(): Promise<Domain | null> {
return this.findOne({ status: DomainStatus.VERIFIED, isActive: true, deletedAt: null });
}
/**
* Find domains pending verification
*/
async findPendingVerification(): Promise<Domain | null> {
return this.findOne({ status: DomainStatus.PENDING, isActive: true, deletedAt: null });
}
/**
* Find domains that need checking
*/
async findNeedingCheck(): Promise<Domain | null> {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
return this.findOne({
deletedAt: null,
$or: [{ lastCheckedAt: null }, { lastCheckedAt: { $lt: oneHourAgo } }],
isActive: true,
});
}
/**
* Get domain statistics for a business
*/
async getBusinessDomainStats(businessId: string): Promise<{
total: number;
verified: number;
pending: number;
failed: number;
}> {
const domains = await this.find({ business: businessId, deletedAt: null });
return {
total: domains.length,
verified: domains.filter((d) => d.status === DomainStatus.VERIFIED).length,
pending: domains.filter((d) => d.status === DomainStatus.PENDING).length,
failed: domains.filter((d) => d.status === DomainStatus.FAILED).length,
};
}
}
+334
View File
@@ -0,0 +1,334 @@
import * as crypto from "crypto";
import * as dns from "dns";
import { promisify } from "util";
import { EntityManager } from "@mikro-orm/core";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { firstValueFrom } from "rxjs";
import { DnsRecordMessage, DomainMessage } from "../../../common/enums/message.enum";
import { MailServerService } from "../../mail-server/services/mail-server.service";
import { CreateDnsRecordDto } from "../DTO/create-dns-record.dto";
import { DnsRecord } from "../entities/dns-record.entity";
import { Domain } from "../entities/domain.entity";
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
import { DnsRecordRepository } from "../repositories/dns-record.repository";
import { DomainRepository } from "../repositories/domain.repository";
@Injectable()
export class DnsService {
private readonly logger = new Logger(DnsService.name);
private readonly mailServerDomain: string = "mail.danakcorp.com";
private readonly spfRecord: string = `v=spf1 include:${this.mailServerDomain} ~all`;
private readonly dmarcRecord: string = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${this.mailServerDomain}; ruf=mailto:dmarc@${this.mailServerDomain}`;
private readonly resolveTxt = promisify(dns.resolveTxt);
private readonly resolveCname = promisify(dns.resolveCname);
private readonly resolveMx = promisify(dns.resolveMx);
constructor(
private readonly dnsRecordRepository: DnsRecordRepository,
private readonly domainRepository: DomainRepository,
private readonly mailServerService: MailServerService,
private readonly em: EntityManager,
) {}
/*******************************/
async createDnsRecord(createDnsRecordDto: CreateDnsRecordDto) {
const domain = await this.domainRepository.findOne({ id: createDnsRecordDto.domainId });
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
const dnsRecord = this.dnsRecordRepository.create({
...createDnsRecordDto,
domain,
});
await this.em.persistAndFlush(dnsRecord);
this.logger.log(`DNS record created: ${dnsRecord.name} ${dnsRecord.type}`);
return { dnsRecord };
}
/**
* Get DNS records for domain
*/
async getDomainDnsRecords(domainId: string) {
const dnsRecords = await this.dnsRecordRepository.findByDomainId(domainId);
return { dnsRecords };
}
/**
* Update DNS record
*/
async updateDnsRecord(id: string, updates: Partial<DnsRecord>) {
const dnsRecord = await this.dnsRecordRepository.findOne({ id });
if (!dnsRecord) throw new BadRequestException(DnsRecordMessage.DNS_RECORD_NOT_FOUND);
this.em.assign(dnsRecord, updates);
await this.em.persistAndFlush(dnsRecord);
return { dnsRecord };
}
/*******************************/
async deleteDnsRecord(id: string) {
const dnsRecord = await this.dnsRecordRepository.findOne({ id });
if (!dnsRecord) throw new BadRequestException(DnsRecordMessage.DNS_RECORD_NOT_FOUND);
dnsRecord.isActive = false;
dnsRecord.deletedAt = new Date();
await this.em.persistAndFlush(dnsRecord);
return { message: DnsRecordMessage.DNS_RECORD_DELETED };
}
/*******************************/
async generateRequiredDnsRecords(domain: Domain) {
const requiredRecords: Partial<DnsRecord>[] = [];
// MX Record
requiredRecords.push({
name: domain.name,
type: DNSRecordType.MX,
value: this.mailServerDomain,
priority: 10,
isRequired: true,
description: "Mail exchange record for email delivery",
});
// SPF Record
requiredRecords.push({
name: domain.name,
type: DNSRecordType.SPF,
value: this.spfRecord,
isRequired: true,
description: "Sender Policy Framework record",
});
// DMARC Record
requiredRecords.push({
name: `_dmarc.${domain.name}`,
type: DNSRecordType.DMARC,
value: this.dmarcRecord,
isRequired: true,
description: "DMARC policy record",
});
const dnsRecords: DnsRecord[] = [];
for (const recordData of requiredRecords) {
const dnsRecord = this.dnsRecordRepository.create({
name: recordData.name!,
type: recordData.type!,
value: recordData.value!,
domain,
isRequired: recordData.isRequired ?? true,
description: recordData.description,
priority: recordData.priority,
ttl: recordData.ttl,
});
dnsRecords.push(dnsRecord);
}
await this.em.persistAndFlush(dnsRecords);
this.logger.log(`Generated ${dnsRecords.length} required DNS records for ${domain.name}`);
return { dnsRecords };
}
/**
* Create DKIM record
*/
async createDKIMRecord(domain: Domain, selector: string, dkimName: string, dkimValue: string, wildduckId: string) {
const dkimRecord = this.dnsRecordRepository.create({
name: dkimName,
type: DNSRecordType.DKIM,
value: dkimValue,
domain,
isRequired: true,
description: `DKIM public key for selector ${selector}`,
wildduckId,
});
await this.em.persistAndFlush(dkimRecord);
this.logger.log(`DKIM record created for ${domain.name} with selector ${selector}`);
return { dkimRecord };
}
/**
* Generate DKIM keys
*/
async generateDKIMKeysByMailServer(domainName: string, selector: string) {
const data = await firstValueFrom(this.mailServerService.dkim.createDKIMKey({ domain: domainName, selector }));
return { publicKey: data.publicKey, privateKey: data.dnsTxt.value, dnsTxt: data.dnsTxt, wildduckId: data.id };
}
/**
* Generate DKIM keys native
*/
async generateDKIMKeysByNative() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: {
type: "spki",
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
});
// Extract the public key without headers and newlines for DNS
const publicKeyForDNS = publicKey
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
.replace(/-----END PUBLIC KEY-----/g, "")
.replace(/\n/g, "");
return {
privateKey,
publicKey: publicKeyForDNS,
};
}
/**
* Verify domain DNS configuration
*/
async verifyDomainDNS(domain: Domain) {
const dnsRecords = await this.dnsRecordRepository.findByDomainId(domain.id);
const requiredRecords = dnsRecords.filter((record) => record.isRequired && record.isActive);
let allVerified = true;
for (const record of requiredRecords) {
const isVerified = await this.verifyDnsRecord(record);
if (!isVerified) {
allVerified = false;
}
}
return allVerified;
}
/**
* Verify individual DNS record
*/
async verifyDnsRecord(dnsRecord: DnsRecord) {
try {
dnsRecord.verificationAttempts += 1;
dnsRecord.lastCheckedAt = new Date();
let isVerified = false;
switch (dnsRecord.type) {
case DNSRecordType.TXT:
case DNSRecordType.SPF:
case DNSRecordType.DMARC:
case DNSRecordType.DKIM:
isVerified = await this.verifyTxtRecord(dnsRecord);
break;
case DNSRecordType.MX:
isVerified = await this.verifyMxRecord(dnsRecord);
break;
case DNSRecordType.CNAME:
isVerified = await this.verifyCnameRecord(dnsRecord);
break;
default:
this.logger.warn(`DNS record type ${dnsRecord.type} verification not implemented`);
isVerified = false;
}
if (isVerified) {
dnsRecord.status = VerificationStatus.VERIFIED;
dnsRecord.lastVerifiedAt = new Date();
dnsRecord.errorMessage = undefined;
} else {
dnsRecord.status = VerificationStatus.FAILED;
dnsRecord.errorMessage = "DNS record not found or value mismatch";
}
await this.em.persistAndFlush(dnsRecord);
return isVerified;
} catch (error) {
dnsRecord.status = VerificationStatus.FAILED;
dnsRecord.errorMessage = error instanceof Error ? error.message : "Unknown error";
await this.em.persistAndFlush(dnsRecord);
this.logger.error(`DNS verification failed for ${dnsRecord.name}:`, error);
return false;
}
}
/**
* Verify TXT record
*/
private async verifyTxtRecord(dnsRecord: DnsRecord) {
try {
const txtRecords = await this.resolveTxt(dnsRecord.name);
const flatRecords = txtRecords.flat();
return flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value);
} catch (error) {
this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
return false;
}
}
/**
* Verify MX record
*/
private async verifyMxRecord(dnsRecord: DnsRecord) {
try {
const mxRecords = await this.resolveMx(dnsRecord.name);
return mxRecords.some((mx) => mx.exchange === dnsRecord.value && mx.priority === (dnsRecord.priority || 10));
} catch (error) {
this.logger.debug(`MX record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
return false;
}
}
/**
* Verify CNAME record
*/
private async verifyCnameRecord(dnsRecord: DnsRecord) {
try {
const cnameRecords = await this.resolveCname(dnsRecord.name);
return cnameRecords.includes(dnsRecord.value);
} catch (error) {
this.logger.debug(`CNAME record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
return false;
}
}
/**
* Get DNS record statistics
*/
async getDnsRecordStats(domainId: string): Promise<{
total: number;
verified: number;
pending: number;
failed: number;
required: number;
}> {
return this.dnsRecordRepository.getDomainDnsStats(domainId);
}
/**
* Verify all DNS records that need checking
*/
async verifyPendingDnsRecords(): Promise<void> {
const records = await this.dnsRecordRepository.findNeedingVerification();
for (const record of records) {
try {
await this.verifyDnsRecord(record);
} catch (error) {
this.logger.error(`Failed to verify DNS record ${record.name}:`, error);
}
}
}
}
@@ -0,0 +1,450 @@
import { EntityManager } from "@mikro-orm/core";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { firstValueFrom } from "rxjs";
import { DnsService } from "./dns.service";
import { BusinessMessage, DomainMessage, MailServerMessage } from "../../../common/enums/message.enum";
import { Business } from "../../businesses/entities/business.entity";
import { MailServerService } from "../../mail-server/services/mail-server.service";
import { formatDomainMessage } from "../../utils/services/message.utils";
import { CreateDomainDto } from "../DTO/create-domain.dto";
import { UpdateDomainDto } from "../DTO/update-domain.dto";
import { DnsRecord } from "../entities/dns-record.entity";
import { Domain } from "../entities/domain.entity";
import { DomainStatus, VerificationStatus } from "../enums/domain-status.enum";
import { DomainRepository } from "../repositories/domain.repository";
@Injectable()
export class DomainsService {
private readonly logger = new Logger(DomainsService.name);
constructor(
private readonly domainRepository: DomainRepository,
private readonly em: EntityManager,
private readonly dnsService: DnsService,
private readonly mailServerService: MailServerService,
) {}
/**
* Create a new domain
*/
async createDomain(createDomainDto: CreateDomainDto, businessId: string) {
const { name, notes } = createDomainDto;
const existBusinessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null });
if (existBusinessDomain) throw new BadRequestException(DomainMessage.BUSINESS_ALREADY_HAS_DOMAIN);
// Check if domain already exists
const existingDomain = await this.domainRepository.findByName(name);
if (existingDomain) throw new BadRequestException(formatDomainMessage(DomainMessage.DOMAIN_ALREADY_EXISTS, { name }));
// Verify business exists
const business = await this.em.findOne(Business, { id: businessId });
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
const domain = this.domainRepository.create({
name,
business,
notes,
dkimEnabled: true,
dkimSelector: "default",
});
await this.em.persistAndFlush(domain);
// Generate required DNS records (MX, SPF, DMARC)
await this.dnsService.generateRequiredDnsRecords(domain);
// Generate and create DKIM record
try {
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, "default");
// Update domain with DKIM keys
domain.dkimPrivateKey = dkimKeys.privateKey;
domain.dkimPublicKey = dkimKeys.publicKey;
// Create DKIM DNS record
await this.dnsService.createDKIMRecord(domain, "default", dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
await this.em.persistAndFlush(domain);
this.logger.log(`DKIM automatically enabled for domain ${name}`);
} catch (error) {
this.logger.warn(`Failed to create DKIM for domain ${name}:`, error);
// Continue with domain creation even if DKIM fails
domain.dkimEnabled = false;
await this.em.persistAndFlush(domain);
}
// Create initial verification
this.logger.log(`Domain ${name} created for business ${businessId} with all required records`);
// Get updated DNS records including DKIM
const { dnsRecords: allDnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
// Return domain setup response with complete instructions
return this.formatDomainSetupResponse(domain, allDnsRecords);
}
/**
* Get domain by ID
*/
async getDomainById(id: string): Promise<Domain> {
const domain = await this.domainRepository.findOne({ id, deletedAt: null }, { populate: ["business", "dnsRecords"] });
if (!domain) throw new NotFoundException(DomainMessage.DOMAIN_NOT_FOUND);
return domain;
}
/**
* Get domain by name
*/
async getDomainByName(name: string) {
const domain = await this.domainRepository.findByName(name);
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
return { domain };
}
/**
* Get domains for a business
*/
async getBusinessDomain(businessId: string) {
const domain = await this.domainRepository.findByBusinessId(businessId);
return { domain };
}
/**
* Update domain
*/
async updateDomain(id: string, updateDomainDto: UpdateDomainDto) {
const domain = await this.getDomainById(id);
this.em.assign(domain, updateDomainDto);
await this.em.flush();
this.logger.log(`Domain ${domain.name} updated`);
return { domain, message: DomainMessage.DOMAIN_UPDATED_SUCCESSFULLY };
}
/**
* Delete domain
*/
async deleteDomain(id: string) {
const domain = await this.getDomainById(id);
// Soft delete
domain.deletedAt = new Date();
domain.isActive = false;
await this.em.persistAndFlush(domain);
this.logger.log(`Domain ${domain.name} deleted`);
return { message: DomainMessage.DOMAIN_DELETED_SUCCESSFULLY };
}
/**
* Verify domain ownership
*/
async verifyDomain(id: string) {
const domain = await this.getDomainById(id);
// Check DNS records for verification
const isVerified = await this.dnsService.verifyDomainDNS(domain);
if (isVerified) {
domain.status = DomainStatus.VERIFIED;
domain.isVerified = true;
domain.verifiedAt = new Date();
// Setup domain in mail server
await this.setupDomainInMailServer(domain);
await this.em.persistAndFlush(domain);
this.logger.log(`Domain ${domain.name} verified successfully`);
} else {
domain.status = DomainStatus.FAILED;
await this.em.persistAndFlush(domain);
this.logger.warn(`Domain ${domain.name} verification failed`);
}
return { domain };
}
/**
* Setup domain in mail server
*/
private async setupDomainInMailServer(domain: Domain) {
try {
// Create DKIM keys if enabled
if (domain.dkimEnabled && domain.dkimSelector) {
const dkimKey = await firstValueFrom(
this.mailServerService.dkim.createDKIMKey({
domain: domain.name,
selector: domain.dkimSelector,
description: `DKIM for ${domain.name}`,
}),
);
if (!dkimKey.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_DKIM_KEY);
}
this.logger.log(`Mail server setup completed for domain ${domain.name}`);
} catch (error) {
this.logger.error(`Failed to setup domain ${domain.name} in mail server:`, error);
throw error;
}
}
/**
* Enable DKIM for domain
*/
async enableDKIM(id: string, selector?: string) {
const domain = await this.getDomainById(id);
const dkimSelector = selector || "default";
// Generate DKIM keys
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, dkimSelector);
domain.dkimEnabled = true;
domain.dkimSelector = dkimSelector;
domain.dkimPrivateKey = dkimKeys.privateKey;
domain.dkimPublicKey = dkimKeys.publicKey;
// Create DKIM DNS record
await this.dnsService.createDKIMRecord(domain, dkimSelector, dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
await this.em.persistAndFlush(domain);
this.logger.log(`DKIM enabled for domain ${domain.name}`);
return { domain };
}
/**
* Get domain statistics
*/
async getDomainStats(businessId: string) {
return this.domainRepository.getBusinessDomainStats(businessId);
}
/**
* Check all domains that need verification
*/
async checkDomainsNeedingVerification() {
const domain = await this.domainRepository.findNeedingCheck();
if (!domain) return { message: DomainMessage.NO_DOMAIN_FOUND };
await this.verifyDomain(domain.id);
return { message: DomainMessage.DOMAIN_CHECKED, domain: domain };
}
/**
* Get domain setup instructions
*/
async getDomainSetupInstructions(domainId: string) {
const domain = await this.getDomainById(domainId);
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId);
return this.formatDomainSetupResponse(domain, dnsRecords);
}
/**
* Get DNS records for domain
*/
async getDomainDnsRecords(domainId: string) {
// Verify domain exists
await this.getDomainById(domainId);
return this.dnsService.getDomainDnsRecords(domainId);
}
/**
* Check domain verification status
*/
async checkDomainVerificationStatus(domainId: string) {
await this.getDomainById(domainId);
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId);
const verificationStatuses = [];
let verified = 0;
let pending = 0;
let failed = 0;
const recommendations: string[] = [];
for (const record of dnsRecords) {
// Verify each DNS record
await this.dnsService.verifyDnsRecord(record);
const status = {
recordId: record.id,
name: record.name,
type: record.type,
expectedValue: record.value,
currentValue: undefined,
status: record.status,
lastChecked: record.lastCheckedAt || new Date(),
errorMessage: record.errorMessage,
isCorrect: record.status === VerificationStatus.VERIFIED,
};
verificationStatuses.push(status);
if (record.status === VerificationStatus.VERIFIED) {
verified++;
} else if (record.status === VerificationStatus.FAILED) {
failed++;
if (record.errorMessage) {
recommendations.push(
formatDomainMessage(DomainMessage.RECORD_NEEDS_FIXING, {
type: record.type,
name: record.name,
error: record.errorMessage,
}),
);
}
} else {
pending++;
recommendations.push(
formatDomainMessage(DomainMessage.RECORD_PENDING_VERIFICATION_DETAIL, {
type: record.type,
name: record.name,
}),
);
}
}
const isVerified = failed === 0 && pending === 0 && verified > 0;
return {
dnsRecords: verificationStatuses,
overallStatus: {
isVerified,
verified,
pending,
failed,
total: dnsRecords.length,
lastChecked: new Date(),
},
recommendations,
};
}
/**
* Format domain setup response with instructions
*/
private formatDomainSetupResponse(domain: Domain, dnsRecords: DnsRecord[]) {
const dnsInstructions = dnsRecords.map((record) => ({
id: record.id,
name: record.name,
type: record.type,
value: record.value,
ttl: record.ttl,
priority: record.priority,
description:
record.description ||
formatDomainMessage(DomainMessage.RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY, {
type: record.type,
}),
isRequired: record.isRequired,
status: record.status,
instructions: this.generateRecordInstructions(record),
}));
const verified = dnsRecords.filter((r) => r.status === VerificationStatus.VERIFIED).length;
const pending = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING).length;
const failed = dnsRecords.filter((r) => r.status === VerificationStatus.FAILED).length;
const nextSteps = this.generateNextSteps(domain, dnsRecords);
return {
domain,
dnsRecords: dnsInstructions,
setupStatus: {
isComplete: failed === 0 && pending === 0 && verified > 0,
verified,
pending,
failed,
total: dnsRecords.length,
},
nextSteps,
estimatedPropagationTime: DomainMessage.ESTIMATED_PROPAGATION_TIME,
};
}
/**
* Generate instructions for a specific DNS record
*/
private generateRecordInstructions(record: DnsRecord): string {
switch (record.type) {
case "MX":
return formatDomainMessage(DomainMessage.ADD_MX_RECORD, {
name: record.name,
value: record.value,
priority: record.priority,
});
case "TXT":
case "SPF":
return formatDomainMessage(DomainMessage.ADD_TXT_RECORD, {
name: record.name,
value: record.value,
});
case "DKIM":
return formatDomainMessage(DomainMessage.ADD_DKIM_RECORD, {
name: record.name,
value: record.value,
});
case "DMARC":
return formatDomainMessage(DomainMessage.ADD_DMARC_RECORD, {
name: record.name,
value: record.value,
});
case "CNAME":
return formatDomainMessage(DomainMessage.ADD_CNAME_RECORD, {
name: record.name,
value: record.value,
});
default:
return formatDomainMessage(DomainMessage.ADD_GENERIC_RECORD, {
type: record.type,
name: record.name,
value: record.value,
});
}
}
/**
* Generate next steps for domain setup
*/
private generateNextSteps(domain: Domain, dnsRecords: DnsRecord[]): string[] {
const steps: string[] = [];
if (domain.status === DomainStatus.PENDING) {
steps.push(`1. ${DomainMessage.STEP_LOGIN_DNS_PANEL}`);
steps.push(`2. ${DomainMessage.STEP_ADD_ALL_RECORDS}`);
steps.push(`3. ${DomainMessage.STEP_INCLUDE_ALL_TYPES}`);
steps.push(`4. ${DomainMessage.STEP_WAIT_PROPAGATION}`);
steps.push(`5. ${DomainMessage.STEP_USE_CHECK_ENDPOINT}`);
steps.push(`6. ${DomainMessage.STEP_DOMAIN_READY}`);
}
const pendingRecords = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING);
if (pendingRecords.length > 0) {
steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: pendingRecords.length })}`);
}
const requiredRecords = dnsRecords.filter((r) => r.isRequired);
if (requiredRecords.length > 0) {
steps.push(`⚠️ ${formatDomainMessage(DomainMessage.ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED, { count: requiredRecords.length })}`);
}
if (domain.status === DomainStatus.VERIFIED) {
steps.push(`${DomainMessage.DOMAIN_FULLY_VERIFIED}`);
steps.push(`🚀 ${DomainMessage.EMAIL_ACCOUNTS_READY}`);
}
return steps;
}
}
@@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsOptional } from "class-validator";
export class CreateAddressDto {
@ApiProperty({
description: "Email address to use as an alias",
example: "alias@example.com",
})
@IsEmail()
address: string;
@ApiPropertyOptional({
description: "Whether this is the default address for the user",
default: false,
})
@IsOptional()
@IsBoolean()
main?: boolean;
}
export class UpdateAddressDto {
@ApiProperty({
description: "Set as the default address for the user",
example: true,
})
@IsBoolean()
main: boolean;
}
@@ -0,0 +1,57 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsOptional, IsString } from "class-validator";
export enum AuthScope {
MASTER = "master",
IMAP = "imap",
POP3 = "pop3",
SMTP = "smtp",
}
export class AuthenticateDto {
@ApiProperty({
description: "Username or email address of the user",
example: "testuser",
})
@IsString()
username: string;
@ApiProperty({
description: "Password for the user (master or application specific)",
example: "secretpassword123",
})
@IsString()
password: string;
@ApiPropertyOptional({
description: "Scope to request for",
enum: AuthScope,
default: AuthScope.MASTER,
})
@IsOptional()
@IsEnum(AuthScope)
scope?: AuthScope;
@ApiPropertyOptional({
description: "Application type this authentication is made from",
example: "API",
})
@IsOptional()
@IsString()
protocol?: string;
@ApiPropertyOptional({
description: "Session identifier for logging",
})
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({
description: "IP address the request was made from",
example: "192.168.1.1",
})
@IsOptional()
@IsString()
ip?: string;
}
@@ -0,0 +1,97 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsEmail, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
export class CreateUserDto {
@ApiProperty({ description: "Username for the user (letters and numbers only)", example: "testuser" })
@IsString()
username: string;
@ApiProperty({ description: "Password for the user", example: "secretpassword123" })
@IsString()
password: string;
@ApiPropertyOptional({ description: "Main email address for the user", example: "testuser@example.com" })
@IsOptional()
@IsEmail()
address?: string;
@ApiPropertyOptional({ description: "If true, do not set up an address for the user", default: false })
@IsOptional()
@IsBoolean()
emptyAddress?: boolean;
@ApiPropertyOptional({ description: "Display name for the user", example: "Test User" })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: "Array of email addresses to forward all messages to",
example: ["forward1@example.com", "forward2@example.com"],
})
@IsOptional()
@IsArray()
@IsEmail({}, { each: true })
forward?: string[];
@ApiPropertyOptional({ description: "URL to upload all messages to", example: "https://example.com/webhook" })
@IsOptional()
@IsUrl()
targetUrl?: string;
@ApiPropertyOptional({ description: "Maximum storage in bytes allowed for this user", example: 1073741824 })
@IsOptional()
@IsNumber()
quota?: number;
@ApiPropertyOptional({ description: "Default retention time in milliseconds for mailboxes", example: 2592000000 })
@IsOptional()
@IsNumber()
retention?: number;
@ApiPropertyOptional({ description: "Language code for the user", example: "en" })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ description: "Maximum number of recipients allowed to send mail to in 24h", example: 2000 })
@IsOptional()
@IsNumber()
recipients?: number;
@ApiPropertyOptional({ description: "Maximum number of forwarded emails in 24h", example: 2000 })
@IsOptional()
@IsNumber()
forwards?: number;
@ApiPropertyOptional({ description: "Array of tags to associate with the user", example: ["vip", "customer"] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ description: "PGP public key for encryption" })
@IsOptional()
@IsString()
pubKey?: string;
@ApiPropertyOptional({ description: "Whether to encrypt stored messages", default: false })
@IsOptional()
@IsBoolean()
encryptMessages?: boolean;
@ApiPropertyOptional({ description: "Whether to encrypt forwarded messages", default: false })
@IsOptional()
@IsBoolean()
encryptForwarded?: boolean;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address the request was made from", example: "192.168.1.1" })
@IsOptional()
@IsString()
ip?: string;
}
+27
View File
@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
export class CreateDKIMDto {
@ApiProperty({ description: "Domain name", example: "example.com" })
@IsString()
domain: string;
@ApiProperty({ description: "DKIM selector", example: "default" })
@IsString()
selector: string;
@ApiPropertyOptional({ description: "Description for the DKIM key" })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address" })
@IsOptional()
@IsString()
ip?: string;
}
+112
View File
@@ -0,0 +1,112 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
export class CreateFilterDto {
@ApiPropertyOptional({
description: "Name of the filter",
example: "Important emails",
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: "String to match against the From: header",
example: "boss@company.com",
})
@IsOptional()
@IsString()
query_from?: string;
@ApiPropertyOptional({
description: "String to match against the To:/Cc: headers",
example: "team@company.com",
})
@IsOptional()
@IsString()
query_to?: string;
@ApiPropertyOptional({
description: "String to match against the Subject: header",
example: "urgent",
})
@IsOptional()
@IsString()
query_subject?: string;
@ApiPropertyOptional({
description: "String to match against the message text",
example: "important project",
})
@IsOptional()
@IsString()
query_text?: string;
@ApiPropertyOptional({
description: "Require message to have attachments (true) or not (false)",
})
@IsOptional()
@IsBoolean()
query_ha?: boolean;
@ApiPropertyOptional({
description: "Message size requirement (positive for larger, negative for smaller)",
example: 1000000,
})
@IsOptional()
@IsNumber()
query_size?: number;
@ApiPropertyOptional({
description: "Mark message as seen (true) or unseen (false)",
})
@IsOptional()
@IsBoolean()
action_seen?: boolean;
@ApiPropertyOptional({
description: "Mark message as flagged (true) or not (false)",
})
@IsOptional()
@IsBoolean()
action_flag?: boolean;
@ApiPropertyOptional({
description: "Delete message immediately (true)",
})
@IsOptional()
@IsBoolean()
action_delete?: boolean;
@ApiPropertyOptional({
description: "Mark message as spam (true) or not spam (false)",
})
@IsOptional()
@IsBoolean()
action_spam?: boolean;
@ApiPropertyOptional({
description: "Mailbox ID to move the message to",
})
@IsOptional()
@IsString()
action_mailbox?: string;
@ApiPropertyOptional({
description: "Email address to forward the message to",
example: "forward@example.com",
})
@IsOptional()
@IsEmail()
action_forward?: string;
@ApiPropertyOptional({
description: "Web URL to upload the message to",
example: "https://webhook.example.com/messages",
})
@IsOptional()
@IsUrl()
action_targetUrl?: string;
}
export class UpdateFilterDto extends CreateFilterDto {}
+30
View File
@@ -0,0 +1,30 @@
// User DTOs
export * from "./create-user.dto";
export * from "./update-user.dto";
// Message DTOs
export * from "./message.dto";
// Mailbox DTOs
export * from "./mailbox.dto";
// Address DTOs
export * from "./address.dto";
// Authentication DTOs
export * from "./authenticate.dto";
// Filter DTOs
export * from "./filter.dto";
// Submission DTOs
export * from "./submission.dto";
// DKIM DTOs
export * from "./dkim.dto";
// Webhook DTOs
export * from "./webhook.dto";
// Two-Factor Authentication DTOs
export * from "./two-factor.dto";
@@ -0,0 +1,47 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsNumber, IsOptional, IsString } from "class-validator";
export class CreateMailboxDto {
@ApiProperty({
description: "Mailbox path with slashes as folder separators",
example: "Important/Work",
})
@IsString()
path: string;
@ApiPropertyOptional({
description: "Retention time in milliseconds for messages in this mailbox",
example: 2592000000,
})
@IsOptional()
@IsNumber()
retention?: number;
}
export class UpdateMailboxDto {
@ApiPropertyOptional({
description: "New mailbox path if renaming",
example: "Renamed Folder",
})
@IsOptional()
@IsString()
path?: string;
@ApiPropertyOptional({
description: "New retention time in milliseconds",
example: 5184000000,
})
@IsOptional()
@IsNumber()
retention?: number;
}
export class ListMailboxesQueryDto {
@ApiPropertyOptional({
description: "Include message counters in results",
default: false,
})
@IsOptional()
@IsBoolean()
counters?: boolean;
}
+121
View File
@@ -0,0 +1,121 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
export class ListMessagesQueryDto {
@ApiPropertyOptional({ description: "Message ordering", enum: ["asc", "desc"], default: "desc" })
@IsOptional()
@IsString()
order?: "asc" | "desc";
@ApiPropertyOptional({ description: "Number of messages to return", minimum: 1, maximum: 250, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(250)
limit?: number;
@ApiPropertyOptional({ description: "Page number for pagination", minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@ApiPropertyOptional({ description: "Cursor for next page" })
@IsOptional()
@IsString()
next?: string;
@ApiPropertyOptional({ description: "Cursor for previous page" })
@IsOptional()
@IsString()
previous?: string;
}
export class UpdateMessageDto {
@ApiPropertyOptional({ description: "ID of the destination mailbox if moving the message" })
@IsOptional()
@IsString()
moveTo?: string;
@ApiPropertyOptional({ description: "Mark message as seen or unseen" })
@IsOptional()
@IsBoolean()
seen?: boolean;
@ApiPropertyOptional({ description: "Mark message as flagged or not" })
@IsOptional()
@IsBoolean()
flagged?: boolean;
@ApiPropertyOptional({ description: "Mark message as draft or not" })
@IsOptional()
@IsBoolean()
draft?: boolean;
@ApiPropertyOptional({ description: "Mark message as deleted or not" })
@IsOptional()
@IsBoolean()
deleted?: boolean;
@ApiPropertyOptional({ description: "Expiration date for auto-deletion or false to clear", example: "2024-12-31T23:59:59.000Z" })
@IsOptional()
expires?: string | false;
}
export class SearchMessagesQueryDto {
@ApiProperty({ description: "Query string to search for", example: "important meeting" })
@IsString()
query: string;
@ApiPropertyOptional({ description: "Message ordering", enum: ["asc", "desc"], default: "desc" })
@IsOptional()
@IsString()
order?: "asc" | "desc";
@ApiPropertyOptional({ description: "Number of results to return", minimum: 1, maximum: 250, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(250)
limit?: number;
@ApiPropertyOptional({ description: "Page number for pagination", minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
}
export class UploadMessageDto {
@ApiProperty({
description: "Raw message content in RFC822 format",
example: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test\n\nMessage body",
})
@IsString()
raw: string;
@ApiPropertyOptional({ description: "Mark message as seen", default: false })
@IsOptional()
@IsBoolean()
seen?: boolean;
@ApiPropertyOptional({ description: "Mark message as flagged", default: false })
@IsOptional()
@IsBoolean()
flagged?: boolean;
@ApiPropertyOptional({ description: "Mark message as draft", default: false })
@IsOptional()
@IsBoolean()
draft?: boolean;
@ApiPropertyOptional({ description: "Message date", example: "2024-01-01T12:00:00.000Z" })
@IsOptional()
@IsDateString()
date?: string;
}
@@ -0,0 +1,92 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEmail, IsOptional, IsString, ValidateNested } from "class-validator";
export class EmailAddress {
@ApiPropertyOptional({ description: "Display name", example: "John Doe" })
@IsOptional()
@IsString()
name?: string;
@ApiProperty({ description: "Email address", example: "john@example.com" })
@IsEmail()
address: string;
}
export class MessageAttachment {
@ApiPropertyOptional({ description: "Filename", example: "document.pdf" })
@IsOptional()
@IsString()
filename?: string;
@ApiPropertyOptional({ description: "Content type", example: "application/pdf" })
@IsOptional()
@IsString()
contentType?: string;
@ApiProperty({ description: "Base64 encoded content" })
@IsString()
content: string;
}
export class SubmitMessageDto {
@ApiProperty({ description: "From address" })
@ValidateNested()
@Type(() => EmailAddress)
from: EmailAddress;
@ApiProperty({ description: "To addresses", type: [EmailAddress] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddress)
to: EmailAddress[];
@ApiPropertyOptional({ description: "CC addresses", type: [EmailAddress] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddress)
cc?: EmailAddress[];
@ApiPropertyOptional({ description: "BCC addresses", type: [EmailAddress] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddress)
bcc?: EmailAddress[];
@ApiProperty({ description: "Subject line", example: "Test email" })
@IsString()
subject: string;
@ApiPropertyOptional({ description: "Plain text content" })
@IsOptional()
@IsString()
text?: string;
@ApiPropertyOptional({ description: "HTML content" })
@IsOptional()
@IsString()
html?: string;
@ApiPropertyOptional({ description: "Attachments", type: [MessageAttachment] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MessageAttachment)
attachments?: MessageAttachment[];
@ApiPropertyOptional({ description: "Custom headers" })
@IsOptional()
headers?: Record<string, string>;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address" })
@IsOptional()
@IsString()
ip?: string;
}
@@ -0,0 +1,56 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString } from "class-validator";
export class SetupTOTPDto {
@ApiPropertyOptional({ description: "TOTP issuer name", example: "My Email Service" })
@IsOptional()
@IsString()
issuer?: string;
@ApiPropertyOptional({ description: "Generate fresh secret", default: false })
@IsOptional()
@IsBoolean()
fresh?: boolean;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address" })
@IsOptional()
@IsString()
ip?: string;
}
export class VerifyTOTPDto {
@ApiProperty({ description: "TOTP token", example: "123456" })
@IsString()
token: string;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address" })
@IsOptional()
@IsString()
ip?: string;
}
export class UseBackupCodeDto {
@ApiProperty({ description: "Backup code", example: "abcd1234" })
@IsString()
code: string;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address" })
@IsOptional()
@IsString()
ip?: string;
}
@@ -0,0 +1,129 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
export class UpdateUserDto {
@ApiPropertyOptional({
description: "Updated display name for the user",
example: "Updated User Name",
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
description: "Updated password for the user",
example: "newsecretpassword123",
})
@IsOptional()
@IsString()
password?: string;
@ApiPropertyOptional({
description: "Array of email addresses to forward all messages to",
example: ["forward1@example.com", "forward2@example.com"],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
forward?: string[];
@ApiPropertyOptional({
description: "URL to upload all messages to",
example: "https://example.com/webhook",
})
@IsOptional()
@IsUrl()
targetUrl?: string;
@ApiPropertyOptional({
description: "Maximum storage in bytes allowed for this user",
example: 2147483648,
})
@IsOptional()
@IsNumber()
quota?: number;
@ApiPropertyOptional({
description: "Default retention time in milliseconds for mailboxes",
example: 2592000000,
})
@IsOptional()
@IsNumber()
retention?: number;
@ApiPropertyOptional({
description: "Language code for the user",
example: "en",
})
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({
description: "Maximum number of recipients allowed to send mail to in 24h",
example: 3000,
})
@IsOptional()
@IsNumber()
recipients?: number;
@ApiPropertyOptional({
description: "Maximum number of forwarded emails in 24h",
example: 3000,
})
@IsOptional()
@IsNumber()
forwards?: number;
@ApiPropertyOptional({
description: "Array of tags to associate with the user",
example: ["premium", "customer"],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({
description: "PGP public key for encryption",
})
@IsOptional()
@IsString()
pubKey?: string;
@ApiPropertyOptional({
description: "Whether to encrypt stored messages",
})
@IsOptional()
@IsBoolean()
encryptMessages?: boolean;
@ApiPropertyOptional({
description: "Whether to encrypt forwarded messages",
})
@IsOptional()
@IsBoolean()
encryptForwarded?: boolean;
@ApiPropertyOptional({
description: "Current password for verification",
})
@IsOptional()
@IsString()
existingPassword?: string;
@ApiPropertyOptional({
description: "Session identifier for logging",
})
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({
description: "IP address the request was made from",
example: "192.168.1.1",
})
@IsOptional()
@IsString()
ip?: string;
}
@@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsOptional, IsString, IsUrl } from "class-validator";
export class CreateWebhookDto {
@ApiProperty({
description: "Event types to trigger webhook",
example: ["messageNew", "messageDeleted"],
type: [String],
})
@IsArray()
@IsString({ each: true })
type: string[];
@ApiPropertyOptional({ description: "User ID to limit webhook to specific user" })
@IsOptional()
@IsString()
user?: string;
@ApiProperty({ description: "Webhook URL", example: "https://example.com/webhook" })
@IsUrl()
url: string;
@ApiPropertyOptional({ description: "Session identifier for logging" })
@IsOptional()
@IsString()
sess?: string;
@ApiPropertyOptional({ description: "IP address" })
@IsOptional()
@IsString()
ip?: string;
}
@@ -0,0 +1,40 @@
export interface AddressInfo {
id: string;
address: string;
main: boolean;
user?: string;
created: string;
tags?: string[];
metaData?: Record<string, any>;
internalData?: Record<string, any>;
}
export interface AddressListResponse {
success: true;
total: number;
page: number;
previousCursor: string | false;
nextCursor: string | false;
results: AddressInfo[];
}
export interface AddressCreateResponse {
success: true;
id: string;
}
export interface AddressDetailsResponse extends AddressInfo {
success: true;
}
export interface AddressResolveResponse {
success: true;
id: string;
address: string;
user?: string;
targets?: Array<{
id: string;
type: "relay" | "webhook" | "http";
value: string;
}>;
}
@@ -0,0 +1,18 @@
export interface ApplicationPassword {
id: string;
description: string;
scopes: string[];
lastUse?: {
time: string;
event: string;
};
created: string;
}
export interface CreateApplicationPasswordData {
description: string;
scopes?: string[] | string;
generateMobileconfig?: boolean;
sess?: string;
ip?: string;
}
@@ -0,0 +1,57 @@
export interface ArchivedMessage {
id: string;
user: string;
from: {
address: string;
name?: string;
};
to: Array<{
address: string;
name?: string;
}>;
subject: string;
messageId: string;
date: string;
size: number;
archived: string;
expires?: string;
}
export interface ArchiveListResponse {
success: true;
total: number;
page: number;
previousCursor: string | false;
nextCursor: string | false;
results: ArchivedMessage[];
}
export interface ArchiveDetailsResponse extends ArchivedMessage {
success: true;
headers?: Record<string, string>;
html?: string;
text?: string;
attachments?: Array<{
id: string;
filename: string;
contentType: string;
size: number;
}>;
}
export interface ArchiveRestoreResponse {
success: true;
id: string;
message: string;
restored: boolean;
}
export interface ArchiveDeleteResponse {
success: true;
deleted: number;
}
export interface ArchiveCreateResponse {
success: true;
id: string;
}
@@ -0,0 +1,14 @@
export interface ArchiveMessage {
id: string;
user: string;
mailbox: string;
message: number;
archived: string;
expires?: string;
}
export interface ArchiveMessageData {
expires?: string;
sess?: string;
ip?: string;
}
@@ -0,0 +1,42 @@
export interface AuditLogEntry {
id: string;
user?: string;
action: string;
result: "success" | "fail";
sess?: string;
ip: string;
created: string;
expires?: string;
meta?: {
protocol?: string;
method?: string;
source?: string;
userAgent?: string;
[key: string]: any;
};
}
export interface AuditListResponse {
success: true;
total: number;
page: number;
previousCursor: string | false;
nextCursor: string | false;
results: AuditLogEntry[];
}
export interface AuditDetailsResponse extends AuditLogEntry {
success: true;
}
export interface AuditExportResponse {
success: true;
id: string;
url: string;
expires: string;
}
export interface AuditCreateResponse {
success: true;
id: string;
}
@@ -0,0 +1,20 @@
export interface AuditEntry {
id: string;
user: string;
action: string;
result: string;
sess: string;
ip: string;
created: string;
expires: string;
meta?: any;
}
export interface CreateAuditEntryData {
user: string;
action: string;
result: "success" | "fail";
sess?: string;
ip?: string;
meta?: any;
}
@@ -0,0 +1,43 @@
export interface AuthenticateResponse {
success: boolean;
id: string;
username: string;
scope: string;
require2fa: string[] | false;
requirePasswordChange: boolean;
u2fAuthRequest?: any;
}
export interface Setup2FAResponse {
success: true;
qrcode: string;
seed?: string;
secret?: string;
}
export interface ApplicationPasswordInfo {
id: string;
description: string;
scopes: string[];
created: string;
lastUse?: {
time: string;
event: string;
};
}
export interface ApplicationPasswordListResponse {
success: true;
results: ApplicationPasswordInfo[];
}
export interface ApplicationPasswordCreateResponse {
success: true;
id: string;
password: string;
mobileconfig?: string;
}
export interface ApplicationPasswordDetailsResponse extends ApplicationPasswordInfo {
success: true;
}
@@ -0,0 +1,36 @@
export interface AutoreplyInfo {
id: string;
user: string;
status: boolean;
subject?: string;
message?: string;
text?: string;
html?: string;
start?: string;
end?: string;
created: string;
updated?: string;
}
export interface AutoreplyListResponse {
success: true;
results: AutoreplyInfo[];
}
export interface AutoreplyCreateResponse {
success: true;
id: string;
}
export interface AutoreplyDetailsResponse extends AutoreplyInfo {
success: true;
}
export interface AutoreplyStatusResponse {
success: true;
status: boolean;
subject?: string;
message?: string;
start?: string;
end?: string;
}
@@ -0,0 +1,10 @@
export interface AutoreplyData {
status?: boolean;
subject?: string;
text?: string;
html?: string;
start?: string;
end?: string;
sess?: string;
ip?: string;
}

Some files were not shown because too many files have changed in this diff Show More