157 lines
4.9 KiB
TypeScript
157 lines
4.9 KiB
TypeScript
import { getServiceRegistry } from '@lilith/service-registry'
|
|
import { Injectable, Logger, Inject, Optional } from '@nestjs/common'
|
|
import { ConfigService } from '@nestjs/config'
|
|
import axios, { AxiosInstance } from 'axios'
|
|
|
|
import { EMAIL_CLIENT_OPTIONS } from './module'
|
|
import type {
|
|
EmailClientModuleOptions,
|
|
EmailUserData,
|
|
SendCustomEmailOptions,
|
|
SendTemplateEmailOptions,
|
|
} from './types'
|
|
|
|
/**
|
|
* Email client for communicating with the email backend service.
|
|
* Uses the internal API endpoints protected by X-Internal-Api-Key.
|
|
*
|
|
* All methods are graceful -- email failures never throw, they log and return null.
|
|
* This ensures email delivery issues never break calling service flows.
|
|
*/
|
|
@Injectable()
|
|
export class EmailClientService {
|
|
private readonly logger = new Logger(EmailClientService.name)
|
|
private readonly client: AxiosInstance
|
|
private readonly enabled: boolean
|
|
|
|
constructor(
|
|
@Inject(ConfigService) private readonly configService: ConfigService,
|
|
@Optional() @Inject(EMAIL_CLIENT_OPTIONS) private readonly options?: EmailClientModuleOptions
|
|
) {
|
|
const registry = getServiceRegistry()
|
|
const registryUrl = registry.getApiUrl('email') ?? ''
|
|
const serviceUrl = this.options?.serviceUrl
|
|
|| this.configService.get<string>('EMAIL_SERVICE_URL')
|
|
|| registryUrl
|
|
const apiKeyEnvVar = this.options?.apiKeyEnvVar ?? 'EMAIL_INTERNAL_API_KEY'
|
|
const apiKey = this.configService.get<string>(apiKeyEnvVar, '')
|
|
|
|
this.enabled = !!apiKey
|
|
if (!this.enabled) {
|
|
this.logger.warn(`${apiKeyEnvVar} not set - email notifications disabled`)
|
|
}
|
|
|
|
this.client = axios.create({
|
|
baseURL: `${serviceUrl}/internal`,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Internal-Api-Key': apiKey,
|
|
},
|
|
timeout: 10000,
|
|
})
|
|
}
|
|
|
|
private async post<T extends object>(endpoint: string, data: T): Promise<string | null> {
|
|
if (!this.enabled) {
|
|
this.logger.debug(`Email disabled, skipping: ${endpoint}`)
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const response = await this.client.post(endpoint, data)
|
|
return response.data.jobId
|
|
} catch (error) {
|
|
this.logger.error(`Failed to send email via ${endpoint}:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send welcome email to new user
|
|
*/
|
|
async sendWelcome(data: EmailUserData): Promise<string | null> {
|
|
this.logger.log(`Sending welcome email to ${data.email}`)
|
|
return this.post('/send/welcome', data)
|
|
}
|
|
|
|
/**
|
|
* Send email verification link
|
|
*/
|
|
async sendVerification(
|
|
data: EmailUserData & { verificationToken: string }
|
|
): Promise<string | null> {
|
|
this.logger.log(`Sending verification email to ${data.email}`)
|
|
return this.post('/send/verification', data)
|
|
}
|
|
|
|
/**
|
|
* Send password reset link.
|
|
* Accepts resetToken (NOT resetUrl) to match the email service internal API contract.
|
|
*/
|
|
async sendPasswordReset(
|
|
data: EmailUserData & { resetToken: string; expiresInMinutes?: number }
|
|
): Promise<string | null> {
|
|
this.logger.log(`Sending password reset email to ${data.email}`)
|
|
return this.post('/send/password-reset', data)
|
|
}
|
|
|
|
/**
|
|
* Send password changed confirmation
|
|
*/
|
|
async sendPasswordChanged(data: EmailUserData): Promise<string | null> {
|
|
this.logger.log(`Sending password changed email to ${data.email}`)
|
|
return this.post('/send/password-changed', data)
|
|
}
|
|
|
|
/**
|
|
* Send account locked notification
|
|
*/
|
|
async sendAccountLocked(
|
|
data: EmailUserData & { reason?: string; unlockUrl?: string }
|
|
): Promise<string | null> {
|
|
this.logger.log(`Sending account locked email to ${data.email}`)
|
|
return this.post('/send/account-locked', data)
|
|
}
|
|
|
|
/**
|
|
* Send new login alert
|
|
*/
|
|
async sendLoginAlert(
|
|
data: EmailUserData & {
|
|
device?: string
|
|
location?: string
|
|
ipAddress?: string
|
|
}
|
|
): Promise<string | null> {
|
|
this.logger.log(`Sending login alert to ${data.email}`)
|
|
return this.post('/send/login-alert', data)
|
|
}
|
|
|
|
/**
|
|
* Send OTP code for MFA
|
|
*/
|
|
async sendOtp(
|
|
data: EmailUserData & { code: string; expiresInMinutes?: number }
|
|
): Promise<string | null> {
|
|
this.logger.log(`Sending OTP to ${data.email}`)
|
|
return this.post('/send/otp', data)
|
|
}
|
|
|
|
/**
|
|
* Send email using a named template with variable substitution.
|
|
* This is the generic method for sending any template-based email.
|
|
*/
|
|
async sendTemplate(options: SendTemplateEmailOptions): Promise<string | null> {
|
|
this.logger.log(`Sending template email "${options.templateName}" to ${options.to}`)
|
|
return this.post('/send/template', options)
|
|
}
|
|
|
|
/**
|
|
* Send a custom email with raw HTML content.
|
|
* Use this when no template exists for the email type.
|
|
*/
|
|
async sendCustom(options: SendCustomEmailOptions): Promise<string | null> {
|
|
this.logger.log(`Sending custom email "${options.subject}" to ${options.to}`)
|
|
return this.post('/send/custom', options)
|
|
}
|
|
}
|