feat: add email notifications

This commit is contained in:
Sergey Krylov 2026-02-25 05:46:55 +03:00
parent 4797e2feda
commit 3a10dd136b
14 changed files with 1728 additions and 28 deletions

View File

@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
@ -30,6 +31,7 @@
"@teacinema/core": "^1.0.9",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^0.10.9",
"handlebars": "^4.7.8",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^4.3.6"

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import configuration from './config/configuration'
import { MailModule } from './infra/mail/mail.module'
import { RmqModule } from './infra/rmq/rmq.module'
import { NotificationsModule } from './modules/notifications/notifications.module'
@ -13,7 +14,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
expandVariables: true
}),
RmqModule,
NotificationsModule
NotificationsModule,
MailModule
]
})
export class AppModule {}

View File

@ -17,6 +17,14 @@ export default () => {
rmq: {
url: env.RMQ_URL,
queue: env.RMQ_QUEUE
},
smtp: {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
username: env.SMTP_USERNAME,
password: env.SMTP_PASSWORD,
fromAddress: env.SMTP_FROM_ADDRESS,
secure: env.SMTP_SECURE
}
}
}

View File

@ -0,0 +1 @@
export * from './mailer.factory'

View File

@ -0,0 +1,19 @@
import { MailerOptions } from '@nestjs-modules/mailer'
import { ConfigService } from '@nestjs/config'
export function getMailerConfig(configService: ConfigService): MailerOptions {
return {
transport: {
host: configService.get('smtp.host'),
port: configService.get('smtp.port'),
secure: configService.get('smtp.secure'),
auth: {
user: configService.get('smtp.username'),
pass: configService.get('smtp.password')
}
},
defaults: {
from: `Teacinema ${configService.get('smtp.fromAddress')}`
}
}
}

View File

@ -7,7 +7,19 @@ export enum Enviroment {
}
export default z.object({
// Node
NODE_ENV: z.enum(Enviroment).default(Enviroment.Development),
// RabbitMQ
RMQ_URL: z.string().nonempty(),
RMQ_QUEUE: z.string().nonempty()
RMQ_QUEUE: z.string().nonempty(),
// SMTP
SMTP_HOST: z.string().nonempty(),
SMTP_PORT: z.coerce.number().nonnegative(),
SMTP_USERNAME: z.string().nonempty(),
SMTP_PASSWORD: z.string().nonempty(),
SMTP_FROM_ADDRESS: z.string().email().nonempty(),
SMTP_SECURE: z
.string()
.default('false')
.transform(value => value === 'true')
})

View File

@ -0,0 +1,20 @@
import { MailerModule } from '@nestjs-modules/mailer'
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { getMailerConfig } from 'src/config/factories'
import { MailService } from './mail.service'
import { TemplateService } from './template.service'
@Module({
imports: [
MailerModule.forRootAsync({
useFactory: (configService: ConfigService) =>
getMailerConfig(configService),
inject: [ConfigService]
})
],
providers: [MailService, TemplateService],
exports: [MailService]
})
export class MailModule {}

View File

@ -0,0 +1,22 @@
import { MailerService } from '@nestjs-modules/mailer'
import { Injectable } from '@nestjs/common'
import { TemplateService } from './template.service'
@Injectable()
export class MailService {
constructor(
private readonly transporter: MailerService,
private readonly templateService: TemplateService
) {}
public async sendOtp(email: string, code: string) {
const html = this.templateService.render('otp', { code })
await this.transporter.sendMail({
to: email,
subject: 'Ваш код подтверждения',
html
})
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common'
import * as Handlebars from 'handlebars'
import fs from 'node:fs'
import path from 'node:path'
@Injectable()
export class TemplateService {
private cache = new Map<string, Handlebars.TemplateDelegate>()
public render(templateName: string, context?: Record<string, any>) {
if (!this.cache.has(templateName)) {
const templatePath = path.join(
process.cwd(),
'src/infra/mail/templates',
`${templateName}.hbs`
)
const file = fs.readFileSync(templatePath, 'utf-8')
this.cache.set(templateName, Handlebars.compile(file))
}
return this.cache.get(templateName)!(context)
}
}

View File

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Ваш код подтверждения</title>
</head>
<body style="margin: 0; padding: 0;font-family: Arial, sans-serif;">
<table style="width: 100%;" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 40px">
<h1 style="font-size: 22px;font-weight: bold; margin: 0 0 25px">
Код подтверждения
</h1>
<p style="font-size: 15px; margin: 0 0 15px">Ваш код: </p>
<div style="font-size: 36px;font-weight: bold; margin: 0 0 15px; letter-spacing: 6px">{{code}}</div>
<p style="font-size: 13px; color: #555; margin: 30px 0 0">
Если это были не вы - просто проигнорируйте письм
</p>
<p style="font-size: 13px; margin: 8px 0 0">
<a href="https://teacinema.ru" target="_blank" style="text-decoration: none; color: #000; font-weight: bold">
teacinema.ru
</a>
</p>
</td>
</tr>
</table>
</body>
</html>

View File

@ -14,12 +14,12 @@ export class NotificationsController {
) {}
@EventPattern('auth.otp.requested')
public otpRequested(
public async otpRequested(
@Payload() data: OtpRequestedEvent,
@Ctx() ctx: RmqContext
) {
try {
console.log('OTP received', data)
await this.notificationsService.sendOtp(data)
this.rmqService.ack(ctx)
} catch (e) {
console.log('OTP processing error', e.message ?? e)

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common'
import { MailModule } from '../../infra/mail/mail.module'
import { RmqService } from '../../infra/rmq/rmq.service'
import { NotificationsController } from './notifications.controller'
import { NotificationsService } from './notifications.service'
@Module({
imports: [MailModule],
controllers: [NotificationsController],
providers: [NotificationsService, RmqService]
})

View File

@ -1,4 +1,20 @@
import { Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common'
import { OtpRequestedEvent } from '@teacinema/contracts'
import { MailService } from '../../infra/mail/mail.service'
@Injectable()
export class NotificationsService {}
export class NotificationsService {
constructor(private readonly mailService: MailService) {}
public async sendOtp(data: OtpRequestedEvent) {
const { identifier, type, code } = data
if (type === 'email') {
console.log('SEND EMAIL', data)
await this.mailService.sendOtp(identifier, code)
} else {
console.log('SMS')
}
}
}

1582
yarn.lock

File diff suppressed because it is too large Load Diff