Enable users to submit receipts by emailing them to [email protected]. 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).
Receipt documents are stored and linked to created records
## Summary
Enable users to submit receipts by emailing them to `[email protected]`. 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 `[email protected]`
- 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 `[email protected]`
- [ ] 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
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
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
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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Enable users to submit receipts by emailing them to
[email protected]. 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
[email protected]POST /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
[email protected]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:
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_SECRET[email protected], 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)