From 8bcac80818c423229b2becc5aacb30d9633bc641 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:27:37 -0600 Subject: [PATCH] feat: add email ingestion notification handler with logging (refs #159) - Extract all notification logic from EmailIngestionService into dedicated EmailIngestionNotificationHandler class - Add notification_logs entries for every email sent (success/failure) - Add in-app user_notifications for all error scenarios (no vehicles, no attachments, OCR failure, processing failure) - Update email templates with enhanced variables: merchantName, totalAmount, date, guidance - Update pending vehicle notification title to 'Vehicle Selection Required' - Add sample variables for receipt templates in test email flow Co-Authored-By: Claude Opus 4.6 --- .../domain/email-ingestion.service.ts | 256 +++----------- .../domain/notification-handler.ts | 333 ++++++++++++++++++ .../migrations/002_create_email_templates.sql | 44 +-- .../domain/notifications.service.ts | 23 ++ 4 files changed, 429 insertions(+), 227 deletions(-) create mode 100644 backend/src/features/email-ingestion/domain/notification-handler.ts diff --git a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts index 349a028..f2d66a9 100644 --- a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts +++ b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts @@ -1,7 +1,8 @@ /** * @ai-summary Core processing service for the email-to-record pipeline * @ai-context Orchestrates sender validation, OCR extraction, record classification, - * vehicle association, status tracking, retry logic, and reply emails + * vehicle association, status tracking, and retry logic. Delegates all notifications + * (emails, in-app, logging) to EmailIngestionNotificationHandler. */ import { Pool } from 'pg'; @@ -17,6 +18,7 @@ import { EmailService } from '../../notifications/domain/email.service'; import { ocrService } from '../../ocr/domain/ocr.service'; import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types'; import { ReceiptClassifier } from './receipt-classifier'; +import { EmailIngestionNotificationHandler } from './notification-handler'; import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service'; import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository'; import { FuelType } from '../../fuel-logs/domain/fuel-logs.types'; @@ -56,9 +58,7 @@ export class EmailIngestionService { private resendClient: ResendInboundClient; private userProfileRepository: UserProfileRepository; private vehiclesRepository: VehiclesRepository; - private notificationsRepository: NotificationsRepository; - private templateService: TemplateService; - private emailService: EmailService; + private notificationHandler: EmailIngestionNotificationHandler; private classifier: ReceiptClassifier; private fuelLogsService: FuelLogsService; private maintenanceService: MaintenanceService; @@ -69,9 +69,12 @@ export class EmailIngestionService { this.resendClient = new ResendInboundClient(); this.userProfileRepository = new UserProfileRepository(p); this.vehiclesRepository = new VehiclesRepository(p); - this.notificationsRepository = new NotificationsRepository(p); - this.templateService = new TemplateService(); - this.emailService = new EmailService(); + const notificationsRepository = new NotificationsRepository(p); + this.notificationHandler = new EmailIngestionNotificationHandler( + notificationsRepository, + new TemplateService(), + new EmailService(), + ); this.classifier = new ReceiptClassifier(); this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p)); this.maintenanceService = new MaintenanceService(); @@ -96,7 +99,7 @@ export class EmailIngestionService { // 2. Validate sender const userProfile = await this.validateSender(senderEmail); if (!userProfile) { - await this.handleUnregisteredSender(emailId, senderEmail, subject); + await this.handleUnregisteredSender(emailId, senderEmail); return; } @@ -112,7 +115,7 @@ export class EmailIngestionService { // 4. Filter valid attachments const validAttachments = this.filterAttachments(attachments); if (validAttachments.length === 0) { - await this.handleNoValidAttachments(emailId, senderEmail, userName, subject); + await this.handleNoValidAttachments(emailId, userId, userName, senderEmail); return; } @@ -129,7 +132,7 @@ export class EmailIngestionService { userId, validAttachments, emailClassification, emailId ); if (!ocrResult) { - await this.handleOcrFailure(emailId, senderEmail, userName, subject, 'No receipt data could be extracted from attachments'); + await this.handleOcrFailure(emailId, userId, userName, senderEmail, 'No receipt data could be extracted from attachments'); return; } @@ -147,9 +150,6 @@ export class EmailIngestionService { processingResult, }); - // 10. Send confirmation email - await this.sendConfirmationEmail(senderEmail, userName, processingResult); - logger.info('Email processing completed successfully', { emailId, userId, @@ -463,7 +463,7 @@ export class EmailIngestionService { // No vehicles: user must add a vehicle first if (vehicles.length === 0) { - await this.sendNoVehiclesEmail(userEmail, userName); + await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail); return { recordType, vehicleId: null, @@ -476,39 +476,38 @@ export class EmailIngestionService { // Single vehicle: auto-associate and create record if (vehicles.length === 1) { - const vehicleId = vehicles[0].id; + const vehicle = vehicles[0]; let recordId: string | null = null; try { - recordId = await this.createRecord(userId, vehicleId, recordType, extractedData); + recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData); } catch (error) { logger.error('Failed to create record from email receipt', { userId, - vehicleId, + vehicleId: vehicle.id, recordType, error: error instanceof Error ? error.message : String(error), }); - // Record creation failed but association succeeded; notify user of partial success } - const recordLabel = recordType === 'fuel_log' ? 'fuel' : 'maintenance'; - const message = recordId - ? `Your emailed ${recordLabel} receipt has been processed and a ${recordLabel} record was created for your vehicle.` - : `Your emailed ${recordLabel} receipt was processed but the record could not be created automatically. Please add it manually.`; + const vehicleName = vehicle.nickname + || [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ') + || 'your vehicle'; - await this.notificationsRepository.insertUserNotification({ + await this.notificationHandler.notifyReceiptProcessed({ userId, - notificationType: 'email_ingestion', - title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed', - message, - referenceType: recordType, - referenceId: recordId ?? undefined, - vehicleId, + userName, + userEmail, + vehicleName, + recordType, + recordId, + vehicleId: vehicle.id, + extractedData, }); return { recordType, - vehicleId, + vehicleId: vehicle.id, recordId, documentId: null, pendingAssociationId: null, @@ -524,17 +523,15 @@ export class EmailIngestionService { documentId: null, }); - await this.notificationsRepository.insertUserNotification({ + await this.notificationHandler.notifyPendingVehicleSelection({ userId, - notificationType: 'email_ingestion', - title: 'Receipt Pending Vehicle Selection', - message: `Your emailed receipt has been processed. Please select which vehicle this ${recordType === 'fuel_log' ? 'fuel' : 'maintenance'} receipt belongs to.`, - referenceType: 'pending_vehicle_association', - referenceId: pendingAssociation.id, + userName, + userEmail, + recordType, + pendingAssociationId: pendingAssociation.id, + extractedData, }); - await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData); - return { recordType, vehicleId: null, @@ -681,13 +678,13 @@ export class EmailIngestionService { private async handleProcessingError( emailId: string, senderEmail: string, - subject: string | null, + _subject: string | null, error: unknown ): Promise { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Email processing pipeline error', { emailId, error: errorMessage }); - // Get current queue entry for retry count + // Get current queue entry for retry count and userId const queueEntry = await this.repository.getQueueEntry(emailId); const currentRetryCount = queueEntry?.retryCount || 0; const newRetryCount = currentRetryCount + 1; @@ -711,11 +708,15 @@ export class EmailIngestionService { retryCount: newRetryCount, }); - // Send failure email to user - await this.sendFailureEmail(senderEmail, subject, errorMessage).catch(emailErr => { - logger.error('Failed to send failure notification email', { + // Send failure notification (email + in-app if userId available) + await this.notificationHandler.notifyProcessingFailure({ + userId: queueEntry?.userId, + userEmail: senderEmail, + errorReason: errorMessage, + }).catch(notifyErr => { + logger.error('Failed to send failure notification', { emailId, - error: emailErr instanceof Error ? emailErr.message : String(emailErr), + error: notifyErr instanceof Error ? notifyErr.message : String(notifyErr), }); }); } @@ -723,8 +724,7 @@ export class EmailIngestionService { private async handleUnregisteredSender( emailId: string, - senderEmail: string, - subject: string | null + senderEmail: string ): Promise { logger.info('Unregistered sender rejected', { emailId, senderEmail }); @@ -732,12 +732,7 @@ export class EmailIngestionService { errorMessage: 'Sender email is not registered with MotoVaultPro', }); - // Send rejection email - await this.sendFailureEmail( - senderEmail, - subject, - 'This email address is not registered with MotoVaultPro. Please send receipts from the email address associated with your account.' - ).catch(error => { + await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => { logger.error('Failed to send unregistered sender notification', { emailId, error: error instanceof Error ? error.message : String(error), @@ -747,9 +742,9 @@ export class EmailIngestionService { private async handleNoValidAttachments( emailId: string, - senderEmail: string, - _userName: string, - subject: string | null + userId: string, + userName: string, + userEmail: string ): Promise { logger.info('No valid attachments found', { emailId }); @@ -757,11 +752,7 @@ export class EmailIngestionService { errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)', }); - await this.sendFailureEmail( - senderEmail, - subject, - 'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.' - ).catch(error => { + await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => { logger.error('Failed to send no-attachments notification', { emailId, error: error instanceof Error ? error.message : String(error), @@ -771,9 +762,9 @@ export class EmailIngestionService { private async handleOcrFailure( emailId: string, - senderEmail: string, - _userName: string, - subject: string | null, + userId: string, + userName: string, + userEmail: string, reason: string ): Promise { logger.info('OCR extraction failed for all attachments', { emailId, reason }); @@ -782,148 +773,11 @@ export class EmailIngestionService { errorMessage: reason, }); - await this.sendFailureEmail(senderEmail, subject, reason).catch(error => { + await this.notificationHandler.notifyOcrFailure(userId, userName, userEmail, reason).catch(error => { logger.error('Failed to send OCR failure notification', { emailId, error: error instanceof Error ? error.message : String(error), }); }); } - - // ======================== - // Email Replies - // ======================== - - private async sendConfirmationEmail( - recipientEmail: string, - userName: string, - result: EmailProcessingResult - ): Promise { - try { - const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_processed'); - if (!template || !template.isActive) { - logger.warn('receipt_processed template not found or inactive'); - return; - } - - const recordTypeDisplay = result.recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record'; - const variables = { - userName, - vehicleName: result.vehicleId ? 'your vehicle' : 'Pending Selection', - recordType: recordTypeDisplay, - receiptDate: result.extractedData.date || 'N/A', - amount: result.extractedData.total?.toFixed(2) || 'N/A', - }; - - const renderedSubject = this.templateService.render(template.subject, variables); - const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); - - await this.emailService.send(recipientEmail, renderedSubject, renderedHtml); - - logger.info('Confirmation email sent', { recipientEmail }); - } catch (error) { - logger.error('Failed to send confirmation email', { - recipientEmail, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async sendFailureEmail( - recipientEmail: string, - emailSubject: string | null, - errorReason: string - ): Promise { - try { - const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_failed'); - if (!template || !template.isActive) { - logger.warn('receipt_failed template not found or inactive'); - return; - } - - const variables = { - userName: 'MotoVaultPro User', - emailSubject: emailSubject || '(no subject)', - errorReason, - }; - - const renderedSubject = this.templateService.render(template.subject, variables); - const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); - - await this.emailService.send(recipientEmail, renderedSubject, renderedHtml); - - logger.info('Failure email sent', { recipientEmail }); - } catch (error) { - logger.error('Failed to send failure email', { - recipientEmail, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async sendNoVehiclesEmail( - recipientEmail: string, - userName: string - ): Promise { - try { - const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_failed'); - if (!template || !template.isActive) { - logger.warn('receipt_failed template not found or inactive'); - return; - } - - const variables = { - userName, - emailSubject: 'Receipt Submission', - errorReason: 'You do not have any vehicles registered in MotoVaultPro. Please add a vehicle first, then re-send your receipt.', - }; - - const renderedSubject = this.templateService.render(template.subject, variables); - const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); - - await this.emailService.send(recipientEmail, renderedSubject, renderedHtml); - - logger.info('No-vehicles error email sent', { recipientEmail }); - } catch (error) { - logger.error('Failed to send no-vehicles error email', { - recipientEmail, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async sendPendingVehicleEmail( - recipientEmail: string, - userName: string, - recordType: EmailRecordType, - extractedData: ExtractedReceiptData - ): Promise { - try { - const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_pending_vehicle'); - if (!template || !template.isActive) { - logger.warn('receipt_pending_vehicle template not found or inactive'); - return; - } - - const recordTypeDisplay = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record'; - const variables = { - userName, - recordType: recordTypeDisplay, - receiptDate: extractedData.date || 'N/A', - amount: extractedData.total?.toFixed(2) || 'N/A', - }; - - const renderedSubject = this.templateService.render(template.subject, variables); - const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); - - await this.emailService.send(recipientEmail, renderedSubject, renderedHtml); - - logger.info('Pending vehicle email sent', { recipientEmail }); - } catch (error) { - logger.error('Failed to send pending vehicle email', { - recipientEmail, - error: error instanceof Error ? error.message : String(error), - }); - } - } } diff --git a/backend/src/features/email-ingestion/domain/notification-handler.ts b/backend/src/features/email-ingestion/domain/notification-handler.ts new file mode 100644 index 0000000..7f6ed6b --- /dev/null +++ b/backend/src/features/email-ingestion/domain/notification-handler.ts @@ -0,0 +1,333 @@ +/** + * @ai-summary Notification handler for the email ingestion pipeline + * @ai-context Encapsulates all email replies, in-app notifications, and notification logging + * for the email-to-record flow. Every email sent is logged to notification_logs. + */ + +import { logger } from '../../../core/logging/logger'; +import { NotificationsRepository } from '../../notifications/data/notifications.repository'; +import { TemplateService } from '../../notifications/domain/template.service'; +import { EmailService } from '../../notifications/domain/email.service'; +import type { TemplateKey } from '../../notifications/domain/notifications.types'; +import type { EmailRecordType, ExtractedReceiptData } from './email-ingestion.types'; + +export class EmailIngestionNotificationHandler { + constructor( + private notificationsRepository: NotificationsRepository, + private templateService: TemplateService, + private emailService: EmailService, + ) {} + + // ======================== + // Success Notifications + // ======================== + + /** + * Notify user that their emailed receipt was successfully processed. + * Sends confirmation email + creates in-app notification + logs to notification_logs. + */ + async notifyReceiptProcessed(params: { + userId: string; + userName: string; + userEmail: string; + vehicleName: string; + recordType: EmailRecordType; + recordId: string | null; + vehicleId: string; + extractedData: ExtractedReceiptData; + }): Promise { + const { userId, userName, userEmail, vehicleName, recordType, recordId, vehicleId, extractedData } = params; + const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record'; + const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown'; + + // In-app notification + const message = recordId + ? `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} has been processed and recorded for ${vehicleName}.` + : `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} was processed but the record could not be created automatically. Please add it manually.`; + + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed', + message, + referenceType: recordType, + referenceId: recordId ?? undefined, + vehicleId, + }); + + // Confirmation email + await this.sendTemplateEmail({ + templateKey: 'receipt_processed', + userId, + userEmail, + variables: { + userName, + vehicleName, + recordType: recordLabel, + merchantName, + totalAmount: extractedData.total?.toFixed(2) || 'N/A', + date: extractedData.date || 'N/A', + }, + referenceType: 'email_ingestion', + referenceId: recordId ?? undefined, + }); + } + + // ======================== + // Pending Vehicle Notification + // ======================== + + /** + * Notify multi-vehicle user that their receipt needs vehicle selection. + * Sends pending-vehicle email + creates in-app notification + logs to notification_logs. + */ + async notifyPendingVehicleSelection(params: { + userId: string; + userName: string; + userEmail: string; + recordType: EmailRecordType; + pendingAssociationId: string; + extractedData: ExtractedReceiptData; + }): Promise { + const { userId, userName, userEmail, recordType, pendingAssociationId, extractedData } = params; + const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record'; + const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown'; + + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Vehicle Selection Required', + message: `Your emailed receipt from ${merchantName} has been processed. Please select which vehicle this ${recordLabel.toLowerCase()} belongs to.`, + referenceType: 'pending_vehicle_association', + referenceId: pendingAssociationId, + }); + + // Pending vehicle email + await this.sendTemplateEmail({ + templateKey: 'receipt_pending_vehicle', + userId, + userEmail, + variables: { + userName, + recordType: recordLabel, + merchantName, + totalAmount: extractedData.total?.toFixed(2) || 'N/A', + date: extractedData.date || 'N/A', + }, + referenceType: 'email_ingestion', + referenceId: pendingAssociationId, + }); + } + + // ======================== + // Error Notifications + // ======================== + + /** + * Notify unregistered sender that their email was rejected. + * Email reply only (no in-app notification since no user account). + */ + async notifyUnregisteredSender(userEmail: string): Promise { + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userEmail, + variables: { + userName: 'MotoVaultPro User', + errorReason: 'This email address is not registered with MotoVaultPro.', + guidance: 'Please send receipts from the email address associated with your account. You can check your registered email in your MotoVaultPro profile settings.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user that they must add a vehicle before emailing receipts. + * Sends error email + creates in-app notification + logs to notification_logs. + */ + async notifyNoVehicles(userId: string, userName: string, userEmail: string): Promise { + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: 'Your emailed receipt could not be processed because you have no vehicles registered. Please add a vehicle first, then re-send your receipt.', + }); + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName, + errorReason: 'You do not have any vehicles registered in MotoVaultPro.', + guidance: 'Please add a vehicle first in the MotoVaultPro app, then re-send your receipt.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user that no valid attachments were found in their email. + * Sends error email + creates in-app notification + logs to notification_logs. + */ + async notifyNoValidAttachments(userId: string, userName: string, userEmail: string): Promise { + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: 'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.', + }); + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName, + errorReason: 'No valid attachments were found in your email.', + guidance: 'Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB. Make sure your receipt is clearly visible in the image.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user that OCR extraction failed after all attempts. + * Sends error email + creates in-app notification + logs to notification_logs. + */ + async notifyOcrFailure(userId: string, userName: string, userEmail: string, reason: string): Promise { + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: `We could not extract data from your receipt: ${reason}`, + }); + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName, + errorReason: reason, + guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user of a general processing failure (after max retries exceeded). + * Sends error email + creates in-app notification (if userId available) + logs. + */ + async notifyProcessingFailure(params: { + userId?: string; + userName?: string; + userEmail: string; + errorReason: string; + }): Promise { + const { userId, userName, userEmail, errorReason } = params; + + // In-app notification (only if we have a userId) + if (userId) { + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: `Your emailed receipt could not be processed: ${errorReason}`, + }); + } + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName: userName || 'MotoVaultPro User', + errorReason, + guidance: 'Please try again or upload the receipt directly through the MotoVaultPro app.', + }, + referenceType: 'email_ingestion', + }); + } + + // ======================== + // Internal Helpers + // ======================== + + /** + * Send a templated email and log to notification_logs. + * Swallows errors to prevent notification failures from breaking the pipeline. + */ + private async sendTemplateEmail(params: { + templateKey: TemplateKey; + userId?: string; + userEmail: string; + variables: Record; + referenceType?: string; + referenceId?: string; + }): Promise { + const { templateKey, userId, userEmail, variables, referenceType, referenceId } = params; + + try { + const template = await this.notificationsRepository.getEmailTemplateByKey(templateKey); + if (!template || !template.isActive) { + logger.warn('Email template not found or inactive', { templateKey }); + return; + } + + const renderedSubject = this.templateService.render(template.subject, variables); + const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); + + await this.emailService.send(userEmail, renderedSubject, renderedHtml); + + // Log successful send + if (userId) { + await this.notificationsRepository.insertNotificationLog({ + user_id: userId, + notification_type: 'email', + template_key: templateKey, + recipient_email: userEmail, + subject: renderedSubject, + reference_type: referenceType, + reference_id: referenceId, + status: 'sent', + }); + } + + logger.info('Email ingestion notification sent', { templateKey, userEmail }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Failed to send email ingestion notification', { + templateKey, + userEmail, + error: errorMessage, + }); + + // Log failed send + if (userId) { + await this.notificationsRepository.insertNotificationLog({ + user_id: userId, + notification_type: 'email', + template_key: templateKey, + recipient_email: userEmail, + reference_type: referenceType, + reference_id: referenceId, + status: 'failed', + error_message: errorMessage, + }).catch(logErr => { + logger.error('Failed to log notification failure', { + error: logErr instanceof Error ? logErr.message : String(logErr), + }); + }); + } + } + } +} diff --git a/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql b/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql index 3df64e0..40d6fcf 100644 --- a/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql +++ b/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql @@ -31,14 +31,15 @@ Your emailed receipt has been successfully processed. Vehicle: {{vehicleName}} Record Type: {{recordType}} -Date: {{receiptDate}} -Amount: ${{amount}} +Merchant: {{merchantName}} +Date: {{date}} +Amount: ${{totalAmount}} The record has been added to your vehicle history. Best regards, MotoVaultPro Team', - '["userName", "vehicleName", "recordType", "receiptDate", "amount"]', + '["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]', ' @@ -65,8 +66,9 @@ MotoVaultPro Team',

Vehicle: {{vehicleName}}

Record Type: {{recordType}}

-

Date: {{receiptDate}}

-

Amount: ${{amount}}

+

Merchant: {{merchantName}}

+

Date: {{date}}

+

Amount: ${{totalAmount}}

@@ -94,19 +96,13 @@ MotoVaultPro Team', We were unable to process the receipt you emailed to us. -Subject: {{emailSubject}} Error: {{errorReason}} -Please try again with a clearer image or PDF, or upload the receipt directly through the app. - -Tips for better results: -- Use a well-lit, high-contrast photo -- Ensure text is legible and not cut off -- PDF receipts work best +{{guidance}} Best regards, MotoVaultPro Team', - '["userName", "emailSubject", "errorReason"]', + '["userName", "errorReason", "guidance"]', ' @@ -131,19 +127,13 @@ MotoVaultPro Team',
-

Subject: {{emailSubject}}

Error: {{errorReason}}

-

Please try again with a clearer image or PDF, or upload the receipt directly through the app.

-

Tips for better results:

-
    -
  • Use a well-lit, high-contrast photo
  • -
  • Ensure text is legible and not cut off
  • -
  • PDF receipts work best
  • -
+

What to do next:

+

{{guidance}}

@@ -171,14 +161,15 @@ Your emailed receipt has been processed, but we need your help to complete the r Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to. Record Type: {{recordType}} -Date: {{receiptDate}} -Amount: ${{amount}} +Merchant: {{merchantName}} +Date: {{date}} +Amount: ${{totalAmount}} You can find the pending receipt in your notifications. Best regards, MotoVaultPro Team', - '["userName", "recordType", "receiptDate", "amount"]', + '["userName", "recordType", "merchantName", "totalAmount", "date"]', ' @@ -208,8 +199,9 @@ MotoVaultPro Team',

Record Type: {{recordType}}

-

Date: {{receiptDate}}

-

Amount: ${{amount}}

+

Merchant: {{merchantName}}

+

Date: {{date}}

+

Amount: ${{totalAmount}}

diff --git a/backend/src/features/notifications/domain/notifications.service.ts b/backend/src/features/notifications/domain/notifications.service.ts index 7c1684c..38e4ac2 100644 --- a/backend/src/features/notifications/domain/notifications.service.ts +++ b/backend/src/features/notifications/domain/notifications.service.ts @@ -445,6 +445,29 @@ export class NotificationsService { reason: 'Subscription upgrade', additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!', }; + case 'receipt_processed': + return { + ...baseVariables, + vehicleName: '2024 Toyota Camry', + recordType: 'Fuel Log', + merchantName: 'Shell Gas Station', + totalAmount: '45.50', + date: new Date().toLocaleDateString(), + }; + case 'receipt_failed': + return { + ...baseVariables, + errorReason: 'Unable to extract receipt data from the attached image.', + guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.', + }; + case 'receipt_pending_vehicle': + return { + ...baseVariables, + recordType: 'Maintenance Record', + merchantName: 'AutoZone', + totalAmount: '89.99', + date: new Date().toLocaleDateString(), + }; default: return baseVariables; }