diff --git a/.env.development b/.env.development index 92535d6e6..cf77b28b5 100644 --- a/.env.development +++ b/.env.development @@ -57,6 +57,12 @@ STRIPE_SECRET_KEY=sk_test_YOUR_KEY STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET +# Email (Brevo/SendinBlue) - GDPR compliant EU provider +# Get your API key from https://app.brevo.com/settings/keys/api +BREVO_API_KEY=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 +BREVO_FROM_EMAIL=noreply@manacore.app +BREVO_FROM_NAME=Mana Core + # ============================================ # CHAT PROJECT # ============================================ diff --git a/apps/chat/apps/backend/package.json b/apps/chat/apps/backend/package.json index 6ce38d5e5..c31ef203f 100644 --- a/apps/chat/apps/backend/package.json +++ b/apps/chat/apps/backend/package.json @@ -27,15 +27,18 @@ "@google/generative-ai": "^0.24.1", "@manacore/shared-errors": "workspace:*", "@manacore/shared-nestjs-auth": "workspace:*", + "@manacore/shared-storage": "workspace:*", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", + "@types/multer": "^1.4.11", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", "drizzle-kit": "^0.30.2", "drizzle-orm": "^0.38.3", + "multer": "^1.4.5-lts.1", "openai": "^4.77.0", "postgres": "^3.4.5", "reflect-metadata": "^0.2.2", diff --git a/apps/chat/apps/backend/src/app.module.ts b/apps/chat/apps/backend/src/app.module.ts index ec4fd9696..699836d41 100644 --- a/apps/chat/apps/backend/src/app.module.ts +++ b/apps/chat/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { SpaceModule } from './space/space.module'; import { DocumentModule } from './document/document.module'; import { ModelModule } from './model/model.module'; import { HealthModule } from './health/health.module'; +import { StorageModule } from './storage/storage.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { HealthModule } from './health/health.module'; DocumentModule, ModelModule, HealthModule, + StorageModule, ], }) export class AppModule {} diff --git a/apps/chat/apps/backend/src/storage/index.ts b/apps/chat/apps/backend/src/storage/index.ts new file mode 100644 index 000000000..a889966c8 --- /dev/null +++ b/apps/chat/apps/backend/src/storage/index.ts @@ -0,0 +1,3 @@ +export * from './storage.module'; +export * from './storage.service'; +export * from './storage.controller'; diff --git a/apps/chat/apps/backend/src/storage/storage.controller.ts b/apps/chat/apps/backend/src/storage/storage.controller.ts new file mode 100644 index 000000000..9ae27024f --- /dev/null +++ b/apps/chat/apps/backend/src/storage/storage.controller.ts @@ -0,0 +1,137 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseInterceptors, + UploadedFile, + BadRequestException, + NotFoundException, + UseGuards, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { StorageService } from './storage.service'; + +interface PresignedUploadRequest { + filename: string; + folder?: string; +} + +@Controller('api/storage') +@UseGuards(JwtAuthGuard) +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + /** + * Upload a file directly + */ + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @CurrentUser() user: CurrentUserData, + @UploadedFile() file: Express.Multer.File, + @Body('folder') folder?: string + ) { + if (!file) { + throw new BadRequestException('No file provided'); + } + + const result = await this.storageService.uploadFile( + user.userId, + file.originalname, + file.buffer, + { + folder, + } + ); + + return { + success: true, + data: result, + }; + } + + /** + * Get a presigned URL for client-side upload + */ + @Post('presigned-upload') + async getPresignedUpload( + @CurrentUser() user: CurrentUserData, + @Body() body: PresignedUploadRequest + ) { + if (!body.filename) { + throw new BadRequestException('Filename is required'); + } + + const result = await this.storageService.getPresignedUploadUrl(user.userId, body.filename, { + folder: body.folder, + }); + + return { + success: true, + data: result, + }; + } + + /** + * Get a presigned URL for downloading + */ + @Get('download/:key(*)') + async getDownloadUrl(@CurrentUser() user: CurrentUserData, @Param('key') key: string) { + // Ensure user can only access their own files + if (!key.startsWith(`users/${user.userId}/`)) { + throw new NotFoundException('File not found'); + } + + const exists = await this.storageService.fileExists(key); + if (!exists) { + throw new NotFoundException('File not found'); + } + + const url = await this.storageService.getPresignedDownloadUrl(key); + + return { + success: true, + data: { url }, + }; + } + + /** + * Delete a file + */ + @Delete(':key(*)') + async deleteFile(@CurrentUser() user: CurrentUserData, @Param('key') key: string) { + // Ensure user can only delete their own files + if (!key.startsWith(`users/${user.userId}/`)) { + throw new NotFoundException('File not found'); + } + + const exists = await this.storageService.fileExists(key); + if (!exists) { + throw new NotFoundException('File not found'); + } + + await this.storageService.deleteFile(key); + + return { + success: true, + message: 'File deleted', + }; + } + + /** + * List user's files + */ + @Get('list') + async listFiles(@CurrentUser() user: CurrentUserData, @Body('folder') folder?: string) { + const files = await this.storageService.listUserFiles(user.userId, folder); + + return { + success: true, + data: { files }, + }; + } +} diff --git a/apps/chat/apps/backend/src/storage/storage.module.ts b/apps/chat/apps/backend/src/storage/storage.module.ts new file mode 100644 index 000000000..0dce6a4d4 --- /dev/null +++ b/apps/chat/apps/backend/src/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StorageService } from './storage.service'; +import { StorageController } from './storage.controller'; + +@Module({ + controllers: [StorageController], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/apps/chat/apps/backend/src/storage/storage.service.ts b/apps/chat/apps/backend/src/storage/storage.service.ts new file mode 100644 index 000000000..29cc9f782 --- /dev/null +++ b/apps/chat/apps/backend/src/storage/storage.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + createChatStorage, + generateUserFileKey, + getContentType, + validateFileSize, + validateFileExtension, + IMAGE_EXTENSIONS, + DOCUMENT_EXTENSIONS, + AUDIO_EXTENSIONS, +} from '@manacore/shared-storage'; +import type { StorageClient, UploadResult } from '@manacore/shared-storage'; + +export interface FileUploadResult { + key: string; + url?: string; + contentType: string; + size: number; +} + +export interface PresignedUploadData { + uploadUrl: string; + key: string; + expiresIn: number; +} + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB +const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...AUDIO_EXTENSIONS]; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private storage: StorageClient | null = null; + + private getStorage(): StorageClient { + if (!this.storage) { + this.storage = createChatStorage(); + } + return this.storage; + } + + /** + * Upload a file to storage + */ + async uploadFile( + userId: string, + filename: string, + data: Buffer, + options?: { folder?: string; public?: boolean } + ): Promise { + // Validate file size (MAX_FILE_SIZE is in bytes) + if (!validateFileSize(data.length, MAX_FILE_SIZE / (1024 * 1024))) { + throw new Error(`File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`); + } + + // Validate file extension + if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) { + throw new Error( + `File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}` + ); + } + + const contentType = getContentType(filename); + const key = generateUserFileKey(userId, filename, options?.folder); + + const storage = this.getStorage(); + const result: UploadResult = await storage.upload(key, data, { + contentType, + public: options?.public ?? false, + }); + + this.logger.log(`File uploaded: ${key} (${data.length} bytes)`); + + return { + key: result.key, + url: result.url, + contentType, + size: data.length, + }; + } + + /** + * Get a presigned URL for uploading (client-side upload) + */ + async getPresignedUploadUrl( + userId: string, + filename: string, + options?: { folder?: string; expiresIn?: number } + ): Promise { + // Validate file extension + if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) { + throw new Error( + `File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}` + ); + } + + const key = generateUserFileKey(userId, filename, options?.folder); + const expiresIn = options?.expiresIn ?? 3600; // 1 hour default + + const storage = this.getStorage(); + const uploadUrl = await storage.getUploadUrl(key, { expiresIn }); + + return { + uploadUrl, + key, + expiresIn, + }; + } + + /** + * Get a presigned URL for downloading + */ + async getPresignedDownloadUrl(key: string, expiresIn = 3600): Promise { + const storage = this.getStorage(); + return storage.getDownloadUrl(key, { expiresIn }); + } + + /** + * Download a file from storage + */ + async downloadFile(key: string): Promise { + const storage = this.getStorage(); + return storage.download(key); + } + + /** + * Delete a file from storage + */ + async deleteFile(key: string): Promise { + const storage = this.getStorage(); + await storage.delete(key); + this.logger.log(`File deleted: ${key}`); + } + + /** + * Check if a file exists + */ + async fileExists(key: string): Promise { + const storage = this.getStorage(); + return storage.exists(key); + } + + /** + * List files for a user + */ + async listUserFiles(userId: string, folder?: string): Promise { + const storage = this.getStorage(); + const prefix = folder ? `users/${userId}/${folder}/` : `users/${userId}/`; + const files = await storage.list(prefix); + return files.map((f) => f.key); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e76e3b239..b6647eb82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,6 +325,9 @@ importers: '@manacore/shared-nestjs-auth': specifier: workspace:* version: link:../../../../packages/shared-nestjs-auth + '@manacore/shared-storage': + specifier: workspace:* + version: link:../../../../packages/shared-storage '@nestjs/common': specifier: ^10.4.15 version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -337,6 +340,9 @@ importers: '@nestjs/platform-express': specifier: ^10.4.15 version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + '@types/multer': + specifier: ^1.4.11 + version: 1.4.13 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -352,6 +358,9 @@ importers: drizzle-orm: specifier: ^0.38.3 version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.7)(expo-sqlite@15.2.14(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(kysely@0.28.8)(postgres@3.4.7)(react@19.1.0) + multer: + specifier: ^1.4.5-lts.1 + version: 1.4.5-lts.2 openai: specifier: ^4.77.0 version: 4.104.0(ws@8.18.3)(zod@3.25.76) @@ -367,7 +376,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.27.0) + version: 10.4.9(esbuild@0.19.12) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -400,7 +409,7 @@ importers: version: 0.5.21 ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) + version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -3454,7 +3463,7 @@ importers: version: 9.39.1 '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9(esbuild@0.27.0) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -3490,7 +3499,7 @@ importers: version: 0.5.21 ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -4189,6 +4198,9 @@ importers: services/mana-core-auth: dependencies: + '@getbrevo/brevo': + specifier: ^3.0.1 + version: 3.0.1 '@google/generative-ai': specifier: ^0.24.1 version: 0.24.1 @@ -6817,6 +6829,9 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@getbrevo/brevo@3.0.1': + resolution: {integrity: sha512-BS5hlgb9qPHhXqjV+VbEOciygnsEVZV8BgoX+JYpD+I+R9u3U05y/euqdmk8nATfcEUCUlQq+aWdWOWTF4cEjQ==} + '@google/genai@1.30.0': resolution: {integrity: sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==} engines: {node: '>=20.0.0'} @@ -10736,6 +10751,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + blurhash@2.0.5: resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} @@ -17286,6 +17304,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rewire@7.0.0: + resolution: {integrity: sha512-DyyNyzwMtGYgu0Zl/ya0PR/oaunM+VuCuBxCuhYJHHaV0V+YvYa3bBGxb5OZ71vndgmp1pYY8F4YOwQo1siRGw==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -22294,7 +22315,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 6.0.15(dux2nvtiztnejw7mxzfaajqvh4) + expo-router: 6.0.15(nttrd3tw67nnyhowcwgdzipb5e) react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -23044,6 +23065,15 @@ snapshots: dependencies: tslib: 2.8.1 + '@getbrevo/brevo@3.0.1': + dependencies: + axios: 1.13.2 + bluebird: 3.7.2 + rewire: 7.0.0 + transitivePeerDependencies: + - debug + - supports-color + '@google/genai@1.30.0': dependencies: google-auth-library: 10.5.0 @@ -23599,6 +23629,43 @@ snapshots: - supports-color - ts-node + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 22.19.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))': dependencies: '@jest/console': 30.2.0 @@ -26660,6 +26727,19 @@ snapshots: jest: 30.2.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + jest-matcher-utils: 30.2.0 + picocolors: 1.1.1 + pretty-format: 30.2.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + react-test-renderer: 19.1.0(react@19.1.0) + redent: 3.0.0 + optionalDependencies: + jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + optional: true + '@testing-library/react-native@13.3.3(jest@30.2.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 @@ -29154,6 +29234,8 @@ snapshots: blake3-wasm@2.1.5: {} + bluebird@3.7.2: {} + blurhash@2.0.5: {} body-parser@1.20.3: @@ -32550,6 +32632,53 @@ snapshots: - supports-color optional: true + expo-router@6.0.15(nttrd3tw67nnyhowcwgdzipb5e): + dependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + '@expo/schema-utils': 0.1.7 + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + '@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)) + expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo-server: 1.0.4 + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.1.0 + react-fast-compare: 3.2.2 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.1.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.1.0) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + optionalDependencies: + '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + '@testing-library/react-native': 13.3.3(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) + react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - supports-color + optional: true + expo-router@6.0.15(qjp3usx4acoq47dkosl6pmu254): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.13)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -34673,6 +34802,26 @@ snapshots: - ts-node optional: true + jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -34858,6 +35007,41 @@ snapshots: - supports-color optional: true + jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.1 + esbuild-register: 3.6.0(esbuild@0.19.12) + ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0)): dependencies: '@babel/core': 7.28.5 @@ -35527,6 +35711,20 @@ snapshots: - ts-node optional: true + jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) @@ -39298,6 +39496,16 @@ snapshots: webpack: 5.100.2(esbuild@0.27.0) webpack-sources: 3.3.3 + react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.97.1(esbuild@0.19.12)): + dependencies: + acorn-loose: 8.5.2 + neo-async: 2.6.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + webpack: 5.97.1(esbuild@0.19.12) + webpack-sources: 3.3.3 + optional: true + react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.0): dependencies: get-nonce: 1.0.1 @@ -39634,6 +39842,12 @@ snapshots: reusify@1.1.0: {} + rewire@7.0.0: + dependencies: + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + rfdc@1.4.1: {} rimraf@2.6.3: @@ -40549,17 +40763,6 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.44.1 - webpack: 5.100.2(esbuild@0.19.12) - optionalDependencies: - esbuild: 0.19.12 - terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -40804,16 +41007,6 @@ snapshots: babel-jest: 30.2.0(@babel/core@7.28.5) jest-util: 30.2.0 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.19.12)): - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.18.3 - micromatch: 4.0.8 - semver: 7.7.3 - source-map: 0.7.6 - typescript: 5.9.3 - webpack: 5.100.2(esbuild@0.19.12) - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)): dependencies: chalk: 4.1.2 @@ -40834,6 +41027,16 @@ snapshots: typescript: 5.9.3 webpack: 5.100.2 + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.3 + micromatch: 4.0.8 + semver: 7.7.3 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.97.1(esbuild@0.19.12) + ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -41803,38 +42006,6 @@ snapshots: - esbuild - uglify-js - webpack@5.100.2(esbuild@0.19.12): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.28.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.7.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.100.2(esbuild@0.19.12)) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.100.2(esbuild@0.27.0): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index d8be64545..e87010302 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -75,6 +75,9 @@ const APP_CONFIGS = [ STRIPE_SECRET_KEY: (env) => env.STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY: (env) => env.STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET: (env) => env.STRIPE_WEBHOOK_SECRET, + BREVO_API_KEY: (env) => env.BREVO_API_KEY || '', + BREVO_FROM_EMAIL: (env) => env.BREVO_FROM_EMAIL || 'noreply@manacore.app', + BREVO_FROM_NAME: (env) => env.BREVO_FROM_NAME || 'Mana Core', CORS_ORIGINS: (env) => env.CORS_ORIGINS, CREDITS_SIGNUP_BONUS: (env) => env.CREDITS_SIGNUP_BONUS, CREDITS_DAILY_FREE: (env) => env.CREDITS_DAILY_FREE, @@ -98,6 +101,12 @@ const APP_CONFIGS = [ GOOGLE_GENAI_API_KEY: (env) => env.GOOGLE_GENAI_API_KEY, MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, DATABASE_URL: (env) => env.CHAT_DATABASE_URL, + // S3 Storage (MinIO local, Hetzner production) + S3_ENDPOINT: (env) => env.S3_ENDPOINT, + S3_REGION: (env) => env.S3_REGION, + S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY, + S3_SECRET_KEY: (env) => env.S3_SECRET_KEY, + CORS_ORIGINS: (env) => env.CORS_ORIGINS, }, }, diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json index 1eb6fb0fc..c8e4843a0 100644 --- a/services/mana-core-auth/package.json +++ b/services/mana-core-auth/package.json @@ -21,6 +21,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@getbrevo/brevo": "^3.0.1", "@google/generative-ai": "^0.24.1", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 41404bbd4..718da8cfb 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -5,6 +5,7 @@ import { APP_FILTER } from '@nestjs/core'; import configuration from './config/configuration'; import { AuthModule } from './auth/auth.module'; import { CreditsModule } from './credits/credits.module'; +import { EmailModule } from './email/email.module'; import { FeedbackModule } from './feedback/feedback.module'; import { ReferralsModule } from './referrals/referrals.module'; import { SettingsModule } from './settings/settings.module'; @@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter'; AiModule, AuthModule, CreditsModule, + EmailModule, FeedbackModule, HealthModule, ReferralsModule, diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts index 13efe856f..6dcab6c30 100644 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ b/services/mana-core-auth/src/auth/better-auth.config.ts @@ -22,6 +22,7 @@ import { getDb } from '../db/connection'; import { organizations, members, invitations } from '../db/schema/organizations.schema'; import { users, sessions, accounts, verificationTokens, jwks } from '../db/schema/auth.schema'; import type { JWTPayloadContext } from './types/better-auth.types'; +import { sendPasswordResetEmail, sendOrganizationInvitationEmail } from '../email/email-sender'; /** * JWT Custom Payload Interface @@ -96,19 +97,8 @@ export function createBetterAuth(databaseUrl: string) { * * @see https://www.better-auth.com/docs/authentication/email-password#password-reset */ - sendResetPassword: async ({ user, url, token }) => { - // TODO: Implement email sending service (e.g., Resend, SendGrid) - // For now, log the reset URL for development - console.log('[Password Reset] User:', user.email); - console.log('[Password Reset] Reset URL:', url); - console.log('[Password Reset] Token:', token); - - // In production, send an email like: - // await sendEmail({ - // to: user.email, - // subject: 'Reset your password', - // html: `Reset your password` - // }); + sendResetPassword: async ({ user, url }) => { + await sendPasswordResetEmail(user.email, url, user.name); }, }, @@ -143,14 +133,16 @@ export function createBetterAuth(databaseUrl: string) { // Email invitation handler async sendInvitationEmail(data) { - const { email, organization } = data; + const { email, organization, inviter } = data; + const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; + const invitationUrl = `${baseUrl}/accept-invitation?id=${data.id}`; - // TODO: Implement email sending service - console.log('TODO: Send invitation email', { - to: email, - organization: organization.name, - invitationId: data.id, - }); + await sendOrganizationInvitationEmail( + email, + organization.name, + invitationUrl, + inviter?.user?.name + ); }, // Custom roles and permissions diff --git a/services/mana-core-auth/src/config/configuration.ts b/services/mana-core-auth/src/config/configuration.ts index 398221369..65f26767c 100644 --- a/services/mana-core-auth/src/config/configuration.ts +++ b/services/mana-core-auth/src/config/configuration.ts @@ -28,6 +28,12 @@ export default () => ({ publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', }, + email: { + brevoApiKey: process.env.BREVO_API_KEY || '', + fromEmail: process.env.BREVO_FROM_EMAIL || 'noreply@manacore.app', + fromName: process.env.BREVO_FROM_NAME || 'Mana Core', + }, + cors: { origin: process.env.CORS_ORIGINS?.split(',') || [ 'http://localhost:3000', diff --git a/services/mana-core-auth/src/email/email-sender.ts b/services/mana-core-auth/src/email/email-sender.ts new file mode 100644 index 000000000..b7ee9ec99 --- /dev/null +++ b/services/mana-core-auth/src/email/email-sender.ts @@ -0,0 +1,322 @@ +/** + * Standalone email sender for Better Auth callbacks + * + * This module provides email sending functionality that can be used + * outside of the NestJS DI context (e.g., in Better Auth callbacks). + */ + +import * as brevo from '@getbrevo/brevo'; + +interface EmailConfig { + apiKey?: string; + fromEmail: string; + fromName: string; +} + +function getEmailConfig(): EmailConfig { + return { + apiKey: process.env.BREVO_API_KEY, + fromEmail: process.env.BREVO_FROM_EMAIL || 'noreply@manacore.app', + fromName: process.env.BREVO_FROM_NAME || 'Mana Core', + }; +} + +let apiInstance: brevo.TransactionalEmailsApi | null = null; + +function getApiInstance(): brevo.TransactionalEmailsApi | null { + const config = getEmailConfig(); + + if (!config.apiKey) { + return null; + } + + if (!apiInstance) { + apiInstance = new brevo.TransactionalEmailsApi(); + apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, config.apiKey); + } + + return apiInstance; +} + +/** + * Send password reset email + */ +export async function sendPasswordResetEmail( + email: string, + resetUrl: string, + userName?: string +): Promise { + const config = getEmailConfig(); + const api = getApiInstance(); + + if (!api) { + console.log('[DEV MODE] Password reset email would be sent to:', email); + console.log('[DEV MODE] Reset URL:', resetUrl); + return; + } + + const sendSmtpEmail = new brevo.SendSmtpEmail(); + sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName }; + sendSmtpEmail.to = [{ email }]; + sendSmtpEmail.subject = 'Reset your Mana Core password'; + sendSmtpEmail.htmlContent = getPasswordResetTemplate(resetUrl, userName); + sendSmtpEmail.textContent = ` +Reset your password + +Hi${userName ? ` ${userName}` : ''}, + +You requested to reset your password. Click the link below to set a new password: + +${resetUrl} + +This link will expire in 1 hour. + +If you didn't request this, you can safely ignore this email. + +- The Mana Core Team +`.trim(); + + try { + await api.sendTransacEmail(sendSmtpEmail); + console.log(`Password reset email sent to ${email}`); + } catch (error) { + console.error(`Failed to send password reset email to ${email}:`, error); + throw error; + } +} + +/** + * Send organization invitation email + */ +export async function sendOrganizationInvitationEmail( + email: string, + organizationName: string, + invitationUrl: string, + inviterName?: string +): Promise { + const config = getEmailConfig(); + const api = getApiInstance(); + + if (!api) { + console.log('[DEV MODE] Invitation email would be sent to:', email); + console.log('[DEV MODE] Organization:', organizationName); + console.log('[DEV MODE] Invitation URL:', invitationUrl); + return; + } + + const sendSmtpEmail = new brevo.SendSmtpEmail(); + sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName }; + sendSmtpEmail.to = [{ email }]; + sendSmtpEmail.subject = `You've been invited to join ${organizationName} on Mana Core`; + sendSmtpEmail.htmlContent = getInvitationTemplate(organizationName, invitationUrl, inviterName); + sendSmtpEmail.textContent = ` +You've been invited to ${organizationName} + +Hi, + +${inviterName ? `${inviterName} has` : 'You have been'} invited you to join ${organizationName} on Mana Core. + +Click the link below to accept the invitation: + +${invitationUrl} + +This invitation will expire in 7 days. + +- The Mana Core Team +`.trim(); + + try { + await api.sendTransacEmail(sendSmtpEmail); + console.log(`Invitation email sent to ${email}`); + } catch (error) { + console.error(`Failed to send invitation email to ${email}:`, error); + throw error; + } +} + +/** + * Send email verification email + */ +export async function sendVerificationEmail( + email: string, + verificationUrl: string, + userName?: string +): Promise { + const config = getEmailConfig(); + const api = getApiInstance(); + + if (!api) { + console.log('[DEV MODE] Verification email would be sent to:', email); + console.log('[DEV MODE] Verification URL:', verificationUrl); + return; + } + + const sendSmtpEmail = new brevo.SendSmtpEmail(); + sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName }; + sendSmtpEmail.to = [{ email }]; + sendSmtpEmail.subject = 'Verify your Mana Core email address'; + sendSmtpEmail.htmlContent = getVerificationTemplate(verificationUrl, userName); + sendSmtpEmail.textContent = ` +Verify your email address + +Hi${userName ? ` ${userName}` : ''}, + +Please verify your email address by clicking the link below: + +${verificationUrl} + +This link will expire in 24 hours. + +If you didn't create a Mana Core account, you can safely ignore this email. + +- The Mana Core Team +`.trim(); + + try { + await api.sendTransacEmail(sendSmtpEmail); + console.log(`Verification email sent to ${email}`); + } catch (error) { + console.error(`Failed to send verification email to ${email}:`, error); + throw error; + } +} + +function getPasswordResetTemplate(resetUrl: string, userName?: string): string { + return ` + + + + + + Reset your password + + + + + + +
+ + + + + + + +
+

Reset your password

+

+ Hi${userName ? ` ${userName}` : ''},

+ You requested to reset your password. Click the button below to set a new password: +

+ + Reset Password + +

+ This link will expire in 1 hour.
+ If you didn't request this, you can safely ignore this email. +

+
+

+ © ${new Date().getFullYear()} Mana Core. All rights reserved. +

+
+
+ + +`.trim(); +} + +function getInvitationTemplate( + organizationName: string, + invitationUrl: string, + inviterName?: string +): string { + return ` + + + + + + Organization Invitation + + + + + + +
+ + + + + + + +
+

You've been invited!

+

+ ${inviterName ? `${inviterName} has` : 'You have been'} invited you to join ${organizationName} on Mana Core. +

+ + Accept Invitation + +

+ This invitation will expire in 7 days. +

+
+

+ © ${new Date().getFullYear()} Mana Core. All rights reserved. +

+
+
+ + +`.trim(); +} + +function getVerificationTemplate(verificationUrl: string, userName?: string): string { + return ` + + + + + + Verify your email + + + + + + +
+ + + + + + + +
+

Verify your email

+

+ Hi${userName ? ` ${userName}` : ''},

+ Thanks for signing up! Please verify your email address by clicking the button below: +

+ + Verify Email + +

+ This link will expire in 24 hours.
+ If you didn't create a Mana Core account, you can safely ignore this email. +

+
+

+ © ${new Date().getFullYear()} Mana Core. All rights reserved. +

+
+
+ + +`.trim(); +} diff --git a/services/mana-core-auth/src/email/email.module.ts b/services/mana-core-auth/src/email/email.module.ts new file mode 100644 index 000000000..d68260078 --- /dev/null +++ b/services/mana-core-auth/src/email/email.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Global() +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/services/mana-core-auth/src/email/email.service.ts b/services/mana-core-auth/src/email/email.service.ts new file mode 100644 index 000000000..3429c9cc5 --- /dev/null +++ b/services/mana-core-auth/src/email/email.service.ts @@ -0,0 +1,320 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as brevo from '@getbrevo/brevo'; + +export interface SendEmailOptions { + to: string; + subject: string; + htmlContent: string; + textContent?: string; +} + +export interface PasswordResetEmailData { + email: string; + name?: string; + resetUrl: string; +} + +export interface InvitationEmailData { + email: string; + organizationName: string; + inviterName?: string; + invitationUrl: string; +} + +export interface VerificationEmailData { + email: string; + name?: string; + verificationUrl: string; +} + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private readonly apiInstance: brevo.TransactionalEmailsApi; + private readonly fromEmail: string; + private readonly fromName: string; + private readonly isConfigured: boolean; + + constructor(private configService: ConfigService) { + const apiKey = this.configService.get('email.brevoApiKey'); + this.fromEmail = this.configService.get('email.fromEmail') || 'noreply@manacore.app'; + this.fromName = this.configService.get('email.fromName') || 'Mana Core'; + + this.apiInstance = new brevo.TransactionalEmailsApi(); + + if (apiKey) { + this.apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey); + this.isConfigured = true; + this.logger.log('Email service configured with Brevo'); + } else { + this.isConfigured = false; + this.logger.warn('Email service not configured - BREVO_API_KEY is missing'); + } + } + + /** + * Send a transactional email via Brevo + */ + async sendEmail(options: SendEmailOptions): Promise { + if (!this.isConfigured) { + this.logger.warn(`[DEV MODE] Would send email to ${options.to}: ${options.subject}`); + this.logger.debug(`Email content: ${options.htmlContent}`); + return true; + } + + try { + const sendSmtpEmail = new brevo.SendSmtpEmail(); + sendSmtpEmail.sender = { email: this.fromEmail, name: this.fromName }; + sendSmtpEmail.to = [{ email: options.to }]; + sendSmtpEmail.subject = options.subject; + sendSmtpEmail.htmlContent = options.htmlContent; + + if (options.textContent) { + sendSmtpEmail.textContent = options.textContent; + } + + await this.apiInstance.sendTransacEmail(sendSmtpEmail); + this.logger.log(`Email sent successfully to ${options.to}`); + return true; + } catch (error) { + this.logger.error(`Failed to send email to ${options.to}:`, error); + throw error; + } + } + + /** + * Send password reset email + */ + async sendPasswordResetEmail(data: PasswordResetEmailData): Promise { + const subject = 'Reset your Mana Core password'; + const htmlContent = this.getPasswordResetTemplate(data); + const textContent = ` +Reset your password + +Hi${data.name ? ` ${data.name}` : ''}, + +You requested to reset your password. Click the link below to set a new password: + +${data.resetUrl} + +This link will expire in 1 hour. + +If you didn't request this, you can safely ignore this email. + +- The Mana Core Team +`.trim(); + + return this.sendEmail({ + to: data.email, + subject, + htmlContent, + textContent, + }); + } + + /** + * Send organization invitation email + */ + async sendInvitationEmail(data: InvitationEmailData): Promise { + const subject = `You've been invited to join ${data.organizationName} on Mana Core`; + const htmlContent = this.getInvitationTemplate(data); + const textContent = ` +You've been invited to ${data.organizationName} + +Hi, + +${data.inviterName ? `${data.inviterName} has` : 'You have been'} invited you to join ${data.organizationName} on Mana Core. + +Click the link below to accept the invitation: + +${data.invitationUrl} + +This invitation will expire in 7 days. + +- The Mana Core Team +`.trim(); + + return this.sendEmail({ + to: data.email, + subject, + htmlContent, + textContent, + }); + } + + /** + * Send email verification email + */ + async sendVerificationEmail(data: VerificationEmailData): Promise { + const subject = 'Verify your Mana Core email address'; + const htmlContent = this.getVerificationTemplate(data); + const textContent = ` +Verify your email address + +Hi${data.name ? ` ${data.name}` : ''}, + +Please verify your email address by clicking the link below: + +${data.verificationUrl} + +This link will expire in 24 hours. + +If you didn't create a Mana Core account, you can safely ignore this email. + +- The Mana Core Team +`.trim(); + + return this.sendEmail({ + to: data.email, + subject, + htmlContent, + textContent, + }); + } + + /** + * Password reset email template + */ + private getPasswordResetTemplate(data: PasswordResetEmailData): string { + return ` + + + + + + Reset your password + + + + + + +
+ + + + + + + +
+

Reset your password

+

+ Hi${data.name ? ` ${data.name}` : ''},

+ You requested to reset your password. Click the button below to set a new password: +

+ + Reset Password + +

+ This link will expire in 1 hour.
+ If you didn't request this, you can safely ignore this email. +

+
+

+ © ${new Date().getFullYear()} Mana Core. All rights reserved. +

+
+
+ + +`.trim(); + } + + /** + * Organization invitation email template + */ + private getInvitationTemplate(data: InvitationEmailData): string { + return ` + + + + + + Organization Invitation + + + + + + +
+ + + + + + + +
+

You've been invited!

+

+ ${data.inviterName ? `${data.inviterName} has` : 'You have been'} invited you to join ${data.organizationName} on Mana Core. +

+ + Accept Invitation + +

+ This invitation will expire in 7 days. +

+
+

+ © ${new Date().getFullYear()} Mana Core. All rights reserved. +

+
+
+ + +`.trim(); + } + + /** + * Email verification template + */ + private getVerificationTemplate(data: VerificationEmailData): string { + return ` + + + + + + Verify your email + + + + + + +
+ + + + + + + +
+

Verify your email

+

+ Hi${data.name ? ` ${data.name}` : ''},

+ Thanks for signing up! Please verify your email address by clicking the button below: +

+ + Verify Email + +

+ This link will expire in 24 hours.
+ If you didn't create a Mana Core account, you can safely ignore this email. +

+
+

+ © ${new Date().getFullYear()} Mana Core. All rights reserved. +

+
+
+ + +`.trim(); + } +} diff --git a/services/mana-core-auth/src/email/index.ts b/services/mana-core-auth/src/email/index.ts new file mode 100644 index 000000000..47e1eacd5 --- /dev/null +++ b/services/mana-core-auth/src/email/index.ts @@ -0,0 +1,2 @@ +export * from './email.service'; +export * from './email.module';