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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-13 09:27:37 -06:00
parent fce60759cf
commit 8bcac80818
4 changed files with 429 additions and 227 deletions

View File

@@ -1,7 +1,8 @@
/** /**
* @ai-summary Core processing service for the email-to-record pipeline * @ai-summary Core processing service for the email-to-record pipeline
* @ai-context Orchestrates sender validation, OCR extraction, record classification, * @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'; import { Pool } from 'pg';
@@ -17,6 +18,7 @@ import { EmailService } from '../../notifications/domain/email.service';
import { ocrService } from '../../ocr/domain/ocr.service'; import { ocrService } from '../../ocr/domain/ocr.service';
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types'; import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
import { ReceiptClassifier } from './receipt-classifier'; import { ReceiptClassifier } from './receipt-classifier';
import { EmailIngestionNotificationHandler } from './notification-handler';
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service'; import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository'; import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
import { FuelType } from '../../fuel-logs/domain/fuel-logs.types'; import { FuelType } from '../../fuel-logs/domain/fuel-logs.types';
@@ -56,9 +58,7 @@ export class EmailIngestionService {
private resendClient: ResendInboundClient; private resendClient: ResendInboundClient;
private userProfileRepository: UserProfileRepository; private userProfileRepository: UserProfileRepository;
private vehiclesRepository: VehiclesRepository; private vehiclesRepository: VehiclesRepository;
private notificationsRepository: NotificationsRepository; private notificationHandler: EmailIngestionNotificationHandler;
private templateService: TemplateService;
private emailService: EmailService;
private classifier: ReceiptClassifier; private classifier: ReceiptClassifier;
private fuelLogsService: FuelLogsService; private fuelLogsService: FuelLogsService;
private maintenanceService: MaintenanceService; private maintenanceService: MaintenanceService;
@@ -69,9 +69,12 @@ export class EmailIngestionService {
this.resendClient = new ResendInboundClient(); this.resendClient = new ResendInboundClient();
this.userProfileRepository = new UserProfileRepository(p); this.userProfileRepository = new UserProfileRepository(p);
this.vehiclesRepository = new VehiclesRepository(p); this.vehiclesRepository = new VehiclesRepository(p);
this.notificationsRepository = new NotificationsRepository(p); const notificationsRepository = new NotificationsRepository(p);
this.templateService = new TemplateService(); this.notificationHandler = new EmailIngestionNotificationHandler(
this.emailService = new EmailService(); notificationsRepository,
new TemplateService(),
new EmailService(),
);
this.classifier = new ReceiptClassifier(); this.classifier = new ReceiptClassifier();
this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p)); this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p));
this.maintenanceService = new MaintenanceService(); this.maintenanceService = new MaintenanceService();
@@ -96,7 +99,7 @@ export class EmailIngestionService {
// 2. Validate sender // 2. Validate sender
const userProfile = await this.validateSender(senderEmail); const userProfile = await this.validateSender(senderEmail);
if (!userProfile) { if (!userProfile) {
await this.handleUnregisteredSender(emailId, senderEmail, subject); await this.handleUnregisteredSender(emailId, senderEmail);
return; return;
} }
@@ -112,7 +115,7 @@ export class EmailIngestionService {
// 4. Filter valid attachments // 4. Filter valid attachments
const validAttachments = this.filterAttachments(attachments); const validAttachments = this.filterAttachments(attachments);
if (validAttachments.length === 0) { if (validAttachments.length === 0) {
await this.handleNoValidAttachments(emailId, senderEmail, userName, subject); await this.handleNoValidAttachments(emailId, userId, userName, senderEmail);
return; return;
} }
@@ -129,7 +132,7 @@ export class EmailIngestionService {
userId, validAttachments, emailClassification, emailId userId, validAttachments, emailClassification, emailId
); );
if (!ocrResult) { 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; return;
} }
@@ -147,9 +150,6 @@ export class EmailIngestionService {
processingResult, processingResult,
}); });
// 10. Send confirmation email
await this.sendConfirmationEmail(senderEmail, userName, processingResult);
logger.info('Email processing completed successfully', { logger.info('Email processing completed successfully', {
emailId, emailId,
userId, userId,
@@ -463,7 +463,7 @@ export class EmailIngestionService {
// No vehicles: user must add a vehicle first // No vehicles: user must add a vehicle first
if (vehicles.length === 0) { if (vehicles.length === 0) {
await this.sendNoVehiclesEmail(userEmail, userName); await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail);
return { return {
recordType, recordType,
vehicleId: null, vehicleId: null,
@@ -476,39 +476,38 @@ export class EmailIngestionService {
// Single vehicle: auto-associate and create record // Single vehicle: auto-associate and create record
if (vehicles.length === 1) { if (vehicles.length === 1) {
const vehicleId = vehicles[0].id; const vehicle = vehicles[0];
let recordId: string | null = null; let recordId: string | null = null;
try { try {
recordId = await this.createRecord(userId, vehicleId, recordType, extractedData); recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData);
} catch (error) { } catch (error) {
logger.error('Failed to create record from email receipt', { logger.error('Failed to create record from email receipt', {
userId, userId,
vehicleId, vehicleId: vehicle.id,
recordType, recordType,
error: error instanceof Error ? error.message : String(error), 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 vehicleName = vehicle.nickname
const message = recordId || [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
? `Your emailed ${recordLabel} receipt has been processed and a ${recordLabel} record was created for your vehicle.` || 'your vehicle';
: `Your emailed ${recordLabel} receipt was processed but the record could not be created automatically. Please add it manually.`;
await this.notificationsRepository.insertUserNotification({ await this.notificationHandler.notifyReceiptProcessed({
userId, userId,
notificationType: 'email_ingestion', userName,
title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed', userEmail,
message, vehicleName,
referenceType: recordType, recordType,
referenceId: recordId ?? undefined, recordId,
vehicleId, vehicleId: vehicle.id,
extractedData,
}); });
return { return {
recordType, recordType,
vehicleId, vehicleId: vehicle.id,
recordId, recordId,
documentId: null, documentId: null,
pendingAssociationId: null, pendingAssociationId: null,
@@ -524,17 +523,15 @@ export class EmailIngestionService {
documentId: null, documentId: null,
}); });
await this.notificationsRepository.insertUserNotification({ await this.notificationHandler.notifyPendingVehicleSelection({
userId, userId,
notificationType: 'email_ingestion', userName,
title: 'Receipt Pending Vehicle Selection', userEmail,
message: `Your emailed receipt has been processed. Please select which vehicle this ${recordType === 'fuel_log' ? 'fuel' : 'maintenance'} receipt belongs to.`, recordType,
referenceType: 'pending_vehicle_association', pendingAssociationId: pendingAssociation.id,
referenceId: pendingAssociation.id, extractedData,
}); });
await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData);
return { return {
recordType, recordType,
vehicleId: null, vehicleId: null,
@@ -681,13 +678,13 @@ export class EmailIngestionService {
private async handleProcessingError( private async handleProcessingError(
emailId: string, emailId: string,
senderEmail: string, senderEmail: string,
subject: string | null, _subject: string | null,
error: unknown error: unknown
): Promise<void> { ): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Email processing pipeline error', { emailId, error: errorMessage }); 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 queueEntry = await this.repository.getQueueEntry(emailId);
const currentRetryCount = queueEntry?.retryCount || 0; const currentRetryCount = queueEntry?.retryCount || 0;
const newRetryCount = currentRetryCount + 1; const newRetryCount = currentRetryCount + 1;
@@ -711,11 +708,15 @@ export class EmailIngestionService {
retryCount: newRetryCount, retryCount: newRetryCount,
}); });
// Send failure email to user // Send failure notification (email + in-app if userId available)
await this.sendFailureEmail(senderEmail, subject, errorMessage).catch(emailErr => { await this.notificationHandler.notifyProcessingFailure({
logger.error('Failed to send failure notification email', { userId: queueEntry?.userId,
userEmail: senderEmail,
errorReason: errorMessage,
}).catch(notifyErr => {
logger.error('Failed to send failure notification', {
emailId, 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( private async handleUnregisteredSender(
emailId: string, emailId: string,
senderEmail: string, senderEmail: string
subject: string | null
): Promise<void> { ): Promise<void> {
logger.info('Unregistered sender rejected', { emailId, senderEmail }); logger.info('Unregistered sender rejected', { emailId, senderEmail });
@@ -732,12 +732,7 @@ export class EmailIngestionService {
errorMessage: 'Sender email is not registered with MotoVaultPro', errorMessage: 'Sender email is not registered with MotoVaultPro',
}); });
// Send rejection email await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => {
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 => {
logger.error('Failed to send unregistered sender notification', { logger.error('Failed to send unregistered sender notification', {
emailId, emailId,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -747,9 +742,9 @@ export class EmailIngestionService {
private async handleNoValidAttachments( private async handleNoValidAttachments(
emailId: string, emailId: string,
senderEmail: string, userId: string,
_userName: string, userName: string,
subject: string | null userEmail: string
): Promise<void> { ): Promise<void> {
logger.info('No valid attachments found', { emailId }); 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)', errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)',
}); });
await this.sendFailureEmail( await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => {
senderEmail,
subject,
'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.'
).catch(error => {
logger.error('Failed to send no-attachments notification', { logger.error('Failed to send no-attachments notification', {
emailId, emailId,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@@ -771,9 +762,9 @@ export class EmailIngestionService {
private async handleOcrFailure( private async handleOcrFailure(
emailId: string, emailId: string,
senderEmail: string, userId: string,
_userName: string, userName: string,
subject: string | null, userEmail: string,
reason: string reason: string
): Promise<void> { ): Promise<void> {
logger.info('OCR extraction failed for all attachments', { emailId, reason }); logger.info('OCR extraction failed for all attachments', { emailId, reason });
@@ -782,148 +773,11 @@ export class EmailIngestionService {
errorMessage: reason, 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', { logger.error('Failed to send OCR failure notification', {
emailId, emailId,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
}); });
} }
// ========================
// Email Replies
// ========================
private async sendConfirmationEmail(
recipientEmail: string,
userName: string,
result: EmailProcessingResult
): Promise<void> {
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<void> {
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<void> {
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<void> {
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),
});
}
}
} }

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
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<string, string | number | boolean | null | undefined>;
referenceType?: string;
referenceId?: string;
}): Promise<void> {
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),
});
});
}
}
}
}

View File

@@ -31,14 +31,15 @@ Your emailed receipt has been successfully processed.
Vehicle: {{vehicleName}} Vehicle: {{vehicleName}}
Record Type: {{recordType}} Record Type: {{recordType}}
Date: {{receiptDate}} Merchant: {{merchantName}}
Amount: ${{amount}} Date: {{date}}
Amount: ${{totalAmount}}
The record has been added to your vehicle history. The record has been added to your vehicle history.
Best regards, Best regards,
MotoVaultPro Team', MotoVaultPro Team',
'["userName", "vehicleName", "recordType", "receiptDate", "amount"]', '["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html> '<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -65,8 +66,9 @@ MotoVaultPro Team',
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;"> <td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{receiptDate}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
</td> </td>
</tr> </tr>
</table> </table>
@@ -94,19 +96,13 @@ MotoVaultPro Team',
We were unable to process the receipt you emailed to us. We were unable to process the receipt you emailed to us.
Subject: {{emailSubject}}
Error: {{errorReason}} Error: {{errorReason}}
Please try again with a clearer image or PDF, or upload the receipt directly through the app. {{guidance}}
Tips for better results:
- Use a well-lit, high-contrast photo
- Ensure text is legible and not cut off
- PDF receipts work best
Best regards, Best regards,
MotoVaultPro Team', MotoVaultPro Team',
'["userName", "emailSubject", "errorReason"]', '["userName", "errorReason", "guidance"]',
'<!DOCTYPE html> '<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -131,19 +127,13 @@ MotoVaultPro Team',
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;"> <table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr> <tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;"> <td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Subject:</strong> {{emailSubject}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p> <p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p>
</td> </td>
</tr> </tr>
</table> </table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">Please try again with a clearer image or PDF, or upload the receipt directly through the app.</p>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;"> <div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">Tips for better results:</p> <p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">What to do next:</p>
<ul style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0; padding-left: 20px;"> <p style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0;">{{guidance}}</p>
<li>Use a well-lit, high-contrast photo</li>
<li>Ensure text is legible and not cut off</li>
<li>PDF receipts work best</li>
</ul>
</div> </div>
</td> </td>
</tr> </tr>
@@ -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. Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to.
Record Type: {{recordType}} Record Type: {{recordType}}
Date: {{receiptDate}} Merchant: {{merchantName}}
Amount: ${{amount}} Date: {{date}}
Amount: ${{totalAmount}}
You can find the pending receipt in your notifications. You can find the pending receipt in your notifications.
Best regards, Best regards,
MotoVaultPro Team', MotoVaultPro Team',
'["userName", "recordType", "receiptDate", "amount"]', '["userName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html> '<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -208,8 +199,9 @@ MotoVaultPro Team',
<tr> <tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;"> <td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{receiptDate}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p> <p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -445,6 +445,29 @@ export class NotificationsService {
reason: 'Subscription upgrade', reason: 'Subscription upgrade',
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!', 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: default:
return baseVariables; return baseVariables;
} }