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
7 changes: 6 additions & 1 deletion app/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ VERIFICATION_MODE="mock"
# Twilio SMS (leave blank to use the logging mock provider)
TWILIO_ACCOUNT_SID=""
TWILIO_AUTH_TOKEN=""
TWILIO_FROM_NUMBER=""
TWILIO_FROM_NUMBER=""

# SendGrid email (leave blank to use the logging mock provider)
SENDGRID_API_KEY=""
EMAIL_FROM_ADDRESS="no-reply@chainforge.local"
EMAIL_FROM_NAME="ChainForge"
1 change: 1 addition & 0 deletions app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@liaoliaots/nestjs-redis": "^10.0.0",
"@sendgrid/mail": "^8.1.5",
"@nestjs/axios": "^4.0.1",
"@nestjs/bull": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
Expand Down
36 changes: 19 additions & 17 deletions app/backend/src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ export class ClaimsService {

// Handle different sharing channels
if (shareDto.channel === 'email' && shareDto.emailAddresses?.length) {
this.sendReceiptViaEmail(
void this.sendReceiptViaEmail(
shareDto.emailAddresses,
receipt,
receiptText,
Expand Down Expand Up @@ -624,30 +624,32 @@ export class ClaimsService {
}

/**
* Send receipt via email
* Stub implementation - replace with actual email service
* Send receipt via email, queued for async delivery through the
* notifications BullMQ processor. Errors are caught and logged here
* since the caller does not await this method.
*/
private sendReceiptViaEmail(
private async sendReceiptViaEmail(
emailAddresses: string[],
receipt: ClaimReceiptDto,
receiptText: string,
_message?: string,
): void {
message?: string,
): Promise<void> {
this.logger.log(
`Sending receipt via email to ${emailAddresses.length} recipient(s)`,
{
claimId: receipt.claimId,
recipients: emailAddresses,
},
`Queuing receipt email to ${emailAddresses.length} recipient(s)`,
{ claimId: receipt.claimId },
);

// TODO: Integrate with email service (SendGrid, AWS SES, etc.)
// For now, this is a stub that logs the action
const subject = `Claim Receipt - ${receipt.claimId}`;
const body = message ? `${message}\n\n${receiptText}` : receiptText;

for (const email of emailAddresses) {
this.logger.debug(
`[EMAIL STUB] Would send receipt to ${email}`,
receiptText.substring(0, 100),
);
try {
await this.notificationsService.sendEmail(email, subject, body);
} catch (error) {
this.logger.error(
`Failed to queue receipt email for claim ${receipt.claimId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}

Expand Down
111 changes: 111 additions & 0 deletions app/backend/src/notifications/email/email.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import sgMail from '@sendgrid/mail';
import { EmailService } from './email.service';
import { MetricsService } from '../../observability/metrics/metrics.service';

jest.mock('@sendgrid/mail', () => ({
__esModule: true,
default: {
setApiKey: jest.fn(),
send: jest.fn(),
},
}));

describe('EmailService', () => {
let service: EmailService;
let configMock: { get: jest.Mock };
let metricsMock: { recordEmailDelivery: jest.Mock };

const buildService = async (apiKey?: string) => {
configMock = {
get: jest.fn((key: string) => {
if (key === 'SENDGRID_API_KEY') return apiKey;
if (key === 'EMAIL_FROM_ADDRESS') return 'no-reply@chainforge.local';
if (key === 'EMAIL_FROM_NAME') return 'ChainForge';
return undefined;
}),
};
metricsMock = { recordEmailDelivery: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
providers: [
EmailService,
{ provide: ConfigService, useValue: configMock },
{ provide: MetricsService, useValue: metricsMock },
],
}).compile();

return module.get<EmailService>(EmailService);
};

beforeEach(() => {
jest.clearAllMocks();
});

it('should be defined', async () => {
service = await buildService('test-api-key');
expect(service).toBeDefined();
});

it('should throw and record a failed metric when no API key is configured', async () => {
service = await buildService(undefined);

await expect(
service.sendEmail({
to: 'recipient@example.com',
subject: 'Subject',
text: 'Body',
}),
).rejects.toThrow('Email provider is not configured');

expect(metricsMock.recordEmailDelivery).toHaveBeenCalledWith(
'failed',
expect.any(Number),
);
});

it('should send via SendGrid and record a success metric', async () => {
(sgMail.send as jest.Mock).mockResolvedValue([
{ statusCode: 202, headers: { 'x-message-id': 'sg-message-1' } },
]);
service = await buildService('test-api-key');

const result = await service.sendEmail({
to: 'recipient@example.com',
subject: 'Subject',
text: 'Body',
});

expect(result).toEqual({ success: true, messageId: 'sg-message-1' });
expect(sgMail.send).toHaveBeenCalledWith(
expect.objectContaining({
to: 'recipient@example.com',
subject: 'Subject',
text: 'Body',
}),
);
expect(metricsMock.recordEmailDelivery).toHaveBeenCalledWith(
'success',
expect.any(Number),
);
});

it('should re-throw and record a failed metric when SendGrid rejects', async () => {
(sgMail.send as jest.Mock).mockRejectedValue(new Error('SendGrid down'));
service = await buildService('test-api-key');

await expect(
service.sendEmail({
to: 'recipient@example.com',
subject: 'Subject',
text: 'Body',
}),
).rejects.toThrow('SendGrid down');

expect(metricsMock.recordEmailDelivery).toHaveBeenCalledWith(
'failed',
expect.any(Number),
);
});
});
92 changes: 92 additions & 0 deletions app/backend/src/notifications/email/email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import sgMail from '@sendgrid/mail';
import { MetricsService } from '../../observability/metrics/metrics.service';

export interface SendEmailParams {
to: string;
subject: string;
text: string;
html?: string;
}

export interface SendEmailResult {
success: true;
messageId?: string;
}

@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly fromAddress: string;
private readonly fromName: string;
private readonly configured: boolean;

constructor(
private readonly configService: ConfigService,
private readonly metricsService: MetricsService,
) {
const apiKey = this.configService.get<string>('SENDGRID_API_KEY');
this.fromAddress =
this.configService.get<string>('EMAIL_FROM_ADDRESS') ||
'no-reply@chainforge.local';
this.fromName =
this.configService.get<string>('EMAIL_FROM_NAME') || 'ChainForge';
this.configured = Boolean(apiKey);

if (this.configured) {
sgMail.setApiKey(apiKey as string);
} else {
this.logger.warn(
'SENDGRID_API_KEY is not configured — outgoing emails will fail until it is set',
);
}
}

/**
* Sends a single email via SendGrid. Throws on any failure so the caller
* (BullMQ job processor) can retry/DLQ rather than silently dropping mail.
*/
async sendEmail(params: SendEmailParams): Promise<SendEmailResult> {
const startedAt = Date.now();

if (!this.configured) {
this.metricsService.recordEmailDelivery(
'failed',
(Date.now() - startedAt) / 1000,
);
throw new Error('Email provider is not configured');
}

try {
const [response] = await sgMail.send({
to: params.to,
from: { email: this.fromAddress, name: this.fromName },
subject: params.subject,
text: params.text,
html: params.html ?? params.text,
});

const messageId = response.headers?.['x-message-id'] as
| string
| undefined;

this.metricsService.recordEmailDelivery(
'success',
(Date.now() - startedAt) / 1000,
);
this.logger.log(`Email delivered with status ${response.statusCode}`);

return { success: true, messageId };
} catch (error) {
this.metricsService.recordEmailDelivery(
'failed',
(Date.now() - startedAt) / 1000,
);
this.logger.error(
`Email delivery failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw error;
}
}
}
2 changes: 2 additions & 0 deletions app/backend/src/notifications/notifications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { NotificationsService } from './notifications.service';
import { NotificationProcessor } from './notifications.processor';
import { OutboxController } from './outbox.controller';
import { EmailService } from './email/email.service';
import { JobsModule } from '../jobs/jobs.module';
import { MetricsModule } from '../observability/metrics/metrics.module';
import { LoggerModule } from '../logger/logger.module';
Expand Down Expand Up @@ -52,6 +53,7 @@ const smsProviderFactory = (configService: ConfigService): SmsProvider => {
providers: [
NotificationsService,
NotificationProcessor,
EmailService,
{
provide: SMS_PROVIDER,
useFactory: smsProviderFactory,
Expand Down
54 changes: 54 additions & 0 deletions app/backend/src/notifications/notifications.processor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Job } from 'bullmq';
import { DlqService } from '../jobs/dlq.service';
import { MetricsService } from '../observability/metrics/metrics.service';
import { SMS_PROVIDER } from './providers/sms-provider.interface';
import { EmailService } from './email/email.service';

describe('NotificationProcessor', () => {
let processor: NotificationProcessor;
Expand All @@ -16,6 +17,7 @@ describe('NotificationProcessor', () => {
};
let metricsMock: { incrementCallbackFailure: jest.Mock };
let smsProviderMock: { send: jest.Mock };
let emailServiceMock: { sendEmail: jest.Mock };

const makeJob = (
overrides: Partial<{
Expand Down Expand Up @@ -48,6 +50,12 @@ describe('NotificationProcessor', () => {
smsProviderMock = {
send: jest.fn().mockResolvedValue({ messageId: 'twilio-sid-1' }),
};
emailServiceMock = {
sendEmail: jest.fn().mockResolvedValue({
success: true,
messageId: 'mock-message-id',
}),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
Expand All @@ -70,6 +78,10 @@ describe('NotificationProcessor', () => {
provide: SMS_PROVIDER,
useValue: smsProviderMock,
},
{
provide: EmailService,
useValue: emailServiceMock,
},
],
}).compile();

Expand Down Expand Up @@ -164,6 +176,48 @@ describe('NotificationProcessor', () => {
'Twilio 500',
);
});

it('should deliver email notifications via EmailService', async () => {
const job = makeJob();

const result = await processor.process(job);

expect(emailServiceMock.sendEmail).toHaveBeenCalledWith({
to: 'test@example.com',
subject: 'ChainForge Notification',
text: 'Test message',
});
expect(result).toEqual({
success: true,
messageId: 'mock-message-id',
});
});

it('should not call EmailService for SMS jobs', async () => {
const job = makeJob({
type: NotificationType.SMS,
recipient: '+15551234567',
});

await processor.process(job);

expect(emailServiceMock.sendEmail).not.toHaveBeenCalled();
});

it('should re-throw and increment failure metric when EmailService rejects', async () => {
emailServiceMock.sendEmail.mockRejectedValueOnce(
new Error('Email provider is not configured'),
);
const job = makeJob({ outboxId: 'outbox-abc' });

await expect(processor.process(job)).rejects.toThrow(
'Email provider is not configured',
);
expect(metricsMock.incrementCallbackFailure).toHaveBeenCalledWith(
'notification_delivery',
'Email provider is not configured',
);
});
});

describe('onCompleted', () => {
Expand Down
Loading
Loading