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
+47
View File
@@ -0,0 +1,47 @@
name: Build and Deploy Docker Images
on:
push:
branches:
- main
jobs:
build_and_deploy:
runs-on: ubuntu-latest
env:
DANAK_SERVER: "https://captain.dev.danakcorp.com"
APP_TOKEN: 397499a13f10e7633b4a5c8000595f3e439038aa6cd32a713f6dc7847005a6a7
APP_NAME: dmail-api
GITHUB_TOKEN: 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
steps:
- name: Check out repositorys
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: zmihamid
password: ${{ env.GITHUB_TOKEN }}
- name: Preset Image Name
run: echo "IMAGE_URL=$(echo ghcr.io/zmihamid/${{ github.event.repository.name }}:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Build and push Docker Image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ env.IMAGE_URL }}
- name: Install CapRover CLI
run: npm install -g caprover
- name: deploy to server
run: |
caprover deploy -a $APP_NAME -u $DANAK_SERVER --appToken $APP_TOKEN -i "$IMAGE_URL"
+184
View File
@@ -0,0 +1,184 @@
# compiled output
/dist
/node_modules
/build
/not
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+1
View File
@@ -0,0 +1 @@
npx --no-install commitlint --edit "$1"
+1
View File
@@ -0,0 +1 @@
pnpm dlx lint-staged
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "auto",
"printWidth": 150,
"arrowParens": "always",
"semi": true,
"tabWidth": 2
}
Executable
+41
View File
@@ -0,0 +1,41 @@
# Stage 1: Build Stage
FROM node:22-alpine AS base
RUN npm install -g corepack@latest
RUN corepack enable && corepack prepare pnpm --activate
# Install tzdata to support timezone settings
RUN apk add --no-cache tzdata
# Set the timezone to Asia/Tehran
RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
FROM base AS deps
WORKDIR /temp-deps
COPY package*.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --loglevel info
FROM base AS builder
WORKDIR /build
COPY . ./
COPY --from=deps /temp-deps/node_modules ./node_modules
RUN if [ -f package.json ] && grep -q '"build":' package.json; then pnpm run build; fi
RUN pnpm install --prod --frozen-lockfile --loglevel info
FROM base AS runner
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY . ./
COPY --from=builder --chown=appuser:appgroup /build/ ./
RUN chown -R appuser:appgroup /app
USER appuser
ENV NODE_ENV=production
EXPOSE 4000
CMD ["npm", "start"]
+296
View File
@@ -0,0 +1,296 @@
# WildDuck Mail Server Gateway
A comprehensive NestJS gateway for interacting with the [WildDuck Email Server](https://wildduck.email/) HTTP API. This gateway provides a clean, type-safe interface for managing email accounts, messages, mailboxes, and all other WildDuck features.
## Features
- **Complete WildDuck API Coverage**: All major WildDuck API endpoints are implemented
- **Type Safety**: Full TypeScript support with comprehensive DTOs and interfaces
- **NestJS Best Practices**: Follows enterprise-grade patterns and conventions
- **Modular Architecture**: Clean separation of concerns with dedicated services
- **Swagger Documentation**: Auto-generated API documentation
- **Error Handling**: Robust error handling and logging
- **Observables**: RxJS-based reactive programming for better async handling
- **Configuration**: Environment-based configuration management
## API Coverage
### User Management
- Create, read, update, delete users
- User authentication and authorization
- Password management and reset
- User quota management
- Authentication logs
### Email Messages
- List, search, and filter messages
- Send, receive, and manage messages
- Message attachments handling
- Draft management
- Message forwarding
- Bulk operations
### Mailboxes
- Create and manage mail folders
- Mailbox permissions and settings
- Message counts and statistics
- Mailbox retention policies
### Email Addresses
- Manage user email addresses and aliases
- Primary address configuration
- Address validation
### Filters and Rules
- Create email filters and rules
- Automatic message processing
- Spam filtering
- Custom actions and conditions
### Authentication & Security
- Two-factor authentication (TOTP)
- Application-specific passwords
- Session management
- Security logging
### Advanced Features
- Auto-replies and out-of-office messages
- Message archiving and restoration
- Audit trails and logging
- Webhook integrations
## Installation & Setup
### 1. Install Dependencies
This gateway is already integrated into your existing NestJS project. The required dependencies are:
```bash
npm install @nestjs/axios axios rxjs
```
### 2. Environment Configuration
Add the following environment variables to your `.env` file:
```env
# WildDuck Mail Server Configuration
WILDDUCK_BASE_URL=http://localhost:8080
WILDDUCK_ACCESS_TOKEN=your-access-token-here
WILDDUCK_TIMEOUT=30000
WILDDUCK_RETRIES=3
```
### 3. Module Import
The `MailServerModule` is already imported in your `AppModule`. The gateway is ready to use!
## Usage Examples
### Basic User Operations
```typescript
import { MailServerService } from "./modules/mail-server/services/mail-server.service";
@Injectable()
export class EmailService {
constructor(private readonly mailServer: MailServerService) {}
// Create a complete email account
async createEmailAccount(username: string, password: string, email: string) {
return this.mailServer
.createEmailAccount({
username,
password,
email,
name: "User Name",
quota: 1073741824, // 1GB
})
.toPromise();
}
// Authenticate user and get profile
async authenticateUser(username: string, password: string) {
return this.mailServer.authenticateAndGetUser(username, password).toPromise();
}
// Get user's mailboxes with message counts
async getUserMailboxes(userId: string) {
return this.mailServer.getUserMailboxes(userId).toPromise();
}
}
```
### Advanced Message Operations
```typescript
// Search messages across all folders
async searchUserMessages(userId: string, query: string) {
return this.mailServer.searchAllMessages(userId, query, 50).toPromise();
}
// Send a message
async sendMessage(userId: string, mailboxId: string, rawMessage: string) {
return this.mailServer.sendMessage(userId, mailboxId, {
raw: rawMessage,
seen: false,
flagged: false
}).toPromise();
}
// Get recent messages
async getRecentMessages(userId: string, mailboxId: string) {
return this.mailServer.getRecentMessages(userId, mailboxId, 20).toPromise();
}
```
### Direct Service Access
For more granular control, you can access individual services directly:
```typescript
@Injectable()
export class DetailedEmailService {
constructor(private readonly mailServer: MailServerService) {}
// Direct access to specific services
async createUser(userData: CreateUserDto) {
return this.mailServer.users.createUser(userData).toPromise();
}
async listMessages(userId: string, mailboxId: string, options: ListMessagesQueryDto) {
return this.mailServer.messages.listMessages(userId, mailboxId, options).toPromise();
}
async createFilter(userId: string, filterData: CreateFilterDto) {
return this.mailServer.filters.createFilter(userId, filterData).toPromise();
}
async setup2FA(userId: string, issuer: string) {
return this.mailServer.auth.setup2FA(userId, { issuer }).toPromise();
}
}
```
## API Endpoints
The gateway exposes REST endpoints under `/mail-server`:
### User Management
- `GET /mail-server/users` - List users
- `POST /mail-server/users` - Create user
- `GET /mail-server/users/:userId` - Get user details
- `PUT /mail-server/users/:userId` - Update user
- `DELETE /mail-server/users/:userId` - Delete user
### Authentication
- `POST /mail-server/authenticate` - Authenticate user
- `POST /mail-server/users/:userId/2fa/totp/setup` - Setup 2FA
- `POST /mail-server/users/:userId/2fa/totp/enable` - Enable 2FA
### Messages
- `GET /mail-server/users/:userId/mailboxes/:mailboxId/messages` - List messages
- `POST /mail-server/users/:userId/mailboxes/:mailboxId/messages` - Upload message
- `GET /mail-server/users/:userId/search` - Search messages
- `GET /mail-server/users/:userId/flagged` - List flagged messages
### Mailboxes
- `GET /mail-server/users/:userId/mailboxes` - List mailboxes
- `POST /mail-server/users/:userId/mailboxes` - Create mailbox
- `PUT /mail-server/users/:userId/mailboxes/:mailboxId` - Update mailbox
### High-Level Operations
- `POST /mail-server/accounts` - Create complete email account
- `POST /mail-server/auth-and-profile` - Authenticate and get profile
- `GET /mail-server/users/:userId/mailboxes-with-counts` - Get mailboxes with counts
## Architecture
The gateway follows a clean, modular architecture:
```
src/modules/mail-server/
├── dto/ # Data Transfer Objects
│ ├── create-user.dto.ts
│ ├── update-user.dto.ts
│ ├── authenticate.dto.ts
│ ├── message.dto.ts
│ ├── mailbox.dto.ts
│ ├── address.dto.ts
│ └── filter.dto.ts
├── interfaces/ # TypeScript interfaces
│ └── wildduck-response.interface.ts
├── services/ # Service layer
│ ├── wildduck-base.service.ts # Base HTTP service
│ ├── wildduck-users.service.ts # User management
│ ├── wildduck-messages.service.ts # Message operations
│ ├── wildduck-mailboxes.service.ts # Mailbox management
│ ├── wildduck-addresses.service.ts # Address management
│ ├── wildduck-auth.service.ts # Authentication
│ ├── wildduck-filters.service.ts # Filter management
│ └── mail-server.service.ts # Main orchestrator
├── mail-server.controller.ts # REST endpoints
└── mail-server.module.ts # Module definition
```
### Service Hierarchy
1. **WildDuckBaseService**: Provides common HTTP client functionality, error handling, and response processing
2. **Specialized Services**: Each service handles a specific domain (users, messages, etc.)
3. **MailServerService**: High-level service that orchestrates operations across multiple specialized services
4. **MailServerController**: REST API endpoints for external access
## Error Handling
The gateway includes comprehensive error handling:
- **HTTP Errors**: Automatic retry logic and timeout handling
- **WildDuck API Errors**: Proper error parsing and re-throwing
- **Validation Errors**: DTO validation with class-validator
- **Logging**: Detailed logging for debugging and monitoring
## Best Practices
1. **Use High-Level Methods**: Prefer `MailServerService` methods for common operations
2. **Handle Observables**: Convert to Promises with `.toPromise()` if needed
3. **Error Handling**: Always wrap calls in try-catch blocks
4. **Type Safety**: Use the provided DTOs for type safety
5. **Environment Variables**: Keep WildDuck credentials secure
## Swagger Documentation
The gateway automatically generates Swagger documentation. Access it at:
- Development: `http://localhost:3000/api`
- Production: `https://your-domain.com/api`
## Contributing
1. Follow the existing code patterns and architecture
2. Add proper TypeScript types and DTOs
3. Include comprehensive error handling
4. Add Swagger documentation for new endpoints
5. Write tests for new functionality
## WildDuck Server Setup
This gateway requires a running WildDuck mail server. For setup instructions, visit:
- [WildDuck Documentation](https://wildduck.email/)
- [GitHub Repository](https://github.com/nodemailer/wildduck)
## License
This project follows the same license as your main application.
+8
View File
@@ -0,0 +1,8 @@
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"subject-case": [2, "always", ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case", "camel-case"]],
"type-enum": [2, "always", ["add", "build", "chore", "update", "docs", "feat", "fix", "refactor", "revert", "style", "test", "sample"]],
"scope-enum": [1, "always", ["common", "core", "app", "testing", "bug"]],
},
};
+36
View File
@@ -0,0 +1,36 @@
import { Options } from "@mikro-orm/core";
import { PostgreSqlDriver } from "@mikro-orm/postgresql";
import dotenv from "dotenv";
dotenv.config();
const config: Options = {
driver: PostgreSqlDriver,
dbName: process.env.DB_NAME,
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASS,
entities: ["./dist/**/*.entity.js"],
entitiesTs: ["./src/**/*.entity.ts"],
debug: process.env.NODE_ENV !== "production",
migrations: {
path: "./database/migrations",
pathTs: "./database/migrations",
tableName: "migrations",
transactional: true,
allOrNothing: true,
dropTables: false,
safe: true,
emit: "ts",
},
seeder: {
path: "./database/seeders",
pathTs: "./database/seeders",
defaultSeeder: "IndexSeeder",
glob: "!(*.d).{js,ts}",
emit: "ts",
},
};
export default config;
+17
View File
@@ -0,0 +1,17 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { Seeder } from "@mikro-orm/seeder";
import { Logger } from "@nestjs/common";
// import { PaymentGatewaySeeder } from "./payment-gateway.seeder";
import { RoleSeeder } from "./role.seeder";
export class IndexSeeder extends Seeder {
private readonly logger = new Logger(IndexSeeder.name);
async run(em: EntityManager): Promise<void> {
this.logger.log("Starting seeding process...");
await new RoleSeeder().run(em);
// await new PaymentGatewaySeeder().run(em);
this.logger.log("Seeding process completed successfully.");
}
}
@@ -0,0 +1,34 @@
// import { EntityManager, RequiredEntityData } from "@mikro-orm/postgresql";
// import { Seeder } from "@mikro-orm/seeder";
// import { Logger } from "@nestjs/common";
// import { PaymentGateway } from "../../src/modules/payments/entities/payment-gateway.entity";
// import { GatewayEnum } from "../../src/modules/payments/enums/gateway.enum";
// export class PaymentGatewaySeeder extends Seeder {
// private readonly logger = new Logger(PaymentGatewaySeeder.name);
// async run(em: EntityManager): Promise<void> {
// const paymentGateways: RequiredEntityData<PaymentGateway>[] = [
// {
// name: GatewayEnum.ZARINPAL,
// nameFa: "زرین پال",
// logoUrl: "https://storage.danakcorp.com/logo/fa-dark.png",
// description: "درگاه پرداخت زرین پال",
// },
// ];
// for (const paymentGateway of paymentGateways) {
// const existingPaymentGateway = await em.findOne(PaymentGateway, { name: paymentGateway.name });
// if (!existingPaymentGateway) {
// const createdPaymentGateway = em.create(PaymentGateway, paymentGateway);
// await em.persistAndFlush(createdPaymentGateway);
// this.logger.log(`[PaymentGatewaySeeder] Created payment gateway: ${createdPaymentGateway.name}`);
// } else {
// this.logger.log(`[PaymentGatewaySeeder] Payment gateway already exists: ${paymentGateway.name}`);
// }
// }
// }
// }
+36
View File
@@ -0,0 +1,36 @@
import { EntityManager, RequiredEntityData } from "@mikro-orm/postgresql";
import { Seeder } from "@mikro-orm/seeder";
import { Logger } from "@nestjs/common";
import { Role } from "../../src/modules/users/entities/role.entity";
import { RoleEnum } from "../../src/modules/users/enums/role.enum";
export class RoleSeeder extends Seeder {
private readonly logger = new Logger(RoleSeeder.name);
async run(em: EntityManager): Promise<void> {
const roles: RequiredEntityData<Role>[] = [
{
name: RoleEnum.ADMIN,
createdAt: new Date(),
updatedAt: new Date(),
},
{
name: RoleEnum.USER,
createdAt: new Date(),
updatedAt: new Date(),
},
];
for (const roleData of roles) {
const existingRole = await em.findOne(Role, { name: roleData.name });
if (!existingRole) {
const role = em.create(Role, roleData);
await em.persistAndFlush(role);
this.logger.log(`[RoleSeeder] Created role: ${roleData.name}`);
} else {
this.logger.log(`[RoleSeeder] Role already exists: ${roleData.name}`);
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintPluginPrettier from "eslint-plugin-prettier";
import eslintPluginImport from "eslint-plugin-import";
import globals from "globals";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
{
ignores: [
"dist/**",
"test-email/**",
"node_modules/**",
"jest.config.js",
"coverage/**",
"database/migrations/**",
"pnpm-lock.yaml",
".prettierrc.js",
"commitlint.config.ts",
"eslint.config.mjs",
],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
// Base configuration for all TypeScript files
{
files: ["**/*.ts"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 2022,
sourceType: "module",
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
prettier: eslintPluginPrettier,
import: eslintPluginImport,
},
rules: {
// Import rules
"sort-imports": [
"error",
{
ignoreCase: false,
ignoreDeclarationSort: true,
ignoreMemberSort: false,
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
allowSeparatedGroups: true,
},
],
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", ["sibling", "parent"], "index", "unknown"],
"newlines-between": "always",
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
// JavaScript Rules
"no-var": "error",
"prefer-const": "error",
"no-multi-spaces": "error",
"space-in-parens": "error",
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }],
semi: ["error", "always"],
// Best Practices
"no-async-promise-executor": "warn",
"no-control-regex": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
"no-extra-semi": "warn",
"no-prototype-builtins": "warn",
"no-regex-spaces": "warn",
"prefer-rest-params": "warn",
"prefer-spread": "warn",
// TypeScript-Specific Rules that don't need type information
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-loss-of-precision": "warn",
"@typescript-eslint/no-misused-new": "warn",
"@typescript-eslint/no-namespace": "warn",
"@typescript-eslint/no-this-alias": "warn",
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
"@typescript-eslint/no-unsafe-declaration-merging": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"@typescript-eslint/no-var-requires": "warn",
"@typescript-eslint/triple-slash-reference": "warn",
},
},
// Additional configuration for type-checked files - disabled for now
{
files: ["**/*.ts"],
// Disable type-aware linting until we can configure it properly
rules: {
"@typescript-eslint/await-thenable": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/unbound-method": "off",
},
},
);
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm/oCsqFA8hF9TMv4UX5E
IeDv1KXzI+KFIafZ+gU1IWgEmWP16ee8Vw1+j7Ixl9Dd7Uxc9DhcWRB6ZDwqCYb3
S3z5zabOIka7wNNBpTEmRKrmV/EzihAw+E96Ksct0PHeN2YFydPimMndBD6VoRWi
WShQD4FklIQKqBYrua0NkuIkFshYLzC3rAbcT8GkLkcbHKrKIlyeucCLwym2nd5U
coI+fKqDB1/3JYEKJRaZgFqthokeFBN4SV9tMaLzeJDk3bNoTAxRtPbtuJ8mVTL9
P0phzjorGxTxV7SbANtmB3Trm6oIXeCqY40Auy+ZTUEVr+hD4Xxs8TMvjuT8vrMe
VQIDAQAB
-----END PUBLIC KEY-----
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+133
View File
@@ -0,0 +1,133 @@
{
"name": "dmail-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start:nest": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start": "NODE_ENV=production node --trace-warnings dist/main",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prepare": "husky || true",
"orm:create": "mikro-orm migration:create --config=database/mikro-orm.config.ts",
"orm:up": "mikro-orm migration:up --config=database/mikro-orm.config.ts",
"orm:down": "mikro-orm migration:down --config=database/mikro-orm.config.ts",
"orm:generate": "mikro-orm migration:create --blank --config=database/mikro-orm.config.ts",
"orm:update": "mikro-orm schema:update --run --config=database/mikro-orm.config.ts",
"orm:drop": "mikro-orm schema:drop --run --drop-migrations-table --drop-db --config=database/mikro-orm.config.ts",
"orm:seed": "mikro-orm seeder:create --config=database/mikro-orm.config.ts",
"seed:run": "mikro-orm seeder:run --config=database/mikro-orm.config.ts"
},
"dependencies": {
"@fastify/static": "^8.2.0",
"@keyv/redis": "^4.4.0",
"@mikro-orm/cli": "^6.4.16",
"@mikro-orm/core": "^6.4.16",
"@mikro-orm/nestjs": "^6.1.1",
"@mikro-orm/postgresql": "^6.4.16",
"@mikro-orm/seeder": "^6.4.16",
"@nest-lab/fastify-multer": "^1.3.0",
"@nest-lab/throttler-storage-redis": "^1.1.0",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^4.0.0",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/cli": "^11.0.7",
"@nestjs/common": "^11.1.3",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.3",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/throttler": "^6.4.0",
"axios": "^1.10.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.54.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dayjs": "^1.11.13",
"decimal.js": "^10.5.0",
"dotenv": "^16.5.0",
"fastify": "^5.4.0",
"nodemailer": "^7.0.3",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"slugify": "^1.6.6",
"uuid": "^11.1.0"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.29.0",
"@nestjs/cli": "^11.0.7",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.3",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.12.1",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.32",
"@types/nodemailer": "^6.4.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.34.1",
"@typescript-eslint/parser": "^8.34.1",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.5.0",
"globals": "^16.2.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.5.2",
"prettier": "^3.5.3",
"source-map-support": "^0.5.21",
"supertest": "^7.1.1",
"ts-jest": "^29.4.0",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"lint-staged": {
"**/*.ts": [
"pnpm format",
"pnpm lint:fix",
"git add ."
]
},
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
}
+12478
View File
File diff suppressed because it is too large Load Diff
+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;
}

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