feat: Email Receipt Ingestion via Resend Webhooks #149

Closed
opened 2026-02-13 02:46:19 +00:00 by egullickson · 1 comment
Owner

Summary

Enable users to submit receipts by emailing them to receipts@motovaultpro.com. The system receives inbound emails via Resend webhooks, validates the sender against the user's registered email, OCR-processes attachments, and auto-creates the appropriate record (fuel log or maintenance record).

Depends on #16 (maintenance receipt OCR must be complete first).

Requirements

Resend Inbound Webhook

  • Configure Resend inbound email for receipts@motovaultpro.com
  • New webhook endpoint: POST /api/webhooks/resend/inbound
  • Webhook signature verification for security
  • New backend feature: backend/src/features/email-ingestion/

Sender Validation

  • Only accept emails from the user's registered email address
  • Lookup user by sender email in users table
  • Unregistered sender: send error reply explaining the email must come from registered address

Content Processing

  • Parse email body text and attachments (PDFs, images)
  • Auto-detect record type based on content:
    • Fuel receipts -> Fuel log entry (existing POST /api/ocr/extract/receipt)
    • Service/repair receipts -> Maintenance record (from #16: POST /api/ocr/extract/maintenance-receipt)
    • Unclassifiable -> Queue as pending with notification to user
  • Store attachments as documents via existing documents feature

Vehicle Association

  • Single vehicle: Auto-associate with user's only vehicle
  • Multiple vehicles: Store as pending; create in-app notification prompting user to select vehicle

Queue and Processing

  • Database table: email_ingestion_queue (sender, received_at, status, attachments, processing_result)
  • Database table: pending_vehicle_associations (user_id, record_type, extracted_data, document_id, status)
  • Process webhook payload immediately (no polling)
  • Retry logic for OCR failures (max 3 attempts)

Error Handling

  • Unregistered sender: error reply email via Resend
  • OCR failure: error reply email with details
  • Processing errors: error reply email with actionable guidance
  • All errors logged to notification_logs table

Notifications

  • In-app notification when email receipt is successfully processed
  • In-app notification when multi-vehicle user has pending association
  • Error email replies sent via existing Resend outbound (EmailService)

Technical Considerations

  • Webhook endpoint must be publicly accessible (Traefik routing)
  • HMAC signature verification on inbound webhook payload
  • Rate limiting on webhook endpoint (abuse prevention)
  • Integration with existing notifications feature for error replies and in-app notifications
  • Email templates needed: receipt_processed, receipt_failed, receipt_pending_vehicle
  • Consider file size limits on email attachments (align with OCR limits: 10MB images, 200MB PDF)

Dependencies

  • OCR feature (complete)
  • #16 - Maintenance Receipt Upload with OCR Auto-populate (maintenance receipt OCR pipeline)

Acceptance Criteria

  • Resend inbound webhook receives emails at receipts@motovaultpro.com
  • Webhook signature is verified before processing
  • Emails from registered addresses are accepted and processed
  • Emails from unregistered addresses receive error reply
  • Attachments (PDF, PNG, JPG) are extracted and OCR'd
  • Record type is auto-detected (fuel vs maintenance)
  • Single-vehicle users have records auto-created
  • Multi-vehicle users receive in-app notification to select vehicle
  • Failed processing sends informative error reply email
  • Successfully processed receipts create in-app notification
  • Receipt documents are stored and linked to created records
## Summary Enable users to submit receipts by emailing them to `receipts@motovaultpro.com`. The system receives inbound emails via Resend webhooks, validates the sender against the user's registered email, OCR-processes attachments, and auto-creates the appropriate record (fuel log or maintenance record). Depends on #16 (maintenance receipt OCR must be complete first). ## Requirements ### Resend Inbound Webhook - Configure Resend inbound email for `receipts@motovaultpro.com` - New webhook endpoint: `POST /api/webhooks/resend/inbound` - Webhook signature verification for security - New backend feature: `backend/src/features/email-ingestion/` ### Sender Validation - Only accept emails from the user's registered email address - Lookup user by sender email in `users` table - Unregistered sender: send error reply explaining the email must come from registered address ### Content Processing - Parse email body text and attachments (PDFs, images) - Auto-detect record type based on content: - Fuel receipts -> Fuel log entry (existing `POST /api/ocr/extract/receipt`) - Service/repair receipts -> Maintenance record (from #16: `POST /api/ocr/extract/maintenance-receipt`) - Unclassifiable -> Queue as pending with notification to user - Store attachments as documents via existing documents feature ### Vehicle Association - **Single vehicle**: Auto-associate with user's only vehicle - **Multiple vehicles**: Store as pending; create in-app notification prompting user to select vehicle ### Queue and Processing - Database table: `email_ingestion_queue` (sender, received_at, status, attachments, processing_result) - Database table: `pending_vehicle_associations` (user_id, record_type, extracted_data, document_id, status) - Process webhook payload immediately (no polling) - Retry logic for OCR failures (max 3 attempts) ### Error Handling - Unregistered sender: error reply email via Resend - OCR failure: error reply email with details - Processing errors: error reply email with actionable guidance - All errors logged to `notification_logs` table ### Notifications - In-app notification when email receipt is successfully processed - In-app notification when multi-vehicle user has pending association - Error email replies sent via existing Resend outbound (EmailService) ## Technical Considerations - Webhook endpoint must be publicly accessible (Traefik routing) - HMAC signature verification on inbound webhook payload - Rate limiting on webhook endpoint (abuse prevention) - Integration with existing `notifications` feature for error replies and in-app notifications - Email templates needed: `receipt_processed`, `receipt_failed`, `receipt_pending_vehicle` - Consider file size limits on email attachments (align with OCR limits: 10MB images, 200MB PDF) ## Dependencies - [x] OCR feature (complete) - [ ] #16 - Maintenance Receipt Upload with OCR Auto-populate (maintenance receipt OCR pipeline) ## Acceptance Criteria - [ ] Resend inbound webhook receives emails at `receipts@motovaultpro.com` - [ ] Webhook signature is verified before processing - [ ] Emails from registered addresses are accepted and processed - [ ] Emails from unregistered addresses receive error reply - [ ] Attachments (PDF, PNG, JPG) are extracted and OCR'd - [ ] Record type is auto-detected (fuel vs maintenance) - [ ] Single-vehicle users have records auto-created - [ ] Multi-vehicle users receive in-app notification to select vehicle - [ ] Failed processing sends informative error reply email - [ ] Successfully processed receipts create in-app notification - [ ] Receipt documents are stored and linked to created records
egullickson added the
status
backlog
type
feature
labels 2026-02-13 02:46:24 +00:00
egullickson added
status
in-progress
and removed
status
backlog
labels 2026-02-13 03:41:39 +00:00
Author
Owner

Plan: Email Receipt Ingestion via Resend Webhooks

Phase: Planning | Agent: Planner | Status: AWAITING_REVIEW


Pre-Planning Analysis

Codebase Analysis: Investigated 11 files across email infrastructure (Resend SDK v3, EmailService, TemplateService), OCR pipeline (extractReceipt, extractMaintenanceReceipt), documents feature, notifications feature, webhook patterns (Stripe), Traefik routing, and feature capsule architecture.

Decision Critic: Stress-tested 5 architectural decisions. Two decisions were revised:

  • Sync processing -> Async with DB queue: Resend webhook timeout (~15-30s) is incompatible with OCR processing time (3-120s). Must return 200 immediately and process asynchronously via setImmediate().
  • Process in request lifecycle -> Decouple receipt from processing: email_ingestion_queue table serves as state machine with idempotency guard on email_id.

Three decisions confirmed: Resend SDK webhooks.verify(), mailparser for raw email parsing, keyword-based receipt classification.


Architecture Overview

Resend (email.received webhook)
     |
     v
POST /api/webhooks/resend/inbound
     |
     +-- 1. Verify svix signature
     +-- 2. Dedup check (email_id UNIQUE)
     +-- 3. Insert email_ingestion_queue (status=pending)
     +-- 4. Return 200
     +-- 5. setImmediate() -> processEmail()
                |
                v
         EmailIngestionService.processEmail()
                |
                +-- 6. Validate sender (UserProfileRepository.getByEmail)
                +-- 7. Fetch raw email (Resend API -> download -> mailparser)
                +-- 8. Extract attachments (filter by type/size)
                +-- 9. Classify receipt type (keywords from subject + body)
                +-- 10. OCR extraction (extractReceipt or extractMaintenanceReceipt)
                +-- 11. Store document (DocumentsService)
                +-- 12. Associate vehicle (auto if single, pending if multi)
                +-- 13. Create record (fuel log or maintenance record)
                +-- 14. Send notifications (in-app + confirmation email)

Dependencies

  • Blocking: #16 (Maintenance Receipt Upload with OCR) - maintenance receipt OCR pipeline must be complete
  • New npm packages: mailparser, @types/mailparser
  • New env var: RESEND_WEBHOOK_SECRET
  • Resend config: Configure inbound email for receipts@motovaultpro.com, add webhook URL

Milestones

Each milestone maps 1:1 to a sub-issue per workflow contract.

Milestone 1: Database Schema and Types (refs #154)

Agent: Feature Agent
Files:

  • backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql
  • backend/src/features/email-ingestion/migrations/002_create_email_templates.sql
  • backend/src/features/email-ingestion/domain/email-ingestion.types.ts

Details:

  • Create email_ingestion_queue table: id (UUID PK), email_id (VARCHAR UNIQUE), sender_email, user_id (nullable FK), received_at, subject, status (CHECK: pending/processing/completed/failed), processing_result (JSONB), error_message, retry_count (DEFAULT 0), created_at, updated_at
  • Create pending_vehicle_associations table: id (UUID PK), user_id (VARCHAR NOT NULL), record_type (CHECK: fuel_log/maintenance_record), extracted_data (JSONB NOT NULL), document_id (UUID FK documents ON DELETE SET NULL), status (CHECK: pending/resolved/expired), created_at, resolved_at
  • Seed 3 email templates: receipt_processed, receipt_failed, receipt_pending_vehicle
  • Define TypeScript types: EmailIngestionQueueRecord, PendingVehicleAssociation, EmailIngestionStatus, PendingAssociationStatus, ResendWebhookPayload, ResendEmailReceivedEvent

Exit criteria: Tables created, types exported, templates seeded


Milestone 2: Resend Inbound Client and Webhook Endpoint (refs #155)

Agent: Feature Agent
Files:

  • backend/src/features/email-ingestion/api/email-ingestion.routes.ts
  • backend/src/features/email-ingestion/api/email-ingestion.controller.ts
  • backend/src/features/email-ingestion/external/resend-inbound.client.ts
  • backend/src/features/email-ingestion/index.ts
  • backend/src/app.ts (register routes)
  • backend/package.json (add mailparser)

Details:

  • Webhook route: POST /api/webhooks/resend/inbound, config: { rawBody: true }, no preHandler auth
  • Controller: Extract rawBody + svix headers, call service.handleWebhook()
  • Signature verification: resend.webhooks.verify({ payload, headers: { id, timestamp, signature }, webhookSecret })
  • Idempotency: Check email_ingestion_queue for existing email_id before insert
  • Return 200 immediately after queue insert
  • Trigger async processing via setImmediate(() => service.processEmail(queueId))
  • ResendInboundClient: getEmail(emailId) -> downloadRawEmail(url) -> parseEmail(raw) using mailparser
  • Install mailparser + @types/mailparser

Exit criteria: Webhook receives and verifies signatures, dedup works, raw email fetched and parsed


Milestone 3: Email Ingestion Processing Service (refs #156)

Agent: Feature Agent
Files:

  • backend/src/features/email-ingestion/domain/email-ingestion.service.ts
  • backend/src/features/email-ingestion/data/email-ingestion.repository.ts

Details:

  • EmailIngestionService orchestrates the full pipeline:
    1. Update queue status to 'processing'
    2. Validate sender: UserProfileRepository.getByEmail(senderEmail.toLowerCase()) - if null, send error reply
    3. Fetch and parse email via ResendInboundClient
    4. Filter attachments: supported types (image/jpeg, image/png, image/heic, application/pdf), max 10MB each
    5. For each valid attachment: classify, OCR, store document, associate vehicle, create record
    6. Update queue status to 'completed' with processing_result JSONB
    7. On error: increment retry_count, if < 3 re-queue, else mark 'failed'
  • EmailIngestionRepository: insertQueueEntry, updateQueueStatus, findByEmailId, insertPendingAssociation, getPendingAssociations, resolvePendingAssociation, deletePendingAssociation
  • All repository methods use mapRow() for snake_case -> camelCase conversion

Exit criteria: Full processing pipeline works end-to-end, retry logic functional


Milestone 4: Receipt Classifier and OCR Integration (refs #157)

Agent: Feature Agent
Files:

  • backend/src/features/email-ingestion/domain/receipt-classifier.ts

Details:

  • ReceiptClassifier.classify(subject, bodyText, attachmentFilenames):
    • Fuel keywords: gas, fuel, gallons, octane, pump, diesel, unleaded, shell, chevron, exxon, bp, gasoline
    • Maintenance keywords: oil change, brake, alignment, tire, rotation, inspection, labor, parts, service, repair, transmission, coolant, mechanic, auto shop
    • Score: count keyword matches in subject + body, case-insensitive
    • If fuel score >= 2 and > maintenance score: type='fuel'
    • If maintenance score >= 2 and > fuel score: type='maintenance'
    • If both equal or both < 2: type='unclassified'
    • Returns: { type: 'fuel' | 'maintenance' | 'unclassified', confidence: number }
  • OCR integration:
    • fuel -> OcrService.extractReceipt(userId, { fileBuffer, contentType })
    • maintenance -> OcrService.extractMaintenanceReceipt(userId, { fileBuffer, contentType })
    • unclassified -> store as pending, create in-app notification for user review
  • Document storage: DocumentsService.createDocument() with document_type based on receipt type

Exit criteria: Classification works for fuel and maintenance keywords, OCR called correctly per type


Milestone 5: Vehicle Association and Record Creation (refs #158)

Agent: Feature Agent
Files:

  • Extends backend/src/features/email-ingestion/domain/email-ingestion.service.ts

Details:

  • Vehicle association:
    • VehiclesService.getUserVehicles(userId) -> count vehicles
    • 0 vehicles: send error email, mark queue as failed
    • 1 vehicle: auto-associate, proceed to record creation
    • 2+ vehicles: insert pending_vehicle_associations row, create in-app notification
  • Fuel log creation (single vehicle path):
    • Map OCR fields: merchantName->stationName, transactionDate->date, totalAmount->totalCost, fuelQuantity->gallons, pricePerUnit->pricePerGallon, fuelGrade->grade
    • Call FuelLogsService.createFuelLog(mappedData, userId)
  • Maintenance record creation (single vehicle path):
    • Map OCR fields: merchantName->shopName, transactionDate->date, totalAmount->cost, service->category+subtypes, odometer->odometerReading
    • Set receiptDocumentId to stored document ID
    • Call maintenance record creation endpoint
  • Pending association resolution (called from frontend):
    • Accept vehicleId, retrieve extracted_data from pending row
    • Create the appropriate record with selected vehicle
    • Update pending status to 'resolved'

Exit criteria: Single-vehicle auto-creates records, multi-vehicle creates pending associations


Milestone 6: Notifications and Error Emails (refs #159)

Agent: Feature Agent
Files:

  • backend/src/features/email-ingestion/domain/notification-handler.ts

Details:

  • NotificationHandler class, injected with NotificationsService + EmailService + TemplateService
  • Success path: in-app notification (type='email_ingestion', referenceType='fuel_log'|'maintenance_record', referenceId, vehicleId) + confirmation email (receipt_processed template)
  • Pending vehicle path: in-app notification (type='email_ingestion', title='Vehicle Selection Required', referenceType='pending_vehicle_association')
  • Error paths (each sends email reply + logs to notification_logs):
    • Unregistered sender: "This email address is not registered with MotoVaultPro"
    • No vehicles: "Please add a vehicle to your account before submitting receipts"
    • OCR failure: "We couldn't process your receipt. Please try again or upload manually"
    • Unsupported format: "Supported formats: PDF, PNG, JPG, JPEG, HEIC"
    • File too large: "Maximum attachment size is 10MB"
  • All emails logged via NotificationsRepository.insertNotificationLog() with reference_type='email_ingestion'

Exit criteria: All notification paths work, emails sent and logged


Milestone 7: Pending Vehicle Association Resolution UI (refs #160)

Agent: Frontend Agent + Feature Agent (API)
Files:

  • Backend: routes + controller additions for authenticated endpoints
  • Frontend: frontend/src/features/email-ingestion/ (new feature)

Details:

  • Backend API:
    • GET /api/email-ingestion/pending (authenticated, returns pending associations for user)
    • POST /api/email-ingestion/pending/:id/resolve (body: { vehicleId })
    • DELETE /api/email-ingestion/pending/:id (dismiss)
  • Frontend components:
    • PendingAssociationBanner: Conditional render on dashboard, shows count badge
    • PendingAssociationList: Card list with merchant, date, amount, type info
    • ResolveAssociationDialog: Vehicle dropdown + confirm button
    • usePendingAssociations hook: fetch, resolve, dismiss mutations
  • Mobile: Bottom sheet for dialog, full-width cards, 44px touch targets
  • Desktop: Dialog modal, wider card layout

Exit criteria: Dashboard shows banner, user can resolve or dismiss, mobile + desktop responsive


Execution Order

#154 (schema/types)
  |
  v
#155 (webhook/client) --> depends on #154 for types + queue table
  |
  v
#156 (processing service) --> depends on #155 for client + controller
  |
  v
#157 (classifier/OCR) --> depends on #156 for service orchestration
  |
  v
#158 (vehicle/records) --> depends on #157 for classified + OCR'd data
  |
  v
#159 (notifications) --> depends on #158 for record creation results
  |
  v
#160 (frontend UI) --> depends on #159 for backend API completeness

Branch and PR Strategy


Risk Mitigation

Risk Mitigation
Resend webhook timeout causes retries Idempotency on email_id (UNIQUE constraint) + immediate 200 response
OCR cold start (30-60s) setImmediate() decouples processing from webhook response
Malicious email attachments File type validation (magic bytes), size limits (10MB), no executable types
Sender spoofing HMAC signature verification + sender email validation against registered user
Duplicate record creation Queue status tracking prevents re-processing completed emails

Verdict: AWAITING_REVIEW | Next: Plan review cycle (QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs)

## Plan: Email Receipt Ingestion via Resend Webhooks **Phase**: Planning | **Agent**: Planner | **Status**: AWAITING_REVIEW --- ### Pre-Planning Analysis **Codebase Analysis**: Investigated 11 files across email infrastructure (Resend SDK v3, EmailService, TemplateService), OCR pipeline (extractReceipt, extractMaintenanceReceipt), documents feature, notifications feature, webhook patterns (Stripe), Traefik routing, and feature capsule architecture. **Decision Critic**: Stress-tested 5 architectural decisions. Two decisions were revised: - **Sync processing** -> **Async with DB queue**: Resend webhook timeout (~15-30s) is incompatible with OCR processing time (3-120s). Must return 200 immediately and process asynchronously via `setImmediate()`. - **Process in request lifecycle** -> **Decouple receipt from processing**: `email_ingestion_queue` table serves as state machine with idempotency guard on `email_id`. Three decisions confirmed: Resend SDK `webhooks.verify()`, `mailparser` for raw email parsing, keyword-based receipt classification. --- ### Architecture Overview ``` Resend (email.received webhook) | v POST /api/webhooks/resend/inbound | +-- 1. Verify svix signature +-- 2. Dedup check (email_id UNIQUE) +-- 3. Insert email_ingestion_queue (status=pending) +-- 4. Return 200 +-- 5. setImmediate() -> processEmail() | v EmailIngestionService.processEmail() | +-- 6. Validate sender (UserProfileRepository.getByEmail) +-- 7. Fetch raw email (Resend API -> download -> mailparser) +-- 8. Extract attachments (filter by type/size) +-- 9. Classify receipt type (keywords from subject + body) +-- 10. OCR extraction (extractReceipt or extractMaintenanceReceipt) +-- 11. Store document (DocumentsService) +-- 12. Associate vehicle (auto if single, pending if multi) +-- 13. Create record (fuel log or maintenance record) +-- 14. Send notifications (in-app + confirmation email) ``` --- ### Dependencies - **Blocking**: #16 (Maintenance Receipt Upload with OCR) - maintenance receipt OCR pipeline must be complete - **New npm packages**: `mailparser`, `@types/mailparser` - **New env var**: `RESEND_WEBHOOK_SECRET` - **Resend config**: Configure inbound email for `receipts@motovaultpro.com`, add webhook URL --- ### Milestones Each milestone maps 1:1 to a sub-issue per workflow contract. #### Milestone 1: Database Schema and Types (refs #154) **Agent**: Feature Agent **Files**: - `backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql` - `backend/src/features/email-ingestion/migrations/002_create_email_templates.sql` - `backend/src/features/email-ingestion/domain/email-ingestion.types.ts` **Details**: - Create `email_ingestion_queue` table: id (UUID PK), email_id (VARCHAR UNIQUE), sender_email, user_id (nullable FK), received_at, subject, status (CHECK: pending/processing/completed/failed), processing_result (JSONB), error_message, retry_count (DEFAULT 0), created_at, updated_at - Create `pending_vehicle_associations` table: id (UUID PK), user_id (VARCHAR NOT NULL), record_type (CHECK: fuel_log/maintenance_record), extracted_data (JSONB NOT NULL), document_id (UUID FK documents ON DELETE SET NULL), status (CHECK: pending/resolved/expired), created_at, resolved_at - Seed 3 email templates: receipt_processed, receipt_failed, receipt_pending_vehicle - Define TypeScript types: EmailIngestionQueueRecord, PendingVehicleAssociation, EmailIngestionStatus, PendingAssociationStatus, ResendWebhookPayload, ResendEmailReceivedEvent **Exit criteria**: Tables created, types exported, templates seeded --- #### Milestone 2: Resend Inbound Client and Webhook Endpoint (refs #155) **Agent**: Feature Agent **Files**: - `backend/src/features/email-ingestion/api/email-ingestion.routes.ts` - `backend/src/features/email-ingestion/api/email-ingestion.controller.ts` - `backend/src/features/email-ingestion/external/resend-inbound.client.ts` - `backend/src/features/email-ingestion/index.ts` - `backend/src/app.ts` (register routes) - `backend/package.json` (add mailparser) **Details**: - Webhook route: `POST /api/webhooks/resend/inbound`, `config: { rawBody: true }`, no preHandler auth - Controller: Extract rawBody + svix headers, call service.handleWebhook() - Signature verification: `resend.webhooks.verify({ payload, headers: { id, timestamp, signature }, webhookSecret })` - Idempotency: Check `email_ingestion_queue` for existing email_id before insert - Return 200 immediately after queue insert - Trigger async processing via `setImmediate(() => service.processEmail(queueId))` - ResendInboundClient: `getEmail(emailId)` -> `downloadRawEmail(url)` -> `parseEmail(raw)` using mailparser - Install `mailparser` + `@types/mailparser` **Exit criteria**: Webhook receives and verifies signatures, dedup works, raw email fetched and parsed --- #### Milestone 3: Email Ingestion Processing Service (refs #156) **Agent**: Feature Agent **Files**: - `backend/src/features/email-ingestion/domain/email-ingestion.service.ts` - `backend/src/features/email-ingestion/data/email-ingestion.repository.ts` **Details**: - EmailIngestionService orchestrates the full pipeline: 1. Update queue status to 'processing' 2. Validate sender: `UserProfileRepository.getByEmail(senderEmail.toLowerCase())` - if null, send error reply 3. Fetch and parse email via ResendInboundClient 4. Filter attachments: supported types (image/jpeg, image/png, image/heic, application/pdf), max 10MB each 5. For each valid attachment: classify, OCR, store document, associate vehicle, create record 6. Update queue status to 'completed' with processing_result JSONB 7. On error: increment retry_count, if < 3 re-queue, else mark 'failed' - EmailIngestionRepository: insertQueueEntry, updateQueueStatus, findByEmailId, insertPendingAssociation, getPendingAssociations, resolvePendingAssociation, deletePendingAssociation - All repository methods use mapRow() for snake_case -> camelCase conversion **Exit criteria**: Full processing pipeline works end-to-end, retry logic functional --- #### Milestone 4: Receipt Classifier and OCR Integration (refs #157) **Agent**: Feature Agent **Files**: - `backend/src/features/email-ingestion/domain/receipt-classifier.ts` **Details**: - ReceiptClassifier.classify(subject, bodyText, attachmentFilenames): - Fuel keywords: gas, fuel, gallons, octane, pump, diesel, unleaded, shell, chevron, exxon, bp, gasoline - Maintenance keywords: oil change, brake, alignment, tire, rotation, inspection, labor, parts, service, repair, transmission, coolant, mechanic, auto shop - Score: count keyword matches in subject + body, case-insensitive - If fuel score >= 2 and > maintenance score: type='fuel' - If maintenance score >= 2 and > fuel score: type='maintenance' - If both equal or both < 2: type='unclassified' - Returns: `{ type: 'fuel' | 'maintenance' | 'unclassified', confidence: number }` - OCR integration: - fuel -> `OcrService.extractReceipt(userId, { fileBuffer, contentType })` - maintenance -> `OcrService.extractMaintenanceReceipt(userId, { fileBuffer, contentType })` - unclassified -> store as pending, create in-app notification for user review - Document storage: `DocumentsService.createDocument()` with document_type based on receipt type **Exit criteria**: Classification works for fuel and maintenance keywords, OCR called correctly per type --- #### Milestone 5: Vehicle Association and Record Creation (refs #158) **Agent**: Feature Agent **Files**: - Extends `backend/src/features/email-ingestion/domain/email-ingestion.service.ts` **Details**: - Vehicle association: - `VehiclesService.getUserVehicles(userId)` -> count vehicles - 0 vehicles: send error email, mark queue as failed - 1 vehicle: auto-associate, proceed to record creation - 2+ vehicles: insert `pending_vehicle_associations` row, create in-app notification - Fuel log creation (single vehicle path): - Map OCR fields: merchantName->stationName, transactionDate->date, totalAmount->totalCost, fuelQuantity->gallons, pricePerUnit->pricePerGallon, fuelGrade->grade - Call `FuelLogsService.createFuelLog(mappedData, userId)` - Maintenance record creation (single vehicle path): - Map OCR fields: merchantName->shopName, transactionDate->date, totalAmount->cost, service->category+subtypes, odometer->odometerReading - Set `receiptDocumentId` to stored document ID - Call maintenance record creation endpoint - Pending association resolution (called from frontend): - Accept vehicleId, retrieve extracted_data from pending row - Create the appropriate record with selected vehicle - Update pending status to 'resolved' **Exit criteria**: Single-vehicle auto-creates records, multi-vehicle creates pending associations --- #### Milestone 6: Notifications and Error Emails (refs #159) **Agent**: Feature Agent **Files**: - `backend/src/features/email-ingestion/domain/notification-handler.ts` **Details**: - NotificationHandler class, injected with NotificationsService + EmailService + TemplateService - Success path: in-app notification (type='email_ingestion', referenceType='fuel_log'|'maintenance_record', referenceId, vehicleId) + confirmation email (receipt_processed template) - Pending vehicle path: in-app notification (type='email_ingestion', title='Vehicle Selection Required', referenceType='pending_vehicle_association') - Error paths (each sends email reply + logs to notification_logs): - Unregistered sender: "This email address is not registered with MotoVaultPro" - No vehicles: "Please add a vehicle to your account before submitting receipts" - OCR failure: "We couldn't process your receipt. Please try again or upload manually" - Unsupported format: "Supported formats: PDF, PNG, JPG, JPEG, HEIC" - File too large: "Maximum attachment size is 10MB" - All emails logged via `NotificationsRepository.insertNotificationLog()` with reference_type='email_ingestion' **Exit criteria**: All notification paths work, emails sent and logged --- #### Milestone 7: Pending Vehicle Association Resolution UI (refs #160) **Agent**: Frontend Agent + Feature Agent (API) **Files**: - Backend: routes + controller additions for authenticated endpoints - Frontend: `frontend/src/features/email-ingestion/` (new feature) **Details**: - Backend API: - `GET /api/email-ingestion/pending` (authenticated, returns pending associations for user) - `POST /api/email-ingestion/pending/:id/resolve` (body: { vehicleId }) - `DELETE /api/email-ingestion/pending/:id` (dismiss) - Frontend components: - PendingAssociationBanner: Conditional render on dashboard, shows count badge - PendingAssociationList: Card list with merchant, date, amount, type info - ResolveAssociationDialog: Vehicle dropdown + confirm button - usePendingAssociations hook: fetch, resolve, dismiss mutations - Mobile: Bottom sheet for dialog, full-width cards, 44px touch targets - Desktop: Dialog modal, wider card layout **Exit criteria**: Dashboard shows banner, user can resolve or dismiss, mobile + desktop responsive --- ### Execution Order ``` #154 (schema/types) | v #155 (webhook/client) --> depends on #154 for types + queue table | v #156 (processing service) --> depends on #155 for client + controller | v #157 (classifier/OCR) --> depends on #156 for service orchestration | v #158 (vehicle/records) --> depends on #157 for classified + OCR'd data | v #159 (notifications) --> depends on #158 for record creation results | v #160 (frontend UI) --> depends on #159 for backend API completeness ``` ### Branch and PR Strategy - **Branch**: `issue-149-email-receipt-ingestion` (from main) - **PR**: `feat: Email Receipt Ingestion via Resend Webhooks (#149)` - **PR body**: Fixes #149, Fixes #154, Fixes #155, Fixes #156, Fixes #157, Fixes #158, Fixes #159, Fixes #160 --- ### Risk Mitigation | Risk | Mitigation | |------|------------| | Resend webhook timeout causes retries | Idempotency on email_id (UNIQUE constraint) + immediate 200 response | | OCR cold start (30-60s) | setImmediate() decouples processing from webhook response | | Malicious email attachments | File type validation (magic bytes), size limits (10MB), no executable types | | Sender spoofing | HMAC signature verification + sender email validation against registered user | | Duplicate record creation | Queue status tracking prevents re-processing completed emails | --- *Verdict*: AWAITING_REVIEW | *Next*: Plan review cycle (QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: egullickson/motovaultpro#149