Email Automation with LLMs
Build email automation with LLM-powered classification, response generation, personalization, and campaign management in Node.js.
Email Automation with LLMs
Email remains the backbone of business communication, but most email automation is stuck in the template era. Large Language Models change everything. Instead of rigid templates with mail-merge tokens, you can generate genuinely personalized emails, classify inbound messages intelligently, draft contextual responses, and run campaigns that read like they were written by a human — because in a meaningful sense, they were.
This guide walks through building a production-grade email automation system in Node.js that uses LLMs for classification, response generation, personalization, and campaign management. We will cover the full pipeline from inbound processing to outbound delivery, with working code you can deploy.
Prerequisites
- Node.js v18 or later
- OpenAI API key (or another LLM provider)
- SendGrid API key for email delivery
- MongoDB for storing email records and campaign data
- Basic familiarity with Express.js, Promises, and REST APIs
- npm packages:
openai,@sendgrid/mail,nodemailer,node-cron,express,mongoose,imap,mailparser
Install the dependencies:
npm install openai @sendgrid/mail nodemailer node-cron express mongoose imap mailparser
How LLMs Transform Email Automation
Traditional email automation relies on decision trees and template variables. You define a trigger, pick a template, swap in {{first_name}}, and send. The result is emails that feel automated because they are.
LLMs break this pattern in three ways. First, they can read and understand incoming emails — classifying intent, extracting entities, and gauging urgency without brittle regex rules. Second, they generate responses that account for context, tone, and conversation history. Third, they produce marketing copy that adapts to individual recipients rather than treating everyone as a segment.
The tradeoff is cost and latency. An LLM API call takes 1-3 seconds and costs fractions of a cent per email. For most businesses, that is a rounding error compared to the value of better communication. But you need to design your pipeline to handle failures, rate limits, and quality control.
Designing an Email Generation Pipeline
A well-architected email pipeline separates concerns into discrete stages. Each stage can be tested, monitored, and scaled independently.
Inbound Email
|
v
[Fetch & Parse] --> [Classify] --> [Route]
|
+-------------+-------------+
| | |
[Auto-Reply] [Escalate] [Archive]
|
v
[Generate Response]
|
v
[Quality Filter]
|
v
[Send via SMTP/API]
For outbound campaigns, the flow is different:
[Recipient List] --> [Personalize with LLM] --> [Quality Filter] --> [Schedule] --> [Send] --> [Track]
Here is the core pipeline module:
var OpenAI = require("openai");
var sgMail = require("@sendgrid/mail");
var openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
function createPipeline(stages) {
return function(input) {
var promise = Promise.resolve(input);
for (var i = 0; i < stages.length; i++) {
promise = promise.then(stages[i]);
}
return promise;
};
}
var inboundPipeline = createPipeline([
parseEmail,
classifyEmail,
routeEmail,
generateResponse,
filterQuality,
sendResponse
]);
module.exports = { createPipeline: createPipeline, inboundPipeline: inboundPipeline };
Implementing Email Classification and Routing
Classification is where LLMs immediately outperform rule-based systems. Instead of maintaining a growing list of regex patterns and keyword matches, you describe your categories and let the model sort incoming mail.
var CATEGORIES = [
{ name: "support", description: "Technical support requests, bug reports, feature requests" },
{ name: "sales", description: "Pricing inquiries, demo requests, partnership proposals" },
{ name: "billing", description: "Invoice questions, refund requests, payment issues" },
{ name: "spam", description: "Unsolicited marketing, phishing attempts, irrelevant messages" },
{ name: "feedback", description: "Product feedback, testimonials, complaints" },
{ name: "urgent", description: "System outages, security incidents, legal notices" }
];
function classifyEmail(emailData) {
var categoryList = CATEGORIES.map(function(c) {
return c.name + ": " + c.description;
}).join("\n");
return openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0,
messages: [
{
role: "system",
content: "You are an email classifier. Classify the email into exactly one category. " +
"Respond with JSON: {\"category\": \"<name>\", \"confidence\": <0-1>, \"urgency\": \"low|medium|high\", \"summary\": \"<one sentence>\"}\n\n" +
"Categories:\n" + categoryList
},
{
role: "user",
content: "From: " + emailData.from + "\nSubject: " + emailData.subject + "\n\n" + emailData.body
}
],
response_format: { type: "json_object" }
}).then(function(response) {
var classification = JSON.parse(response.choices[0].message.content);
emailData.classification = classification;
return emailData;
});
}
function routeEmail(emailData) {
var classification = emailData.classification;
if (classification.category === "spam") {
emailData.action = "archive";
return emailData;
}
if (classification.category === "urgent" || classification.urgency === "high") {
emailData.action = "escalate";
emailData.notifyTeam = true;
return emailData;
}
var autoReplyCategories = ["support", "billing", "feedback"];
if (autoReplyCategories.indexOf(classification.category) !== -1 && classification.confidence > 0.8) {
emailData.action = "auto-reply";
} else {
emailData.action = "queue-for-review";
}
return emailData;
}
The key design decision here is the confidence threshold. Setting it too low means you auto-reply to emails you misunderstood. Setting it too high means everything goes to human review, defeating the purpose. I have found 0.8 to be a good starting point — adjust based on your error rate in production.
Auto-Generating Email Responses with Context
Generating a response is more than calling the API with the inbound email. You need conversation context, company knowledge, and tone guidelines. Without these, the LLM produces generic responses that do not feel like they come from your organization.
var mongoose = require("mongoose");
var ConversationSchema = new mongoose.Schema({
threadId: String,
messages: [{
from: String,
to: String,
subject: String,
body: String,
timestamp: Date,
direction: { type: String, enum: ["inbound", "outbound"] }
}],
customerEmail: String,
category: String,
status: { type: String, default: "open" }
});
var Conversation = mongoose.model("Conversation", ConversationSchema);
function getConversationHistory(emailData) {
return Conversation.findOne({ threadId: emailData.threadId })
.then(function(conversation) {
if (conversation) {
emailData.history = conversation.messages.slice(-5);
} else {
emailData.history = [];
}
return emailData;
});
}
function generateResponse(emailData) {
if (emailData.action !== "auto-reply") {
return emailData;
}
var historyMessages = emailData.history.map(function(msg) {
return {
role: msg.direction === "inbound" ? "user" : "assistant",
content: msg.body
};
});
var systemPrompt = "You are a customer support agent for Acme Software. " +
"Be helpful, professional, and concise. " +
"If you cannot resolve the issue, say you are escalating to a specialist. " +
"Never make promises about timelines. " +
"Never share internal system details. " +
"Sign off as 'The Acme Support Team'.\n\n" +
"Category: " + emailData.classification.category + "\n" +
"Summary: " + emailData.classification.summary;
var messages = [{ role: "system", content: systemPrompt }]
.concat(historyMessages)
.concat([{
role: "user",
content: "Subject: " + emailData.subject + "\n\n" + emailData.body
}]);
return openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.7,
max_tokens: 500,
messages: messages
}).then(function(response) {
emailData.generatedReply = response.choices[0].message.content;
emailData.tokensUsed = response.usage.total_tokens;
return emailData;
});
}
Personalizing Marketing Emails with LLM-Generated Content
Mass personalization is where LLMs really shine. Instead of Hi {{first_name}}, check out our latest feature, you can generate copy that reflects the recipient's industry, usage patterns, and interests.
function personalizeEmail(recipient, campaign) {
var prompt = "Write a marketing email for the following campaign and recipient. " +
"Keep it under 200 words. Be conversational but professional. " +
"Include a clear call-to-action.\n\n" +
"Campaign: " + campaign.name + "\n" +
"Campaign Goal: " + campaign.goal + "\n" +
"Key Message: " + campaign.keyMessage + "\n\n" +
"Recipient Name: " + recipient.name + "\n" +
"Company: " + recipient.company + "\n" +
"Industry: " + recipient.industry + "\n" +
"Current Plan: " + recipient.plan + "\n" +
"Last Activity: " + recipient.lastActivity + "\n" +
"Interests: " + (recipient.interests || []).join(", ");
return openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.8,
messages: [
{ role: "system", content: "You are an expert email copywriter. Write personalized marketing emails." },
{ role: "user", content: prompt }
]
}).then(function(response) {
return {
to: recipient.email,
subject: campaign.subjectLine,
body: response.choices[0].message.content,
recipientId: recipient.id,
campaignId: campaign.id,
generatedAt: new Date()
};
});
}
function personalizeBatch(recipients, campaign, concurrency) {
concurrency = concurrency || 5;
var results = [];
var index = 0;
function processNext() {
if (index >= recipients.length) {
return Promise.resolve(results);
}
var batch = recipients.slice(index, index + concurrency);
index += concurrency;
var promises = batch.map(function(recipient) {
return personalizeEmail(recipient, campaign).catch(function(err) {
console.error("Failed to personalize for " + recipient.email + ": " + err.message);
return null;
});
});
return Promise.all(promises).then(function(batchResults) {
results = results.concat(batchResults.filter(Boolean));
return processNext();
});
}
return processNext();
}
The concurrency limit matters. OpenAI rate limits vary by tier, and SendGrid has its own sending limits. Processing 5 at a time is safe for most accounts. Scale up once you know your limits.
Implementing Email Summarization for Digest Reports
When executives or team leads need to stay informed without reading every message, digest summaries are invaluable. The LLM condenses a batch of emails into actionable bullet points.
function generateDigest(emails, recipientRole) {
var emailSummaries = emails.map(function(email, i) {
return (i + 1) + ". From: " + email.from +
" | Subject: " + email.subject +
" | Category: " + (email.classification ? email.classification.category : "unclassified") +
"\nPreview: " + email.body.substring(0, 200);
}).join("\n\n");
return openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.3,
messages: [
{
role: "system",
content: "Create a concise email digest for a " + recipientRole + ". " +
"Group by category. Highlight urgent items first. " +
"Use bullet points. Include recommended actions where appropriate. " +
"Keep the total digest under 500 words."
},
{ role: "user", content: "Here are " + emails.length + " emails to summarize:\n\n" + emailSummaries }
]
}).then(function(response) {
return {
digest: response.choices[0].message.content,
emailCount: emails.length,
generatedAt: new Date()
};
});
}
Tone Adjustment for Different Audiences
The same message needs different treatment depending on who receives it. A billing reminder to an enterprise client should sound different from one sent to a startup founder. LLMs handle this naturally.
var TONE_PROFILES = {
formal: "Write in a formal, professional tone. Use complete sentences. Avoid contractions. Address the recipient respectfully.",
casual: "Write in a friendly, conversational tone. Use contractions. Keep sentences short. Be warm but professional.",
technical: "Write in a precise, technical tone. Use industry terminology. Include specifics. Be direct and data-driven.",
empathetic: "Write with empathy and understanding. Acknowledge the recipient's situation. Be supportive. Offer clear next steps."
};
function adjustTone(content, targetTone, context) {
var toneInstructions = TONE_PROFILES[targetTone] || TONE_PROFILES.casual;
return openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.5,
messages: [
{
role: "system",
content: "Rewrite the following email content to match this tone:\n" + toneInstructions +
"\n\nContext: " + (context || "General business communication") +
"\n\nPreserve all factual information. Do not add or remove key details."
},
{ role: "user", content: content }
]
}).then(function(response) {
return response.choices[0].message.content;
});
}
Template-Based Generation with Dynamic LLM Sections
Pure LLM generation for every email is expensive and unpredictable. A better approach is hybrid templates where fixed sections (legal disclaimers, headers, footers) are static, and the LLM fills in dynamic sections.
function renderTemplate(template, staticVars, llmSections) {
var rendered = template;
// Replace static variables first
Object.keys(staticVars).forEach(function(key) {
var regex = new RegExp("\\{\\{" + key + "\\}\\}", "g");
rendered = rendered.replace(regex, staticVars[key]);
});
// Generate LLM sections in parallel
var sectionNames = Object.keys(llmSections);
var promises = sectionNames.map(function(sectionName) {
var config = llmSections[sectionName];
return openai.chat.completions.create({
model: config.model || "gpt-4o-mini",
temperature: config.temperature || 0.7,
max_tokens: config.maxTokens || 200,
messages: [
{ role: "system", content: config.systemPrompt },
{ role: "user", content: config.userPrompt }
]
}).then(function(response) {
return { name: sectionName, content: response.choices[0].message.content };
});
});
return Promise.all(promises).then(function(sections) {
sections.forEach(function(section) {
var regex = new RegExp("\\{\\{llm:" + section.name + "\\}\\}", "g");
rendered = rendered.replace(regex, section.content);
});
return rendered;
});
}
// Usage
var template = "<h1>Weekly Update for {{company}}</h1>" +
"<div>{{llm:intro}}</div>" +
"<div>{{llm:highlights}}</div>" +
"<footer>{{unsubscribe_link}}</footer>";
renderTemplate(template,
{ company: "Acme Corp", unsubscribe_link: "<a href='/unsubscribe'>Unsubscribe</a>" },
{
intro: {
systemPrompt: "Write a warm, 2-sentence introduction for a weekly product update email.",
userPrompt: "Company: Acme Corp. This week: launched v2.0 of the API."
},
highlights: {
systemPrompt: "Write 3 bullet points highlighting product updates. Be concise.",
userPrompt: "Updates: New REST API v2.0, improved dashboard, 40% faster queries."
}
}
);
A/B Testing Email Copy Generated by LLMs
LLMs make A/B testing cheap. Instead of a copywriter producing two variants, generate multiple versions with different temperatures, tones, or prompts and let engagement data pick the winner.
function generateVariants(campaign, recipient, variantCount) {
variantCount = variantCount || 3;
var promises = [];
for (var i = 0; i < variantCount; i++) {
var temperature = 0.5 + (i * 0.2);
promises.push(
openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: temperature,
messages: [
{
role: "system",
content: "Write marketing email copy. Variant " + (i + 1) + " of " + variantCount + ". " +
"Each variant should take a different angle or hook."
},
{
role: "user",
content: "Campaign: " + campaign.name + "\nGoal: " + campaign.goal +
"\nAudience: " + recipient.segment
}
]
}).then(function(response) {
return {
variant: "variant_" + (i + 1),
content: response.choices[0].message.content,
temperature: temperature
};
})
);
}
return Promise.all(promises);
}
function assignVariant(recipientId, variants) {
var index = Math.abs(hashCode(recipientId)) % variants.length;
return variants[index];
}
function hashCode(str) {
var hash = 0;
for (var i = 0; i < str.length; i++) {
var char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash | 0;
}
return hash;
}
Spam and Quality Filtering for Generated Emails
Never send LLM output directly to recipients without a quality gate. Models can hallucinate URLs, include inappropriate content, or generate text that triggers spam filters.
function filterQuality(emailData) {
if (!emailData.generatedReply) {
return emailData;
}
return openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0,
messages: [
{
role: "system",
content: "You are an email quality checker. Analyze the draft email and return JSON:\n" +
"{\"pass\": true/false, \"issues\": [\"list of issues\"], \"spamScore\": 0-10, \"readabilityScore\": 0-10}\n\n" +
"Check for: hallucinated URLs, promises we cannot keep, inappropriate content, " +
"spam trigger words (FREE, ACT NOW, GUARANTEED), missing sign-off, excessive length."
},
{ role: "user", content: emailData.generatedReply }
],
response_format: { type: "json_object" }
}).then(function(response) {
var quality = JSON.parse(response.choices[0].message.content);
emailData.qualityCheck = quality;
if (!quality.pass || quality.spamScore > 5) {
emailData.action = "queue-for-review";
emailData.reviewReason = "Failed quality check: " + (quality.issues || []).join(", ");
}
return emailData;
});
}
Implementing Email Threading and Conversation Context
Email threads are identified by In-Reply-To and References headers. Maintaining thread context is essential for coherent auto-replies.
function getOrCreateThread(emailData) {
var threadId = emailData.references || emailData.messageId;
return Conversation.findOne({
$or: [
{ threadId: threadId },
{ "messages.messageId": emailData.inReplyTo }
]
}).then(function(conversation) {
if (!conversation) {
conversation = new Conversation({
threadId: threadId,
customerEmail: emailData.from,
messages: [],
status: "open"
});
}
conversation.messages.push({
from: emailData.from,
to: emailData.to,
subject: emailData.subject,
body: emailData.body,
timestamp: new Date(),
direction: "inbound",
messageId: emailData.messageId
});
return conversation.save().then(function() {
emailData.conversation = conversation;
emailData.history = conversation.messages.slice(-5);
return emailData;
});
});
}
Sending Emails with Nodemailer and SendGrid
For transactional email (auto-replies, notifications), SendGrid's API is reliable and tracks delivery. For development and testing, Nodemailer with an SMTP server works well.
var nodemailer = require("nodemailer");
var sgMail = require("@sendgrid/mail");
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
function sendWithSendGrid(emailData) {
var msg = {
to: emailData.to || emailData.from,
from: { email: "[email protected]", name: "Acme Support" },
subject: "Re: " + emailData.subject,
text: emailData.generatedReply,
html: "<div style='font-family: sans-serif;'>" +
emailData.generatedReply.replace(/\n/g, "<br>") + "</div>",
trackingSettings: {
openTracking: { enable: true },
clickTracking: { enable: true }
},
customArgs: {
campaignId: emailData.campaignId || "",
variant: emailData.variant || ""
}
};
if (emailData.conversation) {
msg.headers = {
"In-Reply-To": emailData.messageId,
"References": emailData.references || emailData.messageId
};
}
return sgMail.send(msg).then(function(response) {
emailData.sendResult = {
statusCode: response[0].statusCode,
sentAt: new Date()
};
return emailData;
});
}
// Development transport
function createDevTransport() {
return nodemailer.createTransport({
host: "localhost",
port: 1025,
ignoreTLS: true
});
}
function sendWithNodemailer(transport, emailData) {
var mailOptions = {
from: "[email protected]",
to: emailData.to || emailData.from,
subject: "Re: " + emailData.subject,
text: emailData.generatedReply
};
return transport.sendMail(mailOptions);
}
Scheduling Email Campaigns with node-cron
Campaign scheduling should be idempotent. If the process restarts, it should not re-send emails. Use a status field in the database to track what has been sent.
var cron = require("node-cron");
var CampaignSchema = new mongoose.Schema({
name: String,
status: { type: String, enum: ["draft", "scheduled", "sending", "completed", "paused"], default: "draft" },
scheduledAt: Date,
recipients: [{ type: mongoose.Schema.Types.ObjectId, ref: "Recipient" }],
sentCount: { type: Number, default: 0 },
totalRecipients: Number,
goal: String,
keyMessage: String,
subjectLine: String
});
var Campaign = mongoose.model("Campaign", CampaignSchema);
// Check for scheduled campaigns every minute
cron.schedule("* * * * *", function() {
var now = new Date();
Campaign.find({
status: "scheduled",
scheduledAt: { $lte: now }
}).then(function(campaigns) {
campaigns.forEach(function(campaign) {
processCampaign(campaign).catch(function(err) {
console.error("Campaign " + campaign.id + " failed: " + err.message);
});
});
});
});
function processCampaign(campaign) {
return Campaign.findByIdAndUpdate(campaign.id, { status: "sending" })
.then(function() {
return mongoose.model("Recipient").find({
_id: { $in: campaign.recipients },
unsubscribed: { $ne: true },
campaignsSent: { $ne: campaign.id }
});
})
.then(function(recipients) {
return personalizeBatch(recipients, campaign, 3);
})
.then(function(emails) {
var sendPromises = emails.map(function(email) {
return sendWithSendGrid({
to: email.to,
subject: email.subject,
generatedReply: email.body,
campaignId: email.campaignId,
variant: email.variant || "default"
}).then(function() {
return Campaign.findByIdAndUpdate(campaign.id, { $inc: { sentCount: 1 } });
});
});
return Promise.all(sendPromises);
})
.then(function() {
return Campaign.findByIdAndUpdate(campaign.id, { status: "completed" });
});
}
Measuring Email Effectiveness
Tracking open rates and click rates against generation cost tells you whether LLM personalization is worth the investment. Store metrics per email, per campaign, and per variant.
var EmailMetricSchema = new mongoose.Schema({
emailId: String,
campaignId: String,
variant: String,
recipientId: String,
sentAt: Date,
openedAt: Date,
clickedAt: Date,
replied: { type: Boolean, default: false },
generationCost: Number,
tokensUsed: Number,
model: String
});
var EmailMetric = mongoose.model("EmailMetric", EmailMetricSchema);
function trackMetrics(emailData) {
var costPer1kTokens = { "gpt-4o": 0.005, "gpt-4o-mini": 0.00015 };
var model = emailData.model || "gpt-4o-mini";
var cost = (emailData.tokensUsed / 1000) * (costPer1kTokens[model] || 0.001);
return EmailMetric.create({
emailId: emailData.messageId,
campaignId: emailData.campaignId,
variant: emailData.variant,
recipientId: emailData.recipientId,
sentAt: new Date(),
generationCost: cost,
tokensUsed: emailData.tokensUsed,
model: model
});
}
function getCampaignReport(campaignId) {
return EmailMetric.aggregate([
{ $match: { campaignId: campaignId } },
{
$group: {
_id: "$variant",
totalSent: { $sum: 1 },
totalOpened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
totalClicked: { $sum: { $cond: [{ $ifNull: ["$clickedAt", false] }, 1, 0] } },
totalCost: { $sum: "$generationCost" },
avgTokens: { $avg: "$tokensUsed" }
}
},
{
$project: {
variant: "$_id",
totalSent: 1,
openRate: { $divide: ["$totalOpened", "$totalSent"] },
clickRate: { $divide: ["$totalClicked", "$totalSent"] },
totalCost: { $round: ["$totalCost", 4] },
costPerOpen: { $cond: [{ $gt: ["$totalOpened", 0] }, { $divide: ["$totalCost", "$totalOpened"] }, 0] }
}
}
]);
}
Handling Unsubscribes and Preferences
Compliance with CAN-SPAM and GDPR is not optional. Every marketing email needs an unsubscribe mechanism, and you must honor opt-outs immediately.
var express = require("express");
var router = express.Router();
var PreferenceSchema = new mongoose.Schema({
email: { type: String, unique: true },
unsubscribed: { type: Boolean, default: false },
unsubscribedAt: Date,
preferences: {
marketing: { type: Boolean, default: true },
product_updates: { type: Boolean, default: true },
digests: { type: Boolean, default: true }
}
});
var Preference = mongoose.model("Preference", PreferenceSchema);
router.get("/unsubscribe", function(req, res) {
var token = req.query.token;
var email = decodeUnsubscribeToken(token);
if (!email) {
return res.status(400).send("Invalid unsubscribe link");
}
Preference.findOneAndUpdate(
{ email: email },
{ unsubscribed: true, unsubscribedAt: new Date() },
{ upsert: true }
).then(function() {
res.send("You have been unsubscribed. You will no longer receive marketing emails from us.");
}).catch(function(err) {
res.status(500).send("Error processing your request. Please contact support.");
});
});
router.post("/preferences", function(req, res) {
var email = req.body.email;
var preferences = req.body.preferences;
Preference.findOneAndUpdate(
{ email: email },
{ preferences: preferences },
{ upsert: true, new: true }
).then(function(pref) {
res.json({ success: true, preferences: pref.preferences });
});
});
function isSubscribed(email, category) {
return Preference.findOne({ email: email }).then(function(pref) {
if (!pref) return true;
if (pref.unsubscribed) return false;
return pref.preferences[category] !== false;
});
}
Complete Working Example
This Express.js service ties together all the components into a deployable email automation system.
var express = require("express");
var mongoose = require("mongoose");
var cron = require("node-cron");
var OpenAI = require("openai");
var sgMail = require("@sendgrid/mail");
var Imap = require("imap");
var simpleParser = require("mailparser").simpleParser;
var app = express();
app.use(express.json());
// Initialize services
var openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
mongoose.connect(process.env.MONGODB_URI);
// --- Models ---
var ConversationModel = mongoose.model("Conversation", new mongoose.Schema({
threadId: String,
customerEmail: String,
messages: [mongoose.Schema.Types.Mixed],
category: String,
status: { type: String, default: "open" }
}));
var CampaignModel = mongoose.model("Campaign", new mongoose.Schema({
name: String,
status: { type: String, default: "draft" },
scheduledAt: Date,
goal: String,
keyMessage: String,
subjectLine: String,
sentCount: { type: Number, default: 0 },
recipientSegment: String
}));
var MetricModel = mongoose.model("EmailMetric", new mongoose.Schema({
campaignId: String,
recipientEmail: String,
variant: String,
sentAt: Date,
openedAt: Date,
clickedAt: Date,
tokensUsed: Number,
cost: Number
}));
// --- Core LLM Functions ---
function classifyInbound(email) {
return openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: "Classify this email. Return JSON: " +
"{\"category\":\"support|sales|billing|spam|feedback|urgent\"," +
"\"confidence\":0.0-1.0,\"urgency\":\"low|medium|high\",\"summary\":\"...\"}"
},
{
role: "user",
content: "From: " + email.from + "\nSubject: " + email.subject + "\n\n" + email.text
}
]
}).then(function(res) {
return JSON.parse(res.choices[0].message.content);
});
}
function generateReply(email, classification, history) {
var messages = [
{
role: "system",
content: "You are a support agent. Category: " + classification.category +
". Be helpful and concise. Sign off as 'The Support Team'."
}
];
history.forEach(function(msg) {
messages.push({
role: msg.direction === "inbound" ? "user" : "assistant",
content: msg.body
});
});
messages.push({ role: "user", content: email.text });
return openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.7,
max_tokens: 500,
messages: messages
}).then(function(res) {
return {
text: res.choices[0].message.content,
tokens: res.usage.total_tokens
};
});
}
function checkQuality(text) {
return openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: "Check email quality. Return JSON: {\"pass\":true/false,\"issues\":[],\"spamScore\":0-10}"
},
{ role: "user", content: text }
]
}).then(function(res) {
return JSON.parse(res.choices[0].message.content);
});
}
// --- API Routes ---
// Process an inbound email (webhook receiver)
app.post("/api/email/inbound", function(req, res) {
var email = req.body;
var classification;
var conversation;
classifyInbound(email)
.then(function(result) {
classification = result;
if (classification.category === "spam") {
return res.json({ action: "archived", reason: "spam" });
}
return ConversationModel.findOne({ customerEmail: email.from, status: "open" });
})
.then(function(conv) {
if (res.headersSent) return;
conversation = conv || new ConversationModel({
threadId: email.messageId,
customerEmail: email.from,
category: classification.category,
messages: []
});
conversation.messages.push({
from: email.from,
subject: email.subject,
body: email.text,
direction: "inbound",
timestamp: new Date()
});
return conversation.save();
})
.then(function() {
if (res.headersSent) return;
if (classification.urgency === "high") {
return res.json({ action: "escalated", classification: classification });
}
if (classification.confidence < 0.8) {
return res.json({ action: "queued-for-review", classification: classification });
}
var history = conversation.messages.slice(-5);
return generateReply(email, classification, history);
})
.then(function(reply) {
if (res.headersSent) return;
return checkQuality(reply.text).then(function(quality) {
return { reply: reply, quality: quality };
});
})
.then(function(result) {
if (res.headersSent) return;
if (!result.quality.pass || result.quality.spamScore > 5) {
return res.json({ action: "queued-for-review", reason: result.quality.issues });
}
conversation.messages.push({
from: "[email protected]",
body: result.reply.text,
direction: "outbound",
timestamp: new Date()
});
return conversation.save().then(function() {
return sgMail.send({
to: email.from,
from: "[email protected]",
subject: "Re: " + email.subject,
text: result.reply.text
});
}).then(function() {
return MetricModel.create({
recipientEmail: email.from,
sentAt: new Date(),
tokensUsed: result.reply.tokens,
cost: (result.reply.tokens / 1000) * 0.005
});
}).then(function() {
res.json({ action: "replied", classification: classification });
});
})
.catch(function(err) {
console.error("Inbound processing error:", err);
if (!res.headersSent) {
res.status(500).json({ error: "Processing failed", message: err.message });
}
});
});
// Create and schedule a campaign
app.post("/api/campaigns", function(req, res) {
CampaignModel.create({
name: req.body.name,
goal: req.body.goal,
keyMessage: req.body.keyMessage,
subjectLine: req.body.subjectLine,
scheduledAt: new Date(req.body.scheduledAt),
recipientSegment: req.body.segment,
status: "scheduled"
}).then(function(campaign) {
res.json({ success: true, campaignId: campaign.id });
}).catch(function(err) {
res.status(500).json({ error: err.message });
});
});
// Get campaign metrics
app.get("/api/campaigns/:id/metrics", function(req, res) {
MetricModel.aggregate([
{ $match: { campaignId: req.params.id } },
{
$group: {
_id: "$variant",
sent: { $sum: 1 },
opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
clicked: { $sum: { $cond: [{ $ifNull: ["$clickedAt", false] }, 1, 0] } },
totalCost: { $sum: "$cost" }
}
}
]).then(function(results) {
var report = results.map(function(r) {
return {
variant: r._id || "default",
sent: r.sent,
openRate: r.sent > 0 ? (r.opened / r.sent * 100).toFixed(1) + "%" : "0%",
clickRate: r.sent > 0 ? (r.clicked / r.sent * 100).toFixed(1) + "%" : "0%",
totalCost: "$" + r.totalCost.toFixed(4)
};
});
res.json(report);
});
});
// Tracking pixel for open tracking
app.get("/api/track/open/:metricId", function(req, res) {
MetricModel.findByIdAndUpdate(req.params.metricId, { openedAt: new Date() })
.then(function() {
// Return a 1x1 transparent GIF
var pixel = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
res.writeHead(200, { "Content-Type": "image/gif", "Content-Length": pixel.length });
res.end(pixel);
});
});
// Unsubscribe endpoint
app.get("/unsubscribe", function(req, res) {
var email = Buffer.from(req.query.token || "", "base64").toString("utf8");
if (!email || email.indexOf("@") === -1) {
return res.status(400).send("Invalid unsubscribe link.");
}
mongoose.model("Preference", new mongoose.Schema({
email: String,
unsubscribed: Boolean,
unsubscribedAt: Date
})).findOneAndUpdate(
{ email: email },
{ unsubscribed: true, unsubscribedAt: new Date() },
{ upsert: true }
).then(function() {
res.send("You have been unsubscribed successfully.");
});
});
// --- Scheduled Jobs ---
// Daily digest at 8 AM
cron.schedule("0 8 * * *", function() {
console.log("Generating daily digest...");
var yesterday = new Date(Date.now() - 86400000);
ConversationModel.find({ "messages.timestamp": { $gte: yesterday } })
.then(function(conversations) {
var emails = [];
conversations.forEach(function(conv) {
conv.messages.forEach(function(msg) {
if (msg.direction === "inbound" && msg.timestamp >= yesterday) {
emails.push(msg);
}
});
});
return openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.3,
messages: [
{ role: "system", content: "Create a concise daily email digest. Group by category. Highlight urgent items." },
{
role: "user",
content: "Summarize these " + emails.length + " inbound emails:\n\n" +
emails.map(function(e, i) {
return (i + 1) + ". From: " + e.from + " - " + e.body.substring(0, 150);
}).join("\n")
}
]
});
})
.then(function(response) {
return sgMail.send({
to: "[email protected]",
from: "[email protected]",
subject: "Daily Email Digest - " + new Date().toISOString().split("T")[0],
text: response.choices[0].message.content
});
})
.then(function() {
console.log("Daily digest sent.");
})
.catch(function(err) {
console.error("Digest generation failed:", err.message);
});
});
// Campaign processor every minute
cron.schedule("* * * * *", function() {
CampaignModel.find({ status: "scheduled", scheduledAt: { $lte: new Date() } })
.then(function(campaigns) {
campaigns.forEach(function(campaign) {
CampaignModel.findByIdAndUpdate(campaign.id, { status: "sending" })
.then(function() {
console.log("Processing campaign: " + campaign.name);
// Campaign processing logic here (see personalizeBatch above)
});
});
});
});
// Start server
var PORT = process.env.PORT || 3000;
app.listen(PORT, function() {
console.log("Email automation service running on port " + PORT);
});
Common Issues and Troubleshooting
1. OpenAI rate limit errors crashing your pipeline
Error: 429 Too Many Requests - Rate limit reached for gpt-4o-mini
This happens when you send a large batch without throttling. Implement exponential backoff:
function callWithRetry(fn, maxRetries, delay) {
maxRetries = maxRetries || 3;
delay = delay || 1000;
return fn().catch(function(err) {
if (maxRetries <= 0 || err.status !== 429) throw err;
console.log("Rate limited. Retrying in " + delay + "ms...");
return new Promise(function(resolve) {
setTimeout(resolve, delay);
}).then(function() {
return callWithRetry(fn, maxRetries - 1, delay * 2);
});
});
}
2. SendGrid delivery failures with no error
Error: Forbidden - The from address does not match a verified Sender Identity
SendGrid requires sender verification. Go to Settings > Sender Authentication in the SendGrid dashboard and verify your domain or single sender. This is the most common reason emails silently fail to deliver.
3. JSON parse errors from LLM responses
SyntaxError: Unexpected token 'T' at position 0 in JSON
Even with response_format: { type: "json_object" }, the model occasionally returns malformed JSON. Wrap your parse in a try-catch and fall back to a default classification:
function safeParseJSON(text, fallback) {
try {
return JSON.parse(text);
} catch (err) {
console.error("JSON parse failed:", text.substring(0, 100));
return fallback || { category: "unknown", confidence: 0, urgency: "medium", summary: "Parse failed" };
}
}
4. MongoDB connection timeouts during campaign processing
MongoServerSelectionError: connection timed out after 30000ms
Long-running campaign jobs hold connections. Use connection pooling and set appropriate timeouts:
mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000
});
5. Email threading breaks across reply chains
Warning: Conversation not found for In-Reply-To: <[email protected]>
Different email clients handle References and In-Reply-To headers inconsistently. Search by both the original Message-ID and any reference in the chain rather than relying on a single header match.
Best Practices
Use gpt-4o-mini for classification and quality checks, gpt-4o for response generation. Classification is a simpler task that does not need the full model. Save the expensive model for customer-facing output where quality matters.
Always implement a quality gate before sending. Never pipe LLM output directly to
sgMail.send(). Even a simple length check and keyword scan catches the worst failures. Use a second LLM call as a reviewer for high-stakes emails.Store every generated email and its metadata. You need this for debugging, compliance, and measuring effectiveness. Include the model used, tokens consumed, temperature, and the full prompt. Storage is cheap; regret is expensive.
Rate-limit your LLM calls with a queue, not just retries. A proper job queue (Bull, BeeQueue, or even a simple in-memory queue) prevents thundering herd problems when you process a batch of 10,000 recipients.
Handle unsubscribes synchronously, not in a batch job. When someone clicks unsubscribe, update their preference immediately — before the next campaign batch runs. CAN-SPAM requires honoring opt-outs within 10 business days, but users expect it instantly.
Set temperature based on the task. Use 0 for classification (you want deterministic results). Use 0.3-0.5 for summaries (some variety, mostly factual). Use 0.7-0.9 for marketing copy (creative variation). Never use temperature above 1.0 for email content.
Cache common classifications and responses. If you receive 50 "password reset" emails a day, classify the first one and cache the category. Use embeddings to find similar past emails and reuse their classification, only calling the LLM for genuinely novel messages.
Test with a staging SendGrid account and real email addresses you control. Automated emails that escape to real customers during development are a serious problem. Use SendGrid's sandbox mode or a separate subuser for development.
Monitor your cost per email and set budget alerts. At $0.005 per 1K tokens with gpt-4o, a 500-token response costs about $0.0025. At 10,000 emails per day, that is $25/day in LLM costs alone. Track this metric alongside open rates to calculate true ROI.
References
- OpenAI API Documentation - Chat completions and structured outputs
- SendGrid Node.js SDK - Email delivery API
- Nodemailer Documentation - SMTP email sending for Node.js
- node-cron Documentation - Task scheduling in Node.js
- CAN-SPAM Act Compliance Guide - Federal Trade Commission
- Mongoose Documentation - MongoDB object modeling
- IMAP Protocol (RFC 3501) - Internet Message Access Protocol specification