From 0b1c76257f9f28db44752981d0a3628f94383993 Mon Sep 17 00:00:00 2001 From: noevidence1017 Date: Thu, 18 Jun 2026 07:23:51 -0700 Subject: [PATCH] feat(backend): implement email receipt delivery via SendGrid --- app/backend/.env.example | 7 +- app/backend/package.json | 1 + app/backend/src/claims/claims.service.ts | 36 +++--- .../notifications/email/email.service.spec.ts | 111 ++++++++++++++++++ .../src/notifications/email/email.service.ts | 92 +++++++++++++++ .../src/notifications/notifications.module.ts | 2 + .../notifications.processor.spec.ts | 54 +++++++++ .../notifications/notifications.processor.ts | 31 +++-- .../metrics/metrics.providers.ts | 13 ++ .../observability/metrics/metrics.service.ts | 19 +++ 10 files changed, 335 insertions(+), 31 deletions(-) create mode 100644 app/backend/src/notifications/email/email.service.spec.ts create mode 100644 app/backend/src/notifications/email/email.service.ts diff --git a/app/backend/.env.example b/app/backend/.env.example index 93a8d36b..ab1af48c 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -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="" \ No newline at end of file +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" diff --git a/app/backend/package.json b/app/backend/package.json index b98768b7..e5562473 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -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", diff --git a/app/backend/src/claims/claims.service.ts b/app/backend/src/claims/claims.service.ts index a7ece43b..990bc0e4 100644 --- a/app/backend/src/claims/claims.service.ts +++ b/app/backend/src/claims/claims.service.ts @@ -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, @@ -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 { 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'}`, + ); + } } } diff --git a/app/backend/src/notifications/email/email.service.spec.ts b/app/backend/src/notifications/email/email.service.spec.ts new file mode 100644 index 00000000..701fadc2 --- /dev/null +++ b/app/backend/src/notifications/email/email.service.spec.ts @@ -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); + }; + + 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), + ); + }); +}); diff --git a/app/backend/src/notifications/email/email.service.ts b/app/backend/src/notifications/email/email.service.ts new file mode 100644 index 00000000..8bea78c2 --- /dev/null +++ b/app/backend/src/notifications/email/email.service.ts @@ -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('SENDGRID_API_KEY'); + this.fromAddress = + this.configService.get('EMAIL_FROM_ADDRESS') || + 'no-reply@chainforge.local'; + this.fromName = + this.configService.get('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 { + 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; + } + } +} diff --git a/app/backend/src/notifications/notifications.module.ts b/app/backend/src/notifications/notifications.module.ts index 43617c43..78d85096 100644 --- a/app/backend/src/notifications/notifications.module.ts +++ b/app/backend/src/notifications/notifications.module.ts @@ -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'; @@ -52,6 +53,7 @@ const smsProviderFactory = (configService: ConfigService): SmsProvider => { providers: [ NotificationsService, NotificationProcessor, + EmailService, { provide: SMS_PROVIDER, useFactory: smsProviderFactory, diff --git a/app/backend/src/notifications/notifications.processor.spec.ts b/app/backend/src/notifications/notifications.processor.spec.ts index 780805e4..b8f35069 100644 --- a/app/backend/src/notifications/notifications.processor.spec.ts +++ b/app/backend/src/notifications/notifications.processor.spec.ts @@ -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; @@ -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<{ @@ -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: [ @@ -70,6 +78,10 @@ describe('NotificationProcessor', () => { provide: SMS_PROVIDER, useValue: smsProviderMock, }, + { + provide: EmailService, + useValue: emailServiceMock, + }, ], }).compile(); @@ -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', () => { diff --git a/app/backend/src/notifications/notifications.processor.ts b/app/backend/src/notifications/notifications.processor.ts index db4e05b1..b3fffb84 100644 --- a/app/backend/src/notifications/notifications.processor.ts +++ b/app/backend/src/notifications/notifications.processor.ts @@ -10,10 +10,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { DlqService } from '../jobs/dlq.service'; import { MetricsService } from '../observability/metrics/metrics.service'; -import { - SmsProvider, - SMS_PROVIDER, -} from './providers/sms-provider.interface'; +import { SmsProvider, SMS_PROVIDER } from './providers/sms-provider.interface'; +import { EmailService } from './email/email.service'; @Processor('notifications', { concurrency: parseInt(process.env.QUEUE_CONCURRENCY || '5'), @@ -26,6 +24,7 @@ export class NotificationProcessor extends WorkerHost { private readonly dlqService: DlqService, private readonly metricsService: MetricsService, @Inject(SMS_PROVIDER) private readonly smsProvider: SmsProvider, + private readonly emailService: EmailService, ) { super(); } @@ -66,16 +65,22 @@ export class NotificationProcessor extends WorkerHost { return { success: true, messageId }; } - // Email delivery is still mocked pending a provider integration. - this.logger.debug( - `[Mock] Sending ${job.data.type} to ${job.data.recipient}: ${job.data.message}`, - ); - await new Promise(resolve => setTimeout(resolve, 100)); + if (job.data.type === NotificationType.EMAIL) { + const result = await this.emailService.sendEmail({ + to: job.data.recipient, + subject: job.data.subject ?? 'ChainForge Notification', + text: job.data.message, + }); - return { - success: true, - messageId: `mock-msg-${Date.now()}`, - }; + return { success: true, messageId: result.messageId }; + } + + // Exhaustive: every NotificationType is handled above. This guards + // against a new type being added without a corresponding branch. + const unsupportedType: never = job.data.type; + throw new Error( + `Unsupported notification type: ${String(unsupportedType)}`, + ); } catch (error) { this.logger.error( `Notification job ${job.id} failed: ${error instanceof Error ? error.message : 'Unknown error'}`, diff --git a/app/backend/src/observability/metrics/metrics.providers.ts b/app/backend/src/observability/metrics/metrics.providers.ts index 4ee82a66..a7ecb9c3 100644 --- a/app/backend/src/observability/metrics/metrics.providers.ts +++ b/app/backend/src/observability/metrics/metrics.providers.ts @@ -112,6 +112,19 @@ export const metricsProviders = [ ], }), + // Email Metrics + makeCounterProvider({ + name: 'email_delivery_total', + help: 'Total number of email delivery attempts', + labelNames: ['status'], + }), + makeHistogramProvider({ + name: 'email_delivery_duration_seconds', + help: 'Duration of email delivery attempts in seconds', + labelNames: ['status'], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + }), + // Analytics Cache Metrics makeCounterProvider({ name: 'analytics_cache_hits_total', diff --git a/app/backend/src/observability/metrics/metrics.service.ts b/app/backend/src/observability/metrics/metrics.service.ts index 114edd62..37f7ca5d 100644 --- a/app/backend/src/observability/metrics/metrics.service.ts +++ b/app/backend/src/observability/metrics/metrics.service.ts @@ -43,6 +43,10 @@ export class MetricsService { public analyticsCacheMissesCounter: Counter, @InjectMetric('analytics_cache_invalidations_total') public analyticsCacheInvalidationsCounter: Counter, + @InjectMetric('email_delivery_total') + public emailDeliveryCounter: Counter, + @InjectMetric('email_delivery_duration_seconds') + public emailDeliveryDuration: Histogram, ) {} /** @@ -224,4 +228,19 @@ export class MetricsService { incrementAnalyticsCacheInvalidation(reason: string): void { this.analyticsCacheInvalidationsCounter.inc({ reason }); } + + /** + * Record an email delivery attempt and its duration. + */ + recordEmailDelivery( + status: 'success' | 'failed', + durationSeconds: number, + ): void { + this.emailDeliveryCounter.inc({ status }); + this.emailDeliveryDuration.observe({ status }, durationSeconds); + + if (status === 'failed') { + this.errorRateCounter.inc({ error_type: 'email_delivery_failure' }); + } + } }