first commit
This commit is contained in:
commit
1b2ee461a6
13
.env.example
Normal file
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Shopify Settings
|
||||||
|
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_secret
|
||||||
|
SHOP_NAME="My Awesome Store"
|
||||||
|
|
||||||
|
# SMTP / Email Settings
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your_email@gmail.com
|
||||||
|
SMTP_PASS=your_email_password_or_app_password
|
||||||
|
SMTP_FROM_EMAIL=your_email@gmail.com
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
webhook.log
|
||||||
|
test-email.js
|
||||||
53
README.md
Normal file
53
README.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Shopify Invoice Email Plugin
|
||||||
|
|
||||||
|
This is a custom Node.js Express server that listens for Shopify `orders/create` webhooks. When an order is created, it dynamically generates a PDF invoice and sends an email to the customer with the PDF attached.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v14 or higher)
|
||||||
|
- A Shopify Store
|
||||||
|
- An Email Account (e.g., Gmail, SendGrid) to send emails via SMTP.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Install Dependencies**
|
||||||
|
Navigate to the directory and run:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment Variables**
|
||||||
|
Copy `.env.example` to a new file named `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
Fill in your configuration in the `.env` file:
|
||||||
|
- `SHOPIFY_WEBHOOK_SECRET`: The webhook secret key from your Shopify Admin panel.
|
||||||
|
- `SMTP_*`: Your email provider's SMTP settings. (If using Gmail, use an App Password).
|
||||||
|
|
||||||
|
3. **Start the Server**
|
||||||
|
```bash
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shopify Configuration
|
||||||
|
|
||||||
|
1. Log in to your Shopify Admin.
|
||||||
|
2. Go to **Settings** > **Notifications**.
|
||||||
|
3. Scroll down to **Webhooks** and click **Create webhook**.
|
||||||
|
4. Set the **Event** to `Order creation`.
|
||||||
|
5. Set the **Format** to `JSON`.
|
||||||
|
6. Set the **URL** to your server's endpoint (e.g., `https://your-domain.com/webhooks/orders/create`).
|
||||||
|
*(Note: You will need a tool like ngrok to test locally).*
|
||||||
|
7. Set the **Webhook API version** to the latest version.
|
||||||
|
8. Save the webhook.
|
||||||
|
|
||||||
|
At the bottom of the Webhooks section in Shopify, you'll see a message saying "All your webhooks will be signed with xxx...". Use that secret string as your `SHOPIFY_WEBHOOK_SECRET` in the `.env` file.
|
||||||
|
|
||||||
|
## Testing Locally
|
||||||
|
|
||||||
|
If you are developing locally, you can use `ngrok` to expose your local port 3000 to the internet:
|
||||||
|
```bash
|
||||||
|
ngrok http 3000
|
||||||
|
```
|
||||||
|
Use the `https` ngrok URL in your Shopify webhook configuration.
|
||||||
50
mailer.js
Normal file
50
mailer.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an email with the PDF attachment.
|
||||||
|
* @param {string} toEmail - The recipient's email address
|
||||||
|
* @param {string|number} orderNumber - The Shopify order number
|
||||||
|
* @param {Buffer} pdfBuffer - The PDF data buffer
|
||||||
|
*/
|
||||||
|
const sendEmailWithAttachment = async (toEmail, orderNumber, pdfBuffer) => {
|
||||||
|
// Create a transporter using your email service credentials
|
||||||
|
// You can configure SMTP, SendGrid, Amazon SES, etc.
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT) || 465,
|
||||||
|
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"${process.env.SHOP_NAME || 'Your Store'}" <${process.env.SMTP_FROM_EMAIL}>`,
|
||||||
|
to: toEmail,
|
||||||
|
subject: `Invoice for your order #${orderNumber}`,
|
||||||
|
text: `Thank you for your order! Attached is the invoice for order #${orderNumber}.`,
|
||||||
|
html: `<p>Thank you for your order!</p><p>Attached is the invoice for order <b>#${orderNumber}</b>.</p>`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: `Invoice_${orderNumber}.pdf`,
|
||||||
|
content: pdfBuffer,
|
||||||
|
contentType: 'application/pdf'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
console.log(`Email sent: ${info.messageId}`);
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { sendEmailWithAttachment };
|
||||||
1082
package-lock.json
generated
Normal file
1082
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "invoice-email-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"crypto": "^1.0.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
|
"pdfkit": "^0.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
pdfGenerator.js
Normal file
90
pdfGenerator.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a PDF invoice based on Shopify order data.
|
||||||
|
* @param {Object} orderData - The Shopify order payload
|
||||||
|
* @returns {Promise<Buffer>} - A promise that resolves to a Buffer containing the PDF data
|
||||||
|
*/
|
||||||
|
const generateInvoicePDF = (orderData) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const doc = new PDFDocument({ margin: 50 });
|
||||||
|
let buffers = [];
|
||||||
|
|
||||||
|
doc.on('data', buffers.push.bind(buffers));
|
||||||
|
doc.on('end', () => {
|
||||||
|
const pdfData = Buffer.concat(buffers);
|
||||||
|
resolve(pdfData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invoice Header
|
||||||
|
doc.fontSize(20).text('Invoice', { align: 'right' });
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
doc.fontSize(12)
|
||||||
|
.text(`Order Number: ${orderData.name || orderData.order_number}`)
|
||||||
|
.text(`Date: ${new Date(orderData.created_at).toLocaleDateString()}`)
|
||||||
|
.moveDown();
|
||||||
|
|
||||||
|
// Store Info (You can customize this)
|
||||||
|
const shopName = process.env.SHOP_NAME || 'Your Store Name';
|
||||||
|
doc.fontSize(14).text(shopName);
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
// Customer Info
|
||||||
|
doc.fontSize(14).text('Billed To:');
|
||||||
|
doc.fontSize(12).text(`${orderData.billing_address?.first_name || ''} ${orderData.billing_address?.last_name || ''}`);
|
||||||
|
doc.text(`${orderData.billing_address?.address1 || ''}`);
|
||||||
|
if (orderData.billing_address?.address2) {
|
||||||
|
doc.text(`${orderData.billing_address.address2}`);
|
||||||
|
}
|
||||||
|
doc.text(`${orderData.billing_address?.city || ''}, ${orderData.billing_address?.province || ''} ${orderData.billing_address?.zip || ''}`);
|
||||||
|
doc.text(`${orderData.billing_address?.country || ''}`);
|
||||||
|
doc.moveDown(2);
|
||||||
|
|
||||||
|
// Line Items Table Header
|
||||||
|
doc.fontSize(12).font('Helvetica-Bold');
|
||||||
|
doc.text('Item', 50, doc.y, { continued: true });
|
||||||
|
doc.text('Qty', 300, doc.y, { continued: true, width: 50, align: 'right' });
|
||||||
|
doc.text('Price', 350, doc.y, { continued: true, width: 100, align: 'right' });
|
||||||
|
doc.text('Total', 450, doc.y, { width: 100, align: 'right' });
|
||||||
|
|
||||||
|
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Line Items Table Rows
|
||||||
|
doc.font('Helvetica');
|
||||||
|
let currentY = doc.y;
|
||||||
|
|
||||||
|
if (orderData.line_items && orderData.line_items.length > 0) {
|
||||||
|
orderData.line_items.forEach(item => {
|
||||||
|
doc.text(item.name, 50, currentY, { width: 250 });
|
||||||
|
doc.text(item.quantity.toString(), 300, currentY, { width: 50, align: 'right' });
|
||||||
|
doc.text(`${orderData.currency} ${parseFloat(item.price).toFixed(2)}`, 350, currentY, { width: 100, align: 'right' });
|
||||||
|
const total = parseFloat(item.price) * item.quantity;
|
||||||
|
doc.text(`${orderData.currency} ${total.toFixed(2)}`, 450, currentY, { width: 100, align: 'right' });
|
||||||
|
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
currentY = doc.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.moveTo(50, currentY).lineTo(550, currentY).stroke();
|
||||||
|
doc.moveDown(1);
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
doc.text(`Subtotal: ${orderData.currency} ${orderData.subtotal_price}`, { align: 'right' });
|
||||||
|
if (orderData.total_tax > 0) {
|
||||||
|
doc.text(`Tax: ${orderData.currency} ${orderData.total_tax}`, { align: 'right' });
|
||||||
|
}
|
||||||
|
doc.text(`Shipping: ${orderData.currency} ${orderData.total_shipping_price_set?.shop_money?.amount || '0.00'}`, { align: 'right' });
|
||||||
|
doc.font('Helvetica-Bold').text(`Total: ${orderData.currency} ${orderData.total_price}`, { align: 'right' });
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { generateInvoicePDF };
|
||||||
82
server.js
Normal file
82
server.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
require('dotenv').config({ override: true });
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { generateInvoicePDF } = require('./pdfGenerator');
|
||||||
|
const { sendEmailWithAttachment } = require('./mailer');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware to capture raw body for Shopify webhook HMAC verification
|
||||||
|
app.use(express.json({
|
||||||
|
verify: (req, res, buf) => {
|
||||||
|
req.rawBody = buf;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Function to verify Shopify Webhook
|
||||||
|
const verifyShopifyWebhook = (req, res, next) => {
|
||||||
|
const hmacHeader = req.get('X-Shopify-Hmac-Sha256');
|
||||||
|
const body = req.rawBody;
|
||||||
|
const secret = process.env.SHOPIFY_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (!hmacHeader || !body || !secret) {
|
||||||
|
return res.status(401).send('Webhook verification failed: Missing headers or secret.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedHash = crypto
|
||||||
|
.createHmac('sha256', secret)
|
||||||
|
.update(body, 'utf8')
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
if (generatedHash !== hmacHeader) {
|
||||||
|
return res.status(401).send('Webhook verification failed: Invalid HMAC.');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Webhook endpoint for Order Creation
|
||||||
|
app.post('/webhooks/orders/create', verifyShopifyWebhook, async (req, res) => {
|
||||||
|
const fs = require('fs');
|
||||||
|
const logMessage = `[${new Date().toISOString()}] Received webhook request\n`;
|
||||||
|
fs.appendFileSync('webhook.log', logMessage);
|
||||||
|
|
||||||
|
// Acknowledge receipt of the webhook immediately
|
||||||
|
res.status(200).send('Webhook received');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orderData = req.body;
|
||||||
|
const processMessage = `[${new Date().toISOString()}] Processing Order ${orderData.order_number}\n`;
|
||||||
|
fs.appendFileSync('webhook.log', processMessage);
|
||||||
|
|
||||||
|
// 1. Generate the PDF invoice
|
||||||
|
const pdfBuffer = await generateInvoicePDF(orderData);
|
||||||
|
|
||||||
|
// 2. Send the email with the attached PDF
|
||||||
|
const customerEmail = orderData.email || orderData.contact_email;
|
||||||
|
if (!customerEmail) {
|
||||||
|
const errorMsg = `[${new Date().toISOString()}] Order ${orderData.order_number} has no email address associated.\n`;
|
||||||
|
fs.appendFileSync('webhook.log', errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailWithAttachment(
|
||||||
|
customerEmail,
|
||||||
|
orderData.order_number,
|
||||||
|
pdfBuffer
|
||||||
|
);
|
||||||
|
|
||||||
|
const successMsg = `[${new Date().toISOString()}] Successfully processed and sent email for Order ${orderData.order_number}\n`;
|
||||||
|
fs.appendFileSync('webhook.log', successMsg);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = `[${new Date().toISOString()}] Error processing order webhook: ${error.stack}\n`;
|
||||||
|
fs.appendFileSync('webhook.log', errorMsg);
|
||||||
|
console.error('Error processing order webhook:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Shopify Invoice Email Plugin running on port ${PORT}`);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user