Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -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' };
}
}

View file

@ -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 {}

View 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,
});
}
}

View 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;
}

View 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;
}
}

View 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 {}

View 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;
}
}

View 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 {}

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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,
};
}
}

View file

@ -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;
}

View file

@ -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 };
}
}

View file

@ -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 {}

View 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,
});
}
}

View 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;
}

View 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>;

View 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();
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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';

View file

@ -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;

View 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[];
}

View 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 };
}
}

View 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 {}

View 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);
}
}

View 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;
}

View file

@ -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 };
}
}

View 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 {}

View 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;
}
}

View file

@ -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(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View 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[];
}

View 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 };
}
}

View 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 {}

View 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,
}))
);
}
}
}

View 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();

View file

@ -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;
}
}

View file

@ -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);
},
});
}
}

View 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(),
},
};
}
}

View 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 {}

View file

@ -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>;
}

View file

@ -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+/),
};
}
}

View file

@ -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;
}
}

View file

@ -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,
};
}
}

View 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 };
}
}

View 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 {}

View 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;
}
}