chore: add new feature for the ai generate template

This commit is contained in:
mahyargdz
2025-07-31 11:52:00 +03:30
parent 48166e3b6b
commit 6c1904cb60
17 changed files with 622 additions and 459 deletions
-279
View File
@@ -1,279 +0,0 @@
# Email Polling System
This document describes the email polling system that replaced the webhook-based approach for real-time email notifications.
## Overview
The email polling system uses **BullMQ** (Redis-based job queue) to periodically check for new emails when users are connected via WebSocket. This approach is more reliable than webhooks since it doesn't depend on external webhook delivery.
## Architecture
### Components
1. **EmailPollingService** - Manages polling jobs for connected users
2. **EmailPollingProcessor** - Processes email polling jobs and checks for new emails
3. **EmailGateway** - WebSocket gateway that starts/stops polling on user connection/disconnection
4. **BullMQ Queue** - Redis-based job queue for scheduling recurring email checks
### Flow
```
User connects to WebSocket
EmailGateway.handleConnection()
EmailPollingService.startPollingForUser()
BullMQ creates recurring job (every 30 seconds)
EmailPollingProcessor.process() - checks for new emails
If new emails found → notify via WebSocket
User disconnects
EmailGateway.handleDisconnect()
EmailPollingService.stopPollingForUser()
```
## Configuration
### Environment Variables
Required environment variables (see `environment.sample`):
```bash
# Redis for BullMQ
REDIS_URI=redis://localhost:6379
# WildDuck Mail Server
WILDDUCK_SERVER=http://localhost:8080
WILDDUCK_API_TOKEN=your-wildduck-access-token
# Email Polling (optional)
EMAIL_POLLING_INTERVAL=30000 # 30 seconds
EMAIL_POLLING_ENABLED=true
```
### BullMQ Configuration
The system uses the existing BullMQ configuration from `src/configs/bullmq.config.ts`:
```typescript
export function bullMqConfig(): SharedBullAsyncConfiguration {
return {
connection: {
url: configService.getOrThrow<string>("REDIS_URI"),
},
prefix: "dmail",
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
attempts: 5,
},
};
}
```
## Implementation Details
### Email Polling Service
Located at `src/modules/email-utils/services/email-polling.service.ts`
**Key Methods:**
- `startPollingForUser(userId, connectionId)` - Start polling when user connects
- `stopPollingForUser(userId)` - Stop polling when user disconnects
- `getPollingStats()` - Get statistics about active polling jobs
**Job Configuration:**
```typescript
const job = await this.emailPollingQueue.add(`email-poll-${userId}`, jobData, {
repeat: {
every: 30000, // 30 seconds interval
},
removeOnComplete: 5,
removeOnFail: 10,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
});
```
### Email Polling Processor
Located at `src/modules/email-utils/queue/email-polling.processor.ts`
**Processing Logic:**
1. Check if user is still connected (skip if disconnected)
2. Get user's mailbox information
3. Query WildDuck API for recent emails since last check
4. For each new email:
- Send WebSocket notification via `EmailGateway.notifyNewEmail()`
- Update unread count via `EmailGateway.notifyUnreadCountUpdate()`
5. Update job data with new timestamp for next run
### WebSocket Integration
The `EmailGateway` automatically manages polling:
```typescript
// On user connection
await this.emailPollingService.startPollingForUser(user.id, client.id);
// On user disconnection
await this.emailPollingService.stopPollingForUser(userInfo.userId);
```
## WebSocket Events
The system emits these WebSocket events when new emails are found:
### `new_email` Event
```typescript
{
userId: string;
messageId: number;
subject: string;
from: { name?: string; address: string };
to: Array<{ name?: string; address: string }>;
hasAttachments: boolean;
timestamp: string;
mailboxId: string;
mailboxName: string;
isRead: boolean;
}
```
### `unread_count_updated` Event
```typescript
{
userId: string;
totalUnread: number;
mailboxCounts: Record<string, number>;
count: number;
timestamp: string;
}
```
## Monitoring & Debugging
### Check Active Polling Jobs
```typescript
const stats = emailPollingService.getPollingStats();
console.log({
activeJobs: stats.activeJobs,
userIds: stats.userIds,
connections: stats.connections,
});
```
### BullMQ Dashboard
You can use BullMQ's web dashboard to monitor jobs:
```bash
npm install -g @bull-board/express
# Then access the dashboard at http://localhost:3000/admin/queues
```
### Logs
The system logs important events:
```
[EmailPollingService] Started email polling for user 12345 with job ID: email-poll-12345
[EmailPollingProcessor] Found 3 new emails for user 12345
[EmailPollingProcessor] Notified user 12345 of 3 new emails
[EmailPollingService] Stopped email polling for user 12345
```
## Performance Considerations
### Polling Interval
- Default: 30 seconds
- Adjustable via `EMAIL_POLLING_INTERVAL` environment variable
- Balance between real-time updates and API load
### Job Management
- Jobs are automatically cleaned up when users disconnect
- Failed jobs are retried up to 3 times with exponential backoff
- Completed jobs are kept (last 5) for debugging
### Resource Usage
- Each connected user creates one recurring BullMQ job
- Jobs only run when users are connected (checked at start of each job)
- Redis is used for job queue storage
## Advantages over Webhooks
1. **Reliability** - No dependency on external webhook delivery
2. **Connection Awareness** - Only polls when users are actually connected
3. **Error Handling** - Built-in retry mechanism with exponential backoff
4. **Monitoring** - Full visibility into job status and performance
5. **Scalability** - Redis-based queue can handle high loads
6. **Control** - Can adjust polling frequency per user if needed
## Migration from Webhooks
The old webhook system has been completely removed:
**Deleted Files:**
- `src/modules/email-utils/controllers/webhook.controller.ts`
- `src/modules/email-utils/services/webhook-management.service.ts`
- `src/modules/email-utils/services/webhook-email-events.service.ts`
- `src/modules/email/interfaces/webhook-events.interface.ts`
**Updated Files:**
- `src/modules/email-utils/email-utils.module.ts` - Added BullMQ and new services
- `src/modules/email-gateway/email.gateway.ts` - Added polling integration
- `src/modules/email-gateway/email-gateway.module.ts` - Added EmailUtilsModule import
## Troubleshooting
### Common Issues
**Issue: Jobs not running**
- Check Redis connection
- Verify `REDIS_URI` environment variable
- Check BullMQ logs
**Issue: Users not receiving notifications**
- Verify user is connected to WebSocket
- Check if polling job is active: `emailPollingService.getPollingStats()`
- Verify WildDuck API credentials and server URL
**Issue: High Redis memory usage**
- Adjust `removeOnComplete` and `removeOnFail` values
- Consider shorter polling intervals for fewer concurrent jobs
**Issue: API rate limiting**
- Increase polling interval
- Implement user-specific rate limiting
- Use WildDuck API bulk endpoints if available
## Future Enhancements
1. **Dynamic Polling Intervals** - Adjust frequency based on user activity
2. **Priority Users** - More frequent polling for premium users
3. **Email Filtering** - Only notify for emails matching user preferences
4. **Batch Processing** - Process multiple users in single API calls
5. **Caching** - Cache recent emails to reduce API calls
-77
View File
@@ -1,77 +0,0 @@
# Database Configuration
DATABASE_URL=postgresql://username:password@localhost:5432/dmail_db
# Redis Configuration (for BullMQ)
REDIS_URI=redis://localhost:6379
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-here
JWT_EXPIRES_IN=7d
# Mail Server Configuration (WildDuck API)
WILDDUCK_SERVER=http://localhost:8080
WILDDUCK_API_TOKEN=your-wildduck-access-token-here
# Email Polling Configuration
EMAIL_POLLING_INTERVAL=30000 # 30 seconds (in milliseconds)
EMAIL_POLLING_ENABLED=true
# WebSocket Configuration
WS_CORS_ORIGIN=http://localhost:3000
WS_TRANSPORTS=websocket,polling
# Application Configuration
NODE_ENV=development
PORT=3000
APP_NAME=dmail-api
LOG_LEVEL=debug
# AWS S3 Configuration (for file uploads)
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key
AWS_S3_BUCKET=your-s3-bucket-name
# Notification Configuration (for push notifications)
VAPID_PUBLIC_KEY=your-vapid-public-key
VAPID_PRIVATE_KEY=your-vapid-private-key
VAPID_SUBJECT=mailto:admin@yourapp.com
# Rate Limiting
RATE_LIMIT_TTL=60000 # 1 minute
RATE_LIMIT_LIMIT=100 # 100 requests per minute per IP
# Cache Configuration
CACHE_TTL=300000 # 5 minutes
# Business Configuration
BUSINESS_QUOTA_DEFAULT=1000
BUSINESS_QUOTA_PREMIUM=10000
# AI Assistant Configuration (OpenAI)
OPENAI_API_KEY=your-openai-api-key
OPENAI_MODEL=gpt-3.5-turbo
# CORS Configuration
CORS_ORIGINS=http://localhost:3000,https://yourapp.com
# Email Templates
EMAIL_TEMPLATE_DIR=./templates
EMAIL_FROM_ADDRESS=noreply@yourapp.com
EMAIL_FROM_NAME=Your App Name
# Monitoring & Logging
SENTRY_DSN=your-sentry-dsn-here
LOG_FORMAT=combined
LOG_FILE_PATH=./logs/app.log
# Development Settings
SWAGGER_ENABLED=true
DEBUG=false
# Queue Configuration
QUEUE_REDIS_URL=redis://localhost:6379
QUEUE_PREFIX=dmail
QUEUE_DEFAULT_JOB_OPTIONS_REMOVE_ON_COMPLETE=true
QUEUE_DEFAULT_JOB_OPTIONS_REMOVE_ON_FAIL=false
QUEUE_DEFAULT_JOB_OPTIONS_ATTEMPTS=5
+1
View File
@@ -67,6 +67,7 @@
"fastify": "^5.4.0",
"nodemailer": "^7.0.4",
"openai": "^5.10.2",
"parse-json": "^8.3.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
+19
View File
@@ -119,6 +119,9 @@ importers:
openai:
specifier: ^5.10.2
version: 5.10.2
parse-json:
specifier: ^8.3.0
version: 8.3.0
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
@@ -3918,6 +3921,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
index-to-position@1.1.0:
resolution: {integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==}
engines: {node: '>=18'}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@@ -5114,6 +5121,10 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@@ -11028,6 +11039,8 @@ snapshots:
imurmurhash@0.1.4: {}
index-to-position@1.1.0: {}
inflight@1.0.6:
dependencies:
once: 1.4.0
@@ -12637,6 +12650,12 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.27.1
index-to-position: 1.1.0
type-fest: 4.41.0
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
+12
View File
@@ -574,6 +574,18 @@ export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = "خطا در دریافت پاسخ از مدل AI",
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = "ایمیل یافت نشد یا دسترسی آن را ندارید",
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = "محتوای ایمیل خالی است یا قابل استخراج نیست",
DESCRIPTION_IS_REQUIRED = "شرح و هدف قالب الزامی است",
DESCRIPTION_MUST_BE_A_STRING = "شرح قالب باید یک رشته باشد",
DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS = "شرح قالب باید حداقل ۱۰ کاراکتر باشد",
DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS = "شرح قالب نمی‌تواند بیشتر از ۵۰۰ کاراکتر باشد",
THEME_MUST_BE_A_VALID_VALUE = "تم قالب باید یکی از مقادیر مجاز باشد",
STYLE_MUST_BE_A_VALID_VALUE = "سبک قالب باید یکی از مقادیر مجاز باشد",
PURPOSE_MUST_BE_A_VALID_VALUE = "هدف قالب باید یکی از مقادیر مجاز باشد",
TEMPLATE_NAME_MUST_BE_A_STRING = "نام قالب باید یک رشته باشد",
TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS = "نام قالب نمی‌تواند بیشتر از ۱۰۰ کاراکتر باشد",
PREFERRED_COLORS_MUST_BE_A_STRING = "رنگ‌های ترجیحی باید یک رشته باشد",
PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS = "رنگ‌های ترجیحی نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد",
PREFERRED_COLORS_IS_REQUIRED = "رنگ‌های ترجیحی الزامی است",
}
export const enum TwoFactorMessage {
+163 -50
View File
@@ -2,51 +2,7 @@ import { FactoryProvider } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AI_CONFIG } from "../common/constants";
export interface AIConfig {
baseUrl: string;
apiKey: string;
defaultModel: string;
temperature: number;
maxTokens: number;
timeout: number;
farsiPrompts: FarsiPrompts;
}
export interface FarsiPrompts {
summarization: {
systemPrompt: string;
promptTemplate: {
intro: string;
subjectLabel: string;
fromLabel: string;
toLabel: string;
dateLabel: string;
contentLabel: string;
attachmentsLabel: string;
analysisRequirements: string;
summaryLengthLabel: string;
toneLabel: string;
};
};
writingAssistance: {
systemPrompt: string;
promptTemplate: {
intro: string;
mainMessageLabel: string;
emailDetailsLabel: string;
purposeLabel: string;
toneLabel: string;
lengthLabel: string;
keyPointsLabel: string;
contextLabel: string;
originalSubjectLabel: string;
originalSenderLabel: string;
originalContentLabel: string;
additionalInstructionsLabel: string;
};
};
}
import { AIConfig, FarsiPrompts } from "../modules/ai-assistant/interfaces/ai.interface";
const FARSI_PROMPTS: FarsiPrompts = {
summarization: {
@@ -118,17 +74,174 @@ const FARSI_PROMPTS: FarsiPrompts = {
additionalInstructionsLabel: "دستورالعمل‌های اضافی:",
},
},
templateGeneration: {
systemPrompt: `شما یک طراح ماهر قالب‌های ایمیل حرفه‌ای هستید که قالب‌هایی در سطح شرکت‌های بزرگ مانند Figma، Stripe، Airbnb، و Apple طراحی می‌کنید. وظیفه شما تولید قالب‌های قدرتمند، جذاب و کاملاً حرفه‌ای است.
**الزامات ساختاری - بسیار مهم:**
- قالب باید دقیقاً از ساختار PersonalityDataType پیروی کند
- ساختار شامل سه بخش است: header, content, footer
- هر بخش دارای columnsCount (عدد) و items (آرایه) است
- هر item دارای id منحصر به فرد (رشته) است
- استفاده از enums مشخص شده الزامی است
**Enums مجاز:**
- HorizontalAlignment: "left" | "center" | "right"
- VerticalAlignment: "top" | "middle" | "bottom"
- ButtonSize: "small" | "medium" | "large"
- ImageSize: "cover" | "contain" | "auto" | "repeat" | "repeat-x" | "repeat-y" | "no-repeat"
- BackgroundSize: "cover" | "contain" | "round"
- BackgroundPosition: "center center" | "top left" | "top center" | "top right" | "center left" | "center right" | "bottom left" | "bottom center" | "bottom right"
- SocialNetworkType: "facebook" | "twitter" | "instagram" | "linkedin" | "youtube" | "telegram" | "whatsapp" | "tiktok" | "pinterest" | "snapchat" | "github" | "discord"
- SocialIconSize: "small" | "medium" | "large"
- SocialIconStyle: "circle" | "square" | "rounded"
**ساختار دقیق هر SectionItemType:**
{
"id": "unique-string-id",
"backgroundColor": "#hex-color",
"backgroundImage": "optional-url",
"texts": [
{
"id": "text-unique-id",
"text": "محتوای متن",
"fontSize": number,
"fontFamily": "font-name",
"color": "#hex-color",
"alignment": "left|center|right",
"verticalAlignment": "top|middle|bottom"
}
],
"buttons": [
{
"id": "button-unique-id",
"text": "متن دکمه",
"link": "url",
"size": "small|medium|large",
"isBorder": boolean,
"borderSize": number,
"textColor": "#hex-color",
"backgroundColor": "#hex-color",
"borderColor": "#hex-color",
"alignment": "left|center|right",
"verticalAlignment": "top|middle|bottom"
}
],
"images": [
{
"id": "image-unique-id",
"src": "https://real-image-url-from-internet.com",
"alt": "توضیح تصویر",
"size": "cover|contain|auto|repeat|repeat-x|repeat-y|no-repeat",
"width": "100%",
"height": "auto",
"alignment": "left|center|right",
"verticalAlignment": "top|middle|bottom"
}
],
"socials": [
{
"id": "social-unique-id",
"networkType": "facebook|twitter|instagram|linkedin|youtube|telegram|whatsapp|tiktok|pinterest|snapchat|github|discord",
"link": "https://social-link.com",
"size": "small|medium|large",
"style": "circle|square|rounded",
"color": "#hex-color",
"alignment": "left|center|right",
"verticalAlignment": "top|middle|bottom"
}
],
"alignment": "left|center|right",
"verticalAlignment": "top|middle|bottom",
"style": {
"borderRadius": number,
"padding": "css-padding-value",
"fontFamily": "font-family-name",
"backgroundSize": "cover|contain|round",
"backgroundRepeat": "no-repeat|repeat|repeat-x|repeat-y",
"backgroundPosition": "center center|top left|..."
}
}
**الزامات طراحی حرفه‌ای (مانند شرکت‌های بزرگ):**
1. **Header Excellence**:
- لوگو با اندازه مناسب (حداکثر ارتفاع 60px، عرض متناسب)
- لوگو نباید خیلی بزرگ یا غیرحرفه‌ای باشد
- ناوبری، پیام‌های مهم، شخصی‌سازی
2. **Content Power**:
- محتوای جذاب، CTA قوی
- تصاویر واقعی از اینترنت (نه placeholder)
- layout تمیز و حرفه‌ای
3. **Footer Excellence - بسیار مهم**:
- طراحی فوق‌العاده زیبا، صاف و نرم (smooth)
- آیکون‌های شبکه‌های اجتماعی زیبا و هماهنگ (حداقل 4-6 شبکه)
- اطلاعات تماس شرکت با فونت مناسب
- لینک‌های مهم (Privacy Policy, Terms, Unsubscribe) با فاصله‌گذاری مناسب
- آدرس شرکت با طراحی زیبا
- کپی‌رایت و برندینگ حرفه‌ای
- padding و margin های مناسب برای ظاهر نرم و صاف
- رنگ‌بندی هارمونیک و ملایم
4. **Professional Design**:
- رنگ‌های هماهنگ و مدرن
- typography حرفه‌ای
- spacing و padding مناسب
- responsive design
- accessibility standards
**استراتژی طراحی:**
- از الگوهای شرکت‌های معتبر الهام بگیرید
- **حتماً تصاویر واقعی از اینترنت استفاده کنید** (مثل https://images.unsplash.com، https://via.placeholder.com، یا سایت‌های معتبر تصویر)
- محتوای placeholder واقع‌گرایانه استفاده کنید
- CTA های قوی و جذاب طراحی کنید
- Social proof و trust signals اضافه کنید
- Mobile-first approach
- Brand consistency
- **لوگو را در اندازه مناسب و حرفه‌ای نگه دارید**
- **Footer را بسیار زیبا، نرم و حرفه‌ای طراحی کنید**
**فرمت پاسخ JSON:**
{
"templateName": "نام قالب حرفه‌ای",
"structure": {
"header": {
"columnsCount": number,
"items": [SectionItemType...]
},
"content": {
"columnsCount": number,
"items": [SectionItemType...]
},
"footer": {
"columnsCount": number,
"items": [SectionItemType...] // حتماً شامل socials و اطلاعات شرکت
}
},
"description": "توضیح کامل قالب و ویژگی‌های حرفه‌ای آن",
}`,
promptTemplate: {
intro: "لطفاً یک قالب ایمیل حرفه‌ای در سطح شرکت‌های بزرگ با مشخصات زیر و ساختار دقیق PersonalityDataType طراحی کنید:",
descriptionLabel: "شرح و هدف قالب:",
themeLabel: "تم قالب:",
styleLabel: "سبک قالب:",
purposeLabel: "هدف استفاده:",
colorsLabel: "رنگ‌های ترجیحی:",
templateNameLabel: "نام پیشنهادی:",
requirementsLabel: "الزامات حرفه‌ای:",
structureRequirements: "- ساختار دقیق PersonalityDataType با header/content/footer کامل",
htmlRequirements: "- HTML حرفه‌ای، responsive و سازگار با email clients",
designRequirements: "- استفاده از socials در footer، طراحی مانند شرکت‌های بزرگ، CTA قوی",
},
},
};
export const aiConfigProvider: FactoryProvider<AIConfig> = {
provide: AI_CONFIG,
useFactory: (configService: ConfigService): AIConfig => ({
baseUrl: configService.get<string>("AI_BASE_URL", "https://api.openai.com/v1"),
apiKey: configService.get<string>("AI_API_KEY", ""),
defaultModel: configService.get<string>("AI_DEFAULT_MODEL", "gpt-3.5-turbo"),
baseUrl: configService.getOrThrow<string>("AI_BASE_URL"),
apiKey: configService.getOrThrow<string>("AI_API_KEY"),
defaultModel: configService.getOrThrow<string>("AI_DEFAULT_MODEL"),
temperature: configService.get<number>("AI_TEMPERATURE", 0.7),
maxTokens: configService.get<number>("AI_MAX_TOKENS", 2000),
timeout: configService.get<number>("AI_TIMEOUT", 30000),
maxTokens: configService.get<number>("AI_MAX_TOKENS", 5000),
timeout: configService.get<number>("AI_TIMEOUT", 80000),
farsiPrompts: FARSI_PROMPTS,
}),
inject: [ConfigService],
@@ -0,0 +1,78 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
import { AiMessage } from "../../../common/enums/message.enum";
export enum TemplateThemeEnum {
MODERN = "modern",
CLASSIC = "classic",
MINIMAL = "minimal",
CORPORATE = "corporate",
CREATIVE = "creative",
NEWSLETTER = "newsletter",
}
export enum TemplateStyleEnum {
PROFESSIONAL = "professional",
CASUAL = "casual",
ELEGANT = "elegant",
BOLD = "bold",
CLEAN = "clean",
}
export enum TemplatePurposeEnum {
MARKETING = "marketing",
NEWSLETTER = "newsletter",
ANNOUNCEMENT = "announcement",
WELCOME = "welcome",
TRANSACTIONAL = "transactional",
PROMOTIONAL = "promotional",
INVITATION = "invitation",
GENERAL = "general",
}
export class GenerateTemplateDto {
@IsNotEmpty({ message: AiMessage.DESCRIPTION_IS_REQUIRED })
@IsString({ message: AiMessage.DESCRIPTION_MUST_BE_A_STRING })
@MinLength(10, { message: AiMessage.DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS })
@MaxLength(1000, { message: AiMessage.DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS })
@ApiProperty({
description: "Description of the template purpose and content requirements",
example: "Create a professional newsletter template for announcing new products and company updates",
minLength: 10,
maxLength: 500,
})
description: string;
@IsOptional()
@IsEnum(TemplateThemeEnum)
@ApiPropertyOptional({ enum: TemplateThemeEnum, description: "Visual theme of the template", example: TemplateThemeEnum.MODERN })
theme?: TemplateThemeEnum;
@IsOptional()
@IsEnum(TemplateStyleEnum)
@ApiPropertyOptional({ enum: TemplateStyleEnum, description: "Style approach for the template", example: TemplateStyleEnum.PROFESSIONAL })
style?: TemplateStyleEnum;
@IsOptional()
@IsEnum(TemplatePurposeEnum)
@ApiPropertyOptional({ enum: TemplatePurposeEnum, description: "Primary purpose of the template", example: TemplatePurposeEnum.NEWSLETTER })
purpose?: TemplatePurposeEnum;
@IsOptional()
@IsString({ message: AiMessage.TEMPLATE_NAME_MUST_BE_A_STRING })
@MaxLength(100, { message: AiMessage.TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS })
@ApiPropertyOptional({ description: "Suggested name for the template", example: "Monthly Product Newsletter", maxLength: 100 })
templateName?: string;
@IsOptional()
@IsNotEmpty({ message: AiMessage.PREFERRED_COLORS_IS_REQUIRED })
@IsString({ message: AiMessage.PREFERRED_COLORS_MUST_BE_A_STRING })
@MaxLength(200, { message: AiMessage.PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS })
@ApiPropertyOptional({
description: "Preferred color scheme or specific colors to use",
example: "Blue and white corporate colors, #1e3a8a, #ffffff",
maxLength: 200,
})
preferredColors?: string;
}
@@ -1,6 +1,7 @@
import { Body, Controller, Post } from "@nestjs/common";
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
import { GenerateTemplateDto } from "./DTO/generate-template.dto";
import { SummarizeEmailDto } from "./DTO/summarize-email.dto";
import { WriteEmailAssistanceDto } from "./DTO/write-email-assistance.dto";
import { AiAssistantService } from "./services/ai-assistant.service";
@@ -30,4 +31,10 @@ export class AiAssistantController {
async assistEmailWriting(@Body() writeDto: WriteEmailAssistanceDto, @UserDec("wildduckUserId") wildduckUserId: string) {
return this.aiAssistantService.assistEmailWriting(writeDto, wildduckUserId);
}
@Post("generate-template")
@ApiOperation({ summary: "Generate an email template using AI" })
async generateTemplate(@Body() generateTemplateDto: GenerateTemplateDto) {
return this.aiAssistantService.generateTemplate(generateTemplateDto);
}
}
@@ -1,5 +1,7 @@
import { PriorityEnum } from "../../email/enums/email-header.enum";
import { MessageAttachment } from "../../mail-server/interfaces/messages-response.interface";
import { PersonalityDataType } from "../../templates/interfaces/structure.interface";
import { TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
import { LanguageEnum, SentimentEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
export interface IEmailData {
@@ -18,6 +20,36 @@ export interface SummarizationOptions {
language?: LanguageEnum;
}
export interface TemplateGenerationOptions {
theme?: TemplateThemeEnum;
style?: TemplateStyleEnum;
purpose?: TemplatePurposeEnum;
templateName?: string;
preferredColors?: string;
language?: LanguageEnum;
}
export interface ITemplateGenerationRequest {
description: string;
options: TemplateGenerationOptions;
}
export interface AITemplateGenerationResponse {
templateName: string;
structure: PersonalityDataType;
rawHtml: string;
description: string;
designPrinciples: string[];
suggestedImprovements: string[];
colorScheme: {
primary: string;
secondary: string;
accent?: string;
background: string;
text: string;
};
}
export interface AISummaryResponse {
summary: string;
keyPoints: string[];
@@ -37,4 +69,5 @@ export interface AIWritingResponse {
export interface AIServiceInterface {
summarizeEmail(emailData: IEmailData, options: SummarizationOptions): Promise<AISummaryResponse>;
assistEmailWriting(prompt: string): Promise<AIWritingResponse>;
generateTemplate(request: ITemplateGenerationRequest): Promise<AITemplateGenerationResponse>;
}
@@ -0,0 +1,60 @@
export interface AIConfig {
baseUrl: string;
apiKey: string;
defaultModel: string;
temperature: number;
maxTokens: number;
timeout: number;
farsiPrompts: FarsiPrompts;
}
export interface FarsiPrompts {
summarization: {
systemPrompt: string;
promptTemplate: {
intro: string;
subjectLabel: string;
fromLabel: string;
toLabel: string;
dateLabel: string;
contentLabel: string;
attachmentsLabel: string;
analysisRequirements: string;
summaryLengthLabel: string;
toneLabel: string;
};
};
writingAssistance: {
systemPrompt: string;
promptTemplate: {
intro: string;
mainMessageLabel: string;
emailDetailsLabel: string;
purposeLabel: string;
toneLabel: string;
lengthLabel: string;
keyPointsLabel: string;
contextLabel: string;
originalSubjectLabel: string;
originalSenderLabel: string;
originalContentLabel: string;
additionalInstructionsLabel: string;
};
};
templateGeneration: {
systemPrompt: string;
promptTemplate: {
intro: string;
descriptionLabel: string;
themeLabel: string;
styleLabel: string;
purposeLabel: string;
colorsLabel: string;
templateNameLabel: string;
requirementsLabel: string;
structureRequirements: string;
htmlRequirements: string;
designRequirements: string;
};
};
}
@@ -5,10 +5,11 @@ import { AiService } from "./ai.service";
import { AiMessage } from "../../../common/enums/message.enum";
import { MessageDetails } from "../../mail-server/interfaces/messages-response.interface";
import { WildDuckMessagesService } from "../../mail-server/services/wildduck-messages.service";
import { GenerateTemplateDto, TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
import { SummarizeEmailDto } from "../DTO/summarize-email.dto";
import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto";
import { SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
import { IEmailData, SummarizationOptions } from "../interfaces/ai-service.interface";
import { IEmailData, ITemplateGenerationRequest, SummarizationOptions, TemplateGenerationOptions } from "../interfaces/ai-service.interface";
@Injectable()
export class AiAssistantService {
@@ -37,6 +38,51 @@ export class AiAssistantService {
return { assistance };
}
//************************************************* */
async generateTemplate(generateTemplateDto: GenerateTemplateDto) {
if (!generateTemplateDto.description?.trim()) {
throw new BadRequestException(AiMessage.DESCRIPTION_IS_REQUIRED);
}
const templateRequest: ITemplateGenerationRequest = {
description: generateTemplateDto.description.trim(),
options: this.buildTemplateGenerationOptions(generateTemplateDto),
};
const aiResponse = await this.aiService.generateTemplate(templateRequest);
return {
template: {
name: aiResponse.templateName,
description: aiResponse.description,
structure: aiResponse.structure,
rawHtml: aiResponse.rawHtml,
colorScheme: aiResponse.colorScheme,
},
designInsights: {
designPrinciples: aiResponse.designPrinciples,
suggestedImprovements: aiResponse.suggestedImprovements,
},
metadata: {
theme: generateTemplateDto.theme || TemplateThemeEnum.MODERN,
style: generateTemplateDto.style || TemplateStyleEnum.PROFESSIONAL,
purpose: generateTemplateDto.purpose || TemplatePurposeEnum.GENERAL,
generatedAt: new Date().toISOString(),
},
};
}
//************************************************* */
private buildTemplateGenerationOptions(dto: GenerateTemplateDto): TemplateGenerationOptions {
return {
theme: dto.theme,
style: dto.style,
purpose: dto.purpose,
templateName: dto.templateName,
preferredColors: dto.preferredColors,
};
}
//************************************************* */
private extractEmailData(message: MessageDetails) {
return {
@@ -1,14 +1,25 @@
import { Inject, Injectable, InternalServerErrorException } from "@nestjs/common";
import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import OpenAI from "openai";
import parseJson from "parse-json";
import { AI_CONFIG } from "../../../common/constants";
import { AiMessage } from "../../../common/enums/message.enum";
import { AIConfig } from "../../../configs/ai.config";
import { EmailPurposeEnum, LanguageEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
import { AIServiceInterface, AISummaryResponse, AIWritingResponse, IEmailData, SummarizationOptions } from "../interfaces/ai-service.interface";
import {
AIServiceInterface,
AISummaryResponse,
AITemplateGenerationResponse,
AIWritingResponse,
IEmailData,
ITemplateGenerationRequest,
SummarizationOptions,
} from "../interfaces/ai-service.interface";
import { AIConfig } from "../interfaces/ai.interface";
@Injectable()
export class AiService implements AIServiceInterface {
private readonly logger = new Logger(AiService.name);
private readonly openai: OpenAI;
private readonly language: LanguageEnum = LanguageEnum.PERSIAN;
private readonly defaultTone: SummaryToneEnum = SummaryToneEnum.PROFESSIONAL;
@@ -47,7 +58,7 @@ export class AiService implements AIServiceInterface {
const response = completion.choices[0]?.message?.content;
if (!response) throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
const parsedResponse = JSON.parse(response) as AISummaryResponse;
const parsedResponse = this.parseJsonResponse<AISummaryResponse>(response);
return parsedResponse;
}
@@ -74,10 +85,51 @@ export class AiService implements AIServiceInterface {
const response = completion.choices[0]?.message?.content;
if (!response) throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
const parsedResponse = JSON.parse(response) as AIWritingResponse;
const parsedResponse = this.parseJsonResponse<AIWritingResponse>(response);
return parsedResponse;
}
//************************************************* */
async generateTemplate(request: ITemplateGenerationRequest): Promise<AITemplateGenerationResponse> {
const prompt = this.buildTemplateGenerationPrompt(request);
const completion = await this.openai.chat.completions.create({
model: this.aiConfig.defaultModel,
messages: [
{
role: "system",
content: this.getTemplateGenerationSystemPrompt(),
},
{
role: "user",
content: prompt,
},
],
temperature: this.aiConfig.temperature,
max_tokens: this.aiConfig.maxTokens,
response_format: { type: "json_object" },
});
const response = completion.choices[0]?.message?.content;
if (!response) throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
const parsedResponse = this.parseJsonResponse<AITemplateGenerationResponse>(response);
return parsedResponse;
}
//************************************************* */
private parseJsonResponse<T>(response: string): T {
try {
return parseJson(response) as T;
} catch (error) {
this.logger.error("error parsing json response", `${error instanceof Error ? error.message : "Unknown error"}`);
return response as T;
// throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
}
}
//************************************************* */
private getSummarizationSystemPrompt(options: SummarizationOptions): string {
const language = options.language || this.language;
@@ -98,6 +150,11 @@ export class AiService implements AIServiceInterface {
.replace("{language}", language);
}
//************************************************* */
private getTemplateGenerationSystemPrompt(): string {
return this.aiConfig.farsiPrompts.templateGeneration.systemPrompt;
}
//************************************************* */
private buildSummarizationPrompt(emailData: IEmailData, options: SummarizationOptions): string {
const template = this.aiConfig.farsiPrompts.summarization.promptTemplate;
@@ -119,4 +176,39 @@ export class AiService implements AIServiceInterface {
return prompt;
}
//************************************************* */
private buildTemplateGenerationPrompt(request: ITemplateGenerationRequest): string {
const template = this.aiConfig.farsiPrompts.templateGeneration.promptTemplate;
let prompt = `${template.intro}\n\n`;
prompt += `${template.descriptionLabel} ${request.description}\n\n`;
if (request.options.theme) {
prompt += `${template.themeLabel} ${request.options.theme}\n`;
}
if (request.options.style) {
prompt += `${template.styleLabel} ${request.options.style}\n`;
}
if (request.options.purpose) {
prompt += `${template.purposeLabel} ${request.options.purpose}\n`;
}
if (request.options.preferredColors) {
prompt += `${template.colorsLabel} ${request.options.preferredColors}\n`;
}
if (request.options.templateName) {
prompt += `${template.templateNameLabel} ${request.options.templateName}\n`;
}
prompt += `\n${template.requirementsLabel}\n`;
prompt += `${template.structureRequirements}\n`;
prompt += `${template.htmlRequirements}\n`;
prompt += `${template.designRequirements}\n`;
return prompt;
}
}
+1 -3
View File
@@ -17,7 +17,7 @@ import { UserRepository } from "../../users/repositories/user.repository";
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
import { BulkActionDto, BulkActionType } from "../DTO/bulk-actions.dto";
import { MessageListQueryDto, SearchMessagesQueryDto } from "../DTO/email-query.dto";
import { EmailRecipientDto, EmailType, SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto";
import { EmailRecipientDto, SendEmailDto, UpdateDraftDto } from "../DTO/send-email.dto";
import { PriorityEnum } from "../enums/email-header.enum";
import { IEmailMap } from "../interfaces/email-map.interface";
@@ -38,8 +38,6 @@ export class EmailService {
//########################################################
async sendEmail(wildduckUserId: string, userId: string, sendEmailDto: SendEmailDto) {
this.logger.log(`Sending ${EmailType.GENERAL} email from user ${wildduckUserId} to ${sendEmailDto.to.map((t) => t.address).join(", ")}`);
const user = await this.userRepository.findOne({ wildduckUserId, deletedAt: null }, { populate: ["business"] });
if (!user) throw new BadRequestException(EmailMessage.USER_NOT_FOUND);
this.logger.log(`Processing email through business template for business: ${user.business.id}`);
@@ -32,17 +32,67 @@ export enum BackgroundSize {
ROUND = "round",
}
export enum BackgroundPosition {
CENTER = "center center",
TOP_LEFT = "top left",
TOP_CENTER = "top center",
TOP_RIGHT = "top right",
CENTER_LEFT = "center left",
CENTER_RIGHT = "center right",
BOTTOM_LEFT = "bottom left",
BOTTOM_CENTER = "bottom center",
BOTTOM_RIGHT = "bottom right",
}
export enum SectionName {
HEADER = "header",
CONTENT = "content",
FOOTER = "footer",
}
export type PersonalityDataType = {
header: SectionType;
content: SectionType;
footer: SectionType;
};
// New enums for element selection
export enum ElementType {
TEXT = "text",
BUTTON = "button",
IMAGE = "image",
SOCIAL = "social",
}
// Social network types
export enum SocialNetworkType {
FACEBOOK = "facebook",
TWITTER = "twitter",
INSTAGRAM = "instagram",
LINKEDIN = "linkedin",
YOUTUBE = "youtube",
TELEGRAM = "telegram",
WHATSAPP = "whatsapp",
TIKTOK = "tiktok",
PINTEREST = "pinterest",
SNAPCHAT = "snapchat",
GITHUB = "github",
DISCORD = "discord",
}
export enum SocialIconSize {
SMALL = "small",
MEDIUM = "medium",
LARGE = "large",
}
export enum SocialIconStyle {
CIRCLE = "circle",
SQUARE = "square",
ROUNDED = "rounded",
}
// New type for selected element
export type SelectedElement = {
type: ElementType;
elementId: string;
itemId: string;
sectionKey: keyof PersonalityDataType;
} | null;
export type SectionType = {
columnsCount: number;
@@ -56,6 +106,7 @@ export type SectionItemType = {
texts: TextType[];
buttons: ButtonType[];
images: ImageType[];
socials: SocialType[];
alignment?: HorizontalAlignment;
verticalAlignment?: VerticalAlignment;
style?: {
@@ -103,14 +154,33 @@ export type ImageType = {
verticalAlignment?: VerticalAlignment;
};
export type SocialType = {
id: string;
networkType: SocialNetworkType;
link: string;
size: SocialIconSize;
style: SocialIconStyle;
color?: string;
alignment?: HorizontalAlignment;
verticalAlignment?: VerticalAlignment;
};
export type TemplateType = {
name: string;
rawHtml: string;
structure: PersonalityDataType;
thumbnailUrl: string;
};
//main type for the template structure
export type PersonalityDataType = {
header: SectionType;
content: SectionType;
footer: SectionType;
};
export interface EmailContent {
text?: string;
html?: string;
subject?: string;
}
export interface ProcessedEmailResult {
content: string;
isHtml: boolean;
hasTemplate: boolean;
}
@@ -1,52 +1,36 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import { TemplatesService } from "./templates.service";
import { UserMessage } from "../../../common/enums/message.enum";
import { UserRepository } from "../../users/repositories/user.repository";
import { EmailContent, ProcessedEmailResult } from "../interfaces/structure.interface";
import { EmailContent } from "../interfaces/structure.interface";
@Injectable()
export class TemplateProcessorService {
private readonly logger = new Logger(TemplateProcessorService.name);
constructor(
private readonly templatesService: TemplatesService,
private readonly userRepository: UserRepository,
) {}
//**************************************************** */
async processEmailContent(businessId: string, userId: string, emailContent: EmailContent): Promise<ProcessedEmailResult> {
try {
// Get selected template for the business
const selectedTemplate = await this.getSelectedTemplate(businessId, userId);
async processEmailContent(businessId: string, userId: string, emailContent: EmailContent) {
const selectedTemplate = await this.getSelectedTemplate(businessId, userId);
if (!selectedTemplate) {
// No template - return original text content
return {
content: emailContent.text || emailContent.html || "",
isHtml: false,
hasTemplate: false,
};
}
// Process with template
const processedContent = this.injectContentIntoTemplate(selectedTemplate.rawHtml, emailContent);
return {
content: processedContent,
isHtml: true,
hasTemplate: true,
};
} catch (error) {
this.logger.error(`Failed to process email for business ${businessId}:`, error);
// Fallback to original content
if (!selectedTemplate) {
return {
content: emailContent.text || emailContent.html || "",
isHtml: false,
hasTemplate: false,
};
}
const processedContent = await this.injectContentIntoTemplate(selectedTemplate.rawHtml, emailContent);
return {
content: processedContent,
isHtml: true,
hasTemplate: true,
};
}
//**************************************************** */
@@ -54,14 +38,13 @@ export class TemplateProcessorService {
const user = await this.userRepository.findOne({ id: userId, business: { id: businessId }, deletedAt: null }, { populate: ["template"] });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
if (user.template) return user.template;
if (user.template && user.template.rawHtml) return user.template;
const templates = await this.templatesService.getTemplatesByBusinessId(businessId);
return templates.find((template) => template.selected) || null;
return this.templatesService.getSelectedTemplateByBusinessId(businessId);
}
//**************************************************** */
private injectContentIntoTemplate(templateHtml: string | undefined, emailContent: EmailContent): string {
private async injectContentIntoTemplate(templateHtml: string | undefined, emailContent: EmailContent) {
if (!templateHtml) {
return emailContent.text || emailContent.html || "";
}
@@ -102,6 +102,11 @@ export class TemplatesService {
return this.templateRepository.find({ business: { id: businessId }, deletedAt: null }, { populate: ["business"] });
}
//**************************************************** */
async getSelectedTemplateByBusinessId(businessId: string) {
return this.templateRepository.findOne({ business: { id: businessId }, deletedAt: null, selected: true }, { populate: ["business"] });
}
//**************************************************** */
async setSelectedTemplate(id: string, businessId: string) {
const template = await this.templateRepository.findTemplateByIdWithoutRawHtmlAndStructure(id, businessId);
@@ -67,6 +67,8 @@ export class User extends BaseEntity {
@OneToMany(() => RefreshToken, (token) => token.user)
refreshTokens = new Collection<RefreshToken>(this);
//=========================
@ManyToOne(() => Role, { deleteRule: "restrict" })
role!: Role;