feat: Email Receipt Ingestion via Resend Webhooks #149
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
receipts@motovaultpro.comPOST /api/webhooks/resend/inboundbackend/src/features/email-ingestion/Sender Validation
userstableContent Processing
POST /api/ocr/extract/receipt)POST /api/ocr/extract/maintenance-receipt)Vehicle Association
Queue and Processing
email_ingestion_queue(sender, received_at, status, attachments, processing_result)pending_vehicle_associations(user_id, record_type, extracted_data, document_id, status)Error Handling
notification_logstableNotifications
Technical Considerations
notificationsfeature for error replies and in-app notificationsreceipt_processed,receipt_failed,receipt_pending_vehicleDependencies
Acceptance Criteria
receipts@motovaultpro.comPlan: 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:
setImmediate().email_ingestion_queuetable serves as state machine with idempotency guard onemail_id.Three decisions confirmed: Resend SDK
webhooks.verify(),mailparserfor raw email parsing, keyword-based receipt classification.Architecture Overview
Dependencies
mailparser,@types/mailparserRESEND_WEBHOOK_SECRETreceipts@motovaultpro.com, add webhook URLMilestones
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.sqlbackend/src/features/email-ingestion/migrations/002_create_email_templates.sqlbackend/src/features/email-ingestion/domain/email-ingestion.types.tsDetails:
email_ingestion_queuetable: 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_atpending_vehicle_associationstable: 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_atExit 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.tsbackend/src/features/email-ingestion/api/email-ingestion.controller.tsbackend/src/features/email-ingestion/external/resend-inbound.client.tsbackend/src/features/email-ingestion/index.tsbackend/src/app.ts(register routes)backend/package.json(add mailparser)Details:
POST /api/webhooks/resend/inbound,config: { rawBody: true }, no preHandler authresend.webhooks.verify({ payload, headers: { id, timestamp, signature }, webhookSecret })email_ingestion_queuefor existing email_id before insertsetImmediate(() => service.processEmail(queueId))getEmail(emailId)->downloadRawEmail(url)->parseEmail(raw)using mailparsermailparser+@types/mailparserExit 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.tsbackend/src/features/email-ingestion/data/email-ingestion.repository.tsDetails:
UserProfileRepository.getByEmail(senderEmail.toLowerCase())- if null, send error replyExit 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.tsDetails:
{ type: 'fuel' | 'maintenance' | 'unclassified', confidence: number }OcrService.extractReceipt(userId, { fileBuffer, contentType })OcrService.extractMaintenanceReceipt(userId, { fileBuffer, contentType })DocumentsService.createDocument()with document_type based on receipt typeExit 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:
backend/src/features/email-ingestion/domain/email-ingestion.service.tsDetails:
VehiclesService.getUserVehicles(userId)-> count vehiclespending_vehicle_associationsrow, create in-app notificationFuelLogsService.createFuelLog(mappedData, userId)receiptDocumentIdto stored document IDExit 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.tsDetails:
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:
frontend/src/features/email-ingestion/(new feature)Details:
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)Exit criteria: Dashboard shows banner, user can resolve or dismiss, mobile + desktop responsive
Execution Order
Branch and PR Strategy
issue-149-email-receipt-ingestion(from main)feat: Email Receipt Ingestion via Resend Webhooks (#149)Risk Mitigation
Verdict: AWAITING_REVIEW | Next: Plan review cycle (QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs)