mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 07:26:42 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
|
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { AccountService } from './account.service';
|
||||
import { CreateImapAccountDto, UpdateAccountDto, AccountQueryDto } from './dto/account.dto';
|
||||
|
||||
@Controller('accounts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: AccountQueryDto) {
|
||||
const accounts = await this.accountService.findByUserId(user.userId, query);
|
||||
const total = await this.accountService.count(user.userId);
|
||||
|
||||
// Remove sensitive fields from response
|
||||
const safeAccounts = accounts.map((account) => ({
|
||||
...account,
|
||||
encryptedPassword: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
}));
|
||||
|
||||
return { accounts: safeAccounts, total };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const account = await this.accountService.findById(id, user.userId);
|
||||
if (!account) {
|
||||
return { account: null };
|
||||
}
|
||||
|
||||
// Remove sensitive fields
|
||||
const safeAccount = {
|
||||
...account,
|
||||
encryptedPassword: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
};
|
||||
|
||||
return { account: safeAccount };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateImapAccountDto) {
|
||||
const account = await this.accountService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
provider: 'imap',
|
||||
});
|
||||
|
||||
// Remove sensitive fields
|
||||
const safeAccount = {
|
||||
...account,
|
||||
encryptedPassword: undefined,
|
||||
};
|
||||
|
||||
return { account: safeAccount };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateAccountDto
|
||||
) {
|
||||
const account = await this.accountService.update(id, user.userId, dto);
|
||||
|
||||
// Remove sensitive fields
|
||||
const safeAccount = {
|
||||
...account,
|
||||
encryptedPassword: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
};
|
||||
|
||||
return { account: safeAccount };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.accountService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/default')
|
||||
async setDefault(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const account = await this.accountService.setDefault(id, user.userId);
|
||||
|
||||
// Remove sensitive fields
|
||||
const safeAccount = {
|
||||
...account,
|
||||
encryptedPassword: undefined,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
};
|
||||
|
||||
return { account: safeAccount };
|
||||
}
|
||||
|
||||
@Post(':id/sync')
|
||||
async triggerSync(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
// TODO: Trigger sync via SyncService
|
||||
// For now, just return success
|
||||
return { success: true, message: 'Sync triggered' };
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
async testConnection(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
// TODO: Test IMAP/SMTP connection
|
||||
// For now, just return success
|
||||
return { success: true, message: 'Connection test not yet implemented' };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountController],
|
||||
providers: [AccountService],
|
||||
exports: [AccountService],
|
||||
})
|
||||
export class AccountModule {}
|
||||
179
apps-archived/mail/apps/backend/src/account/account.service.ts
Normal file
179
apps-archived/mail/apps/backend/src/account/account.service.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { emailAccounts, type EmailAccount, type NewEmailAccount } from '../db/schema';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface AccountFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {
|
||||
// Get encryption key from environment or use a default for development
|
||||
const key = process.env.ENCRYPTION_KEY || 'dev-encryption-key-32-bytes-long';
|
||||
this.encryptionKey = crypto.scryptSync(key, 'salt', 32);
|
||||
}
|
||||
|
||||
// Encrypt password for storage
|
||||
private encryptPassword(password: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv);
|
||||
let encrypted = cipher.update(password, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
// Decrypt password for use
|
||||
private decryptPassword(encryptedPassword: string): string {
|
||||
const [ivHex, encrypted] = encryptedPassword.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string, filters: AccountFilters = {}): Promise<EmailAccount[]> {
|
||||
const { limit = 50, offset = 0 } = filters;
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.userId, userId))
|
||||
.orderBy(desc(emailAccounts.isDefault), desc(emailAccounts.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<EmailAccount | null> {
|
||||
const [account] = await this.db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)));
|
||||
return account || null;
|
||||
}
|
||||
|
||||
async create(data: NewEmailAccount & { password?: string }): Promise<EmailAccount> {
|
||||
const { password, ...accountData } = data;
|
||||
|
||||
// Encrypt password if provided
|
||||
let encryptedPassword: string | undefined;
|
||||
if (password) {
|
||||
encryptedPassword = this.encryptPassword(password);
|
||||
}
|
||||
|
||||
// If this is set as default, unset other defaults first
|
||||
if (accountData.isDefault) {
|
||||
await this.db
|
||||
.update(emailAccounts)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(emailAccounts.userId, accountData.userId));
|
||||
}
|
||||
|
||||
const [account] = await this.db
|
||||
.insert(emailAccounts)
|
||||
.values({
|
||||
...accountData,
|
||||
encryptedPassword,
|
||||
})
|
||||
.returning();
|
||||
return account;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewEmailAccount>): Promise<EmailAccount> {
|
||||
// If setting as default, unset other defaults first
|
||||
if (data.isDefault) {
|
||||
await this.db
|
||||
.update(emailAccounts)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(emailAccounts.userId, userId));
|
||||
}
|
||||
|
||||
const [account] = await this.db
|
||||
.update(emailAccounts)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!account) {
|
||||
throw new NotFoundException('Email account not found');
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const account = await this.findById(id, userId);
|
||||
if (!account) {
|
||||
throw new NotFoundException('Email account not found');
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(emailAccounts)
|
||||
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)));
|
||||
}
|
||||
|
||||
async setDefault(id: string, userId: string): Promise<EmailAccount> {
|
||||
// Unset all defaults first
|
||||
await this.db
|
||||
.update(emailAccounts)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(emailAccounts.userId, userId));
|
||||
|
||||
// Set this one as default
|
||||
return this.update(id, userId, { isDefault: true });
|
||||
}
|
||||
|
||||
async count(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.userId, userId));
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
|
||||
// Get decrypted password for IMAP/SMTP connection
|
||||
async getDecryptedPassword(id: string, userId: string): Promise<string | null> {
|
||||
const account = await this.findById(id, userId);
|
||||
if (!account || !account.encryptedPassword) {
|
||||
return null;
|
||||
}
|
||||
return this.decryptPassword(account.encryptedPassword);
|
||||
}
|
||||
|
||||
// Update OAuth tokens
|
||||
async updateTokens(
|
||||
id: string,
|
||||
userId: string,
|
||||
tokens: { accessToken: string; refreshToken?: string; expiresAt?: Date }
|
||||
): Promise<EmailAccount> {
|
||||
return this.update(id, userId, {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Update sync state
|
||||
async updateSyncState(id: string, userId: string, syncState: object): Promise<EmailAccount> {
|
||||
return this.update(id, userId, {
|
||||
syncState,
|
||||
lastSyncAt: new Date(),
|
||||
lastSyncError: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Record sync error
|
||||
async recordSyncError(id: string, userId: string, error: string): Promise<EmailAccount> {
|
||||
return this.update(id, userId, {
|
||||
lastSyncError: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
107
apps-archived/mail/apps/backend/src/account/dto/account.dto.ts
Normal file
107
apps-archived/mail/apps/backend/src/account/dto/account.dto.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEmail,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsIn,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class CreateImapAccountDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
email: string;
|
||||
|
||||
// IMAP settings
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
imapHost: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
imapPort: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['ssl', 'tls', 'none'])
|
||||
imapSecurity: string;
|
||||
|
||||
// SMTP settings
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
smtpHost: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(65535)
|
||||
smtpPort: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['ssl', 'tls', 'none'])
|
||||
smtpSecurity: string;
|
||||
|
||||
// Credentials
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
syncEnabled?: boolean;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(1)
|
||||
@Max(60)
|
||||
syncInterval?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export class AccountQueryDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
offset?: number;
|
||||
}
|
||||
30
apps-archived/mail/apps/backend/src/ai/ai.controller.ts
Normal file
30
apps-archived/mail/apps/backend/src/ai/ai.controller.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Controller, Post, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { AIService } from './ai.service';
|
||||
|
||||
@Controller('emails')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AIController {
|
||||
constructor(private readonly aiService: AIService) {}
|
||||
|
||||
@Post(':id/summarize')
|
||||
async summarize(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const result = await this.aiService.summarizeEmail(id, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/suggest-replies')
|
||||
async suggestReplies(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const result = await this.aiService.suggestReplies(id, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/categorize')
|
||||
async categorize(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const result = await this.aiService.categorizeEmail(id, user.userId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
10
apps-archived/mail/apps/backend/src/ai/ai.module.ts
Normal file
10
apps-archived/mail/apps/backend/src/ai/ai.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AIController } from './ai.controller';
|
||||
import { AIService } from './ai.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AIController],
|
||||
providers: [AIService],
|
||||
exports: [AIService],
|
||||
})
|
||||
export class AIModule {}
|
||||
270
apps-archived/mail/apps/backend/src/ai/ai.service.ts
Normal file
270
apps-archived/mail/apps/backend/src/ai/ai.service.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { emails, type Email } from '../db/schema';
|
||||
|
||||
export interface SummaryResult {
|
||||
summary: string;
|
||||
keyPoints?: string[];
|
||||
}
|
||||
|
||||
export interface SmartReplyResult {
|
||||
replies: {
|
||||
text: string;
|
||||
tone: 'positive' | 'neutral' | 'declining';
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CategoryResult {
|
||||
category: 'work' | 'personal' | 'newsletter' | 'transactional' | 'promotional' | 'social';
|
||||
confidence: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AIService {
|
||||
private readonly logger = new Logger(AIService.name);
|
||||
private readonly geminiClient: GoogleGenerativeAI | null = null;
|
||||
private readonly modelName = 'gemini-1.5-flash';
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(DATABASE_CONNECTION) private db: Database
|
||||
) {
|
||||
const apiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
|
||||
if (apiKey) {
|
||||
this.geminiClient = new GoogleGenerativeAI(apiKey);
|
||||
this.logger.log('Google Gemini client initialized for AI features');
|
||||
} else {
|
||||
this.logger.warn('GOOGLE_GENAI_API_KEY is not set - AI features unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
async summarizeEmail(emailId: string, userId: string): Promise<SummaryResult> {
|
||||
const email = await this.getEmail(emailId, userId);
|
||||
|
||||
if (!this.geminiClient) {
|
||||
throw new Error('AI service not configured');
|
||||
}
|
||||
|
||||
const content = email.bodyPlain || email.bodyHtml || email.snippet || '';
|
||||
if (!content) {
|
||||
return { summary: 'Email has no content to summarize.' };
|
||||
}
|
||||
|
||||
const model = this.geminiClient.getGenerativeModel({ model: this.modelName });
|
||||
|
||||
const prompt = `Summarize this email in 1-2 sentences. Focus on the main purpose and any action items.
|
||||
|
||||
Subject: ${email.subject || '(No Subject)'}
|
||||
From: ${email.fromName || email.fromAddress}
|
||||
|
||||
Content:
|
||||
${content.substring(0, 5000)}
|
||||
|
||||
Respond with a JSON object:
|
||||
{
|
||||
"summary": "Brief summary here",
|
||||
"keyPoints": ["Key point 1", "Key point 2"]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
|
||||
// Parse JSON response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Update email with summary
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
aiSummary: parsed.summary,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, emailId));
|
||||
|
||||
return {
|
||||
summary: parsed.summary,
|
||||
keyPoints: parsed.keyPoints,
|
||||
};
|
||||
}
|
||||
|
||||
return { summary: response.trim() };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to summarize email:', error);
|
||||
throw new Error('Failed to generate summary');
|
||||
}
|
||||
}
|
||||
|
||||
async suggestReplies(emailId: string, userId: string): Promise<SmartReplyResult> {
|
||||
const email = await this.getEmail(emailId, userId);
|
||||
|
||||
if (!this.geminiClient) {
|
||||
throw new Error('AI service not configured');
|
||||
}
|
||||
|
||||
const content = email.bodyPlain || email.bodyHtml || email.snippet || '';
|
||||
if (!content) {
|
||||
return { replies: [] };
|
||||
}
|
||||
|
||||
const model = this.geminiClient.getGenerativeModel({ model: this.modelName });
|
||||
|
||||
const prompt = `Generate 3 short reply suggestions for this email. Make them varied in tone: one positive/accepting, one neutral/informative, and one politely declining.
|
||||
|
||||
Subject: ${email.subject || '(No Subject)'}
|
||||
From: ${email.fromName || email.fromAddress}
|
||||
|
||||
Content:
|
||||
${content.substring(0, 3000)}
|
||||
|
||||
Respond with a JSON object:
|
||||
{
|
||||
"replies": [
|
||||
{ "text": "Reply text here", "tone": "positive" },
|
||||
{ "text": "Reply text here", "tone": "neutral" },
|
||||
{ "text": "Reply text here", "tone": "declining" }
|
||||
]
|
||||
}
|
||||
|
||||
Keep replies brief (1-3 sentences each).`;
|
||||
|
||||
try {
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
|
||||
// Parse JSON response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Update email with suggested replies
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
aiSuggestedReplies: parsed.replies,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, emailId));
|
||||
|
||||
return {
|
||||
replies: parsed.replies,
|
||||
};
|
||||
}
|
||||
|
||||
return { replies: [] };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate reply suggestions:', error);
|
||||
throw new Error('Failed to generate reply suggestions');
|
||||
}
|
||||
}
|
||||
|
||||
async categorizeEmail(emailId: string, userId: string): Promise<CategoryResult> {
|
||||
const email = await this.getEmail(emailId, userId);
|
||||
|
||||
if (!this.geminiClient) {
|
||||
throw new Error('AI service not configured');
|
||||
}
|
||||
|
||||
const content = email.bodyPlain || email.bodyHtml || email.snippet || '';
|
||||
|
||||
const model = this.geminiClient.getGenerativeModel({ model: this.modelName });
|
||||
|
||||
const prompt = `Categorize this email and determine its priority.
|
||||
|
||||
Subject: ${email.subject || '(No Subject)'}
|
||||
From: ${email.fromName || ''} <${email.fromAddress}>
|
||||
Snippet: ${email.snippet || content.substring(0, 500)}
|
||||
|
||||
Categories:
|
||||
- work: Work-related emails (meetings, projects, colleagues)
|
||||
- personal: Personal communications (friends, family)
|
||||
- newsletter: Newsletters, subscriptions, updates
|
||||
- transactional: Receipts, confirmations, shipping, billing
|
||||
- promotional: Marketing, sales, offers
|
||||
- social: Social network notifications
|
||||
|
||||
Priority:
|
||||
- high: Urgent, requires immediate attention
|
||||
- medium: Important but not urgent
|
||||
- low: Informational, can wait
|
||||
|
||||
Respond with a JSON object:
|
||||
{
|
||||
"category": "work",
|
||||
"confidence": 0.95,
|
||||
"priority": "high"
|
||||
}`;
|
||||
|
||||
try {
|
||||
const result = await model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
|
||||
// Parse JSON response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Update email with category
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
aiCategory: parsed.category,
|
||||
aiPriority: parsed.priority,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, emailId));
|
||||
|
||||
return {
|
||||
category: parsed.category,
|
||||
confidence: parsed.confidence,
|
||||
priority: parsed.priority,
|
||||
};
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return {
|
||||
category: 'personal',
|
||||
confidence: 0.5,
|
||||
priority: 'medium',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to categorize email:', error);
|
||||
throw new Error('Failed to categorize email');
|
||||
}
|
||||
}
|
||||
|
||||
async autoCategorizeNewEmails(userId: string, emailIds: string[]): Promise<void> {
|
||||
if (!this.geminiClient) {
|
||||
this.logger.warn('AI service not configured, skipping auto-categorization');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const emailId of emailIds) {
|
||||
try {
|
||||
await this.categorizeEmail(emailId, userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to auto-categorize email ${emailId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getEmail(emailId: string, userId: string): Promise<Email> {
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
}
|
||||
36
apps-archived/mail/apps/backend/src/app.module.ts
Normal file
36
apps-archived/mail/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { OAuthModule } from './oauth/oauth.module';
|
||||
import { FolderModule } from './folder/folder.module';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { ComposeModule } from './compose/compose.module';
|
||||
import { AttachmentModule } from './attachment/attachment.module';
|
||||
import { LabelModule } from './label/label.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
import { AIModule } from './ai/ai.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
AccountModule,
|
||||
OAuthModule,
|
||||
FolderModule,
|
||||
EmailModule,
|
||||
ComposeModule,
|
||||
AttachmentModule,
|
||||
LabelModule,
|
||||
SyncModule,
|
||||
AIModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { AttachmentQueryDto, CreateAttachmentDto, UploadUrlDto } from './dto/attachment.dto';
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AttachmentController {
|
||||
constructor(private readonly attachmentService: AttachmentService) {}
|
||||
|
||||
@Get('emails/:emailId/attachments')
|
||||
async findByEmail(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string
|
||||
) {
|
||||
const attachments = await this.attachmentService.findByEmailId(emailId, user.userId);
|
||||
return { attachments };
|
||||
}
|
||||
|
||||
@Get('attachments/:id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const attachment = await this.attachmentService.findById(id, user.userId);
|
||||
if (!attachment) {
|
||||
return { attachment: null };
|
||||
}
|
||||
return { attachment };
|
||||
}
|
||||
|
||||
@Post('attachments')
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAttachmentDto) {
|
||||
const attachment = await this.attachmentService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { attachment };
|
||||
}
|
||||
|
||||
@Delete('attachments/:id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.attachmentService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Get presigned URL for client-side upload
|
||||
@Post('attachments/upload-url')
|
||||
async getUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: UploadUrlDto) {
|
||||
const result = await this.attachmentService.getUploadUrl(user.userId, dto);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get presigned URL for downloading
|
||||
@Get('attachments/:id/download')
|
||||
async getDownloadUrl(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const result = await this.attachmentService.getDownloadUrl(id, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Mark attachment as uploaded (called after client uploads to presigned URL)
|
||||
@Post('attachments/:id/complete')
|
||||
async markUploaded(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { storageKey: string }
|
||||
) {
|
||||
const attachment = await this.attachmentService.markUploaded(id, user.userId, body.storageKey);
|
||||
return { attachment };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AttachmentController } from './attachment.controller';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AttachmentController],
|
||||
providers: [AttachmentService],
|
||||
exports: [AttachmentService],
|
||||
})
|
||||
export class AttachmentModule {}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { attachments, type Attachment, type NewAttachment } from '../db/schema';
|
||||
import { createMailStorage, generateUserFileKey, getContentType } from '@manacore/shared-storage';
|
||||
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
export interface AttachmentFilters {
|
||||
emailId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
private storage = createMailStorage();
|
||||
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByEmailId(emailId: string, userId: string): Promise<Attachment[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(and(eq(attachments.emailId, emailId), eq(attachments.userId, userId)))
|
||||
.orderBy(desc(attachments.createdAt));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Attachment | null> {
|
||||
const [attachment] = await this.db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(and(eq(attachments.id, id), eq(attachments.userId, userId)));
|
||||
return attachment || null;
|
||||
}
|
||||
|
||||
async create(data: NewAttachment): Promise<Attachment> {
|
||||
const [attachment] = await this.db.insert(attachments).values(data).returning();
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const attachment = await this.findById(id, userId);
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
// Delete from storage if uploaded
|
||||
if (attachment.storageKey) {
|
||||
try {
|
||||
await this.storage.delete(attachment.storageKey);
|
||||
} catch (error) {
|
||||
// Log but don't fail if storage deletion fails
|
||||
console.error('Failed to delete attachment from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(attachments)
|
||||
.where(and(eq(attachments.id, id), eq(attachments.userId, userId)));
|
||||
}
|
||||
|
||||
async deleteByEmailId(emailId: string, userId: string): Promise<void> {
|
||||
const emailAttachments = await this.findByEmailId(emailId, userId);
|
||||
|
||||
// Delete all from storage
|
||||
for (const attachment of emailAttachments) {
|
||||
if (attachment.storageKey) {
|
||||
try {
|
||||
await this.storage.delete(attachment.storageKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete attachment from storage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(attachments)
|
||||
.where(and(eq(attachments.emailId, emailId), eq(attachments.userId, userId)));
|
||||
}
|
||||
|
||||
// Generate a presigned URL for uploading
|
||||
async getUploadUrl(
|
||||
userId: string,
|
||||
data: { filename: string; mimeType: string; size: number }
|
||||
): Promise<{ uploadUrl: string; key: string }> {
|
||||
if (data.size > MAX_FILE_SIZE) {
|
||||
throw new BadRequestException(
|
||||
`File size exceeds maximum of ${MAX_FILE_SIZE / 1024 / 1024}MB`
|
||||
);
|
||||
}
|
||||
|
||||
const key = generateUserFileKey(userId, data.filename, 'attachments');
|
||||
const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn: 3600 });
|
||||
|
||||
return { uploadUrl, key };
|
||||
}
|
||||
|
||||
// Generate a presigned URL for downloading
|
||||
async getDownloadUrl(
|
||||
id: string,
|
||||
userId: string
|
||||
): Promise<{ downloadUrl: string; filename: string }> {
|
||||
const attachment = await this.findById(id, userId);
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
if (!attachment.storageKey) {
|
||||
throw new BadRequestException('Attachment not yet uploaded');
|
||||
}
|
||||
|
||||
const downloadUrl = await this.storage.getDownloadUrl(attachment.storageKey, {
|
||||
expiresIn: 3600,
|
||||
});
|
||||
|
||||
return { downloadUrl, filename: attachment.filename };
|
||||
}
|
||||
|
||||
// Mark attachment as uploaded with storage key
|
||||
async markUploaded(id: string, userId: string, storageKey: string): Promise<Attachment> {
|
||||
const attachment = await this.findById(id, userId);
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(attachments)
|
||||
.set({
|
||||
storageKey,
|
||||
isDownloaded: true,
|
||||
})
|
||||
.where(and(eq(attachments.id, id), eq(attachments.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Upload directly (for server-side operations like sync)
|
||||
async uploadDirect(
|
||||
userId: string,
|
||||
emailId: string,
|
||||
data: { filename: string; mimeType: string; content: Buffer }
|
||||
): Promise<Attachment> {
|
||||
if (data.content.length > MAX_FILE_SIZE) {
|
||||
throw new BadRequestException(
|
||||
`File size exceeds maximum of ${MAX_FILE_SIZE / 1024 / 1024}MB`
|
||||
);
|
||||
}
|
||||
|
||||
const key = generateUserFileKey(userId, data.filename, 'attachments');
|
||||
|
||||
// Upload to storage
|
||||
await this.storage.upload(key, data.content, {
|
||||
contentType: data.mimeType,
|
||||
});
|
||||
|
||||
// Create attachment record
|
||||
const attachment = await this.create({
|
||||
emailId,
|
||||
userId,
|
||||
filename: data.filename,
|
||||
mimeType: data.mimeType,
|
||||
size: data.content.length,
|
||||
storageKey: key,
|
||||
isDownloaded: true,
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
// Download content directly (for server-side operations)
|
||||
async downloadDirect(
|
||||
id: string,
|
||||
userId: string
|
||||
): Promise<{ content: Buffer; filename: string; mimeType: string }> {
|
||||
const attachment = await this.findById(id, userId);
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
}
|
||||
|
||||
if (!attachment.storageKey) {
|
||||
throw new BadRequestException('Attachment not available');
|
||||
}
|
||||
|
||||
const content = await this.storage.download(attachment.storageKey);
|
||||
|
||||
return {
|
||||
content,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { IsString, IsOptional, IsUUID, IsNumber, IsIn } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class AttachmentQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
emailId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class CreateAttachmentDto {
|
||||
@IsUUID()
|
||||
emailId: string;
|
||||
|
||||
@IsString()
|
||||
filename: string;
|
||||
|
||||
@IsString()
|
||||
mimeType: string;
|
||||
|
||||
@IsNumber()
|
||||
size: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
contentId?: string;
|
||||
}
|
||||
|
||||
export class UploadUrlDto {
|
||||
@IsString()
|
||||
filename: string;
|
||||
|
||||
@IsString()
|
||||
mimeType: string;
|
||||
|
||||
@IsNumber()
|
||||
size: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ComposeService } from './compose.service';
|
||||
import { CreateDraftDto, UpdateDraftDto, SendEmailDto, DraftQueryDto } from './dto/compose.dto';
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ComposeController {
|
||||
constructor(private readonly composeService: ComposeService) {}
|
||||
|
||||
// ==================== Drafts ====================
|
||||
|
||||
@Get('drafts')
|
||||
async findAllDrafts(@CurrentUser() user: CurrentUserData, @Query() query: DraftQueryDto) {
|
||||
const drafts = await this.composeService.findDraftsByUserId(user.userId, query);
|
||||
const total = await this.composeService.countDrafts(user.userId, query.accountId);
|
||||
return { drafts, total };
|
||||
}
|
||||
|
||||
@Get('drafts/:id')
|
||||
async findDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const draft = await this.composeService.findDraftById(id, user.userId);
|
||||
if (!draft) {
|
||||
return { draft: null };
|
||||
}
|
||||
return { draft };
|
||||
}
|
||||
|
||||
@Post('drafts')
|
||||
async createDraft(@CurrentUser() user: CurrentUserData, @Body() dto: CreateDraftDto) {
|
||||
const draft = await this.composeService.createDraft({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
|
||||
});
|
||||
return { draft };
|
||||
}
|
||||
|
||||
@Patch('drafts/:id')
|
||||
async updateDraft(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateDraftDto
|
||||
) {
|
||||
const draft = await this.composeService.updateDraft(id, user.userId, {
|
||||
...dto,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||
});
|
||||
return { draft };
|
||||
}
|
||||
|
||||
@Delete('drafts/:id')
|
||||
async deleteDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.composeService.deleteDraft(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('drafts/:id/send')
|
||||
async sendDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const result = await this.composeService.sendDraft(id, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Direct Send ====================
|
||||
|
||||
@Post('send')
|
||||
async sendEmail(@CurrentUser() user: CurrentUserData, @Body() dto: SendEmailDto) {
|
||||
const result = await this.composeService.sendEmail(user.userId, dto);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Reply/Forward ====================
|
||||
|
||||
@Post('emails/:id/reply')
|
||||
async createReply(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const draft = await this.composeService.createReplyDraft(user.userId, id, 'reply');
|
||||
return { draft };
|
||||
}
|
||||
|
||||
@Post('emails/:id/reply-all')
|
||||
async createReplyAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const draft = await this.composeService.createReplyDraft(user.userId, id, 'reply-all');
|
||||
return { draft };
|
||||
}
|
||||
|
||||
@Post('emails/:id/forward')
|
||||
async createForward(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const draft = await this.composeService.createReplyDraft(user.userId, id, 'forward');
|
||||
return { draft };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ComposeController } from './compose.controller';
|
||||
import { ComposeService } from './compose.service';
|
||||
import { AccountModule } from '../account/account.module';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule, EmailModule],
|
||||
controllers: [ComposeController],
|
||||
providers: [ComposeService],
|
||||
exports: [ComposeService],
|
||||
})
|
||||
export class ComposeModule {}
|
||||
363
apps-archived/mail/apps/backend/src/compose/compose.service.ts
Normal file
363
apps-archived/mail/apps/backend/src/compose/compose.service.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { drafts, type Draft, type NewDraft, emailAccounts, type EmailAddress } from '../db/schema';
|
||||
import { AccountService } from '../account/account.service';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
export interface DraftFilters {
|
||||
accountId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComposeService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private accountService: AccountService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
// ==================== Draft Management ====================
|
||||
|
||||
async findDraftsByUserId(userId: string, filters: DraftFilters = {}): Promise<Draft[]> {
|
||||
const { accountId, limit = 50, offset = 0 } = filters;
|
||||
|
||||
let conditions = [eq(drafts.userId, userId)];
|
||||
|
||||
if (accountId) {
|
||||
conditions.push(eq(drafts.accountId, accountId));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(drafts)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(drafts.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async findDraftById(id: string, userId: string): Promise<Draft | null> {
|
||||
const [draft] = await this.db
|
||||
.select()
|
||||
.from(drafts)
|
||||
.where(and(eq(drafts.id, id), eq(drafts.userId, userId)));
|
||||
return draft || null;
|
||||
}
|
||||
|
||||
async createDraft(data: NewDraft): Promise<Draft> {
|
||||
const [draft] = await this.db.insert(drafts).values(data).returning();
|
||||
return draft;
|
||||
}
|
||||
|
||||
async updateDraft(id: string, userId: string, data: Partial<NewDraft>): Promise<Draft> {
|
||||
const [draft] = await this.db
|
||||
.update(drafts)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(drafts.id, id), eq(drafts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!draft) {
|
||||
throw new NotFoundException('Draft not found');
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
async deleteDraft(id: string, userId: string): Promise<void> {
|
||||
const draft = await this.findDraftById(id, userId);
|
||||
if (!draft) {
|
||||
throw new NotFoundException('Draft not found');
|
||||
}
|
||||
|
||||
await this.db.delete(drafts).where(and(eq(drafts.id, id), eq(drafts.userId, userId)));
|
||||
}
|
||||
|
||||
async countDrafts(userId: string, accountId?: string): Promise<number> {
|
||||
let conditions = [eq(drafts.userId, userId)];
|
||||
|
||||
if (accountId) {
|
||||
conditions.push(eq(drafts.accountId, accountId));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(drafts)
|
||||
.where(and(...conditions));
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
|
||||
// ==================== Send Email ====================
|
||||
|
||||
async sendEmail(
|
||||
userId: string,
|
||||
data: {
|
||||
accountId: string;
|
||||
subject?: string;
|
||||
toAddresses: EmailAddress[];
|
||||
ccAddresses?: EmailAddress[];
|
||||
bccAddresses?: EmailAddress[];
|
||||
bodyHtml?: string;
|
||||
bodyPlain?: string;
|
||||
replyToEmailId?: string;
|
||||
replyType?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; messageId?: string }> {
|
||||
// Get the account
|
||||
const account = await this.accountService.findById(data.accountId, userId);
|
||||
if (!account) {
|
||||
throw new NotFoundException('Email account not found');
|
||||
}
|
||||
|
||||
// Build the email
|
||||
const mailOptions: nodemailer.SendMailOptions = {
|
||||
from: {
|
||||
name: account.name,
|
||||
address: account.email,
|
||||
},
|
||||
to: data.toAddresses.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
|
||||
cc: data.ccAddresses?.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
|
||||
bcc: data.bccAddresses?.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
|
||||
subject: data.subject || '(No Subject)',
|
||||
html: data.bodyHtml,
|
||||
text: data.bodyPlain,
|
||||
};
|
||||
|
||||
// Add reply headers if replying
|
||||
if (data.replyToEmailId) {
|
||||
const originalEmail = await this.emailService.findById(data.replyToEmailId, userId);
|
||||
if (originalEmail) {
|
||||
mailOptions.inReplyTo = originalEmail.messageId;
|
||||
mailOptions.references = originalEmail.messageId;
|
||||
}
|
||||
}
|
||||
|
||||
// Send based on provider
|
||||
switch (account.provider) {
|
||||
case 'imap':
|
||||
return this.sendViaSMTP(account, mailOptions);
|
||||
case 'gmail':
|
||||
return this.sendViaGmail(account, mailOptions);
|
||||
case 'outlook':
|
||||
return this.sendViaOutlook(account, mailOptions);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown provider: ${account.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendDraft(
|
||||
draftId: string,
|
||||
userId: string
|
||||
): Promise<{ success: boolean; messageId?: string }> {
|
||||
const draft = await this.findDraftById(draftId, userId);
|
||||
if (!draft) {
|
||||
throw new NotFoundException('Draft not found');
|
||||
}
|
||||
|
||||
if (!draft.toAddresses || draft.toAddresses.length === 0) {
|
||||
throw new BadRequestException('Draft must have at least one recipient');
|
||||
}
|
||||
|
||||
const result = await this.sendEmail(userId, {
|
||||
accountId: draft.accountId,
|
||||
subject: draft.subject || undefined,
|
||||
toAddresses: draft.toAddresses,
|
||||
ccAddresses: draft.ccAddresses || undefined,
|
||||
bccAddresses: draft.bccAddresses || undefined,
|
||||
bodyHtml: draft.bodyHtml || undefined,
|
||||
bodyPlain: draft.bodyPlain || undefined,
|
||||
replyToEmailId: draft.replyToEmailId || undefined,
|
||||
replyType: draft.replyType || undefined,
|
||||
});
|
||||
|
||||
// Delete draft after successful send
|
||||
if (result.success) {
|
||||
await this.deleteDraft(draftId, userId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== Provider-specific send methods ====================
|
||||
|
||||
private async sendViaSMTP(
|
||||
account: typeof emailAccounts.$inferSelect,
|
||||
mailOptions: nodemailer.SendMailOptions
|
||||
): Promise<{ success: boolean; messageId?: string }> {
|
||||
if (!account.smtpHost || !account.smtpPort) {
|
||||
throw new BadRequestException('SMTP settings not configured for this account');
|
||||
}
|
||||
|
||||
// Get decrypted password
|
||||
const password = await this.accountService.getDecryptedPassword(account.id, account.userId);
|
||||
if (!password) {
|
||||
throw new BadRequestException('Account password not found');
|
||||
}
|
||||
|
||||
// Create transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: account.smtpSecurity === 'ssl',
|
||||
auth: {
|
||||
user: account.email,
|
||||
pass: password,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false, // Allow self-signed certs in dev
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to send email';
|
||||
throw new BadRequestException(`SMTP send failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaGmail(
|
||||
account: typeof emailAccounts.$inferSelect,
|
||||
mailOptions: nodemailer.SendMailOptions
|
||||
): Promise<{ success: boolean; messageId?: string }> {
|
||||
if (!account.accessToken) {
|
||||
throw new BadRequestException('Gmail access token not found');
|
||||
}
|
||||
|
||||
// Use OAuth2 with Gmail
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
type: 'OAuth2',
|
||||
user: account.email,
|
||||
accessToken: account.accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to send email';
|
||||
throw new BadRequestException(`Gmail send failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaOutlook(
|
||||
account: typeof emailAccounts.$inferSelect,
|
||||
mailOptions: nodemailer.SendMailOptions
|
||||
): Promise<{ success: boolean; messageId?: string }> {
|
||||
if (!account.accessToken) {
|
||||
throw new BadRequestException('Outlook access token not found');
|
||||
}
|
||||
|
||||
// Use Microsoft Graph API to send
|
||||
const { Client } = await import('@microsoft/microsoft-graph-client');
|
||||
|
||||
const client = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, account.accessToken!);
|
||||
},
|
||||
});
|
||||
|
||||
// Convert to Graph API format
|
||||
const message = {
|
||||
subject: mailOptions.subject,
|
||||
body: {
|
||||
contentType: mailOptions.html ? 'HTML' : 'Text',
|
||||
content: mailOptions.html || mailOptions.text || '',
|
||||
},
|
||||
toRecipients: (mailOptions.to as string[])?.map((email) => ({
|
||||
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
|
||||
})),
|
||||
ccRecipients: (mailOptions.cc as string[])?.map((email) => ({
|
||||
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
|
||||
})),
|
||||
bccRecipients: (mailOptions.bcc as string[])?.map((email) => ({
|
||||
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
await client.api('/me/sendMail').post({ message, saveToSentItems: true });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to send email';
|
||||
throw new BadRequestException(`Outlook send failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Reply/Forward Helpers ====================
|
||||
|
||||
async createReplyDraft(
|
||||
userId: string,
|
||||
emailId: string,
|
||||
replyType: 'reply' | 'reply-all' | 'forward'
|
||||
): Promise<Draft> {
|
||||
const originalEmail = await this.emailService.findById(emailId, userId);
|
||||
if (!originalEmail) {
|
||||
throw new NotFoundException('Original email not found');
|
||||
}
|
||||
|
||||
let toAddresses: EmailAddress[] = [];
|
||||
let ccAddresses: EmailAddress[] = [];
|
||||
let subject = originalEmail.subject || '';
|
||||
let bodyHtml = '';
|
||||
|
||||
switch (replyType) {
|
||||
case 'reply':
|
||||
toAddresses = [
|
||||
{ email: originalEmail.fromAddress || '', name: originalEmail.fromName || undefined },
|
||||
];
|
||||
subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
|
||||
break;
|
||||
|
||||
case 'reply-all':
|
||||
toAddresses = [
|
||||
{ email: originalEmail.fromAddress || '', name: originalEmail.fromName || undefined },
|
||||
];
|
||||
ccAddresses =
|
||||
originalEmail.toAddresses?.filter((a) => a.email !== originalEmail.fromAddress) || [];
|
||||
if (originalEmail.ccAddresses) {
|
||||
ccAddresses = [...ccAddresses, ...originalEmail.ccAddresses];
|
||||
}
|
||||
subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
|
||||
break;
|
||||
|
||||
case 'forward':
|
||||
subject = subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Build quoted content
|
||||
const date = originalEmail.sentAt?.toLocaleString() || 'Unknown date';
|
||||
const from = originalEmail.fromName
|
||||
? `${originalEmail.fromName} <${originalEmail.fromAddress}>`
|
||||
: originalEmail.fromAddress;
|
||||
|
||||
bodyHtml = `
|
||||
<br><br>
|
||||
<div style="border-left: 2px solid #ccc; padding-left: 10px; margin-left: 10px;">
|
||||
<p><strong>On ${date}, ${from} wrote:</strong></p>
|
||||
${originalEmail.bodyHtml || `<pre>${originalEmail.bodyPlain || ''}</pre>`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return this.createDraft({
|
||||
userId,
|
||||
accountId: originalEmail.accountId,
|
||||
replyToEmailId: emailId,
|
||||
replyType,
|
||||
subject,
|
||||
toAddresses,
|
||||
ccAddresses,
|
||||
bodyHtml,
|
||||
});
|
||||
}
|
||||
}
|
||||
161
apps-archived/mail/apps/backend/src/compose/dto/compose.dto.ts
Normal file
161
apps-archived/mail/apps/backend/src/compose/dto/compose.dto.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
ValidateNested,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
|
||||
export class EmailAddressDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class CreateDraftDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
subject?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
toAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
ccAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
bccAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bodyHtml?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bodyPlain?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
replyToEmailId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['reply', 'reply-all', 'forward'])
|
||||
replyType?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
scheduledAt?: string;
|
||||
}
|
||||
|
||||
export class UpdateDraftDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
subject?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
toAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
ccAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
bccAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bodyHtml?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bodyPlain?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
scheduledAt?: string;
|
||||
}
|
||||
|
||||
export class SendEmailDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
subject?: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
toAddresses: EmailAddressDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
ccAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddressDto)
|
||||
@IsOptional()
|
||||
bccAddresses?: EmailAddressDto[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bodyHtml?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
bodyPlain?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
replyToEmailId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['reply', 'reply-all', 'forward'])
|
||||
replyType?: string;
|
||||
}
|
||||
|
||||
export class DraftQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
accountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
offset?: number;
|
||||
}
|
||||
38
apps-archived/mail/apps/backend/src/db/connection.ts
Normal file
38
apps-archived/mail/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
30
apps-archived/mail/apps/backend/src/db/database.module.ts
Normal file
30
apps-archived/mail/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import type { OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
import type { Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { pgTable, uuid, timestamp, varchar, integer, boolean } from 'drizzle-orm/pg-core';
|
||||
import { emails } from './emails.schema';
|
||||
|
||||
export const attachments = pgTable('attachments', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
emailId: uuid('email_id')
|
||||
.references(() => emails.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
filename: varchar('filename', { length: 500 }).notNull(),
|
||||
mimeType: varchar('mime_type', { length: 255 }).notNull(),
|
||||
size: integer('size').notNull(),
|
||||
contentId: varchar('content_id', { length: 255 }), // For inline images
|
||||
|
||||
// Storage
|
||||
storageKey: varchar('storage_key', { length: 500 }),
|
||||
storageUrl: varchar('storage_url', { length: 1000 }),
|
||||
isDownloaded: boolean('is_downloaded').default(false),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Attachment = typeof attachments.$inferSelect;
|
||||
export type NewAttachment = typeof attachments.$inferInsert;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, jsonb } from 'drizzle-orm/pg-core';
|
||||
import { emailAccounts } from './email-accounts.schema';
|
||||
import { emails, type EmailAddress } from './emails.schema';
|
||||
|
||||
export const drafts = pgTable('drafts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
accountId: uuid('account_id')
|
||||
.references(() => emailAccounts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// Reply context
|
||||
replyToEmailId: uuid('reply_to_email_id').references(() => emails.id, { onDelete: 'set null' }),
|
||||
replyType: varchar('reply_type', { length: 20 }), // reply, reply-all, forward
|
||||
|
||||
// Content
|
||||
subject: text('subject'),
|
||||
toAddresses: jsonb('to_addresses').$type<EmailAddress[]>(),
|
||||
ccAddresses: jsonb('cc_addresses').$type<EmailAddress[]>(),
|
||||
bccAddresses: jsonb('bcc_addresses').$type<EmailAddress[]>(),
|
||||
bodyHtml: text('body_html'),
|
||||
bodyPlain: text('body_plain'),
|
||||
attachmentIds: jsonb('attachment_ids').$type<string[]>(),
|
||||
|
||||
// Scheduling
|
||||
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Draft = typeof drafts.$inferSelect;
|
||||
export type NewDraft = typeof drafts.$inferInsert;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export interface SyncState {
|
||||
// IMAP sync state
|
||||
uidValidity?: number;
|
||||
lastUid?: number;
|
||||
// Gmail sync state
|
||||
historyId?: string;
|
||||
// Outlook sync state
|
||||
deltaLink?: string;
|
||||
}
|
||||
|
||||
export const emailAccounts = pgTable('email_accounts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// Account info
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull(),
|
||||
provider: varchar('provider', { length: 50 }).notNull(), // gmail, outlook, imap
|
||||
isDefault: boolean('is_default').default(false),
|
||||
|
||||
// IMAP/SMTP credentials (encrypted)
|
||||
imapHost: varchar('imap_host', { length: 255 }),
|
||||
imapPort: integer('imap_port'),
|
||||
imapSecurity: varchar('imap_security', { length: 20 }), // ssl, tls, none
|
||||
smtpHost: varchar('smtp_host', { length: 255 }),
|
||||
smtpPort: integer('smtp_port'),
|
||||
smtpSecurity: varchar('smtp_security', { length: 20 }),
|
||||
encryptedPassword: text('encrypted_password'),
|
||||
|
||||
// OAuth tokens (Gmail/Outlook)
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
|
||||
tokenScopes: jsonb('token_scopes').$type<string[]>(),
|
||||
|
||||
// Sync settings
|
||||
syncEnabled: boolean('sync_enabled').default(true),
|
||||
syncInterval: integer('sync_interval').default(5), // minutes
|
||||
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
|
||||
lastSyncError: text('last_sync_error'),
|
||||
syncState: jsonb('sync_state').$type<SyncState>(),
|
||||
|
||||
// Display settings
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
signature: text('signature'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type EmailAccount = typeof emailAccounts.$inferSelect;
|
||||
export type NewEmailAccount = typeof emailAccounts.$inferInsert;
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { emailAccounts } from './email-accounts.schema';
|
||||
import { folders } from './folders.schema';
|
||||
|
||||
export interface EmailAddress {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const emails = pgTable(
|
||||
'emails',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
accountId: uuid('account_id')
|
||||
.references(() => emailAccounts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
folderId: uuid('folder_id').references(() => folders.id, { onDelete: 'set null' }),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
threadId: uuid('thread_id'), // For conversation threading
|
||||
|
||||
// Message identifiers
|
||||
messageId: varchar('message_id', { length: 500 }).notNull(), // RFC 2822 Message-ID
|
||||
externalId: varchar('external_id', { length: 255 }), // Provider-specific ID
|
||||
|
||||
// Headers
|
||||
subject: text('subject'),
|
||||
fromAddress: varchar('from_address', { length: 255 }),
|
||||
fromName: varchar('from_name', { length: 255 }),
|
||||
toAddresses: jsonb('to_addresses').$type<EmailAddress[]>(),
|
||||
ccAddresses: jsonb('cc_addresses').$type<EmailAddress[]>(),
|
||||
bccAddresses: jsonb('bcc_addresses').$type<EmailAddress[]>(),
|
||||
replyTo: varchar('reply_to', { length: 255 }),
|
||||
inReplyTo: varchar('in_reply_to', { length: 500 }), // Parent message ID
|
||||
references: jsonb('references').$type<string[]>(), // Thread references
|
||||
|
||||
// Content
|
||||
snippet: text('snippet'), // Preview text (first ~200 chars)
|
||||
bodyPlain: text('body_plain'),
|
||||
bodyHtml: text('body_html'),
|
||||
|
||||
// Dates
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||
receivedAt: timestamp('received_at', { withTimezone: true }),
|
||||
|
||||
// Flags
|
||||
isRead: boolean('is_read').default(false),
|
||||
isStarred: boolean('is_starred').default(false),
|
||||
isDraft: boolean('is_draft').default(false),
|
||||
isDeleted: boolean('is_deleted').default(false),
|
||||
isSpam: boolean('is_spam').default(false),
|
||||
hasAttachments: boolean('has_attachments').default(false),
|
||||
|
||||
// AI-generated metadata
|
||||
aiSummary: text('ai_summary'),
|
||||
aiCategory: varchar('ai_category', { length: 50 }), // work, personal, newsletter, etc.
|
||||
aiPriority: varchar('ai_priority', { length: 20 }), // high, medium, low
|
||||
aiSentiment: varchar('ai_sentiment', { length: 20 }), // positive, neutral, negative
|
||||
aiSuggestedReplies: jsonb('ai_suggested_replies').$type<string[]>(),
|
||||
|
||||
// Size and metadata
|
||||
size: integer('size'), // bytes
|
||||
headers: jsonb('headers').$type<Record<string, string>>(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('emails_account_id_idx').on(table.accountId),
|
||||
index('emails_folder_id_idx').on(table.folderId),
|
||||
index('emails_thread_id_idx').on(table.threadId),
|
||||
index('emails_message_id_idx').on(table.messageId),
|
||||
index('emails_received_at_idx').on(table.receivedAt),
|
||||
index('emails_user_id_idx').on(table.userId),
|
||||
]
|
||||
);
|
||||
|
||||
export type Email = typeof emails.$inferSelect;
|
||||
export type NewEmail = typeof emails.$inferInsert;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { pgTable, uuid, timestamp, varchar, integer, boolean } from 'drizzle-orm/pg-core';
|
||||
import { emailAccounts } from './email-accounts.schema';
|
||||
|
||||
export const folders = pgTable('folders', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
accountId: uuid('account_id')
|
||||
.references(() => emailAccounts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(), // inbox, sent, drafts, trash, spam, archive, custom
|
||||
path: varchar('path', { length: 500 }).notNull(), // IMAP folder path
|
||||
color: varchar('color', { length: 7 }),
|
||||
icon: varchar('icon', { length: 50 }),
|
||||
|
||||
// Provider-specific ID
|
||||
externalId: varchar('external_id', { length: 255 }),
|
||||
|
||||
// Counts (cached)
|
||||
totalCount: integer('total_count').default(0),
|
||||
unreadCount: integer('unread_count').default(0),
|
||||
|
||||
// Flags
|
||||
isSystem: boolean('is_system').default(false),
|
||||
isHidden: boolean('is_hidden').default(false),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Folder = typeof folders.$inferSelect;
|
||||
export type NewFolder = typeof folders.$inferInsert;
|
||||
6
apps-archived/mail/apps/backend/src/db/schema/index.ts
Normal file
6
apps-archived/mail/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './email-accounts.schema';
|
||||
export * from './folders.schema';
|
||||
export * from './emails.schema';
|
||||
export * from './attachments.schema';
|
||||
export * from './labels.schema';
|
||||
export * from './drafts.schema';
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { pgTable, uuid, timestamp, varchar, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { emailAccounts } from './email-accounts.schema';
|
||||
import { emails } from './emails.schema';
|
||||
|
||||
export const labels = pgTable('labels', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
accountId: uuid('account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }),
|
||||
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).notNull(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const emailLabels = pgTable(
|
||||
'email_labels',
|
||||
{
|
||||
emailId: uuid('email_id')
|
||||
.references(() => emails.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
labelId: uuid('label_id')
|
||||
.references(() => labels.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.emailId, table.labelId] })]
|
||||
);
|
||||
|
||||
export type Label = typeof labels.$inferSelect;
|
||||
export type NewLabel = typeof labels.$inferInsert;
|
||||
98
apps-archived/mail/apps/backend/src/email/dto/email.dto.ts
Normal file
98
apps-archived/mail/apps/backend/src/email/dto/email.dto.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { IsString, IsOptional, IsUUID, IsBoolean, IsArray, IsIn } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class EmailQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
accountId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
folderId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
threadId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true')
|
||||
isRead?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true')
|
||||
isStarred?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true')
|
||||
hasAttachments?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['work', 'personal', 'newsletter', 'transactional', 'promotional', 'social'])
|
||||
aiCategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
offset?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['receivedAt', 'sentAt', 'subject', 'fromAddress'])
|
||||
orderBy?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class UpdateEmailDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRead?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isStarred?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDeleted?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSpam?: boolean;
|
||||
}
|
||||
|
||||
export class MoveEmailDto {
|
||||
@IsUUID()
|
||||
folderId: string;
|
||||
}
|
||||
|
||||
export class BatchEmailDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
ids: string[];
|
||||
|
||||
@IsString()
|
||||
@IsIn(['read', 'unread', 'star', 'unstar', 'delete', 'spam', 'archive'])
|
||||
action: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
folderId?: string; // For move action
|
||||
}
|
||||
|
||||
export class UpdateLabelsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
labelIds: string[];
|
||||
}
|
||||
172
apps-archived/mail/apps/backend/src/email/email.controller.ts
Normal file
172
apps-archived/mail/apps/backend/src/email/email.controller.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { EmailService } from './email.service';
|
||||
import {
|
||||
EmailQueryDto,
|
||||
UpdateEmailDto,
|
||||
MoveEmailDto,
|
||||
BatchEmailDto,
|
||||
UpdateLabelsDto,
|
||||
} from './dto/email.dto';
|
||||
|
||||
@Controller('emails')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class EmailController {
|
||||
constructor(private readonly emailService: EmailService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: EmailQueryDto) {
|
||||
const emails = await this.emailService.findByUserId(user.userId, query);
|
||||
const total = await this.emailService.count(user.userId, {
|
||||
accountId: query.accountId,
|
||||
folderId: query.folderId,
|
||||
isRead: query.isRead,
|
||||
});
|
||||
return { emails, total };
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
async search(@CurrentUser() user: CurrentUserData, @Query() query: EmailQueryDto) {
|
||||
if (!query.search) {
|
||||
return { emails: [], total: 0 };
|
||||
}
|
||||
const emails = await this.emailService.findByUserId(user.userId, query);
|
||||
return { emails, total: emails.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.findById(id, user.userId);
|
||||
if (!email) {
|
||||
return { email: null };
|
||||
}
|
||||
|
||||
// Automatically mark as read when viewing
|
||||
if (!email.isRead) {
|
||||
await this.emailService.markAsRead(id, user.userId);
|
||||
}
|
||||
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Get(':id/thread')
|
||||
async getThread(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.findById(id, user.userId);
|
||||
if (!email || !email.threadId) {
|
||||
return { emails: email ? [email] : [] };
|
||||
}
|
||||
|
||||
const emails = await this.emailService.findByThreadId(email.threadId, user.userId);
|
||||
return { emails };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEmailDto
|
||||
) {
|
||||
const email = await this.emailService.update(id, user.userId, dto);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/read')
|
||||
async markAsRead(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.markAsRead(id, user.userId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/unread')
|
||||
async markAsUnread(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.markAsUnread(id, user.userId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/star')
|
||||
async toggleStar(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.toggleStar(id, user.userId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
async move(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: MoveEmailDto
|
||||
) {
|
||||
const email = await this.emailService.moveToFolder(id, user.userId, dto.folderId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/trash')
|
||||
async moveToTrash(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.moveToTrash(id, user.userId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/spam')
|
||||
async markAsSpam(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.markAsSpam(id, user.userId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Post(':id/archive')
|
||||
async archive(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const email = await this.emailService.archive(id, user.userId);
|
||||
return { email };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
// Soft delete (move to trash)
|
||||
const email = await this.emailService.moveToTrash(id, user.userId);
|
||||
return { success: true, email };
|
||||
}
|
||||
|
||||
@Delete(':id/permanent')
|
||||
async permanentDelete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
await this.emailService.permanentDelete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
@Post('batch')
|
||||
async batchOperation(@CurrentUser() user: CurrentUserData, @Body() dto: BatchEmailDto) {
|
||||
let affected = 0;
|
||||
|
||||
switch (dto.action) {
|
||||
case 'read':
|
||||
affected = await this.emailService.batchMarkAsRead(dto.ids, user.userId);
|
||||
break;
|
||||
case 'unread':
|
||||
affected = await this.emailService.batchMarkAsUnread(dto.ids, user.userId);
|
||||
break;
|
||||
case 'star':
|
||||
affected = await this.emailService.batchStar(dto.ids, user.userId, true);
|
||||
break;
|
||||
case 'unstar':
|
||||
affected = await this.emailService.batchStar(dto.ids, user.userId, false);
|
||||
break;
|
||||
case 'delete':
|
||||
affected = await this.emailService.batchDelete(dto.ids, user.userId);
|
||||
break;
|
||||
// TODO: Implement move and archive batch operations
|
||||
}
|
||||
|
||||
return { success: true, affected };
|
||||
}
|
||||
}
|
||||
12
apps-archived/mail/apps/backend/src/email/email.module.ts
Normal file
12
apps-archived/mail/apps/backend/src/email/email.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EmailController } from './email.controller';
|
||||
import { EmailService } from './email.service';
|
||||
import { FolderModule } from '../folder/folder.module';
|
||||
|
||||
@Module({
|
||||
imports: [FolderModule],
|
||||
controllers: [EmailController],
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
358
apps-archived/mail/apps/backend/src/email/email.service.ts
Normal file
358
apps-archived/mail/apps/backend/src/email/email.service.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, desc, asc, ilike, or, sql, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { emails, type Email, type NewEmail, emailLabels } from '../db/schema';
|
||||
import { FolderService } from '../folder/folder.service';
|
||||
|
||||
export interface EmailFilters {
|
||||
accountId?: string;
|
||||
folderId?: string;
|
||||
threadId?: string;
|
||||
search?: string;
|
||||
isRead?: boolean;
|
||||
isStarred?: boolean;
|
||||
hasAttachments?: boolean;
|
||||
aiCategory?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private folderService: FolderService
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string, filters: EmailFilters = {}): Promise<Email[]> {
|
||||
const {
|
||||
accountId,
|
||||
folderId,
|
||||
threadId,
|
||||
search,
|
||||
isRead,
|
||||
isStarred,
|
||||
hasAttachments,
|
||||
aiCategory,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
orderBy = 'receivedAt',
|
||||
order = 'desc',
|
||||
} = filters;
|
||||
|
||||
let conditions = [eq(emails.userId, userId), eq(emails.isDeleted, false)];
|
||||
|
||||
if (accountId) {
|
||||
conditions.push(eq(emails.accountId, accountId));
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
conditions.push(eq(emails.folderId, folderId));
|
||||
}
|
||||
|
||||
if (threadId) {
|
||||
conditions.push(eq(emails.threadId, threadId));
|
||||
}
|
||||
|
||||
if (isRead !== undefined) {
|
||||
conditions.push(eq(emails.isRead, isRead));
|
||||
}
|
||||
|
||||
if (isStarred !== undefined) {
|
||||
conditions.push(eq(emails.isStarred, isStarred));
|
||||
}
|
||||
|
||||
if (hasAttachments !== undefined) {
|
||||
conditions.push(eq(emails.hasAttachments, hasAttachments));
|
||||
}
|
||||
|
||||
if (aiCategory) {
|
||||
conditions.push(eq(emails.aiCategory, aiCategory));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(emails.subject, `%${search}%`),
|
||||
ilike(emails.fromAddress, `%${search}%`),
|
||||
ilike(emails.fromName, `%${search}%`),
|
||||
ilike(emails.snippet, `%${search}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
// Determine sort column
|
||||
let sortColumn;
|
||||
switch (orderBy) {
|
||||
case 'sentAt':
|
||||
sortColumn = emails.sentAt;
|
||||
break;
|
||||
case 'subject':
|
||||
sortColumn = emails.subject;
|
||||
break;
|
||||
case 'fromAddress':
|
||||
sortColumn = emails.fromAddress;
|
||||
break;
|
||||
default:
|
||||
sortColumn = emails.receivedAt;
|
||||
}
|
||||
|
||||
const orderFn = order === 'asc' ? asc : desc;
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderFn(sortColumn))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Email | null> {
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, id), eq(emails.userId, userId)));
|
||||
return email || null;
|
||||
}
|
||||
|
||||
async findByMessageId(messageId: string, userId: string): Promise<Email | null> {
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.messageId, messageId), eq(emails.userId, userId)));
|
||||
return email || null;
|
||||
}
|
||||
|
||||
async findByThreadId(threadId: string, userId: string): Promise<Email[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.threadId, threadId), eq(emails.userId, userId)))
|
||||
.orderBy(asc(emails.receivedAt));
|
||||
}
|
||||
|
||||
async create(data: NewEmail): Promise<Email> {
|
||||
const [email] = await this.db.insert(emails).values(data).returning();
|
||||
|
||||
// Update folder counts
|
||||
if (email.folderId) {
|
||||
await this.folderService.incrementTotalCount(email.folderId, 1);
|
||||
if (!email.isRead) {
|
||||
await this.folderService.incrementUnreadCount(email.folderId, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewEmail>): Promise<Email> {
|
||||
const existingEmail = await this.findById(id, userId);
|
||||
if (!existingEmail) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.update(emails)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(emails.id, id), eq(emails.userId, userId)))
|
||||
.returning();
|
||||
|
||||
// Update folder unread counts if read status changed
|
||||
if (
|
||||
data.isRead !== undefined &&
|
||||
existingEmail.isRead !== data.isRead &&
|
||||
existingEmail.folderId
|
||||
) {
|
||||
const delta = data.isRead ? -1 : 1;
|
||||
await this.folderService.incrementUnreadCount(existingEmail.folderId, delta);
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
async markAsRead(id: string, userId: string): Promise<Email> {
|
||||
return this.update(id, userId, { isRead: true });
|
||||
}
|
||||
|
||||
async markAsUnread(id: string, userId: string): Promise<Email> {
|
||||
return this.update(id, userId, { isRead: false });
|
||||
}
|
||||
|
||||
async toggleStar(id: string, userId: string): Promise<Email> {
|
||||
const email = await this.findById(id, userId);
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
return this.update(id, userId, { isStarred: !email.isStarred });
|
||||
}
|
||||
|
||||
async moveToFolder(id: string, userId: string, folderId: string): Promise<Email> {
|
||||
const email = await this.findById(id, userId);
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
const folder = await this.folderService.findById(folderId, userId);
|
||||
if (!folder) {
|
||||
throw new NotFoundException('Folder not found');
|
||||
}
|
||||
|
||||
// Update old folder counts
|
||||
if (email.folderId) {
|
||||
await this.folderService.incrementTotalCount(email.folderId, -1);
|
||||
if (!email.isRead) {
|
||||
await this.folderService.incrementUnreadCount(email.folderId, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// Update new folder counts
|
||||
await this.folderService.incrementTotalCount(folderId, 1);
|
||||
if (!email.isRead) {
|
||||
await this.folderService.incrementUnreadCount(folderId, 1);
|
||||
}
|
||||
|
||||
return this.update(id, userId, { folderId });
|
||||
}
|
||||
|
||||
async moveToTrash(id: string, userId: string): Promise<Email> {
|
||||
const email = await this.findById(id, userId);
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
// Find trash folder
|
||||
const trashFolder = await this.folderService.findByType(email.accountId, userId, 'trash');
|
||||
if (trashFolder) {
|
||||
return this.moveToFolder(id, userId, trashFolder.id);
|
||||
}
|
||||
|
||||
// If no trash folder, just mark as deleted
|
||||
return this.update(id, userId, { isDeleted: true });
|
||||
}
|
||||
|
||||
async markAsSpam(id: string, userId: string): Promise<Email> {
|
||||
const email = await this.findById(id, userId);
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
// Find spam folder
|
||||
const spamFolder = await this.folderService.findByType(email.accountId, userId, 'spam');
|
||||
if (spamFolder) {
|
||||
await this.moveToFolder(id, userId, spamFolder.id);
|
||||
}
|
||||
|
||||
return this.update(id, userId, { isSpam: true });
|
||||
}
|
||||
|
||||
async archive(id: string, userId: string): Promise<Email> {
|
||||
const email = await this.findById(id, userId);
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
// Find archive folder
|
||||
const archiveFolder = await this.folderService.findByType(email.accountId, userId, 'archive');
|
||||
if (archiveFolder) {
|
||||
return this.moveToFolder(id, userId, archiveFolder.id);
|
||||
}
|
||||
|
||||
throw new NotFoundException('Archive folder not found');
|
||||
}
|
||||
|
||||
async permanentDelete(id: string, userId: string): Promise<void> {
|
||||
const email = await this.findById(id, userId);
|
||||
if (!email) {
|
||||
throw new NotFoundException('Email not found');
|
||||
}
|
||||
|
||||
// Update folder counts
|
||||
if (email.folderId) {
|
||||
await this.folderService.incrementTotalCount(email.folderId, -1);
|
||||
if (!email.isRead) {
|
||||
await this.folderService.incrementUnreadCount(email.folderId, -1);
|
||||
}
|
||||
}
|
||||
|
||||
await this.db.delete(emails).where(and(eq(emails.id, id), eq(emails.userId, userId)));
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
async batchMarkAsRead(ids: string[], userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(emails)
|
||||
.set({ isRead: true, updatedAt: new Date() })
|
||||
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
|
||||
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
async batchMarkAsUnread(ids: string[], userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(emails)
|
||||
.set({ isRead: false, updatedAt: new Date() })
|
||||
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
|
||||
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
async batchStar(ids: string[], userId: string, starred: boolean): Promise<number> {
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({ isStarred: starred, updatedAt: new Date() })
|
||||
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
|
||||
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
async batchDelete(ids: string[], userId: string): Promise<number> {
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({ isDeleted: true, updatedAt: new Date() })
|
||||
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
|
||||
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
async count(userId: string, filters: Partial<EmailFilters> = {}): Promise<number> {
|
||||
let conditions = [eq(emails.userId, userId), eq(emails.isDeleted, false)];
|
||||
|
||||
if (filters.accountId) {
|
||||
conditions.push(eq(emails.accountId, filters.accountId));
|
||||
}
|
||||
|
||||
if (filters.folderId) {
|
||||
conditions.push(eq(emails.folderId, filters.folderId));
|
||||
}
|
||||
|
||||
if (filters.isRead !== undefined) {
|
||||
conditions.push(eq(emails.isRead, filters.isRead));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(emails)
|
||||
.where(and(...conditions));
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
|
||||
// Update AI metadata
|
||||
async updateAIMetadata(
|
||||
id: string,
|
||||
userId: string,
|
||||
metadata: {
|
||||
aiSummary?: string;
|
||||
aiCategory?: string;
|
||||
aiPriority?: string;
|
||||
aiSentiment?: string;
|
||||
aiSuggestedReplies?: string[];
|
||||
}
|
||||
): Promise<Email> {
|
||||
return this.update(id, userId, metadata);
|
||||
}
|
||||
}
|
||||
57
apps-archived/mail/apps/backend/src/folder/dto/folder.dto.ts
Normal file
57
apps-archived/mail/apps/backend/src/folder/dto/folder.dto.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { IsString, IsOptional, IsUUID, IsBoolean, MaxLength, IsIn } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class CreateFolderDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export class UpdateFolderDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isHidden?: boolean;
|
||||
}
|
||||
|
||||
export class FolderQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
accountId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['inbox', 'sent', 'drafts', 'trash', 'spam', 'archive', 'custom'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true')
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FolderService } from './folder.service';
|
||||
import { CreateFolderDto, UpdateFolderDto, FolderQueryDto } from './dto/folder.dto';
|
||||
|
||||
@Controller('folders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FolderController {
|
||||
constructor(private readonly folderService: FolderService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: FolderQueryDto) {
|
||||
const folders = await this.folderService.findByUserId(user.userId, query);
|
||||
return { folders };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const folder = await this.folderService.findById(id, user.userId);
|
||||
if (!folder) {
|
||||
return { folder: null };
|
||||
}
|
||||
return { folder };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFolderDto) {
|
||||
const folder = await this.folderService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
type: 'custom',
|
||||
path: dto.name, // For custom folders, path is the name
|
||||
isSystem: false,
|
||||
});
|
||||
return { folder };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateFolderDto
|
||||
) {
|
||||
const existingFolder = await this.folderService.findById(id, user.userId);
|
||||
if (!existingFolder) {
|
||||
throw new BadRequestException('Folder not found');
|
||||
}
|
||||
|
||||
// Don't allow renaming system folders
|
||||
if (existingFolder.isSystem && dto.name) {
|
||||
throw new BadRequestException('Cannot rename system folders');
|
||||
}
|
||||
|
||||
const folder = await this.folderService.update(id, user.userId, dto);
|
||||
return { folder };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.folderService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/hide')
|
||||
async toggleHidden(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const folder = await this.folderService.findById(id, user.userId);
|
||||
if (!folder) {
|
||||
throw new BadRequestException('Folder not found');
|
||||
}
|
||||
|
||||
const updatedFolder = await this.folderService.update(id, user.userId, {
|
||||
isHidden: !folder.isHidden,
|
||||
});
|
||||
return { folder: updatedFolder };
|
||||
}
|
||||
}
|
||||
10
apps-archived/mail/apps/backend/src/folder/folder.module.ts
Normal file
10
apps-archived/mail/apps/backend/src/folder/folder.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FolderController } from './folder.controller';
|
||||
import { FolderService } from './folder.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FolderController],
|
||||
providers: [FolderService],
|
||||
exports: [FolderService],
|
||||
})
|
||||
export class FolderModule {}
|
||||
200
apps-archived/mail/apps/backend/src/folder/folder.service.ts
Normal file
200
apps-archived/mail/apps/backend/src/folder/folder.service.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { folders, type Folder, type NewFolder } from '../db/schema';
|
||||
|
||||
export interface FolderFilters {
|
||||
accountId?: string;
|
||||
type?: string;
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// Standard folder types that should be created for each account
|
||||
export const SYSTEM_FOLDERS = [
|
||||
{ type: 'inbox', name: 'Inbox', path: 'INBOX', icon: 'inbox' },
|
||||
{ type: 'sent', name: 'Sent', path: 'Sent', icon: 'send' },
|
||||
{ type: 'drafts', name: 'Drafts', path: 'Drafts', icon: 'file-text' },
|
||||
{ type: 'trash', name: 'Trash', path: 'Trash', icon: 'trash' },
|
||||
{ type: 'spam', name: 'Spam', path: 'Spam', icon: 'alert-triangle' },
|
||||
{ type: 'archive', name: 'Archive', path: 'Archive', icon: 'archive' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class FolderService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string, filters: FolderFilters = {}): Promise<Folder[]> {
|
||||
const { accountId, type, includeHidden = false } = filters;
|
||||
|
||||
let conditions = [eq(folders.userId, userId)];
|
||||
|
||||
if (accountId) {
|
||||
conditions.push(eq(folders.accountId, accountId));
|
||||
}
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(folders.type, type));
|
||||
}
|
||||
|
||||
if (!includeHidden) {
|
||||
conditions.push(eq(folders.isHidden, false));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(folders.isSystem), folders.name);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Folder | null> {
|
||||
const [folder] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)));
|
||||
return folder || null;
|
||||
}
|
||||
|
||||
async findByAccountId(accountId: string, userId: string): Promise<Folder[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.accountId, accountId), eq(folders.userId, userId)))
|
||||
.orderBy(desc(folders.isSystem), folders.name);
|
||||
}
|
||||
|
||||
async findByType(accountId: string, userId: string, type: string): Promise<Folder | null> {
|
||||
const [folder] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(eq(folders.accountId, accountId), eq(folders.userId, userId), eq(folders.type, type))
|
||||
);
|
||||
return folder || null;
|
||||
}
|
||||
|
||||
async create(data: NewFolder): Promise<Folder> {
|
||||
const [folder] = await this.db.insert(folders).values(data).returning();
|
||||
return folder;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewFolder>): Promise<Folder> {
|
||||
const [folder] = await this.db
|
||||
.update(folders)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!folder) {
|
||||
throw new NotFoundException('Folder not found');
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const folder = await this.findById(id, userId);
|
||||
if (!folder) {
|
||||
throw new NotFoundException('Folder not found');
|
||||
}
|
||||
|
||||
// Prevent deletion of system folders
|
||||
if (folder.isSystem) {
|
||||
throw new NotFoundException('Cannot delete system folder');
|
||||
}
|
||||
|
||||
await this.db.delete(folders).where(and(eq(folders.id, id), eq(folders.userId, userId)));
|
||||
}
|
||||
|
||||
// Create system folders for a new account
|
||||
async createSystemFolders(accountId: string, userId: string): Promise<Folder[]> {
|
||||
const createdFolders: Folder[] = [];
|
||||
|
||||
for (const systemFolder of SYSTEM_FOLDERS) {
|
||||
const folder = await this.create({
|
||||
accountId,
|
||||
userId,
|
||||
name: systemFolder.name,
|
||||
type: systemFolder.type,
|
||||
path: systemFolder.path,
|
||||
icon: systemFolder.icon,
|
||||
isSystem: true,
|
||||
isHidden: false,
|
||||
});
|
||||
createdFolders.push(folder);
|
||||
}
|
||||
|
||||
return createdFolders;
|
||||
}
|
||||
|
||||
// Update folder counts
|
||||
async updateCounts(id: string, totalCount: number, unreadCount: number): Promise<void> {
|
||||
await this.db
|
||||
.update(folders)
|
||||
.set({ totalCount, unreadCount, updatedAt: new Date() })
|
||||
.where(eq(folders.id, id));
|
||||
}
|
||||
|
||||
// Increment/decrement counts
|
||||
async incrementUnreadCount(id: string, delta: number): Promise<void> {
|
||||
await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
unreadCount: sql`GREATEST(0, ${folders.unreadCount} + ${delta})`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(folders.id, id));
|
||||
}
|
||||
|
||||
async incrementTotalCount(id: string, delta: number): Promise<void> {
|
||||
await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
totalCount: sql`GREATEST(0, ${folders.totalCount} + ${delta})`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(folders.id, id));
|
||||
}
|
||||
|
||||
// Sync folders from external provider
|
||||
async syncFromProvider(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
providerFolders: Array<{ name: string; path: string; type?: string; externalId?: string }>
|
||||
): Promise<Folder[]> {
|
||||
const existingFolders = await this.findByAccountId(accountId, userId);
|
||||
const existingPaths = new Set(existingFolders.map((f) => f.path));
|
||||
const newFolders: Folder[] = [];
|
||||
|
||||
for (const pf of providerFolders) {
|
||||
if (!existingPaths.has(pf.path)) {
|
||||
// Determine folder type
|
||||
let type = pf.type || 'custom';
|
||||
const lowerPath = pf.path.toLowerCase();
|
||||
|
||||
if (!pf.type) {
|
||||
if (lowerPath === 'inbox') type = 'inbox';
|
||||
else if (lowerPath.includes('sent')) type = 'sent';
|
||||
else if (lowerPath.includes('draft')) type = 'drafts';
|
||||
else if (lowerPath.includes('trash') || lowerPath.includes('deleted')) type = 'trash';
|
||||
else if (lowerPath.includes('spam') || lowerPath.includes('junk')) type = 'spam';
|
||||
else if (lowerPath.includes('archive')) type = 'archive';
|
||||
}
|
||||
|
||||
const folder = await this.create({
|
||||
accountId,
|
||||
userId,
|
||||
name: pf.name,
|
||||
path: pf.path,
|
||||
type,
|
||||
externalId: pf.externalId,
|
||||
isSystem: ['inbox', 'sent', 'drafts', 'trash', 'spam', 'archive'].includes(type),
|
||||
});
|
||||
newFolders.push(folder);
|
||||
}
|
||||
}
|
||||
|
||||
return newFolders;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'mail-backend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
45
apps-archived/mail/apps/backend/src/label/dto/label.dto.ts
Normal file
45
apps-archived/mail/apps/backend/src/label/dto/label.dto.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { IsString, IsOptional, IsUUID, IsArray, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateLabelDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color (e.g., #FF5733)' })
|
||||
color: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export class UpdateLabelDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color (e.g., #FF5733)' })
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class LabelQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export class AddLabelsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
labelIds: string[];
|
||||
}
|
||||
|
||||
export class RemoveLabelsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
labelIds: string[];
|
||||
}
|
||||
107
apps-archived/mail/apps/backend/src/label/label.controller.ts
Normal file
107
apps-archived/mail/apps/backend/src/label/label.controller.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { LabelService } from './label.service';
|
||||
import {
|
||||
CreateLabelDto,
|
||||
UpdateLabelDto,
|
||||
LabelQueryDto,
|
||||
AddLabelsDto,
|
||||
RemoveLabelsDto,
|
||||
} from './dto/label.dto';
|
||||
|
||||
@Controller('labels')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LabelController {
|
||||
constructor(private readonly labelService: LabelService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: LabelQueryDto) {
|
||||
const labels = await this.labelService.findByUserId(user.userId, query);
|
||||
return { labels };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const label = await this.labelService.findById(id, user.userId);
|
||||
if (!label) {
|
||||
return { label: null };
|
||||
}
|
||||
return { label };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLabelDto) {
|
||||
const label = await this.labelService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { label };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateLabelDto
|
||||
) {
|
||||
const label = await this.labelService.update(id, user.userId, dto);
|
||||
return { label };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.labelService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Email-Label associations
|
||||
@Get('/email/:emailId')
|
||||
async getEmailLabels(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string
|
||||
) {
|
||||
const labels = await this.labelService.getEmailLabels(emailId, user.userId);
|
||||
return { labels };
|
||||
}
|
||||
|
||||
@Post('/email/:emailId/add')
|
||||
async addLabelsToEmail(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string,
|
||||
@Body() dto: AddLabelsDto
|
||||
) {
|
||||
await this.labelService.addLabelsToEmail(emailId, dto.labelIds, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('/email/:emailId/remove')
|
||||
async removeLabelsFromEmail(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string,
|
||||
@Body() dto: RemoveLabelsDto
|
||||
) {
|
||||
await this.labelService.removeLabelsFromEmail(emailId, dto.labelIds, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('/email/:emailId/set')
|
||||
async setEmailLabels(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string,
|
||||
@Body() dto: AddLabelsDto
|
||||
) {
|
||||
await this.labelService.setEmailLabels(emailId, dto.labelIds, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps-archived/mail/apps/backend/src/label/label.module.ts
Normal file
10
apps-archived/mail/apps/backend/src/label/label.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { LabelController } from './label.controller';
|
||||
import { LabelService } from './label.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LabelController],
|
||||
providers: [LabelService],
|
||||
exports: [LabelService],
|
||||
})
|
||||
export class LabelModule {}
|
||||
194
apps-archived/mail/apps/backend/src/label/label.service.ts
Normal file
194
apps-archived/mail/apps/backend/src/label/label.service.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { Injectable, Inject, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { labels, emailLabels, type Label, type NewLabel } from '../db/schema';
|
||||
|
||||
export interface LabelFilters {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string, filters: LabelFilters = {}): Promise<Label[]> {
|
||||
const { accountId } = filters;
|
||||
|
||||
let conditions = [eq(labels.userId, userId)];
|
||||
|
||||
if (accountId) {
|
||||
conditions.push(eq(labels.accountId, accountId));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Label | null> {
|
||||
const [label] = await this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.id, id), eq(labels.userId, userId)));
|
||||
return label || null;
|
||||
}
|
||||
|
||||
async create(data: NewLabel): Promise<Label> {
|
||||
// Check for duplicate name within same user/account
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(
|
||||
and(
|
||||
eq(labels.userId, data.userId),
|
||||
eq(labels.name, data.name),
|
||||
data.accountId ? eq(labels.accountId, data.accountId) : undefined
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException('A label with this name already exists');
|
||||
}
|
||||
|
||||
const [label] = await this.db.insert(labels).values(data).returning();
|
||||
return label;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewLabel>): Promise<Label> {
|
||||
// Check name uniqueness if name is being updated
|
||||
if (data.name) {
|
||||
const label = await this.findById(id, userId);
|
||||
if (!label) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(
|
||||
and(
|
||||
eq(labels.userId, userId),
|
||||
eq(labels.name, data.name),
|
||||
label.accountId ? eq(labels.accountId, label.accountId) : undefined
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.length > 0 && existing[0].id !== id) {
|
||||
throw new ConflictException('A label with this name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(labels)
|
||||
.set(data)
|
||||
.where(and(eq(labels.id, id), eq(labels.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const label = await this.findById(id, userId);
|
||||
if (!label) {
|
||||
throw new NotFoundException('Label not found');
|
||||
}
|
||||
|
||||
// Email labels will be deleted via cascade
|
||||
await this.db.delete(labels).where(and(eq(labels.id, id), eq(labels.userId, userId)));
|
||||
}
|
||||
|
||||
// Get labels for a specific email
|
||||
async getEmailLabels(emailId: string, userId: string): Promise<Label[]> {
|
||||
const result = await this.db
|
||||
.select({
|
||||
label: labels,
|
||||
})
|
||||
.from(emailLabels)
|
||||
.innerJoin(labels, eq(emailLabels.labelId, labels.id))
|
||||
.where(and(eq(emailLabels.emailId, emailId), eq(labels.userId, userId)));
|
||||
|
||||
return result.map((r) => r.label);
|
||||
}
|
||||
|
||||
// Add labels to an email
|
||||
async addLabelsToEmail(emailId: string, labelIds: string[], userId: string): Promise<void> {
|
||||
// Verify all labels belong to user
|
||||
const userLabels = await this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.userId, userId), inArray(labels.id, labelIds)));
|
||||
|
||||
if (userLabels.length !== labelIds.length) {
|
||||
throw new NotFoundException('One or more labels not found');
|
||||
}
|
||||
|
||||
// Get existing labels for this email
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(emailLabels)
|
||||
.where(and(eq(emailLabels.emailId, emailId), inArray(emailLabels.labelId, labelIds)));
|
||||
|
||||
const existingIds = new Set(existing.map((e) => e.labelId));
|
||||
const newLabelIds = labelIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
if (newLabelIds.length > 0) {
|
||||
await this.db.insert(emailLabels).values(
|
||||
newLabelIds.map((labelId) => ({
|
||||
emailId,
|
||||
labelId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove labels from an email
|
||||
async removeLabelsFromEmail(emailId: string, labelIds: string[], userId: string): Promise<void> {
|
||||
// Verify all labels belong to user
|
||||
const userLabels = await this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.userId, userId), inArray(labels.id, labelIds)));
|
||||
|
||||
if (userLabels.length !== labelIds.length) {
|
||||
throw new NotFoundException('One or more labels not found');
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(emailLabels)
|
||||
.where(and(eq(emailLabels.emailId, emailId), inArray(emailLabels.labelId, labelIds)));
|
||||
}
|
||||
|
||||
// Set labels for an email (replace all existing)
|
||||
async setEmailLabels(emailId: string, labelIds: string[], userId: string): Promise<void> {
|
||||
// Verify all labels belong to user
|
||||
if (labelIds.length > 0) {
|
||||
const userLabels = await this.db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.userId, userId), inArray(labels.id, labelIds)));
|
||||
|
||||
if (userLabels.length !== labelIds.length) {
|
||||
throw new NotFoundException('One or more labels not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all existing labels
|
||||
await this.db.delete(emailLabels).where(eq(emailLabels.emailId, emailId));
|
||||
|
||||
// Add new labels
|
||||
if (labelIds.length > 0) {
|
||||
await this.db.insert(emailLabels).values(
|
||||
labelIds.map((labelId) => ({
|
||||
emailId,
|
||||
labelId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
apps-archived/mail/apps/backend/src/main.ts
Normal file
40
apps-archived/mail/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5186',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3017;
|
||||
await app.listen(port);
|
||||
console.log(`Mail backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { google, Auth } from 'googleapis';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface GoogleTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface GoogleUserInfo {
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleOAuthService {
|
||||
private oauth2Client: Auth.OAuth2Client;
|
||||
private readonly scopes = [
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/gmail.send',
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
];
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const clientId = this.configService.get<string>('GOOGLE_CLIENT_ID');
|
||||
const clientSecret = this.configService.get<string>('GOOGLE_CLIENT_SECRET');
|
||||
const redirectUri = this.configService.get<string>('GOOGLE_REDIRECT_URI');
|
||||
|
||||
if (clientId && clientSecret && redirectUri) {
|
||||
this.oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
|
||||
}
|
||||
}
|
||||
|
||||
private encodeState(data: { userId: string }): string {
|
||||
const json = JSON.stringify(data);
|
||||
return Buffer.from(json).toString('base64url');
|
||||
}
|
||||
|
||||
private decodeState(state: string): { userId: string } {
|
||||
try {
|
||||
const json = Buffer.from(state, 'base64url').toString('utf-8');
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid state parameter');
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.oauth2Client;
|
||||
}
|
||||
|
||||
getAuthUrl(userId: string): string {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Google OAuth is not configured');
|
||||
}
|
||||
|
||||
const state = this.encodeState({ userId });
|
||||
|
||||
return this.oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: this.scopes,
|
||||
state,
|
||||
prompt: 'consent', // Force consent to get refresh token
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<{ userId: string; tokens: GoogleTokens; userInfo: GoogleUserInfo }> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Google OAuth is not configured');
|
||||
}
|
||||
|
||||
const { userId } = this.decodeState(state);
|
||||
|
||||
// Exchange code for tokens
|
||||
const { tokens } = await this.oauth2Client.getToken(code);
|
||||
|
||||
if (!tokens.access_token) {
|
||||
throw new BadRequestException('Failed to get access token from Google');
|
||||
}
|
||||
|
||||
// Get user info
|
||||
this.oauth2Client.setCredentials(tokens);
|
||||
const oauth2 = google.oauth2({ version: 'v2', auth: this.oauth2Client });
|
||||
const { data: userInfo } = await oauth2.userinfo.get();
|
||||
|
||||
const expiresAt = tokens.expiry_date ? new Date(tokens.expiry_date) : undefined;
|
||||
|
||||
return {
|
||||
userId,
|
||||
tokens: {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || undefined,
|
||||
expiresAt,
|
||||
scopes: tokens.scope?.split(' ') || this.scopes,
|
||||
},
|
||||
userInfo: {
|
||||
email: userInfo.email || '',
|
||||
name: userInfo.name || undefined,
|
||||
picture: userInfo.picture || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Google OAuth is not configured');
|
||||
}
|
||||
|
||||
this.oauth2Client.setCredentials({ refresh_token: refreshToken });
|
||||
|
||||
const { credentials } = await this.oauth2Client.refreshAccessToken();
|
||||
|
||||
if (!credentials.access_token) {
|
||||
throw new BadRequestException('Failed to refresh access token');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: credentials.access_token,
|
||||
refreshToken: credentials.refresh_token || refreshToken,
|
||||
expiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : undefined,
|
||||
scopes: credentials.scope?.split(' ') || this.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
getAuthenticatedClient(accessToken: string): Auth.OAuth2Client {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Google OAuth is not configured');
|
||||
}
|
||||
|
||||
const client = new google.auth.OAuth2(
|
||||
this.configService.get<string>('GOOGLE_CLIENT_ID'),
|
||||
this.configService.get<string>('GOOGLE_CLIENT_SECRET')
|
||||
);
|
||||
client.setCredentials({ access_token: accessToken });
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client } from '@microsoft/microsoft-graph-client';
|
||||
|
||||
export interface MicrosoftTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export interface MicrosoftUserInfo {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftOAuthService {
|
||||
private clientId: string;
|
||||
private clientSecret: string;
|
||||
private redirectUri: string;
|
||||
private tenantId: string;
|
||||
|
||||
private readonly scopes = [
|
||||
'Mail.Read',
|
||||
'Mail.Send',
|
||||
'Mail.ReadWrite',
|
||||
'User.Read',
|
||||
'offline_access',
|
||||
];
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.clientId = this.configService.get<string>('MICROSOFT_CLIENT_ID') || '';
|
||||
this.clientSecret = this.configService.get<string>('MICROSOFT_CLIENT_SECRET') || '';
|
||||
this.redirectUri = this.configService.get<string>('MICROSOFT_REDIRECT_URI') || '';
|
||||
this.tenantId = this.configService.get<string>('MICROSOFT_TENANT_ID') || 'common';
|
||||
}
|
||||
|
||||
private encodeState(data: { userId: string }): string {
|
||||
const json = JSON.stringify(data);
|
||||
return Buffer.from(json).toString('base64url');
|
||||
}
|
||||
|
||||
private decodeState(state: string): { userId: string } {
|
||||
try {
|
||||
const json = Buffer.from(state, 'base64url').toString('utf-8');
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid state parameter');
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!(this.clientId && this.clientSecret && this.redirectUri);
|
||||
}
|
||||
|
||||
getAuthUrl(userId: string): string {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Microsoft OAuth is not configured');
|
||||
}
|
||||
|
||||
const state = this.encodeState({ userId });
|
||||
const scope = this.scopes.join(' ');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: this.redirectUri,
|
||||
response_mode: 'query',
|
||||
scope,
|
||||
state,
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return `https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<{ userId: string; tokens: MicrosoftTokens; userInfo: MicrosoftUserInfo }> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Microsoft OAuth is not configured');
|
||||
}
|
||||
|
||||
const { userId } = this.decodeState(state);
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch(
|
||||
`https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
scope: this.scopes.join(' '),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new BadRequestException(`Failed to get tokens from Microsoft: ${error}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
// Get user info using Graph API
|
||||
const client = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, tokenData.access_token);
|
||||
},
|
||||
});
|
||||
|
||||
const user = await client.api('/me').select('mail,displayName,userPrincipalName').get();
|
||||
|
||||
const expiresAt = tokenData.expires_in
|
||||
? new Date(Date.now() + tokenData.expires_in * 1000)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
userId,
|
||||
tokens: {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt,
|
||||
scopes: tokenData.scope?.split(' ') || this.scopes,
|
||||
},
|
||||
userInfo: {
|
||||
email: user.mail || user.userPrincipalName || '',
|
||||
name: user.displayName || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(refreshToken: string): Promise<MicrosoftTokens> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new BadRequestException('Microsoft OAuth is not configured');
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(
|
||||
`https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
scope: this.scopes.join(' '),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new BadRequestException(`Failed to refresh Microsoft token: ${error}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
const expiresAt = tokenData.expires_in
|
||||
? new Date(Date.now() + tokenData.expires_in * 1000)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token || refreshToken,
|
||||
expiresAt,
|
||||
scopes: tokenData.scope?.split(' ') || this.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
getGraphClient(accessToken: string): Client {
|
||||
return Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, accessToken);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
150
apps-archived/mail/apps/backend/src/oauth/oauth.controller.ts
Normal file
150
apps-archived/mail/apps/backend/src/oauth/oauth.controller.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { Controller, Get, Post, Query, Res, UseGuards, BadRequestException } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { GoogleOAuthService } from './google-oauth.service';
|
||||
import { MicrosoftOAuthService } from './microsoft-oauth.service';
|
||||
import { AccountService } from '../account/account.service';
|
||||
|
||||
@Controller('oauth')
|
||||
export class OAuthController {
|
||||
constructor(
|
||||
private readonly googleOAuthService: GoogleOAuthService,
|
||||
private readonly microsoftOAuthService: MicrosoftOAuthService,
|
||||
private readonly accountService: AccountService
|
||||
) {}
|
||||
|
||||
// ==================== Google OAuth ====================
|
||||
|
||||
@Post('google/init')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async initGoogleOAuth(@CurrentUser() user: CurrentUserData) {
|
||||
if (!this.googleOAuthService.isConfigured()) {
|
||||
throw new BadRequestException(
|
||||
'Google OAuth is not configured. Please set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI.'
|
||||
);
|
||||
}
|
||||
|
||||
const authUrl = this.googleOAuthService.getAuthUrl(user.userId);
|
||||
return { authUrl };
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
async googleCallback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Query('error') error: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
// Redirect URL for the frontend
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5186';
|
||||
|
||||
if (error) {
|
||||
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(error)}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return res.redirect(`${frontendUrl}/accounts?error=missing_params`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId, tokens, userInfo } = await this.googleOAuthService.handleCallback(
|
||||
code,
|
||||
state
|
||||
);
|
||||
|
||||
// Create the email account
|
||||
await this.accountService.create({
|
||||
userId,
|
||||
name: userInfo.name || userInfo.email,
|
||||
email: userInfo.email,
|
||||
provider: 'gmail',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
tokenScopes: tokens.scopes,
|
||||
syncEnabled: true,
|
||||
});
|
||||
|
||||
return res.redirect(`${frontendUrl}/accounts?success=gmail_connected`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Microsoft OAuth ====================
|
||||
|
||||
@Post('microsoft/init')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async initMicrosoftOAuth(@CurrentUser() user: CurrentUserData) {
|
||||
if (!this.microsoftOAuthService.isConfigured()) {
|
||||
throw new BadRequestException(
|
||||
'Microsoft OAuth is not configured. Please set MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, and MICROSOFT_REDIRECT_URI.'
|
||||
);
|
||||
}
|
||||
|
||||
const authUrl = this.microsoftOAuthService.getAuthUrl(user.userId);
|
||||
return { authUrl };
|
||||
}
|
||||
|
||||
@Get('microsoft/callback')
|
||||
async microsoftCallback(
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Query('error') error: string,
|
||||
@Query('error_description') errorDescription: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
// Redirect URL for the frontend
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5186';
|
||||
|
||||
if (error) {
|
||||
const message = errorDescription || error;
|
||||
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return res.redirect(`${frontendUrl}/accounts?error=missing_params`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId, tokens, userInfo } = await this.microsoftOAuthService.handleCallback(
|
||||
code,
|
||||
state
|
||||
);
|
||||
|
||||
// Create the email account
|
||||
await this.accountService.create({
|
||||
userId,
|
||||
name: userInfo.name || userInfo.email,
|
||||
email: userInfo.email,
|
||||
provider: 'outlook',
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
tokenExpiresAt: tokens.expiresAt,
|
||||
tokenScopes: tokens.scopes,
|
||||
syncEnabled: true,
|
||||
});
|
||||
|
||||
return res.redirect(`${frontendUrl}/accounts?success=outlook_connected`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Status ====================
|
||||
|
||||
@Get('status')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getOAuthStatus() {
|
||||
return {
|
||||
google: {
|
||||
configured: this.googleOAuthService.isConfigured(),
|
||||
},
|
||||
microsoft: {
|
||||
configured: this.microsoftOAuthService.isConfigured(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps-archived/mail/apps/backend/src/oauth/oauth.module.ts
Normal file
13
apps-archived/mail/apps/backend/src/oauth/oauth.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { OAuthController } from './oauth.controller';
|
||||
import { GoogleOAuthService } from './google-oauth.service';
|
||||
import { MicrosoftOAuthService } from './microsoft-oauth.service';
|
||||
import { AccountModule } from '../account/account.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule],
|
||||
controllers: [OAuthController],
|
||||
providers: [GoogleOAuthService, MicrosoftOAuthService],
|
||||
exports: [GoogleOAuthService, MicrosoftOAuthService],
|
||||
})
|
||||
export class OAuthModule {}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { type EmailAccount, type Email, type Folder } from '../../db/schema';
|
||||
|
||||
export interface SyncState {
|
||||
lastSyncAt?: Date;
|
||||
lastMessageId?: string;
|
||||
historyId?: string; // Gmail specific
|
||||
deltaLink?: string; // Outlook specific
|
||||
uidValidity?: number; // IMAP specific
|
||||
highestModSeq?: string; // IMAP specific
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
newEmails: number;
|
||||
updatedEmails: number;
|
||||
deletedEmails: number;
|
||||
newFolders: number;
|
||||
error?: string;
|
||||
newSyncState: SyncState;
|
||||
}
|
||||
|
||||
export interface FetchedEmail {
|
||||
messageId: string;
|
||||
externalId?: string;
|
||||
subject?: string;
|
||||
fromAddress?: string;
|
||||
fromName?: string;
|
||||
toAddresses: { email: string; name?: string }[];
|
||||
ccAddresses?: { email: string; name?: string }[];
|
||||
bccAddresses?: { email: string; name?: string }[];
|
||||
snippet?: string;
|
||||
bodyPlain?: string;
|
||||
bodyHtml?: string;
|
||||
sentAt?: Date;
|
||||
receivedAt?: Date;
|
||||
isRead: boolean;
|
||||
isStarred: boolean;
|
||||
hasAttachments: boolean;
|
||||
threadId?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string[];
|
||||
headers?: Record<string, string>;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
contentId?: string;
|
||||
content?: Buffer;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FetchedFolder {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'spam' | 'archive' | 'custom';
|
||||
delimiter?: string;
|
||||
flags?: string[];
|
||||
}
|
||||
|
||||
export interface EmailProvider {
|
||||
/**
|
||||
* Connect to the email provider
|
||||
*/
|
||||
connect(account: EmailAccount, password?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Disconnect from the email provider
|
||||
*/
|
||||
disconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sync folders from the provider
|
||||
*/
|
||||
syncFolders(account: EmailAccount): Promise<FetchedFolder[]>;
|
||||
|
||||
/**
|
||||
* Perform delta sync to get new/updated/deleted emails
|
||||
*/
|
||||
sync(account: EmailAccount, state: SyncState): Promise<SyncResult>;
|
||||
|
||||
/**
|
||||
* Fetch a single email by ID
|
||||
*/
|
||||
fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null>;
|
||||
|
||||
/**
|
||||
* Fetch emails from a folder
|
||||
*/
|
||||
fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]>;
|
||||
|
||||
/**
|
||||
* Update email flags (read, starred)
|
||||
*/
|
||||
updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Move email to a different folder
|
||||
*/
|
||||
moveEmail(account: EmailAccount, externalId: string, targetFolderPath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete an email
|
||||
*/
|
||||
deleteEmail(account: EmailAccount, externalId: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { google, gmail_v1 } from 'googleapis';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from '../interfaces/email-provider.interface';
|
||||
import { type EmailAccount } from '../../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class GmailProvider implements EmailProvider {
|
||||
private readonly logger = new Logger(GmailProvider.name);
|
||||
private gmail: gmail_v1.Gmail | null = null;
|
||||
|
||||
async connect(account: EmailAccount): Promise<void> {
|
||||
if (!account.accessToken) {
|
||||
throw new Error('Gmail access token not configured');
|
||||
}
|
||||
|
||||
const auth = new google.auth.OAuth2();
|
||||
auth.setCredentials({ access_token: account.accessToken });
|
||||
|
||||
this.gmail = google.gmail({ version: 'v1', auth });
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.gmail = null;
|
||||
}
|
||||
|
||||
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const folders: FetchedFolder[] = [];
|
||||
const response = await this.gmail.users.labels.list({ userId: 'me' });
|
||||
|
||||
for (const label of response.data.labels || []) {
|
||||
folders.push({
|
||||
name: label.name || '',
|
||||
path: label.id || '',
|
||||
type: this.mapLabelType(label.id || ''),
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
newSyncState: { ...state },
|
||||
};
|
||||
|
||||
try {
|
||||
// Use Gmail History API for incremental sync
|
||||
if (state.historyId) {
|
||||
const historyResponse = await this.gmail.users.history.list({
|
||||
userId: 'me',
|
||||
startHistoryId: state.historyId,
|
||||
});
|
||||
|
||||
const history = historyResponse.data.history || [];
|
||||
for (const record of history) {
|
||||
result.newEmails += record.messagesAdded?.length || 0;
|
||||
result.deletedEmails += record.messagesDeleted?.length || 0;
|
||||
}
|
||||
|
||||
result.newSyncState.historyId = historyResponse.data.historyId || state.historyId;
|
||||
} else {
|
||||
// Initial sync - fetch recent messages
|
||||
const messagesResponse = await this.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
maxResults: 100,
|
||||
q: 'in:inbox',
|
||||
});
|
||||
|
||||
result.newEmails = messagesResponse.data.messages?.length || 0;
|
||||
|
||||
// Get current history ID for future syncs
|
||||
const profile = await this.gmail.users.getProfile({ userId: 'me' });
|
||||
result.newSyncState.historyId = profile.data.historyId || undefined;
|
||||
}
|
||||
|
||||
result.newSyncState.lastSyncAt = new Date();
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error.message : 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
try {
|
||||
const response = await this.gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
format: 'full',
|
||||
});
|
||||
|
||||
return this.parseGmailMessage(response.data);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch email ${externalId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const emails: FetchedEmail[] = [];
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
// Build query
|
||||
let query = `in:${folderPath === 'INBOX' ? 'inbox' : folderPath}`;
|
||||
if (options?.since) {
|
||||
const dateStr = options.since.toISOString().split('T')[0];
|
||||
query += ` after:${dateStr}`;
|
||||
}
|
||||
|
||||
const listResponse = await this.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
maxResults: limit,
|
||||
q: query,
|
||||
});
|
||||
|
||||
for (const message of listResponse.data.messages || []) {
|
||||
if (message.id) {
|
||||
const email = await this.fetchEmail(account, message.id);
|
||||
if (email) emails.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
async updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
const addLabels: string[] = [];
|
||||
const removeLabels: string[] = [];
|
||||
|
||||
if (flags.isRead !== undefined) {
|
||||
if (flags.isRead) {
|
||||
removeLabels.push('UNREAD');
|
||||
} else {
|
||||
addLabels.push('UNREAD');
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isStarred !== undefined) {
|
||||
if (flags.isStarred) {
|
||||
addLabels.push('STARRED');
|
||||
} else {
|
||||
removeLabels.push('STARRED');
|
||||
}
|
||||
}
|
||||
|
||||
if (addLabels.length > 0 || removeLabels.length > 0) {
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
requestBody: {
|
||||
addLabelIds: addLabels.length > 0 ? addLabels : undefined,
|
||||
removeLabelIds: removeLabels.length > 0 ? removeLabels : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
targetFolderPath: string
|
||||
): Promise<void> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
// In Gmail, moving is done by modifying labels
|
||||
const targetLabel = this.pathToLabelId(targetFolderPath);
|
||||
|
||||
await this.gmail.users.messages.modify({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
requestBody: {
|
||||
addLabelIds: [targetLabel],
|
||||
removeLabelIds: ['INBOX'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
|
||||
if (!this.gmail) throw new Error('Not connected');
|
||||
|
||||
// Move to trash (or permanently delete)
|
||||
await this.gmail.users.messages.trash({
|
||||
userId: 'me',
|
||||
id: externalId,
|
||||
});
|
||||
}
|
||||
|
||||
private mapLabelType(labelId: string): FetchedFolder['type'] {
|
||||
const labelMap: Record<string, FetchedFolder['type']> = {
|
||||
INBOX: 'inbox',
|
||||
SENT: 'sent',
|
||||
DRAFT: 'drafts',
|
||||
TRASH: 'trash',
|
||||
SPAM: 'spam',
|
||||
};
|
||||
return labelMap[labelId] || 'custom';
|
||||
}
|
||||
|
||||
private pathToLabelId(path: string): string {
|
||||
const pathMap: Record<string, string> = {
|
||||
inbox: 'INBOX',
|
||||
sent: 'SENT',
|
||||
drafts: 'DRAFT',
|
||||
trash: 'TRASH',
|
||||
spam: 'SPAM',
|
||||
};
|
||||
return pathMap[path.toLowerCase()] || path;
|
||||
}
|
||||
|
||||
private parseGmailMessage(message: gmail_v1.Schema$Message): FetchedEmail {
|
||||
const headers = message.payload?.headers || [];
|
||||
const getHeader = (name: string) =>
|
||||
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value;
|
||||
|
||||
const labels = message.labelIds || [];
|
||||
|
||||
// Extract body
|
||||
let bodyPlain = '';
|
||||
let bodyHtml = '';
|
||||
|
||||
const extractBody = (part: gmail_v1.Schema$MessagePart) => {
|
||||
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||
bodyPlain = Buffer.from(part.body.data, 'base64').toString('utf-8');
|
||||
}
|
||||
if (part.mimeType === 'text/html' && part.body?.data) {
|
||||
bodyHtml = Buffer.from(part.body.data, 'base64').toString('utf-8');
|
||||
}
|
||||
for (const subPart of part.parts || []) {
|
||||
extractBody(subPart);
|
||||
}
|
||||
};
|
||||
|
||||
if (message.payload) {
|
||||
extractBody(message.payload);
|
||||
}
|
||||
|
||||
// Parse from header
|
||||
const fromHeader = getHeader('From') || '';
|
||||
const fromMatch = fromHeader.match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
|
||||
const fromName = fromMatch?.[1]?.trim();
|
||||
const fromAddress = fromMatch?.[2] || fromHeader;
|
||||
|
||||
// Parse to/cc addresses
|
||||
const parseAddresses = (header: string | undefined): { email: string; name?: string }[] => {
|
||||
if (!header) return [];
|
||||
return header.split(',').map((addr) => {
|
||||
const match = addr.trim().match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
|
||||
return {
|
||||
email: match?.[2] || addr.trim(),
|
||||
name: match?.[1]?.trim(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
messageId: getHeader('Message-ID') || message.id || '',
|
||||
externalId: message.id ?? undefined,
|
||||
threadId: message.threadId ?? undefined,
|
||||
subject: getHeader('Subject') ?? undefined,
|
||||
fromAddress,
|
||||
fromName,
|
||||
toAddresses: parseAddresses(getHeader('To') ?? undefined),
|
||||
ccAddresses: parseAddresses(getHeader('Cc') ?? undefined),
|
||||
snippet: message.snippet ?? undefined,
|
||||
bodyPlain: bodyPlain ?? undefined,
|
||||
bodyHtml: bodyHtml ?? undefined,
|
||||
sentAt: getHeader('Date') ? new Date(getHeader('Date')!) : undefined,
|
||||
receivedAt: message.internalDate ? new Date(parseInt(message.internalDate, 10)) : undefined,
|
||||
isRead: !labels.includes('UNREAD'),
|
||||
isStarred: labels.includes('STARRED'),
|
||||
hasAttachments:
|
||||
message.payload?.parts?.some((p) => p.filename && p.filename.length > 0) || false,
|
||||
inReplyTo: getHeader('In-Reply-To') ?? undefined,
|
||||
references: getHeader('References')?.split(/\s+/),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ImapFlow, type MailboxObject, type FetchMessageObject, type ListResponse } from 'imapflow';
|
||||
import { simpleParser, type ParsedMail, type AddressObject, type Attachment } from 'mailparser';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from '../interfaces/email-provider.interface';
|
||||
import { type EmailAccount } from '../../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ImapProvider implements EmailProvider {
|
||||
private readonly logger = new Logger(ImapProvider.name);
|
||||
private client: ImapFlow | null = null;
|
||||
|
||||
async connect(account: EmailAccount, password?: string): Promise<void> {
|
||||
if (!account.imapHost || !account.imapPort) {
|
||||
throw new Error('IMAP settings not configured');
|
||||
}
|
||||
|
||||
this.client = new ImapFlow({
|
||||
host: account.imapHost,
|
||||
port: account.imapPort,
|
||||
secure: account.imapSecurity === 'ssl',
|
||||
auth: {
|
||||
user: account.email,
|
||||
pass: password || '',
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.logout();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const folders: FetchedFolder[] = [];
|
||||
const mailboxes = await this.client.list();
|
||||
|
||||
for (const mailbox of mailboxes) {
|
||||
folders.push({
|
||||
name: mailbox.name,
|
||||
path: mailbox.path,
|
||||
type: this.mapFolderType(mailbox),
|
||||
delimiter: mailbox.delimiter,
|
||||
flags: mailbox.flags ? Array.from(mailbox.flags) : [],
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
newSyncState: { ...state },
|
||||
};
|
||||
|
||||
try {
|
||||
// Open INBOX
|
||||
const mailbox = await this.client.mailboxOpen('INBOX');
|
||||
|
||||
// Use UIDVALIDITY and HIGHESTMODSEQ for incremental sync
|
||||
const currentUidValidity = Number(mailbox.uidValidity);
|
||||
const currentModSeq = mailbox.highestModseq?.toString();
|
||||
|
||||
// If UIDVALIDITY changed, we need full resync
|
||||
if (state.uidValidity && state.uidValidity !== currentUidValidity) {
|
||||
this.logger.warn('UIDVALIDITY changed, full resync required');
|
||||
// Full resync would be handled separately
|
||||
}
|
||||
|
||||
// Fetch new messages since last sync
|
||||
const since = state.lastSyncAt || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // Default: 30 days
|
||||
|
||||
const messages = await this.fetchEmailsInternal('INBOX', { since, limit: 100 });
|
||||
result.newEmails = messages.length;
|
||||
|
||||
result.newSyncState = {
|
||||
lastSyncAt: new Date(),
|
||||
uidValidity: currentUidValidity,
|
||||
highestModSeq: currentModSeq,
|
||||
};
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error.message : 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
try {
|
||||
const mailbox = await this.client.mailboxOpen('INBOX');
|
||||
const uid = parseInt(externalId, 10);
|
||||
|
||||
for await (const message of this.client.fetch(uid, { source: true }, { uid: true })) {
|
||||
if (!message.source) {
|
||||
this.logger.warn(`Email ${externalId} has no source`);
|
||||
continue;
|
||||
}
|
||||
const parsed = await simpleParser(message.source);
|
||||
return this.parseEmail(message, parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch email ${externalId}:`, error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
return this.fetchEmailsInternal(folderPath, options);
|
||||
}
|
||||
|
||||
private async fetchEmailsInternal(
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const emails: FetchedEmail[] = [];
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
await this.client.mailboxOpen(folderPath);
|
||||
|
||||
// Build search criteria
|
||||
const searchCriteria: any = {};
|
||||
if (options?.since) {
|
||||
searchCriteria.since = options.since;
|
||||
}
|
||||
|
||||
const searchResults = await this.client.search(searchCriteria, { uid: true });
|
||||
if (!searchResults || searchResults.length === 0) return emails;
|
||||
|
||||
const uidsToFetch = searchResults.slice(-limit); // Get most recent
|
||||
|
||||
for await (const message of this.client.fetch(
|
||||
uidsToFetch,
|
||||
{ source: true, flags: true },
|
||||
{ uid: true }
|
||||
)) {
|
||||
try {
|
||||
if (!message.source) {
|
||||
this.logger.warn(`Email UID ${message.uid} has no source`);
|
||||
continue;
|
||||
}
|
||||
const parsed = await simpleParser(message.source);
|
||||
const email = this.parseEmail(message, parsed);
|
||||
emails.push(email);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse email UID ${message.uid}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
async updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const uid = parseInt(externalId, 10);
|
||||
await this.client.mailboxOpen('INBOX');
|
||||
|
||||
if (flags.isRead !== undefined) {
|
||||
if (flags.isRead) {
|
||||
await this.client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
||||
} else {
|
||||
await this.client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isStarred !== undefined) {
|
||||
if (flags.isStarred) {
|
||||
await this.client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
|
||||
} else {
|
||||
await this.client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
targetFolderPath: string
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const uid = parseInt(externalId, 10);
|
||||
await this.client.mailboxOpen('INBOX');
|
||||
await this.client.messageMove(uid, targetFolderPath, { uid: true });
|
||||
}
|
||||
|
||||
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const uid = parseInt(externalId, 10);
|
||||
await this.client.mailboxOpen('INBOX');
|
||||
await this.client.messageDelete(uid, { uid: true });
|
||||
}
|
||||
|
||||
private mapFolderType(mailbox: ListResponse): FetchedFolder['type'] {
|
||||
const specialUse = mailbox.specialUse;
|
||||
const nameLower = mailbox.path.toLowerCase();
|
||||
|
||||
if (specialUse === '\\Inbox' || nameLower === 'inbox') return 'inbox';
|
||||
if (specialUse === '\\Sent' || nameLower.includes('sent')) return 'sent';
|
||||
if (specialUse === '\\Drafts' || nameLower.includes('draft')) return 'drafts';
|
||||
if (specialUse === '\\Trash' || nameLower.includes('trash') || nameLower.includes('deleted'))
|
||||
return 'trash';
|
||||
if (specialUse === '\\Junk' || nameLower.includes('spam') || nameLower.includes('junk'))
|
||||
return 'spam';
|
||||
if (specialUse === '\\Archive' || nameLower.includes('archive')) return 'archive';
|
||||
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
private parseEmail(message: FetchMessageObject, parsed: ParsedMail): FetchedEmail {
|
||||
const flags = message.flags || new Set();
|
||||
|
||||
return {
|
||||
messageId: parsed.messageId || `${message.uid}`,
|
||||
externalId: message.uid?.toString(),
|
||||
subject: parsed.subject,
|
||||
fromAddress: this.extractEmail(parsed.from),
|
||||
fromName: this.extractName(parsed.from),
|
||||
toAddresses: this.extractAddresses(parsed.to),
|
||||
ccAddresses: this.extractAddresses(parsed.cc),
|
||||
snippet: parsed.text?.substring(0, 200),
|
||||
bodyPlain: parsed.text,
|
||||
bodyHtml: parsed.html || undefined,
|
||||
sentAt: parsed.date,
|
||||
receivedAt: parsed.date,
|
||||
isRead: flags.has('\\Seen'),
|
||||
isStarred: flags.has('\\Flagged'),
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
inReplyTo: parsed.inReplyTo,
|
||||
references: parsed.references
|
||||
? Array.isArray(parsed.references)
|
||||
? parsed.references
|
||||
: [parsed.references]
|
||||
: [],
|
||||
attachments: parsed.attachments?.map((att: Attachment) => ({
|
||||
filename: att.filename || 'attachment',
|
||||
mimeType: att.contentType,
|
||||
size: att.size,
|
||||
contentId: att.contentId,
|
||||
content: att.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private extractEmail(address: AddressObject | undefined): string | undefined {
|
||||
if (!address?.value?.[0]) return undefined;
|
||||
return address.value[0].address;
|
||||
}
|
||||
|
||||
private extractName(address: AddressObject | undefined): string | undefined {
|
||||
if (!address?.value?.[0]) return undefined;
|
||||
return address.value[0].name;
|
||||
}
|
||||
|
||||
private extractAddresses(
|
||||
address: AddressObject | AddressObject[] | undefined
|
||||
): { email: string; name?: string }[] {
|
||||
if (!address) return [];
|
||||
|
||||
const addresses = Array.isArray(address) ? address : [address];
|
||||
const result: { email: string; name?: string }[] = [];
|
||||
|
||||
for (const addr of addresses) {
|
||||
for (const val of addr.value || []) {
|
||||
if (val.address) {
|
||||
result.push({
|
||||
email: val.address,
|
||||
name: val.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Client } from '@microsoft/microsoft-graph-client';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from '../interfaces/email-provider.interface';
|
||||
import { type EmailAccount } from '../../db/schema';
|
||||
|
||||
interface GraphMessage {
|
||||
id: string;
|
||||
conversationId?: string;
|
||||
subject?: string;
|
||||
from?: { emailAddress: { address: string; name?: string } };
|
||||
toRecipients?: { emailAddress: { address: string; name?: string } }[];
|
||||
ccRecipients?: { emailAddress: { address: string; name?: string } }[];
|
||||
bodyPreview?: string;
|
||||
body?: { content: string; contentType: string };
|
||||
sentDateTime?: string;
|
||||
receivedDateTime?: string;
|
||||
isRead?: boolean;
|
||||
flag?: { flagStatus: string };
|
||||
hasAttachments?: boolean;
|
||||
internetMessageId?: string;
|
||||
parentFolderId?: string;
|
||||
}
|
||||
|
||||
interface GraphMailFolder {
|
||||
id: string;
|
||||
displayName: string;
|
||||
parentFolderId?: string;
|
||||
wellKnownName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OutlookProvider implements EmailProvider {
|
||||
private readonly logger = new Logger(OutlookProvider.name);
|
||||
private client: Client | null = null;
|
||||
|
||||
async connect(account: EmailAccount): Promise<void> {
|
||||
if (!account.accessToken) {
|
||||
throw new Error('Outlook access token not configured');
|
||||
}
|
||||
|
||||
this.client = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, account.accessToken!);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const folders: FetchedFolder[] = [];
|
||||
const response = await this.client.api('/me/mailFolders').get();
|
||||
|
||||
for (const folder of response.value as GraphMailFolder[]) {
|
||||
folders.push({
|
||||
name: folder.displayName,
|
||||
path: folder.id,
|
||||
type: this.mapFolderType(folder.wellKnownName),
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
newSyncState: { ...state },
|
||||
};
|
||||
|
||||
try {
|
||||
// Use delta query for incremental sync
|
||||
let deltaUrl = state.deltaLink || '/me/mailFolders/inbox/messages/delta';
|
||||
|
||||
const response = await this.client.api(deltaUrl).get();
|
||||
|
||||
result.newEmails = (response.value as GraphMessage[]).length;
|
||||
|
||||
// Save delta link for next sync
|
||||
if (response['@odata.deltaLink']) {
|
||||
result.newSyncState.deltaLink = response['@odata.deltaLink'];
|
||||
}
|
||||
|
||||
result.newSyncState.lastSyncAt = new Date();
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error.message : 'Sync failed';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
try {
|
||||
const response = await this.client
|
||||
.api(`/me/messages/${externalId}`)
|
||||
.select(
|
||||
'id,conversationId,subject,from,toRecipients,ccRecipients,bodyPreview,body,sentDateTime,receivedDateTime,isRead,flag,hasAttachments,internetMessageId'
|
||||
)
|
||||
.get();
|
||||
|
||||
return this.parseOutlookMessage(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch email ${externalId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchEmails(
|
||||
account: EmailAccount,
|
||||
folderPath: string,
|
||||
options?: { limit?: number; since?: Date }
|
||||
): Promise<FetchedEmail[]> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const emails: FetchedEmail[] = [];
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
let request = this.client
|
||||
.api(`/me/mailFolders/${folderPath}/messages`)
|
||||
.top(limit)
|
||||
.select(
|
||||
'id,conversationId,subject,from,toRecipients,ccRecipients,bodyPreview,body,sentDateTime,receivedDateTime,isRead,flag,hasAttachments,internetMessageId'
|
||||
);
|
||||
|
||||
if (options?.since) {
|
||||
request = request.filter(`receivedDateTime ge ${options.since.toISOString()}`);
|
||||
}
|
||||
|
||||
const response = await request.get();
|
||||
|
||||
for (const message of response.value as GraphMessage[]) {
|
||||
emails.push(this.parseOutlookMessage(message));
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
async updateFlags(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
const update: Record<string, any> = {};
|
||||
|
||||
if (flags.isRead !== undefined) {
|
||||
update.isRead = flags.isRead;
|
||||
}
|
||||
|
||||
if (flags.isStarred !== undefined) {
|
||||
update.flag = {
|
||||
flagStatus: flags.isStarred ? 'flagged' : 'notFlagged',
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
await this.client.api(`/me/messages/${externalId}`).patch(update);
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
account: EmailAccount,
|
||||
externalId: string,
|
||||
targetFolderPath: string
|
||||
): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
await this.client.api(`/me/messages/${externalId}/move`).post({
|
||||
destinationId: targetFolderPath,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
|
||||
if (!this.client) throw new Error('Not connected');
|
||||
|
||||
// Move to deleted items
|
||||
await this.client.api(`/me/messages/${externalId}/move`).post({
|
||||
destinationId: 'deleteditems',
|
||||
});
|
||||
}
|
||||
|
||||
private mapFolderType(wellKnownName?: string): FetchedFolder['type'] {
|
||||
const folderMap: Record<string, FetchedFolder['type']> = {
|
||||
inbox: 'inbox',
|
||||
sentitems: 'sent',
|
||||
drafts: 'drafts',
|
||||
deleteditems: 'trash',
|
||||
junkemail: 'spam',
|
||||
archive: 'archive',
|
||||
};
|
||||
return folderMap[wellKnownName?.toLowerCase() || ''] || 'custom';
|
||||
}
|
||||
|
||||
private parseOutlookMessage(message: GraphMessage): FetchedEmail {
|
||||
return {
|
||||
messageId: message.internetMessageId || message.id,
|
||||
externalId: message.id,
|
||||
threadId: message.conversationId,
|
||||
subject: message.subject,
|
||||
fromAddress: message.from?.emailAddress.address,
|
||||
fromName: message.from?.emailAddress.name,
|
||||
toAddresses:
|
||||
message.toRecipients?.map((r) => ({
|
||||
email: r.emailAddress.address,
|
||||
name: r.emailAddress.name,
|
||||
})) || [],
|
||||
ccAddresses:
|
||||
message.ccRecipients?.map((r) => ({
|
||||
email: r.emailAddress.address,
|
||||
name: r.emailAddress.name,
|
||||
})) || [],
|
||||
snippet: message.bodyPreview,
|
||||
bodyPlain: message.body?.contentType === 'text' ? message.body.content : undefined,
|
||||
bodyHtml: message.body?.contentType === 'html' ? message.body.content : undefined,
|
||||
sentAt: message.sentDateTime ? new Date(message.sentDateTime) : undefined,
|
||||
receivedAt: message.receivedDateTime ? new Date(message.receivedDateTime) : undefined,
|
||||
isRead: message.isRead ?? false,
|
||||
isStarred: message.flag?.flagStatus === 'flagged',
|
||||
hasAttachments: message.hasAttachments ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
apps-archived/mail/apps/backend/src/sync/sync.controller.ts
Normal file
38
apps-archived/mail/apps/backend/src/sync/sync.controller.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Controller, Post, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
@Controller('sync')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
@Post('accounts/:accountId')
|
||||
async syncAccount(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('accountId', ParseUUIDPipe) accountId: string
|
||||
) {
|
||||
const result = await this.syncService.syncAccount(accountId, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('accounts/:accountId/folders/:folderId')
|
||||
async syncFolder(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('accountId', ParseUUIDPipe) accountId: string,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string
|
||||
) {
|
||||
const result = await this.syncService.syncFolder(accountId, user.userId, folderId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('emails/:emailId/fetch')
|
||||
async fetchFullEmail(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('emailId', ParseUUIDPipe) emailId: string
|
||||
) {
|
||||
// Get the email to find its account
|
||||
await this.syncService.fetchFullEmail('', user.userId, emailId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
16
apps-archived/mail/apps/backend/src/sync/sync.module.ts
Normal file
16
apps-archived/mail/apps/backend/src/sync/sync.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { ImapProvider } from './providers/imap.provider';
|
||||
import { GmailProvider } from './providers/gmail.provider';
|
||||
import { OutlookProvider } from './providers/outlook.provider';
|
||||
import { AccountModule } from '../account/account.module';
|
||||
import { AttachmentModule } from '../attachment/attachment.module';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule, AttachmentModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, ImapProvider, GmailProvider, OutlookProvider],
|
||||
exports: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
425
apps-archived/mail/apps/backend/src/sync/sync.service.ts
Normal file
425
apps-archived/mail/apps/backend/src/sync/sync.service.ts
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
emailAccounts,
|
||||
emails,
|
||||
folders,
|
||||
type EmailAccount,
|
||||
type NewEmail,
|
||||
type NewFolder,
|
||||
} from '../db/schema';
|
||||
import { AccountService } from '../account/account.service';
|
||||
import { AttachmentService } from '../attachment/attachment.service';
|
||||
import { ImapProvider } from './providers/imap.provider';
|
||||
import { GmailProvider } from './providers/gmail.provider';
|
||||
import { OutlookProvider } from './providers/outlook.provider';
|
||||
import {
|
||||
type EmailProvider,
|
||||
type SyncState,
|
||||
type SyncResult,
|
||||
type FetchedEmail,
|
||||
type FetchedFolder,
|
||||
} from './interfaces/email-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private syncInProgress = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private accountService: AccountService,
|
||||
private attachmentService: AttachmentService,
|
||||
private imapProvider: ImapProvider,
|
||||
private gmailProvider: GmailProvider,
|
||||
private outlookProvider: OutlookProvider
|
||||
) {}
|
||||
|
||||
// Run sync every 5 minutes
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async scheduledSync() {
|
||||
this.logger.log('Starting scheduled sync');
|
||||
|
||||
const accounts = await this.db
|
||||
.select()
|
||||
.from(emailAccounts)
|
||||
.where(eq(emailAccounts.syncEnabled, true));
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
await this.syncAccount(account.id, account.userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Scheduled sync failed for account ${account.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncAccount(accountId: string, userId: string): Promise<SyncResult> {
|
||||
// Prevent concurrent syncs for the same account
|
||||
if (this.syncInProgress.has(accountId)) {
|
||||
return {
|
||||
success: false,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
error: 'Sync already in progress',
|
||||
newSyncState: {},
|
||||
};
|
||||
}
|
||||
|
||||
this.syncInProgress.add(accountId);
|
||||
|
||||
try {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
// Sync folders first
|
||||
const fetchedFolders = await provider.syncFolders(account);
|
||||
await this.saveFolders(account, fetchedFolders);
|
||||
|
||||
// Sync emails
|
||||
const syncState: SyncState = (account.syncState as SyncState) || {};
|
||||
const result = await provider.sync(account, syncState);
|
||||
|
||||
// Update account sync state
|
||||
await this.db
|
||||
.update(emailAccounts)
|
||||
.set({
|
||||
syncState: result.newSyncState,
|
||||
lastSyncAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emailAccounts.id, accountId));
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Sync failed for account ${accountId}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
newEmails: 0,
|
||||
updatedEmails: 0,
|
||||
deletedEmails: 0,
|
||||
newFolders: 0,
|
||||
error: error instanceof Error ? error.message : 'Sync failed',
|
||||
newSyncState: {},
|
||||
};
|
||||
} finally {
|
||||
this.syncInProgress.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
async syncFolder(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
folderId: string
|
||||
): Promise<{ emails: number }> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [folder] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, folderId), eq(folders.userId, userId)));
|
||||
|
||||
if (!folder) {
|
||||
throw new Error('Folder not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
const fetchedEmails = await provider.fetchEmails(account, folder.path, { limit: 50 });
|
||||
await this.saveEmails(account, folder.id, fetchedEmails);
|
||||
|
||||
return { emails: fetchedEmails.length };
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchFullEmail(accountId: string, userId: string, emailId: string): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
const fullEmail = await provider.fetchEmail(account, email.externalId);
|
||||
if (fullEmail) {
|
||||
// Update email with full body
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
bodyPlain: fullEmail.bodyPlain,
|
||||
bodyHtml: fullEmail.bodyHtml,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, emailId));
|
||||
|
||||
// Save attachments
|
||||
if (fullEmail.attachments) {
|
||||
for (const att of fullEmail.attachments) {
|
||||
if (att.content) {
|
||||
await this.attachmentService.uploadDirect(userId, emailId, {
|
||||
filename: att.filename,
|
||||
mimeType: att.mimeType,
|
||||
content: att.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async updateEmailFlags(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
emailId: string,
|
||||
flags: { isRead?: boolean; isStarred?: boolean }
|
||||
): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
await provider.updateFlags(account, email.externalId, flags);
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async moveEmail(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
emailId: string,
|
||||
targetFolderId: string
|
||||
): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const [targetFolder] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, targetFolderId), eq(folders.userId, userId)));
|
||||
|
||||
if (!targetFolder) {
|
||||
throw new Error('Target folder not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
await provider.moveEmail(account, email.externalId, targetFolder.path);
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEmail(accountId: string, userId: string, emailId: string): Promise<void> {
|
||||
const account = await this.accountService.findById(accountId, userId);
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const [email] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
|
||||
|
||||
if (!email || !email.externalId) {
|
||||
throw new Error('Email not found');
|
||||
}
|
||||
|
||||
const provider = this.getProvider(account.provider);
|
||||
const password =
|
||||
account.provider === 'imap'
|
||||
? await this.accountService.getDecryptedPassword(accountId, userId)
|
||||
: undefined;
|
||||
|
||||
await provider.connect(account, password || undefined);
|
||||
|
||||
try {
|
||||
await provider.deleteEmail(account, email.externalId);
|
||||
} finally {
|
||||
await provider.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private getProvider(providerType: string): EmailProvider {
|
||||
switch (providerType) {
|
||||
case 'imap':
|
||||
return this.imapProvider;
|
||||
case 'gmail':
|
||||
return this.gmailProvider;
|
||||
case 'outlook':
|
||||
return this.outlookProvider;
|
||||
default:
|
||||
throw new Error(`Unknown provider type: ${providerType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveFolders(account: EmailAccount, fetchedFolders: FetchedFolder[]): Promise<void> {
|
||||
for (const fetched of fetchedFolders) {
|
||||
// Check if folder exists
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.accountId, account.id), eq(folders.path, fetched.path)));
|
||||
|
||||
if (!existing) {
|
||||
await this.db.insert(folders).values({
|
||||
accountId: account.id,
|
||||
userId: account.userId,
|
||||
name: fetched.name,
|
||||
type: fetched.type,
|
||||
path: fetched.path,
|
||||
isSystem: ['inbox', 'sent', 'drafts', 'trash', 'spam'].includes(fetched.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveEmails(
|
||||
account: EmailAccount,
|
||||
folderId: string,
|
||||
fetchedEmails: FetchedEmail[]
|
||||
): Promise<void> {
|
||||
for (const fetched of fetchedEmails) {
|
||||
// Check if email exists by messageId
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(and(eq(emails.accountId, account.id), eq(emails.messageId, fetched.messageId)));
|
||||
|
||||
if (!existing) {
|
||||
await this.db.insert(emails).values({
|
||||
accountId: account.id,
|
||||
folderId,
|
||||
userId: account.userId,
|
||||
messageId: fetched.messageId,
|
||||
externalId: fetched.externalId,
|
||||
threadId: fetched.threadId
|
||||
? await this.getOrCreateThreadId(account.id, fetched.threadId)
|
||||
: null,
|
||||
subject: fetched.subject,
|
||||
fromAddress: fetched.fromAddress,
|
||||
fromName: fetched.fromName,
|
||||
toAddresses: fetched.toAddresses,
|
||||
ccAddresses: fetched.ccAddresses,
|
||||
snippet: fetched.snippet,
|
||||
bodyPlain: fetched.bodyPlain,
|
||||
bodyHtml: fetched.bodyHtml,
|
||||
sentAt: fetched.sentAt,
|
||||
receivedAt: fetched.receivedAt,
|
||||
isRead: fetched.isRead,
|
||||
isStarred: fetched.isStarred,
|
||||
hasAttachments: fetched.hasAttachments,
|
||||
});
|
||||
} else {
|
||||
// Update existing email flags
|
||||
await this.db
|
||||
.update(emails)
|
||||
.set({
|
||||
isRead: fetched.isRead,
|
||||
isStarred: fetched.isStarred,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emails.id, existing.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateThreadId(accountId: string, externalThreadId: string): Promise<string> {
|
||||
// Find existing email with same external thread ID
|
||||
const [existingEmail] = await this.db
|
||||
.select()
|
||||
.from(emails)
|
||||
.where(eq(emails.accountId, accountId))
|
||||
.limit(1);
|
||||
|
||||
// For simplicity, we generate a new UUID for each thread
|
||||
// In a real implementation, you'd want to track thread IDs properly
|
||||
return externalThreadId;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue