feat(email): add internal module with OTP email support

Add internal email module for service-to-service communication:
- InternalModule and InternalController for internal API endpoints
- OTP code email template for authentication flows

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-29 03:59:02 -08:00
parent e0e549dc60
commit e78b9c4543
4 changed files with 217 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,24 @@
{{!-- templates/users/otp-code.hbs --}}
{{!-- Variables: name, code, expiresIn --}}
<h1>Your Verification Code</h1>
<p>Hi {{name}},</p>
<p>Here's your one-time verification code:</p>
<div style="background: linear-gradient(135deg, #FF69B4 0%, #FF1493 100%); color: white; font-size: 32px; font-weight: bold; letter-spacing: 8px; text-align: center; padding: 24px 40px; border-radius: 12px; margin: 24px 0;">
{{code}}
</div>
<p>This code will expire in <strong>{{expiresIn}}</strong>.</p>
<p style="color: #666; font-size: 14px;">
If you didn't request this code, you can safely ignore this email.
Someone may have entered your email address by mistake.
</p>
<p style="color: #666; font-size: 14px; margin-top: 24px;">
<strong>Security tip:</strong> Never share this code with anyone.
Lilith staff will never ask for your verification code.
</p>