Build an Email Automation Tool with AI: Automate Your Inbox in One Session
This is a tool a business would pay for — and you can build it in an afternoon. Scheduled email digests, automated replies, newsletter blasts — all running on a Node.js server you built with AI. No email marketing platform fees. No monthly subscription. Just code you own and control.
TL;DR
You'll build a working email automation tool that sends scheduled emails, auto-replies, or newsletter blasts using AI-generated code. Stack: Node.js for the runtime, Nodemailer for sending emails via SMTP, node-cron for scheduling, and HTML templates for professional-looking messages. By the end, you'll have a system that can send a weekly digest every Monday at 9am, auto-reply to incoming messages, or blast a newsletter to a subscriber list — all running autonomously on your server. This is the same functionality businesses pay $50-200/month for on platforms like Mailchimp or ConvertKit. Cost: free (using Gmail SMTP). Time: 2-3 hours.
Why AI Coders Need This
Email automation isn't a toy project. It's one of the most monetizable skills you can build. Every business on the planet sends automated emails — order confirmations, weekly newsletters, appointment reminders, abandoned cart follow-ups. Mailchimp made $800 million in revenue in 2023. ConvertKit pulled in $35 million. They're all selling the same thing: code that sends emails on a schedule.
And you can build the core of that system in an afternoon.
Here's why this project matters if you're building with AI tools:
- SMTP and email protocols — Every web app you'll ever build sends emails. Password resets, notifications, receipts. Understanding how email actually gets from your server to someone's inbox is foundational knowledge that AI will assume you have.
- Environment variables — This project forces you to deal with secrets properly. Your email password can't be hardcoded. You'll learn the pattern that every production application uses.
- Cron scheduling — "Run this code every Monday at 9am" is a pattern you'll use everywhere. Backup scripts, data cleanup, report generation, health checks. node-cron teaches you cron syntax that works on every operating system.
- HTML templating — Email clients are notoriously picky about HTML rendering. You'll learn why your beautiful CSS doesn't work in Outlook, and how to build emails that look good everywhere.
- API design — You'll build endpoints that accept subscriber data, trigger emails, and report delivery status. This is real backend development.
But the real reason to build this? Freelance clients will pay you for exactly this. A local business owner who wants a weekly email going out to their customer list will pay $500-2,000 for a custom solution. You're not learning an abstract concept — you're building a billable service.
Real Scenario: What Happens When You Ask AI to Build This
You open Claude and type something like:
"Build me a tool that sends a weekly email digest every Monday morning. I want it to compile a list of items from a data source and send a nicely formatted HTML email to a list of subscribers."
Claude spins up something impressive in about 30 seconds. You get a Node.js file with Nodemailer configured, a cron job scheduled for Monday at 9am, an HTML email template with inline styles, and even a function to pull content from a JSON file. You copy it into a file, run npm install, start the server, and... it looks like it's working.
Then you check the details.
Your Gmail password is sitting right there in the source code — pass: 'myActualPassword123'. The error handling is a single console.log('Error sending email') with no retry logic. There's no rate limiting, so if your subscriber list has 1,000 people, it tries to blast all 1,000 simultaneously and Gmail blocks you after 500. The HTML template looks gorgeous in Chrome but renders as a broken mess in Outlook. And there's no unsubscribe link, which means you're technically violating federal law (CAN-SPAM Act).
The AI got the architecture right. The parts it got wrong are the parts that matter in production. That's exactly what we're going to fix.
What AI Generated
Here's the prompt that produces the best first-pass output. Being specific about the stack and features saves you from getting a half-baked prototype:
"Build an email automation tool with Node.js. Requirements: (1) Use Nodemailer to send emails via SMTP. (2) Use node-cron to schedule a weekly digest email every Monday at 9:00 AM. (3) Read subscriber list from a JSON file (email, name, subscribed status). (4) Build an HTML email template with inline CSS that works in Gmail, Outlook, and Apple Mail. (5) Use environment variables for SMTP credentials (dotenv). (6) Add a simple Express API with POST /api/send-now to trigger an immediate send, POST /api/subscribers to add new subscribers, and GET /api/status to check last send time. (7) Log every send attempt with timestamp, recipient, and success/failure. (8) Add rate limiting — wait 1 second between each email to avoid SMTP throttling."
With that prompt, Claude generates a solid project structure. Here's what you get:
Project Structure
email-automation/
├── .env # SMTP credentials (never commit this)
├── .gitignore # Ignores .env and node_modules
├── package.json
├── server.js # Main application — Express + cron
├── emailService.js # Nodemailer config and send functions
├── templates/
│ └── weekly-digest.html # HTML email template
├── data/
│ ├── subscribers.json # Subscriber list
│ └── content.json # Weekly digest content
└── logs/
└── send-log.json # Delivery log
The Core: emailService.js
This is where the actual email sending happens. Nodemailer wraps the SMTP protocol so you don't have to think about TCP sockets and mail headers:
// emailService.js
const nodemailer = require('nodemailer');
require('dotenv').config();
// Create a reusable transporter
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for 587
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Verify connection on startup
async function verifyConnection() {
try {
await transporter.verify();
console.log('✅ SMTP connection verified');
return true;
} catch (error) {
console.error('❌ SMTP connection failed:', error.message);
return false;
}
}
// Send a single email
async function sendEmail(to, subject, htmlContent) {
const mailOptions = {
from: `"${process.env.FROM_NAME}" <${process.env.FROM_EMAIL}>`,
to: to,
subject: subject,
html: htmlContent,
};
try {
const info = await transporter.sendMail(mailOptions);
console.log(`📧 Sent to ${to}: ${info.messageId}`);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error(`❌ Failed to send to ${to}:`, error.message);
return { success: false, error: error.message };
}
}
module.exports = { verifyConnection, sendEmail };
The Scheduler: server.js
The main file ties everything together — an Express server for the API, and node-cron for the schedule:
// server.js
const express = require('express');
const cron = require('node-cron');
const fs = require('fs');
const path = require('path');
const { verifyConnection, sendEmail } = require('./emailService');
require('dotenv').config();
const app = express();
app.use(express.json());
const SUBSCRIBERS_PATH = path.join(__dirname, 'data', 'subscribers.json');
const LOG_PATH = path.join(__dirname, 'logs', 'send-log.json');
// Load subscribers
function getSubscribers() {
const data = fs.readFileSync(SUBSCRIBERS_PATH, 'utf8');
return JSON.parse(data).filter(sub => sub.subscribed);
}
// Log a send attempt
function logSend(entry) {
let logs = [];
if (fs.existsSync(LOG_PATH)) {
logs = JSON.parse(fs.readFileSync(LOG_PATH, 'utf8'));
}
logs.push({ ...entry, timestamp: new Date().toISOString() });
fs.writeFileSync(LOG_PATH, JSON.stringify(logs, null, 2));
}
// Send digest to all subscribers with rate limiting
async function sendDigest() {
const subscribers = getSubscribers();
const template = fs.readFileSync(
path.join(__dirname, 'templates', 'weekly-digest.html'),
'utf8'
);
console.log(`📬 Sending digest to ${subscribers.length} subscribers...`);
for (const subscriber of subscribers) {
// Personalize the template
const personalizedHtml = template
.replace(/{{NAME}}/g, subscriber.name)
.replace(/{{EMAIL}}/g, subscriber.email)
.replace(/{{UNSUBSCRIBE_URL}}/g,
`${process.env.BASE_URL}/api/unsubscribe?email=${subscriber.email}`
);
const result = await sendEmail(
subscriber.email,
'Your Weekly Digest — ' + new Date().toLocaleDateString(),
personalizedHtml
);
logSend({
recipient: subscriber.email,
success: result.success,
messageId: result.messageId || null,
error: result.error || null,
});
// Rate limiting: wait 1 second between sends
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log('✅ Digest send complete');
}
// Schedule: Every Monday at 9:00 AM
cron.schedule('0 9 * * 1', () => {
console.log('⏰ Cron triggered: Weekly digest');
sendDigest();
});
// API: Trigger immediate send
app.post('/api/send-now', async (req, res) => {
sendDigest();
res.json({ message: 'Digest send initiated' });
});
// API: Add subscriber
app.post('/api/subscribers', (req, res) => {
const { email, name } = req.body;
if (!email || !name) {
return res.status(400).json({ error: 'Email and name required' });
}
const subscribers = JSON.parse(fs.readFileSync(SUBSCRIBERS_PATH, 'utf8'));
if (subscribers.find(s => s.email === email)) {
return res.status(409).json({ error: 'Already subscribed' });
}
subscribers.push({ email, name, subscribed: true });
fs.writeFileSync(SUBSCRIBERS_PATH, JSON.stringify(subscribers, null, 2));
res.status(201).json({ message: `${name} subscribed successfully` });
});
// API: Check status
app.get('/api/status', (req, res) => {
let lastSend = null;
if (fs.existsSync(LOG_PATH)) {
const logs = JSON.parse(fs.readFileSync(LOG_PATH, 'utf8'));
if (logs.length > 0) {
lastSend = logs[logs.length - 1].timestamp;
}
}
res.json({ status: 'running', lastSend, nextScheduled: 'Monday 9:00 AM' });
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, async () => {
await verifyConnection();
console.log(`🚀 Email automation server running on port ${PORT}`);
});
The Environment File
# .env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your.email@gmail.com
SMTP_PASS=your-app-password-here
FROM_NAME=Your Weekly Digest
FROM_EMAIL=your.email@gmail.com
BASE_URL=http://localhost:3000
PORT=3000
This is a solid starting point. The separation of concerns is good — email logic in one file, server logic in another, templates separate. But there's a lot that needs fixing before this is production-ready. Let's break down every piece so you actually understand what's happening.
Understanding Each Part
If you just copy-paste the AI's output and run it, you're a script kiddie. If you understand why each piece exists, you're a developer. Let's walk through the four critical systems in this project.
SMTP: How Email Actually Gets Sent
SMTP — Simple Mail Transfer Protocol — is the system that moves email across the internet. It's been around since 1982, and it's shockingly simple at its core. When your code calls transporter.sendMail(), here's what actually happens:
- Nodemailer opens a TCP connection to the SMTP server (like
smtp.gmail.comon port 587) - Your server and Gmail do a TLS handshake to encrypt the connection
- Your server authenticates with your credentials
- Your server says "I want to send a message FROM this address TO that address"
- Gmail's server accepts the message and puts it in a delivery queue
- Gmail's server looks up the recipient's mail server (via DNS MX records) and forwards the message
The key thing to understand: your code doesn't deliver the email directly. It hands it off to an SMTP server (Gmail, SendGrid, etc.) which handles actual delivery. That's why you need SMTP credentials — you're authenticating with a service that does the heavy lifting.
Port numbers matter here. Port 587 uses STARTTLS (starts unencrypted, upgrades to encrypted). Port 465 uses implicit TLS (encrypted from the start). Port 25 is the old unencrypted port that most services block. If AI generates code with port 25, change it to 587.
Nodemailer Configuration
The createTransport() call is where you configure your connection. The AI usually gets this right, but let's understand the options:
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', // SMTP server address
port: 587, // TLS port
secure: false, // false for port 587 (STARTTLS), true for 465
auth: {
user: 'you@gmail.com',
pass: 'abcd-efgh-ijkl-mnop', // App Password, NOT your real password
},
pool: true, // Reuse connections (important for bulk sending)
maxConnections: 5, // Don't overwhelm the SMTP server
maxMessages: 100, // Messages per connection before recycling
rateLimit: 10, // Max messages per second
});
Notice the pool and rateLimit options. AI almost never includes these, but they're critical for sending to more than a handful of recipients. Without connection pooling, Nodemailer opens a new TCP connection for every single email. With 500 subscribers, that's 500 connections — Gmail will absolutely throttle you.
The secure: false on port 587 confuses people. It doesn't mean the connection is unencrypted. It means the connection starts unencrypted and then upgrades via STARTTLS. Setting secure: true on port 587 will break your connection. This is one of the most common bugs in AI-generated email code.
Cron Scheduling: Making Code Run on a Timer
The node-cron library lets you schedule code to run on a repeating schedule using cron syntax. If you've never seen a cron expression before, it looks like gibberish:
// ┌────────── minute (0-59)
// │ ┌──────── hour (0-23)
// │ │ ┌────── day of month (1-31)
// │ │ │ ┌──── month (1-12)
// │ │ │ │ ┌── day of week (0-7, 0 and 7 are Sunday)
// │ │ │ │ │
// * * * * *
cron.schedule('0 9 * * 1', () => {
// Runs at 9:00 AM every Monday
sendDigest();
});
cron.schedule('*/15 * * * *', () => {
// Runs every 15 minutes
checkForReplies();
});
cron.schedule('0 8 1 * *', () => {
// Runs at 8:00 AM on the 1st of every month
sendMonthlyReport();
});
The five positions are: minute, hour, day-of-month, month, day-of-week. An asterisk means "every." A number means "at exactly this value." A slash means "every N intervals."
Here's the critical thing AI won't tell you: node-cron runs inside your Node.js process. If your server crashes or restarts, the schedule resets. It doesn't "remember" that it was supposed to send at 9am if the server was down at 9am. For a production system, you'd want a separate scheduler like a system cron job, or a queue system like BullMQ that persists scheduled jobs to a database.
For this project, node-cron is perfect. Just know its limitation: the server has to be running for the schedule to fire.
HTML Email Templates: A Different World
Building HTML emails is nothing like building web pages. Email clients (Gmail, Outlook, Apple Mail) are stuck in approximately 2004 in terms of CSS support. Here's what works and what doesn't:
Email HTML: What Works vs. What Doesn't
- Works: Inline styles, tables for layout, basic fonts, background colors, padding, margin, border
- Doesn't work: Flexbox, Grid, external stylesheets,
<style>tags (sometimes), CSS variables, position, float (inconsistent), media queries (limited) - Outlook specifically breaks: Most things. It uses Microsoft Word's rendering engine. Yes, really.
This is why every email template uses tables for layout. It feels like building websites in 1999, but it's the only approach that works everywhere:
<!-- templates/weekly-digest.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4;
font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 20px 0;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0"
style="background-color: #ffffff; border-radius: 8px;
overflow: hidden;">
<!-- Header -->
<tr>
<td style="background-color: #0A0E1A; padding: 30px;
text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">
Your Weekly Digest
</h1>
<p style="color: #94a3b8; margin: 8px 0 0;">
{{DATE}}
</p>
</td>
</tr>
<!-- Greeting -->
<tr>
<td style="padding: 30px;">
<p style="font-size: 16px; color: #333; margin: 0 0 15px;">
Hey {{NAME}},
</p>
<p style="font-size: 16px; color: #555; line-height: 1.6;
margin: 0 0 20px;">
Here's what happened this week:
</p>
{{CONTENT}}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 20px 30px;
text-align: center; font-size: 12px; color: #999;">
<p style="margin: 0 0 8px;">
You're receiving this because you subscribed at example.com
</p>
<p style="margin: 0;">
<a href="{{UNSUBSCRIBE_URL}}" style="color: #666;">
Unsubscribe
</a>
|
123 Main St, Your City, ST 12345
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
Notice three things: (1) Every style is inline — no <style> block. (2) Layout uses tables, not divs. (3) The footer includes a physical address and unsubscribe link — both legally required for marketing emails.
The {{NAME}}, {{CONTENT}}, and {{UNSUBSCRIBE_URL}} placeholders get replaced in your Node.js code before sending. This is the simplest form of templating — string replacement. For more complex emails, you'd use a library like Handlebars or EJS, but for most projects, .replace() works fine.
What AI Gets Wrong
AI-generated email code has predictable failure points. Here's exactly what to look for and fix.
1. Hardcoded Credentials
This is the single most common mistake. You'll see this in almost every AI-generated example:
// ❌ AI does this — your password in plain text
const transporter = nodemailer.createTransport({
auth: {
user: 'myemail@gmail.com',
pass: 'MyActualPassword123',
},
});
If you commit this to GitHub, bots will find it within minutes. Not hours — minutes. There are automated scrapers that specifically look for SMTP credentials in public repos. Your Gmail account will be compromised, and Google will lock it.
// ✅ Fix: use environment variables
require('dotenv').config();
const transporter = nodemailer.createTransport({
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
And your .gitignore must include .env. If you're not familiar with how environment variables work, read our guide on what environment variables are and why they matter.
2. No Error Handling for Failed Sends
AI typically wraps the send call in a try-catch and logs the error. That's not handling — that's acknowledging. When an email fails, you need to know why and decide what to do:
// ❌ AI does this — catches the error but does nothing useful
try {
await transporter.sendMail(mailOptions);
} catch (error) {
console.log('Error:', error);
}
// ✅ Fix: retry logic with exponential backoff
async function sendWithRetry(mailOptions, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const info = await transporter.sendMail(mailOptions);
return { success: true, messageId: info.messageId, attempt };
} catch (error) {
console.error(`Attempt ${attempt} failed: ${error.message}`);
if (attempt === maxRetries) {
return { success: false, error: error.message, attempts: maxRetries };
}
// Exponential backoff: 2s, 4s, 8s...
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
SMTP connections are flaky. Servers go down, rate limits kick in, network hiccups happen. Without retry logic, a temporary Gmail outage means none of your 500 subscribers get the newsletter.
3. No Rate Limiting
Gmail allows 500 emails per day and throttles if you send too many too fast. AI-generated code typically fires off emails as fast as possible:
// ❌ AI does this — blasts everything at once
for (const subscriber of subscribers) {
await sendEmail(subscriber.email, subject, html);
// No delay between sends
}
// ✅ Fix: send in controlled batches
async function sendBatch(subscribers, subject, html, delayMs = 1000) {
const results = [];
let sent = 0;
for (const subscriber of subscribers) {
const result = await sendWithRetry({
from: process.env.FROM_EMAIL,
to: subscriber.email,
subject: subject,
html: html.replace(/{{NAME}}/g, subscriber.name),
});
results.push({ email: subscriber.email, ...result });
sent++;
// Progress reporting
if (sent % 50 === 0) {
console.log(`📊 Progress: ${sent}/${subscribers.length}`);
}
// Rate limiting delay
await new Promise(resolve => setTimeout(resolve, delayMs));
}
return results;
}
That 1-second delay between emails means sending to 500 people takes about 8 minutes instead of 30 seconds. But those 500 emails actually get delivered instead of getting your account suspended.
4. Gmail Password vs. App Password
AI will tell you to use your Gmail password. Since 2022, Gmail doesn't allow regular password login for SMTP. You need an "App Password" — a 16-character generated code that gives limited access to your account. To get one:
- Go to
myaccount.google.com/security - Enable 2-Step Verification (required)
- Search for "App passwords" in Google Account settings
- Generate a new password for "Mail" on "Other (custom name)"
- Use that 16-character code as your
SMTP_PASS
If AI generates code that uses your regular Gmail password, it will fail with an "Invalid login" error. This trips up almost everyone the first time.
5. Missing Unsubscribe Mechanism
The CAN-SPAM Act (US) and GDPR (EU) require a working unsubscribe mechanism in every marketing email. AI almost never includes this. Here's a minimal implementation:
// Add to server.js
app.get('/api/unsubscribe', (req, res) => {
const { email } = req.query;
if (!email) return res.status(400).send('Email required');
const subscribers = JSON.parse(fs.readFileSync(SUBSCRIBERS_PATH, 'utf8'));
const updated = subscribers.map(sub =>
sub.email === email ? { ...sub, subscribed: false } : sub
);
fs.writeFileSync(SUBSCRIBERS_PATH, JSON.stringify(updated, null, 2));
res.send(`
<html><body style="text-align:center; padding:50px; font-family:sans-serif">
<h1>You've been unsubscribed</h1>
<p>${email} has been removed from our mailing list.</p>
</body></html>
`);
});
Simple, but it satisfies the legal requirement. The unsubscribe link in your email footer points to this endpoint.
Security Considerations
Email automation touches sensitive data (passwords, subscriber PII) and can cause real harm if misconfigured (spam, phishing). Here's what you need to lock down. If you're new to security concepts, our security basics guide covers the fundamentals.
Environment Variables for Everything Sensitive
Never put credentials in code. Period. Use a .env file locally and environment variables in production:
# .env — never commit this file
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your.email@gmail.com
SMTP_PASS=abcd-efgh-ijkl-mnop
FROM_NAME=Weekly Digest
FROM_EMAIL=your.email@gmail.com
BASE_URL=https://your-domain.com
# .gitignore — this file IS committed
.env
node_modules/
logs/
data/subscribers.json
Notice that subscribers.json is also in .gitignore. Your subscriber list contains personal information (names, email addresses). Don't put that in a public repository either.
OAuth 2.0 for Gmail (Production)
App Passwords work fine for personal projects, but for anything production-grade, use OAuth 2.0. Instead of storing a static password, OAuth uses short-lived access tokens that refresh automatically:
// OAuth 2.0 transporter (production setup)
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: process.env.GMAIL_USER,
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
refreshToken: process.env.OAUTH_REFRESH_TOKEN,
accessToken: process.env.OAUTH_ACCESS_TOKEN,
},
});
Setting up OAuth is more complex (you need to create a project in Google Cloud Console), but it's more secure because you never store a master password, and tokens can be revoked instantly if compromised.
SPF, DKIM, and DMARC: Why Emails Get Rejected
If you're sending from a custom domain (not Gmail), email servers will check three things:
- SPF (Sender Policy Framework) — A DNS record that says "these servers are allowed to send email from my domain." Without it, receiving servers might reject your email.
- DKIM (DomainKeys Identified Mail) — A cryptographic signature in the email header that proves it wasn't tampered with in transit. The receiving server checks this against a public key in your DNS.
- DMARC — A policy that tells receiving servers what to do when SPF or DKIM checks fail (quarantine, reject, or do nothing).
If you're using Gmail's SMTP with your Gmail address, Google handles all of this for you. If you're using a custom domain through SendGrid or Mailgun, you'll need to add these DNS records. The service will walk you through it — just don't skip the step.
Input Validation on the Subscriber API
The POST /api/subscribers endpoint AI generated accepts any input. Someone could submit a script injection, a 10MB string, or garbage data:
// ✅ Validate subscriber input
app.post('/api/subscribers', (req, res) => {
const { email, name } = req.body;
// Check required fields
if (!email || !name) {
return res.status(400).json({ error: 'Email and name required' });
}
// Validate email format (basic check)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Limit name length
if (name.length > 100) {
return res.status(400).json({ error: 'Name too long' });
}
// Sanitize: strip HTML tags from name
const cleanName = name.replace(/<[^>]*>/g, '');
// ... rest of subscriber logic
});
Taking It Further
The basic tool works. Now let's talk about what turns this from a weekend project into something clients would actually pay for.
Add a Web Dashboard
Ask your AI to build a simple dashboard that shows: total subscribers, last send date, delivery success rate, and a button to trigger an immediate send. This is a natural extension — you already have the API endpoints, you just need a frontend that calls them. If you've built a dashboard with AI before, the pattern is identical.
// Add a dashboard route
app.get('/dashboard', (req, res) => {
const subscribers = JSON.parse(fs.readFileSync(SUBSCRIBERS_PATH, 'utf8'));
const logs = fs.existsSync(LOG_PATH)
? JSON.parse(fs.readFileSync(LOG_PATH, 'utf8'))
: [];
const totalSubscribers = subscribers.length;
const activeSubscribers = subscribers.filter(s => s.subscribed).length;
const lastSend = logs.length > 0 ? logs[logs.length - 1].timestamp : 'Never';
const successRate = logs.length > 0
? (logs.filter(l => l.success).length / logs.length * 100).toFixed(1)
: 'N/A';
res.send(`
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 50px auto;">
<h1>📧 Email Dashboard</h1>
<p><strong>Subscribers:</strong> ${activeSubscribers} active / ${totalSubscribers} total</p>
<p><strong>Last send:</strong> ${lastSend}</p>
<p><strong>Success rate:</strong> ${successRate}%</p>
<p><strong>Total emails sent:</strong> ${logs.length}</p>
<button onclick="fetch('/api/send-now', {method:'POST'}).then(()=>alert('Sending!'))">
Send Now
</button>
</body>
</html>
`);
});
Track Open Rates
Email open tracking works by embedding a tiny invisible image (called a tracking pixel) in the email. When the recipient opens the email, their email client loads the image from your server, and you log that request:
// Tracking pixel endpoint
app.get('/track/:emailId', (req, res) => {
const { emailId } = req.params;
// Log the open
logSend({
type: 'open',
emailId: emailId,
ip: req.ip,
userAgent: req.get('User-Agent'),
});
// Return a 1x1 transparent pixel
const pixel = Buffer.from(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
'base64'
);
res.set('Content-Type', 'image/gif');
res.set('Cache-Control', 'no-store, no-cache');
res.send(pixel);
});
Add this to your HTML template: <img src="{{BASE_URL}}/track/{{EMAIL_ID}}" width="1" height="1" />. Open tracking isn't 100% accurate (some email clients block images by default), but it gives you a rough sense of engagement.
Multiple Email Templates
Instead of one template, build a template system. Create different templates for digests, announcements, welcome emails, and reminders. Ask your AI: "Create a template selection system where each template is an HTML file in the templates/ directory and the send function accepts a template name parameter."
Unsubscribe Links That Actually Work
The basic unsubscribe endpoint we added earlier works, but a professional system adds a confirmation page, logs who unsubscribed and when (important for compliance), and supports one-click unsubscribe via the List-Unsubscribe email header — which Gmail and Apple Mail use to show an "Unsubscribe" button right in the inbox.
// Add List-Unsubscribe header for one-click unsubscribe
const mailOptions = {
from: process.env.FROM_EMAIL,
to: subscriber.email,
subject: subject,
html: html,
headers: {
'List-Unsubscribe': `<${process.env.BASE_URL}/api/unsubscribe?email=${subscriber.email}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
};
Move Subscribers to a Database
A JSON file works for 50 subscribers. For anything bigger, you need a real database. This is where learning caching patterns and database design pays off. Ask your AI to migrate the subscriber storage from JSON to SQLite or PostgreSQL — it's a great exercise in understanding why databases exist.
Running the Project
Here's how to get everything up and running from scratch:
# Create the project
mkdir email-automation && cd email-automation
npm init -y
# Install dependencies
npm install express nodemailer node-cron dotenv
# Create the directory structure
mkdir -p templates data logs
# Create the subscriber file
echo '[{"email":"test@example.com","name":"Test User","subscribed":true}]' > data/subscribers.json
# Create the .env file (edit with your real credentials)
cat > .env << 'EOF'
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your.email@gmail.com
SMTP_PASS=your-app-password
FROM_NAME=Weekly Digest
FROM_EMAIL=your.email@gmail.com
BASE_URL=http://localhost:3000
PORT=3000
EOF
# Start the server
node server.js
Test it by hitting the API:
# Check status
curl http://localhost:3000/api/status
# Add a subscriber
curl -X POST http://localhost:3000/api/subscribers \
-H "Content-Type: application/json" \
-d '{"email":"friend@example.com","name":"Friend"}'
# Trigger an immediate send
curl -X POST http://localhost:3000/api/send-now
Pro tip for testing: Don't use your real email for development. Use Ethereal Email — a fake SMTP service designed for testing. Nodemailer can generate test accounts automatically:
// For development: auto-generate test SMTP credentials
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
// After sending, get a URL to view the email
const info = await transporter.sendMail(mailOptions);
console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
What to Learn Next
This project connects to almost every backend concept you'll encounter. Here's where to go from here:
- What Is a REST API? — You built API endpoints in this project. Understanding REST architecture will help you design better ones — proper status codes, error responses, and resource-based URLs.
- What Is an Environment Variable? — You used
dotenvto keep secrets out of your code. This guide explains the full picture — how environment variables work in different hosting environments and CI/CD pipelines. - Security Basics for AI Coders — Email automation handles personal data and authentication credentials. This guide covers the security fundamentals every AI-enabled builder needs to know.
- What Is Caching? — When your subscriber list grows, you don't want to read a JSON file (or query a database) on every request. Caching is how production systems handle this.
If you want another project that builds on these skills, try building a full REST API with AI — you'll use the same Express.js patterns but go deeper into database operations and authentication.
Frequently Asked Questions
Can I send emails for free with Nodemailer?
Nodemailer itself is free and open source. But you need an SMTP server to actually deliver emails. Gmail gives you 500 emails per day for free (with an App Password or OAuth). Services like SendGrid offer 100 emails/day on their free tier, Mailgun gives 1,000/month, and Amazon SES charges about $0.10 per 1,000 emails. For a small project or personal newsletter, Gmail's free tier is plenty.
Why do my emails end up in spam?
Emails land in spam for three main reasons: (1) You're sending from a generic Gmail account without proper authentication — set up SPF and DKIM records for your domain. (2) Your email content triggers spam filters — avoid ALL CAPS subjects, excessive exclamation marks, and words like "FREE MONEY." (3) You're sending too many emails too fast without warming up your sending reputation. Start with small batches and gradually increase volume over days or weeks.
Is it legal to send automated emails?
Yes, but with rules. In the US, the CAN-SPAM Act requires: (1) accurate "From" and "Subject" lines, (2) a physical mailing address in every email, (3) a clear unsubscribe mechanism that works within 10 business days, and (4) no deceptive content. In Europe, GDPR is stricter — you need explicit opt-in consent before sending any marketing emails. For transactional emails (order confirmations, password resets), you generally don't need consent. Always include an unsubscribe link.
Should I use Gmail SMTP or a service like SendGrid?
For development and small personal projects (under 100 emails/day), Gmail SMTP is fine — it's free and you already have an account. For anything you'd show a client or use in production, use a dedicated email service like SendGrid, Mailgun, or Amazon SES. They handle deliverability, provide analytics, manage bounce handling, and won't get your personal Gmail account flagged for unusual activity. SendGrid's free tier (100 emails/day) is enough for most small projects.
How do I test emails without sending real ones?
Use Ethereal Email — it's a fake SMTP service built specifically for testing. Nodemailer can even generate test accounts automatically with nodemailer.createTestAccount(). Emails get "sent" to Ethereal's server where you can view them in a web interface, but they never reach real inboxes. Mailtrap is another popular option. This lets you test your email templates, scheduling logic, and error handling without annoying real people or burning through your sending quota.