Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/src/notifications/email/adapters/email.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SendEmailPayload, EmailSendResult } from '../email.types';

export interface EmailAdapter {
send(payload: SendEmailPayload): Promise<EmailSendResult>;
}
19 changes: 19 additions & 0 deletions apps/api/src/notifications/email/adapters/mock.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { EmailAdapter } from './email.adapter';
import { SendEmailPayload, EmailSendResult } from '../email.types';
import { Logger } from '@nestjs/common';

export class MockEmailAdapter implements EmailAdapter {
private readonly logger = new Logger(MockEmailAdapter.name);

async send(payload: SendEmailPayload): Promise<EmailSendResult> {
this.logger.log(
`[MOCK EMAIL] → ${payload.to} | ${payload.subject}`,
);

return {
success: true,
provider: 'mock',
messageId: `mock-${Date.now()}`,
};
}
}
36 changes: 36 additions & 0 deletions apps/api/src/notifications/email/adapters/sendgrid.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { EmailAdapter } from './email.adapter';
import { SendEmailPayload, EmailSendResult } from '../email.types';
import * as SendGrid from '@sendgrid/mail';
import { Logger } from '@nestjs/common';

export class SendGridEmailAdapter implements EmailAdapter {
private readonly logger = new Logger(SendGridEmailAdapter.name);

constructor(apiKey: string, private readonly fromEmail: string) {
SendGrid.setApiKey(apiKey);
}

async send(payload: SendEmailPayload): Promise<EmailSendResult> {
try {
const [response] = await SendGrid.send({
to: payload.to,
from: this.fromEmail,
subject: payload.subject,
html: payload.data.html,
});

return {
success: true,
provider: 'sendgrid',
messageId: response.headers['x-message-id'],
};
} catch (error) {
this.logger.error('SendGrid email failed', error);
return {
success: false,
provider: 'sendgrid',
error,
};
}
}
}
37 changes: 37 additions & 0 deletions apps/api/src/notifications/email/email.logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Logger } from '@nestjs/common';
import { EmailSendResult, SendEmailPayload } from './email.types';

export class EmailLogger {
private static readonly logger = new Logger('EmailNotification');

static logSendAttempt(payload: SendEmailPayload) {
this.logger.log(
`Sending email → to=${payload.to} | subject="${payload.subject}" | template=${payload.template}`,
);
}

static logSuccess(
payload: SendEmailPayload,
result: EmailSendResult,
) {
this.logger.log(
`Email sent successfully → to=${payload.to} | provider=${result.provider} | messageId=${result.messageId}`,
);
}

static logFailure(
payload: SendEmailPayload,
result: EmailSendResult,
) {
this.logger.error(
`Email failed → to=${payload.to} | provider=${result.provider}`,
result.error?.stack || result.error,
);
}

static logTemplateRender(template: string) {
this.logger.debug(
`Rendering email template → ${template}`,
);
}
}
31 changes: 31 additions & 0 deletions apps/api/src/notifications/email/email.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
import { MockEmailAdapter } from './adapters/mock.adapter';
import { SendGridEmailAdapter } from './adapters/sendgrid.adapter';

@Module({
providers: [
{
provide: 'EMAIL_ADAPTER',
useFactory: () => {
const provider = process.env.EMAIL_PROVIDER;

if (provider === 'sendgrid') {
return new SendGridEmailAdapter(
process.env.SENDGRID_API_KEY!,
process.env.EMAIL_FROM!,
);
}

return new MockEmailAdapter();
},
},
{
provide: EmailService,
useFactory: (adapter) => new EmailService(adapter),
inject: ['EMAIL_ADAPTER'],
},
],
exports: [EmailService],
})
export class EmailModule {}
70 changes: 70 additions & 0 deletions apps/api/src/notifications/email/email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { EmailAdapter } from './adapters/email.adapter';
import { SendEmailPayload } from './email.types';
import { EmailLogger } from './email.logger';

import * as Handlebars from 'handlebars';
import * as fs from 'fs';
import * as path from 'path';

@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);

constructor(private readonly adapter: EmailAdapter) {}

async sendEmail(payload: SendEmailPayload) {
const html = this.renderTemplate(payload.template, payload.data);

const result = await this.adapter.send({
...payload,
data: { html },
});

if (!result.success) {
this.logger.error(
`Email failed → ${payload.to}`,
result.error,
);
} else {
this.logger.log(
`Email sent → ${payload.to} (${result.provider})`,
);
}

return result;
}

private renderTemplate(templateName: string, data: Record<string, any>) {
const templatePath = path.join(
__dirname,
'templates',
`${templateName}.hbs`,
);

const source = fs.readFileSync(templatePath, 'utf-8');
const template = Handlebars.compile(source);

return template(data);
}

async sendEmail(payload: SendEmailPayload) {
EmailLogger.logSendAttempt(payload);
EmailLogger.logTemplateRender(payload.template);

const html = this.renderTemplate(payload.template, payload.data);

const result = await this.adapter.send({
...payload,
data: { html },
});

if (result.success) {
EmailLogger.logSuccess(payload, result);
} else {
EmailLogger.logFailure(payload, result);
}

return result;
}
}
13 changes: 13 additions & 0 deletions apps/api/src/notifications/email/email.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface SendEmailPayload {
to: string;
subject: string;
template: string;
data: Record<string, any>;
}

export interface EmailSendResult {
success: boolean;
provider: string;
messageId?: string;
error?: any;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<h2>Booking Confirmed 🎉</h2>
<p>Hello {{userName}},</p>

<p>Your booking for <strong>{{serviceName}}</strong> has been confirmed.</p>

<p>
Date: {{date}} <br/>
Reference: {{reference}}
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<h2>Payment Successful ✅</h2>

<p>Hi {{userName}},</p>

<p>
Your payment of <strong>{{amount}}</strong> was successful.
</p>

<p>Transaction ID: {{transactionId}}</p>
Loading
Loading