diff --git a/features/email/backend/src/app.module.ts b/features/email/backend/src/app.module.ts index 84b3e35db..435fd5cd9 100644 --- a/features/email/backend/src/app.module.ts +++ b/features/email/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { OrdersModule } from './orders/orders.module' import { UsersEmailModule } from './users/users-email.module' import { EmployeesModule } from './employees/employees.module' import { AdminModule } from './admin/admin.module' +import { InternalModule } from './internal/internal.module' import { HealthController } from './health.controller' @Module({ @@ -75,6 +76,7 @@ import { HealthController } from './health.controller' UsersEmailModule, EmployeesModule, AdminModule, + InternalModule, ], controllers: [HealthController], }) diff --git a/features/email/backend/src/internal/internal.controller.ts b/features/email/backend/src/internal/internal.controller.ts new file mode 100644 index 000000000..62c7b7a3c --- /dev/null +++ b/features/email/backend/src/internal/internal.controller.ts @@ -0,0 +1,176 @@ +import { + Controller, + Post, + Body, + Headers, + UnauthorizedException, + Logger, +} from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { ApiTags, ApiOperation, ApiHeader } from '@nestjs/swagger' + +import { UsersEmailService } from '../users/users-email.service' +import { EmailQueueService } from '../core/email-queue.service' +import { EmailCategory } from '../core/entities/email-log.entity' + +/** + * Internal API for service-to-service email requests. + * Protected by X-Internal-Api-Key header. + */ +@ApiTags('Internal') +@Controller('internal') +export class InternalController { + private readonly logger = new Logger(InternalController.name) + private readonly internalApiKey: string + + constructor( + private readonly usersEmailService: UsersEmailService, + private readonly emailQueue: EmailQueueService, + private readonly configService: ConfigService + ) { + this.internalApiKey = this.configService.get('INTERNAL_API_KEY', '') + } + + private validateApiKey(apiKey: string | undefined): void { + if (!this.internalApiKey) { + this.logger.warn('INTERNAL_API_KEY not configured - internal API disabled') + throw new UnauthorizedException('Internal API not configured') + } + if (apiKey !== this.internalApiKey) { + throw new UnauthorizedException('Invalid API key') + } + } + + @Post('send/welcome') + @ApiOperation({ summary: 'Send welcome email to new user' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendWelcome( + @Headers('x-internal-api-key') apiKey: string, + @Body() body: { userId: string; email: string; name?: string } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: welcome email for ${body.email}`) + + const jobId = await this.usersEmailService.sendWelcomeEmail(body) + return { jobId } + } + + @Post('send/verification') + @ApiOperation({ summary: 'Send email verification link' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendVerification( + @Headers('x-internal-api-key') apiKey: string, + @Body() + body: { userId: string; email: string; name?: string; verificationToken: string } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: verification email for ${body.email}`) + + const jobId = await this.usersEmailService.sendEmailVerification(body) + return { jobId } + } + + @Post('send/password-reset') + @ApiOperation({ summary: 'Send password reset link' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendPasswordReset( + @Headers('x-internal-api-key') apiKey: string, + @Body() body: { userId: string; email: string; name?: string; resetToken: string } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: password reset for ${body.email}`) + + const jobId = await this.usersEmailService.sendPasswordReset(body) + return { jobId } + } + + @Post('send/password-changed') + @ApiOperation({ summary: 'Send password changed confirmation' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendPasswordChanged( + @Headers('x-internal-api-key') apiKey: string, + @Body() body: { userId: string; email: string; name?: string } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: password changed for ${body.email}`) + + const jobId = await this.usersEmailService.sendPasswordChanged(body) + return { jobId } + } + + @Post('send/account-locked') + @ApiOperation({ summary: 'Send account locked notification' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendAccountLocked( + @Headers('x-internal-api-key') apiKey: string, + @Body() + body: { + userId: string + email: string + name?: string + reason?: string + unlockUrl?: string + } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: account locked for ${body.email}`) + + const jobId = await this.usersEmailService.sendAccountLocked(body) + return { jobId } + } + + @Post('send/login-alert') + @ApiOperation({ summary: 'Send new login alert' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendLoginAlert( + @Headers('x-internal-api-key') apiKey: string, + @Body() + body: { + userId: string + email: string + name?: string + device?: string + location?: string + ipAddress?: string + } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: login alert for ${body.email}`) + + const jobId = await this.usersEmailService.sendLoginAlert(body) + return { jobId } + } + + @Post('send/otp') + @ApiOperation({ summary: 'Send OTP code for MFA' }) + @ApiHeader({ name: 'X-Internal-Api-Key', required: true }) + async sendOtp( + @Headers('x-internal-api-key') apiKey: string, + @Body() + body: { + userId: string + email: string + name?: string + code: string + expiresInMinutes?: number + } + ): Promise<{ jobId: string }> { + this.validateApiKey(apiKey) + this.logger.log(`Internal request: OTP for ${body.email}`) + + const jobId = await this.emailQueue.queueEmail({ + to: body.email, + templateName: 'otp-code', + variables: { + name: body.name || 'there', + code: body.code, + expiresIn: `${body.expiresInMinutes || 10} minutes`, + }, + category: EmailCategory.USERS, + userId: body.userId, + priority: 'high', + }) + + return { jobId } + } +} diff --git a/features/email/backend/src/internal/internal.module.ts b/features/email/backend/src/internal/internal.module.ts new file mode 100644 index 000000000..9c084273e --- /dev/null +++ b/features/email/backend/src/internal/internal.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' + +import { CoreModule } from '../core/core.module' +import { UsersEmailModule } from '../users/users-email.module' +import { InternalController } from './internal.controller' + +/** + * Internal API module for service-to-service communication. + * Provides endpoints that other platform services can call to send emails. + */ +@Module({ + imports: [CoreModule, UsersEmailModule], + controllers: [InternalController], +}) +export class InternalModule {} diff --git a/features/email/backend/templates/users/otp-code.hbs b/features/email/backend/templates/users/otp-code.hbs new file mode 100644 index 000000000..f7bbfd322 --- /dev/null +++ b/features/email/backend/templates/users/otp-code.hbs @@ -0,0 +1,24 @@ +{{!-- templates/users/otp-code.hbs --}} +{{!-- Variables: name, code, expiresIn --}} + +

Your Verification Code

+ +

Hi {{name}},

+ +

Here's your one-time verification code:

+ +
+ {{code}} +
+ +

This code will expire in {{expiresIn}}.

+ +

+ If you didn't request this code, you can safely ignore this email. + Someone may have entered your email address by mistake. +

+ +

+ Security tip: Never share this code with anyone. + Lilith staff will never ask for your verification code. +