feat: add email ingestion database schema and types (refs #154)
- Create email_ingestion_queue table with UNIQUE email_id constraint - Create pending_vehicle_associations table with documents FK - Seed 3 email templates: receipt_processed, receipt_failed, receipt_pending_vehicle - Add TypeScript types for queue records, associations, and Resend webhook payloads - Register email-ingestion in migration runner order Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ const MIGRATION_ORDER = [
|
|||||||
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
|
'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/backup', // Admin backup feature; depends on update_updated_at_column()
|
||||||
'features/notifications', // Depends on maintenance and documents
|
'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/terms-agreement', // Terms & Conditions acceptance audit trail
|
||||||
'features/audit-log', // Centralized audit logging; independent
|
'features/audit-log', // Centralized audit logging; independent
|
||||||
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
|
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"]',
|
||||||
|
'<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Receipt Processed</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #2e7d32; padding: 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Receipt Processed</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been successfully processed.</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<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>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; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">The record has been added to your vehicle history.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||||
|
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'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"]',
|
||||||
|
'<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Receipt Processing Failed</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #d32f2f; padding: 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Processing Failed</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">We were unable to process the receipt you emailed to us.</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</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;">
|
||||||
|
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">Tips for better results:</p>
|
||||||
|
<ul style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0; padding-left: 20px;">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||||
|
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'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"]',
|
||||||
|
'<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Select Vehicle for Receipt</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f57c00; padding: 30px; text-align: center;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Vehicle Selection Needed</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been processed, but we need your help to complete the record.</p>
|
||||||
|
<div style="background-color: #fff3e0; border-left: 4px solid #f57c00; padding: 20px; margin: 20px 0;">
|
||||||
|
<p style="color: #e65100; font-size: 16px; font-weight: bold; margin: 0 0 10px 0;">Action Required</p>
|
||||||
|
<p style="color: #333333; font-size: 14px; margin: 0;">Since you have multiple vehicles, please select which vehicle this receipt belongs to.</p>
|
||||||
|
</div>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<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>Date:</strong> {{receiptDate}}</p>
|
||||||
|
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">You can find the pending receipt in your notifications.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="https://motovaultpro.com/notifications" style="display: inline-block; padding: 14px 28px; background-color: #f57c00; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Select Vehicle</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||||
|
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>'
|
||||||
|
)
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user