diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts
index a5d6e49..2afd27c 100644
--- a/backend/src/_system/migrations/run-all.ts
+++ b/backend/src/_system/migrations/run-all.ts
@@ -26,6 +26,7 @@ const MIGRATION_ORDER = [
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
'features/backup', // Admin backup feature; depends on update_updated_at_column()
'features/notifications', // Depends on maintenance and documents
+ 'features/email-ingestion', // Depends on documents, notifications (extends email_templates)
'features/terms-agreement', // Terms & Conditions acceptance audit trail
'features/audit-log', // Centralized audit logging; independent
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
diff --git a/backend/src/features/email-ingestion/domain/email-ingestion.types.ts b/backend/src/features/email-ingestion/domain/email-ingestion.types.ts
new file mode 100644
index 0000000..30792ad
--- /dev/null
+++ b/backend/src/features/email-ingestion/domain/email-ingestion.types.ts
@@ -0,0 +1,103 @@
+/**
+ * @ai-summary TypeScript types for the email ingestion feature
+ * @ai-context Covers database records, status enums, and Resend webhook payloads
+ */
+
+// ========================
+// Status Enums
+// ========================
+
+export type EmailIngestionStatus = 'pending' | 'processing' | 'completed' | 'failed';
+
+export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired';
+
+export type EmailRecordType = 'fuel_log' | 'maintenance_record';
+
+// ========================
+// Database Records
+// ========================
+
+export interface EmailIngestionQueueRecord {
+ id: string;
+ emailId: string;
+ senderEmail: string;
+ userId: string;
+ receivedAt: string;
+ subject: string | null;
+ status: EmailIngestionStatus;
+ processingResult: EmailProcessingResult | null;
+ errorMessage: string | null;
+ retryCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PendingVehicleAssociation {
+ id: string;
+ userId: string;
+ recordType: EmailRecordType;
+ extractedData: ExtractedReceiptData;
+ documentId: string | null;
+ status: PendingAssociationStatus;
+ createdAt: string;
+ resolvedAt: string | null;
+}
+
+// ========================
+// Processing Results
+// ========================
+
+export interface EmailProcessingResult {
+ recordType: EmailRecordType;
+ vehicleId: string | null;
+ recordId: string | null;
+ documentId: string | null;
+ pendingAssociationId: string | null;
+ extractedData: ExtractedReceiptData;
+}
+
+export interface ExtractedReceiptData {
+ vendor: string | null;
+ date: string | null;
+ total: number | null;
+ odometerReading: number | null;
+ /** Fuel-specific fields */
+ gallons: number | null;
+ pricePerGallon: number | null;
+ fuelType: string | null;
+ /** Maintenance-specific fields */
+ category: string | null;
+ subtypes: string[] | null;
+ shopName: string | null;
+ description: string | null;
+}
+
+// ========================
+// Resend Webhook Payloads
+// ========================
+
+/** Top-level Resend webhook event envelope */
+export interface ResendWebhookEvent {
+ type: string;
+ created_at: string;
+ data: ResendWebhookEventData;
+}
+
+/** Resend email.received webhook event data */
+export interface ResendWebhookEventData {
+ email_id: string;
+ from: string;
+ to: string[];
+ subject: string;
+ text: string | null;
+ html: string | null;
+ created_at: string;
+ attachments: ResendEmailAttachment[];
+}
+
+/** Attachment metadata from Resend inbound email */
+export interface ResendEmailAttachment {
+ filename: string;
+ content_type: string;
+ content: string;
+}
diff --git a/backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql b/backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql
new file mode 100644
index 0000000..f3c8788
--- /dev/null
+++ b/backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql
@@ -0,0 +1,71 @@
+/**
+ * Migration: Create email ingestion tables
+ * @ai-summary Creates email_ingestion_queue and pending_vehicle_associations tables
+ * @ai-context Supports inbound email receipt processing via Resend webhooks
+ */
+
+-- email_ingestion_queue: Tracks inbound emails from Resend webhooks
+CREATE TABLE IF NOT EXISTS email_ingestion_queue (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ email_id VARCHAR(255) NOT NULL,
+ sender_email VARCHAR(255) NOT NULL,
+ user_id VARCHAR(255) NOT NULL,
+ received_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ subject VARCHAR(500),
+ status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
+ 'pending', 'processing', 'completed', 'failed'
+ )),
+ processing_result JSONB,
+ error_message TEXT,
+ retry_count INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Unique constraint on email_id to prevent duplicate processing
+ALTER TABLE email_ingestion_queue
+ ADD CONSTRAINT uq_email_ingestion_queue_email_id UNIQUE (email_id);
+
+-- Trigger for updated_at (reuses update_updated_at_column() from vehicles feature)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_email_ingestion_queue'
+ ) THEN
+ CREATE TRIGGER set_timestamp_email_ingestion_queue
+ BEFORE UPDATE ON email_ingestion_queue
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+ END IF;
+END $$;
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id);
+CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_status ON email_ingestion_queue(status);
+CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_sender ON email_ingestion_queue(sender_email);
+CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_received_at ON email_ingestion_queue(received_at DESC);
+
+-- pending_vehicle_associations: Holds records needing vehicle selection (multi-vehicle users)
+CREATE TABLE IF NOT EXISTS pending_vehicle_associations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id VARCHAR(255) NOT NULL,
+ record_type VARCHAR(30) NOT NULL CHECK (record_type IN (
+ 'fuel_log', 'maintenance_record'
+ )),
+ extracted_data JSONB NOT NULL,
+ document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
+ 'pending', 'resolved', 'expired'
+ )),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ resolved_at TIMESTAMP WITH TIME ZONE
+);
+
+-- Trigger for pending_vehicle_associations does not need updated_at (uses resolved_at instead)
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id);
+CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_status ON pending_vehicle_associations(status)
+ WHERE status = 'pending';
+CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_document_id ON pending_vehicle_associations(document_id)
+ WHERE document_id IS NOT NULL;
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
new file mode 100644
index 0000000..3df64e0
--- /dev/null
+++ b/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql
@@ -0,0 +1,241 @@
+/**
+ * Migration: Add email ingestion email templates
+ * @ai-summary Extends email_templates CHECK constraint and seeds 3 receipt templates
+ * @ai-context Templates for receipt processing confirmations, failures, and pending vehicle selection
+ */
+
+-- Extend template_key CHECK constraint to include email ingestion templates
+ALTER TABLE email_templates
+DROP CONSTRAINT IF EXISTS email_templates_template_key_check;
+
+ALTER TABLE email_templates
+ADD CONSTRAINT email_templates_template_key_check
+CHECK (template_key IN (
+ 'maintenance_due_soon', 'maintenance_overdue',
+ 'document_expiring', 'document_expired',
+ 'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day',
+ 'subscription_tier_change',
+ 'receipt_processed', 'receipt_failed', 'receipt_pending_vehicle'
+));
+
+-- Insert email ingestion templates
+INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES
+ (
+ 'receipt_processed',
+ 'Receipt Processed Successfully',
+ 'Sent when an emailed receipt is successfully processed and recorded',
+ 'MotoVaultPro: Receipt Processed for {{vehicleName}}',
+ 'Hi {{userName}},
+
+Your emailed receipt has been successfully processed.
+
+Vehicle: {{vehicleName}}
+Record Type: {{recordType}}
+Date: {{receiptDate}}
+Amount: ${{amount}}
+
+The record has been added to your vehicle history.
+
+Best regards,
+MotoVaultPro Team',
+ '["userName", "vehicleName", "recordType", "receiptDate", "amount"]',
+ '
+
+
+
+
+ Receipt Processed
+
+
+
+
+
+
+
+
+ Receipt Processed
+ |
+
+
+ |
+ Hi {{userName}},
+ Your emailed receipt has been successfully processed.
+
+
+ |
+ Vehicle: {{vehicleName}}
+ Record Type: {{recordType}}
+ Date: {{receiptDate}}
+ Amount: ${{amount}}
+ |
+
+
+ The record has been added to your vehicle history.
+ |
+
+
+ |
+ Best regards, MotoVaultPro Team
+ |
+
+
+ |
+
+
+
+'
+ ),
+ (
+ 'receipt_failed',
+ 'Receipt Processing Failed',
+ 'Sent when an emailed receipt fails OCR processing or validation',
+ 'MotoVaultPro: Unable to Process Your Receipt',
+ 'Hi {{userName}},
+
+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
+
+Best regards,
+MotoVaultPro Team',
+ '["userName", "emailSubject", "errorReason"]',
+ '
+
+
+
+
+ Receipt Processing Failed
+
+
+
+
+
+
+
+
+ Processing Failed
+ |
+
+
+ |
+ Hi {{userName}},
+ 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
+
+
+ |
+
+
+ |
+ Best regards, MotoVaultPro Team
+ |
+
+
+ |
+
+
+
+'
+ ),
+ (
+ 'receipt_pending_vehicle',
+ 'Receipt Pending Vehicle Selection',
+ 'Sent when a multi-vehicle user needs to select which vehicle a receipt belongs to',
+ 'MotoVaultPro: Select Vehicle for Your Receipt',
+ 'Hi {{userName}},
+
+Your emailed receipt has been processed, but we need your help to complete the record.
+
+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}}
+
+You can find the pending receipt in your notifications.
+
+Best regards,
+MotoVaultPro Team',
+ '["userName", "recordType", "receiptDate", "amount"]',
+ '
+
+
+
+
+ Select Vehicle for Receipt
+
+
+
+
+
+
+
+
+ Vehicle Selection Needed
+ |
+
+
+ |
+ Hi {{userName}},
+ Your emailed receipt has been processed, but we need your help to complete the record.
+
+ Action Required
+ Since you have multiple vehicles, please select which vehicle this receipt belongs to.
+
+
+
+ |
+ Record Type: {{recordType}}
+ Date: {{receiptDate}}
+ Amount: ${{amount}}
+ |
+
+
+ You can find the pending receipt in your notifications.
+
+ |
+
+
+ |
+ Best regards, MotoVaultPro Team
+ |
+
+
+ |
+
+
+
+'
+ )
+ON CONFLICT (template_key) DO UPDATE SET
+ name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ subject = EXCLUDED.subject,
+ body = EXCLUDED.body,
+ variables = EXCLUDED.variables,
+ html_body = EXCLUDED.html_body,
+ updated_at = NOW();