This commit is contained in:
19
README.md
19
README.md
@@ -15,6 +15,7 @@ A TypeScript Express application for receiving and processing Instant Payment No
|
|||||||
- ✅ Transaction-safe database operations
|
- ✅ Transaction-safe database operations
|
||||||
- ✅ Idempotent IPN processing (handles duplicate callbacks)
|
- ✅ Idempotent IPN processing (handles duplicate callbacks)
|
||||||
- ✅ Error handling and logging
|
- ✅ Error handling and logging
|
||||||
|
- ✅ Automatic email receipts sent to buyers upon purchase confirmation
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -47,6 +48,23 @@ Edit `.env` and configure:
|
|||||||
- `DB_PASSWORD` - Database password
|
- `DB_PASSWORD` - Database password
|
||||||
- `DB_NAME` - Database name (default: cbd420)
|
- `DB_NAME` - Database name (default: cbd420)
|
||||||
|
|
||||||
|
**SMTP Configuration (for email receipts):**
|
||||||
|
- Configure SMTP settings for sending order confirmation emails:
|
||||||
|
- `SMTP_HOST` - SMTP server hostname (e.g., smtp.gmail.com, smtp.mailgun.org)
|
||||||
|
- `SMTP_PORT` - SMTP server port (e.g., 587 for STARTTLS, 465 for SSL, 25 for unencrypted)
|
||||||
|
- **Recommended: 587** (STARTTLS) - most reliable and commonly not blocked
|
||||||
|
- Port 465 (SSL) may be blocked by some firewalls
|
||||||
|
- `SMTP_USER` - SMTP username/email
|
||||||
|
- `SMTP_PASSWORD` - SMTP password or app-specific password
|
||||||
|
- `SMTP_FROM_EMAIL` - Email address to send from (must match SMTP_USER for most providers)
|
||||||
|
- `SMTP_FROM_NAME` - Display name for sender (optional, default: "CBD420")
|
||||||
|
|
||||||
|
**SMTP Troubleshooting:**
|
||||||
|
- If you get connection timeouts, try switching from port 465 to 587
|
||||||
|
- Ensure your firewall allows outbound connections to the SMTP server
|
||||||
|
- Some providers require app-specific passwords (e.g., Gmail)
|
||||||
|
- Test SMTP connectivity: `telnet SMTP_HOST SMTP_PORT` or use `nc -zv SMTP_HOST SMTP_PORT`
|
||||||
|
|
||||||
### 3. Database Migration
|
### 3. Database Migration
|
||||||
|
|
||||||
The application requires the `expires_at` column in the `pending_orders` table for the 10-minute reservation mechanism. Run the migration:
|
The application requires the `expires_at` column in the `pending_orders` table for the 10-minute reservation mechanism. Run the migration:
|
||||||
@@ -141,6 +159,7 @@ When a payment status is `finished` or `confirmed`, the system:
|
|||||||
- `waiting`, `confirming` → Acknowledge and wait
|
- `waiting`, `confirming` → Acknowledge and wait
|
||||||
4. **Final Inventory Check** - Validates inventory is still available before creating sale
|
4. **Final Inventory Check** - Validates inventory is still available before creating sale
|
||||||
5. **Create Sale Record** - Inserts into `sales` table and deletes from `pending_orders`
|
5. **Create Sale Record** - Inserts into `sales` table and deletes from `pending_orders`
|
||||||
|
6. **Send Email Receipt** - Sends order confirmation email to buyer (after transaction commit)
|
||||||
|
|
||||||
All operations are performed within a database transaction to ensure data consistency and prevent race conditions.
|
All operations are performed within a database transaction to ensure data consistency and prevent race conditions.
|
||||||
|
|
||||||
|
|||||||
1354
package-lock.json
generated
1354
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,12 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"mysql2": "^3.16.0"
|
"mysql2": "^3.16.0",
|
||||||
|
"nodemailer": "^7.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|||||||
@@ -398,7 +398,66 @@ export async function movePaymentToSalesWithInventoryCheck(
|
|||||||
await connection.commit();
|
await connection.commit();
|
||||||
|
|
||||||
console.log(`✅ Successfully moved pending order ${pendingOrderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`);
|
console.log(`✅ Successfully moved pending order ${pendingOrderId} (payment_id: ${paymentId}) from pending_orders to sales (sale_id: ${saleId})`);
|
||||||
return saleRows[0];
|
|
||||||
|
const sale = saleRows[0];
|
||||||
|
|
||||||
|
// Send email receipt after successful transaction
|
||||||
|
// This is done outside the transaction to avoid blocking the payment flow
|
||||||
|
// Email failures are logged but don't affect the sale creation
|
||||||
|
// Send email asynchronously without awaiting to avoid blocking
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
// Import email service dynamically to avoid circular dependencies
|
||||||
|
const { sendReceiptEmail, isSMTPConfigured } = await import('../services/emailService');
|
||||||
|
|
||||||
|
if (!isSMTPConfigured()) {
|
||||||
|
console.warn(`⚠️ SMTP not configured - skipping email receipt for sale ${sale.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📧 Attempting to send receipt email for sale ${sale.id}...`);
|
||||||
|
|
||||||
|
const buyer = await getBuyerById(sale.buyer_id);
|
||||||
|
const buyerData = await getBuyerDataById(sale.buyer_data_id);
|
||||||
|
|
||||||
|
console.log(`📧 Buyer lookup: ${buyer ? `found (${buyer.email})` : 'not found'}, BuyerData lookup: ${buyerData ? 'found' : 'not found'}`);
|
||||||
|
|
||||||
|
if (buyer && buyerData) {
|
||||||
|
// Convert price_amount to number (MySQL DECIMAL returns as string)
|
||||||
|
const priceAmount = typeof pendingOrder.price_amount === 'string'
|
||||||
|
? parseFloat(pendingOrder.price_amount)
|
||||||
|
: Number(pendingOrder.price_amount);
|
||||||
|
|
||||||
|
console.log(`📧 Calling sendReceiptEmail with priceAmount: ${priceAmount}, currency: ${pendingOrder.price_currency}`);
|
||||||
|
|
||||||
|
await sendReceiptEmail(
|
||||||
|
sale,
|
||||||
|
drop,
|
||||||
|
{
|
||||||
|
email: buyer.email,
|
||||||
|
username: buyer.username,
|
||||||
|
fullname: buyerData.fullname,
|
||||||
|
address: buyerData.address,
|
||||||
|
phone: buyerData.phone,
|
||||||
|
},
|
||||||
|
priceAmount,
|
||||||
|
pendingOrder.price_currency
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📧 sendReceiptEmail completed for sale ${sale.id}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`Could not send receipt email for sale ${sale.id}: buyer or buyer_data not found`);
|
||||||
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
// Log but don't throw - email failure shouldn't break the payment flow
|
||||||
|
console.error(`❌ Error sending receipt email for sale ${sale.id}:`, emailError);
|
||||||
|
if (emailError instanceof Error) {
|
||||||
|
console.error(`❌ Error details: ${emailError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sale;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback transaction on error
|
// Rollback transaction on error
|
||||||
await connection.rollback();
|
await connection.rollback();
|
||||||
@@ -443,3 +502,59 @@ export async function paymentExistsInSales(paymentId: string): Promise<boolean>
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get buyer information by buyer_id
|
||||||
|
*/
|
||||||
|
export interface BuyerInfo {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBuyerById(buyerId: number): Promise<BuyerInfo | null> {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id, email, username FROM buyers WHERE id = ?',
|
||||||
|
[buyerId]
|
||||||
|
) as [BuyerInfo[], any];
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting buyer by id:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get buyer data information by buyer_data_id
|
||||||
|
*/
|
||||||
|
export interface BuyerDataInfo {
|
||||||
|
id: number;
|
||||||
|
buyer_id: number;
|
||||||
|
fullname: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBuyerDataById(buyerDataId: number): Promise<BuyerDataInfo | null> {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id, buyer_id, fullname, address, phone FROM buyer_data WHERE id = ?',
|
||||||
|
[buyerDataId]
|
||||||
|
) as [BuyerDataInfo[], any];
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting buyer data by id:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
409
src/services/emailService.ts
Normal file
409
src/services/emailService.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { Sale, Drop } from '../database/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP configuration from environment variables
|
||||||
|
*/
|
||||||
|
interface SMTPConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure: boolean;
|
||||||
|
auth: {
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
from: string;
|
||||||
|
fromName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SMTP is configured
|
||||||
|
*/
|
||||||
|
export function isSMTPConfigured(): boolean {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = process.env.SMTP_PORT;
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const password = process.env.SMTP_PASSWORD;
|
||||||
|
const from = process.env.SMTP_FROM_EMAIL;
|
||||||
|
|
||||||
|
return !!(host && port && user && password && from);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SMTP configuration from environment variables
|
||||||
|
*/
|
||||||
|
function getSMTPConfig(): SMTPConfig {
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = process.env.SMTP_PORT;
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const password = process.env.SMTP_PASSWORD;
|
||||||
|
const from = process.env.SMTP_FROM_EMAIL;
|
||||||
|
const fromName = process.env.SMTP_FROM_NAME || 'CBD420';
|
||||||
|
|
||||||
|
if (!host || !port || !user || !password || !from) {
|
||||||
|
throw new Error(
|
||||||
|
'SMTP configuration incomplete. Required env variables: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password is not empty
|
||||||
|
if (!password || password.trim() === '') {
|
||||||
|
throw new Error('SMTP_PASSWORD is required and cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if secure (TLS) based on port
|
||||||
|
const portNum = parseInt(port, 10);
|
||||||
|
// Port 465 uses SSL (secure: true), port 587 uses STARTTLS (secure: false, requireTLS: true)
|
||||||
|
const secure = portNum === 465;
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: portNum,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user: user.trim(),
|
||||||
|
password: password.trim(),
|
||||||
|
},
|
||||||
|
from,
|
||||||
|
fromName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create nodemailer transporter
|
||||||
|
*/
|
||||||
|
function createTransporter() {
|
||||||
|
const config = getSMTPConfig();
|
||||||
|
|
||||||
|
// Port 465 uses SSL (implicit TLS) - direct SSL connection
|
||||||
|
// Port 587 uses STARTTLS (explicit TLS) - upgrade after connection
|
||||||
|
const isSSL = config.port === 465;
|
||||||
|
|
||||||
|
const transporterOptions: any = {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: isSSL, // true for 465 (SSL), false for 587 (STARTTLS)
|
||||||
|
auth: {
|
||||||
|
user: config.auth.user,
|
||||||
|
pass: config.auth.password, // nodemailer uses 'pass' not 'password'
|
||||||
|
},
|
||||||
|
connectionTimeout: 20000, // 20 seconds
|
||||||
|
greetingTimeout: 10000, // 10 seconds
|
||||||
|
socketTimeout: 20000, // 20 seconds
|
||||||
|
// Disable DNS lookup timeout issues
|
||||||
|
dnsTimeout: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSSL) {
|
||||||
|
// Port 465: SSL/TLS (implicit encryption from the start)
|
||||||
|
// This matches your provider's "Secure SSL/TLS Settings (Recommended)"
|
||||||
|
transporterOptions.secure = true;
|
||||||
|
transporterOptions.tls = {
|
||||||
|
rejectUnauthorized: false, // Set to true in production with valid certificates
|
||||||
|
minVersion: 'TLSv1', // Support TLSv1, TLSv1.1, TLSv1.2
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Port 587: STARTTLS (upgrade to TLS after connection)
|
||||||
|
// This matches your provider's "Non-SSL Settings (NOT Recommended)"
|
||||||
|
transporterOptions.secure = false;
|
||||||
|
transporterOptions.requireTLS = true;
|
||||||
|
transporterOptions.tls = {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
minVersion: 'TLSv1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📧 [createTransporter] Created transporter with options:`, {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: transporterOptions.secure,
|
||||||
|
requireTLS: transporterOptions.requireTLS,
|
||||||
|
authUser: config.auth.user,
|
||||||
|
hasPassword: !!config.auth.password && config.auth.password.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodemailer.createTransport(transporterOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buyer information for email
|
||||||
|
*/
|
||||||
|
export interface BuyerInfo {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
fullname: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate email receipt HTML
|
||||||
|
*/
|
||||||
|
function generateReceiptHTML(
|
||||||
|
sale: Sale,
|
||||||
|
drop: Drop,
|
||||||
|
buyerInfo: BuyerInfo,
|
||||||
|
priceAmount: number | string,
|
||||||
|
priceCurrency: string
|
||||||
|
): string {
|
||||||
|
const orderDate = new Date(sale.created_at).toLocaleString();
|
||||||
|
const unit = drop.unit === 'kg' ? 'kg' : 'g';
|
||||||
|
const sizeDisplay = drop.unit === 'kg' ? (sale.size / 1000).toFixed(3) : sale.size;
|
||||||
|
|
||||||
|
// Ensure priceAmount is a number
|
||||||
|
const priceAmountNum = typeof priceAmount === 'string' ? parseFloat(priceAmount) : Number(priceAmount);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.order-info {
|
||||||
|
background-color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-left: 4px solid #4CAF50;
|
||||||
|
}
|
||||||
|
.order-details {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.order-details table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
.order-details th,
|
||||||
|
.order-details td {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.order-details th {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.total {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Order Confirmation</h1>
|
||||||
|
<p>Thank you for your purchase!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear ${buyerInfo.fullname || buyerInfo.username},</p>
|
||||||
|
|
||||||
|
<p>Your order has been confirmed and payment has been received. Below are your order details:</p>
|
||||||
|
|
||||||
|
<div class="order-info">
|
||||||
|
<h3>Order Information</h3>
|
||||||
|
<p><strong>Order ID:</strong> ${sale.payment_id}</p>
|
||||||
|
<p><strong>Sale ID:</strong> #${sale.id}</p>
|
||||||
|
<p><strong>Order Date:</strong> ${orderDate}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-details">
|
||||||
|
<h3>Order Details</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<td>${drop.item}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<td>${sizeDisplay} ${unit}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Price per Unit</th>
|
||||||
|
<td>${drop.ppu} ${priceCurrency}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Total Amount</th>
|
||||||
|
<td>${priceAmountNum.toFixed(2)} ${priceCurrency.toUpperCase()}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-info">
|
||||||
|
<h3>Shipping Information</h3>
|
||||||
|
<p><strong>Name:</strong> ${buyerInfo.fullname}</p>
|
||||||
|
<p><strong>Address:</strong> ${buyerInfo.address}</p>
|
||||||
|
<p><strong>Phone:</strong> ${buyerInfo.phone}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>We will process your order and send you a shipping confirmation once it's on its way.</p>
|
||||||
|
|
||||||
|
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>CBD420 Team</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated receipt. Please save this email for your records.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate plain text receipt
|
||||||
|
*/
|
||||||
|
function generateReceiptText(
|
||||||
|
sale: Sale,
|
||||||
|
drop: Drop,
|
||||||
|
buyerInfo: BuyerInfo,
|
||||||
|
priceAmount: number | string,
|
||||||
|
priceCurrency: string
|
||||||
|
): string {
|
||||||
|
const orderDate = new Date(sale.created_at).toLocaleString();
|
||||||
|
const unit = drop.unit === 'kg' ? 'kg' : 'g';
|
||||||
|
const sizeDisplay = drop.unit === 'kg' ? (sale.size / 1000).toFixed(3) : sale.size;
|
||||||
|
|
||||||
|
// Ensure priceAmount is a number
|
||||||
|
const priceAmountNum = typeof priceAmount === 'string' ? parseFloat(priceAmount) : Number(priceAmount);
|
||||||
|
|
||||||
|
return `
|
||||||
|
ORDER CONFIRMATION
|
||||||
|
|
||||||
|
Dear ${buyerInfo.fullname || buyerInfo.username},
|
||||||
|
|
||||||
|
Your order has been confirmed and payment has been received.
|
||||||
|
|
||||||
|
ORDER INFORMATION
|
||||||
|
------------------
|
||||||
|
Order ID: ${sale.payment_id}
|
||||||
|
Sale ID: #${sale.id}
|
||||||
|
Order Date: ${orderDate}
|
||||||
|
|
||||||
|
ORDER DETAILS
|
||||||
|
-------------
|
||||||
|
Item: ${drop.item}
|
||||||
|
Quantity: ${sizeDisplay} ${unit}
|
||||||
|
Price per Unit: ${drop.ppu} ${priceCurrency}
|
||||||
|
Total Amount: ${priceAmountNum.toFixed(2)} ${priceCurrency.toUpperCase()}
|
||||||
|
|
||||||
|
SHIPPING INFORMATION
|
||||||
|
--------------------
|
||||||
|
Name: ${buyerInfo.fullname}
|
||||||
|
Address: ${buyerInfo.address}
|
||||||
|
Phone: ${buyerInfo.phone}
|
||||||
|
|
||||||
|
We will process your order and send you a shipping confirmation once it's on its way.
|
||||||
|
|
||||||
|
If you have any questions, please don't hesitate to contact us.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
CBD420 Team
|
||||||
|
|
||||||
|
---
|
||||||
|
This is an automated receipt. Please save this email for your records.
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email receipt to buyer
|
||||||
|
*/
|
||||||
|
export async function sendReceiptEmail(
|
||||||
|
sale: Sale,
|
||||||
|
drop: Drop,
|
||||||
|
buyerInfo: BuyerInfo,
|
||||||
|
priceAmount: number | string,
|
||||||
|
priceCurrency: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`📧 [sendReceiptEmail] Starting email send for sale ${sale.id} to ${buyerInfo.email}`);
|
||||||
|
|
||||||
|
console.log(`📧 [sendReceiptEmail] Getting SMTP config...`);
|
||||||
|
const config = getSMTPConfig();
|
||||||
|
console.log(`📧 [sendReceiptEmail] SMTP config retrieved: host=${config.host}, port=${config.port}, from=${config.from}`);
|
||||||
|
|
||||||
|
console.log(`📧 [sendReceiptEmail] Creating transporter...`);
|
||||||
|
const transporter = createTransporter();
|
||||||
|
console.log(`📧 [sendReceiptEmail] Transporter created`);
|
||||||
|
|
||||||
|
console.log(`📧 [sendReceiptEmail] Generating email content...`);
|
||||||
|
const html = generateReceiptHTML(sale, drop, buyerInfo, priceAmount, priceCurrency);
|
||||||
|
const text = generateReceiptText(sale, drop, buyerInfo, priceAmount, priceCurrency);
|
||||||
|
console.log(`📧 [sendReceiptEmail] Email content generated (HTML: ${html.length} chars, Text: ${text.length} chars)`);
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"${config.fromName}" <${config.from}>`,
|
||||||
|
to: buyerInfo.email,
|
||||||
|
subject: `Order Confirmation - ${sale.payment_id}`,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip verification if connection is timing out - try sending directly
|
||||||
|
// Some SMTP servers don't respond well to verify() but work fine with sendMail()
|
||||||
|
console.log(`📧 [sendReceiptEmail] Skipping connection verification (will attempt direct send)...`);
|
||||||
|
|
||||||
|
console.log(`📧 [sendReceiptEmail] Sending email via SMTP (host: ${config.host}, port: ${config.port})...`);
|
||||||
|
|
||||||
|
// Try sending with a timeout
|
||||||
|
const sendPromise = transporter.sendMail(mailOptions);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Email send timeout after 30 seconds (host: ${config.host}, port: ${config.port})`)), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await Promise.race([sendPromise, timeoutPromise]) as any;
|
||||||
|
console.log(`✅ Receipt email sent to ${buyerInfo.email} for sale ${sale.id}. Message ID: ${info.messageId}`);
|
||||||
|
} catch (sendError) {
|
||||||
|
// Provide more helpful error message
|
||||||
|
if (sendError instanceof Error && sendError.message.includes('timeout')) {
|
||||||
|
throw new Error(`SMTP connection timeout to ${config.host}:${config.port}. Check firewall rules and ensure the SMTP server is accessible from this network.`);
|
||||||
|
}
|
||||||
|
throw sendError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [sendReceiptEmail] Error sending receipt email:', error);
|
||||||
|
// Don't throw - email failure shouldn't break the payment flow
|
||||||
|
// Log the error for manual review
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(`❌ [sendReceiptEmail] Failed to send email to ${buyerInfo.email}: ${error.message}`);
|
||||||
|
console.error(`❌ [sendReceiptEmail] Stack trace:`, error.stack);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ [sendReceiptEmail] Unknown error type:`, error);
|
||||||
|
}
|
||||||
|
// Re-throw to be caught by the caller's try-catch
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user