AI-powered email automation with CI/CD pipelines
Software Developer

Email automation allows you to send emails automatically based on certain triggers or schedules, so you don’t have to click the Send button every time. This includes things like welcome messages, drip campaigns, and regular newsletters. In this tutorial, you will create a simple system that automatically welcomes new subscribers and sends them updates about technology, all with the help of AI. You will start by setting up a Next.js app using Nodemailer to send emails and MongoDB to store subscriber information. Then, you’ll connect OpenAI to create friendly and relevant email content on the spot. Finally, you will use CircleCI to automate testing, building, and deploying your code, so every update goes live right away. Your emails will continue to be sent out without any extra effort.
Prerequisites
Before you start, make sure you have:
- Node.js installed
- Next.js basics
- Some experience with APIs
- MongoDB account
- SMTP credentials(Mailtrip account)
- A GitHub account
- A CircleCI account
- A RapidAPI account
- A Vercel account
Start by setting up the project
Project setup
In this section of the tutorial, you will set up your Next.js app, select Tailwind CSS while you’re setting it up, and install some packages like Nodemailer and Mongoose.
First, type this command into your terminal:
npx create-next-app@latest ai-email-automation --typescript
cd ai-email-automation
During setup, choose these options:
- No to Eslint
- Yes to Tailwind CSS
- No to using a ‘src’ directory
- Yes to App Router
- Yes to TypeScript
- No to Turbopack
- No to customize import alias
Now install these packages:
npm install mongoose nodemailer axios dotenv
Here’s what your .env.local
should be after you complete the remaining setup:
MONGODB_URI=your_mongo_uri
SMTP_USER=your_mailtrap_username
SMTP_PASS=your_mailtrap_password
RAPIDAPI_KEY=your_rapidapi_key
SMTP_HOST=sandbox.smtp.mailtrap.io
SMTP_PORT=2525
To make sure you do not accidentally commit your .env
file to GitHub, add it to your .gitignore
file.
If you do not already have a .gitignore
file, create one in the root of your project, and add this line:
.env
This tells Git to ignore the .env
file so it stays private and is not included in version control.
Connecting MongoDB
You can choose to use MongoDB Atlas (which is in the cloud) or run MongoDB on your own computer. If you want to use the cloud, first sign up and create a new cluster.
Next, pick the free configuration option and give your cluster a name. Keep the other options as they are. Click Create deployment.
Create your username and password. Click Create database user, then click Choose a connection method to move on to the next step.
Link to your application by selecting Drivers.
Make sure to copy your connection string.
Go to Network Access in the sidebar and add a new IP address (use 0.0.0.0/0
to allow access from everywhere). This will avoid any future errors.
In your .env.local
, add:
MONGO_URI=mongodb+srv://<username>:<password>@cluster.mongodb.net/emailDB?retryWrites=true&w=majority
Link the app to MongoDB and create a basic model to store subscriber emails. This will help you keep track of people who signed up, sent emails, or just to keep general records. You will be using Mongoose, a well-known library that simplifies working with MongoDB in Node.js applications.
Connect to MongoDB
Begin by making a function that can create a database connection you can use over and over again. Make a new file called db.ts
and create a folder named lib
. Put the db.ts
file inside the lib
folder and then copy and paste this code there:
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI as string;
if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI');
}
export async function connectDB() {
if (mongoose.connection.readyState >= 1) return;
return mongoose.connect(MONGODB_URI);
}
What this does is check if there is already a MongoDB connection set up (readyState
). If there isn’t one, it will connect using the Mongo URI found in your .env.local
file. Call this function whenever you need to work with the database, such as when you want to add or get subscribers.
Create the subscriber model
Models in Mongoose are similar to blueprints. In this section, you will create a simple model that only saves email addresses. Make a new file called Subscriber.ts
and create a folder named models
in your project. Then, put the Subscriber.ts
file inside that folder and copy this code into it:
// /models/Subscriber.ts
import mongoose from 'mongoose';
const SubscriberSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
createdAt: { type: Date, default: Date.now }
});
export default mongoose.models.Subscriber || mongoose.model('Subscriber', SubscriberSchema);
You’re creating a structure called a schema that has one field: email
. The part that says required: true
makes sure that empty email addresses can’t be saved. The unique: true
part makes sure that no two emails are the same. The last line helps you avoid creating the model again if it’s already been defined, which is important in serverless settings like Vercel.
Create the email model
Next, create an email model that stores outgoing email records. Inside the models
folder, create a file named Email.ts
. Copy and paste this code into it:
import mongoose from 'mongoose';
const EmailSchema = new mongoose.Schema({
to: String,
subject: String,
content: String,
createdAt: { type: Date, default: Date.now },
});
export default mongoose.models.Email || mongoose.model('Email', EmailSchema);
In this schema, you are outlining what each document in the Email
collection should look like, using four fields. The to
, subject
, and content
fields are all required strings, meaning you can’t save an email record unless you include who it’s addressed to, what the subject is, and what the message says. The createdAt
field automatically logs the date and time when the record is created, using default: Date.now
. Lastly, the export statement checks if the email model has already been created. This helps avoid errors in situations (like serverless environments or when the code is being refreshed) where the code might be run multiple times. It either reuses the existing model or creates a new one if necessary.
Setting up the email service
After you sign up for Mailtrip, choose the Sandbox email testing option, check your SMTP credentials, and then add them to the .env.local
file:
SMTP_HOST=sandbox.smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USER=your_mailtrap_username
SMTP_PASS=your_mailtrap_password
Now you’ll create the code that allows you to send emails using the log-in details you’ve already set up. This code will be kept in a separate file so you can use it whenever you need to send an email. Create a new mailer.ts
file in lib
directory and paste this code:
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendEmail(to: string, subject: string, html: string, text?: string) {
const info = await transporter.sendMail({
from: `"CircleCI" <${process.env.SMTP_USER}>`,
to,
subject,
text,
html,
});
console.log('Mailtrap response:', {
accepted: info.accepted,
messageId: info.messageId,
});
return info;
}
The nodemailer.createTransport()
function is used to create a way to send emails using the SMTP settings from your environment file. The sendEmail
function is what you will use whenever you want to send an email in your app. The html
part is the fancy version of your email, while the text
part is a simpler version for email programs that can’t display HTML, like some mobile apps or dark mode inboxes.
Integrating AI for Email Content
Visit RapidAPI to use the API I used, or you can use OpenAI API as alternative. Select the Basic plan in RapidAPI and subscribe to the test.
After that, look for “Gpt 4o” in the sidebar. Click on it to and then copy your API key and endpoint URL you see in the code snippet.
Update .env.local
:
RAPIDAPI_KEY=your_rapidapi_key
Next you will set up an AI text generation service using a provider from RapidAPI. This service helps you create content for a newsletter based on a prompt that you select. Create a new openai.ts
file in lib
directory and paste this code:
export async function generateEmailContent(prompt: string) {
const response = await fetch('https://chatgpt-42.p.rapidapi.com/gpt4o', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-rapidapi-host': 'chatgpt-42.p.rapidapi.com',
'x-rapidapi-key': process.env.RAPIDAPI_KEY,
},
body: JSON.stringify({
messages: [{ role: 'user', content: prompt }],
web_access: false,
}),
});
const text = await response.text();
// Log the raw response for debugging
console.log('API raw response:', text);
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Failed to parse JSON from API: ${text}`);
}
if (!response.ok) {
throw new Error(`API error ${response.status}: ${JSON.stringify(data)}`);
}
const result = data.result || data.choices?.[0]?.message?.content;
if (!result) {
throw new Error(`No usable content returned from API: ${JSON.stringify(data)}`);
}
return result;
}
The code axios.post(...)
sends a POST
request to the AI API. You’re sending the prompt
to the endpoint. You’re sending an instruction, like “Write a short tech newsletter update.” The response you get will include the text created by the AI, and you send that back to the app.
Creating the email automation flow
In this section of the tutorial, you’ll connect everything together: the form that users fill out, the paths for the backend, how you create the email content, and the process for sending newsletters to all your subscribers.
The newsletter form (front end)
Start with an easy form that lets users enter their email address to sign up for your newsletter. Create a folder in the project’s root directory named app
. In the folder, open the page.tsx
file, copy and paste this code:
"use client";
import { useState } from "react";
export default function Home() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus("Submitting...");
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.ok) setStatus("You’re subscribed! Check your Mailtrap Sanbox inbox.");
else setStatus("Something went wrong.");
};
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 px-4">
<div className="w-full max-w-md rounded-2xl p-8 text-center">
<h1 className="text-4xl font-semibold text-black mb-4">Join Our Newsletter</h1>
<p className="text-gray-600 mb-6">
Stay up to date with the latest articles and tips straight to your
inbox.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400"
required
/>
<button
type="submit"
className="w-full px-4 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition"
>
Subscribe
</button>
</form>
{status && <p className="mt-6 text-green-600">{status}</p>}
</div>
</div>
);
}
This code collects the email address and sends it to the /api/subscribe
endpoint. After that, it will notify the user that they have successfully subscribed, so they can check their inbox for a welcome message.
The /api/subscribe
route
In your app directory, create a new folder called api
with another directory inside, named subscribe
. Create a new file called route.ts
inside the subscribe
directory then paste this code:
// app/api/subscribe/route.ts
import { connectDB } from '@/lib/db';
import Subscriber from '@/models/Subscriber';
import { generateEmailContent } from '@/lib/openai';
import { sendEmail } from '@/lib/mailer';
const SIGNATURE = "\n\nBest regards,\nCircleCI";
export async function POST(req: Request) {
let payload;
try {
payload = await req.json();
} catch (err) {
console.error('Failed to parse JSON:', err);
return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400 });
}
const { email } = payload;
if (!email) {
return new Response(JSON.stringify({ error: 'Email is required' }), { status: 400 });
}
try {
await connectDB();
} catch (err) {
console.error('Database connection error:', err);
return new Response(JSON.stringify({ error: 'Database connection failed' }), { status: 500 });
}
try {
const existing = await Subscriber.findOne({ email });
if (existing) {
return new Response(JSON.stringify({ message: 'Already subscribed' }), { status: 200 });
}
} catch (err) {
console.error('Error checking existing subscriber:', err);
return new Response(JSON.stringify({ error: 'Failed to check subscription' }), { status: 500 });
}
try {
await Subscriber.create({ email });
} catch (err) {
console.error('Error creating subscriber:', err);
return new Response(JSON.stringify({ error: 'Failed to subscribe' }), { status: 500 });
}
let aiContent: string;
try {
const prompt = `You are an email assistant. Write a warm welcome email to a new subscriber at ${email}, thanking them for joining our newsletter and telling them they’ll receive tech news every day. Do NOT include a greeting (e.g. "Hello"), closing, or signature—just the core content.`;
aiContent = await generateEmailContent(prompt);
if (!aiContent) throw new Error('Empty AI response');
} catch (err) {
console.error('AI content generation error:', err);
return new Response(JSON.stringify({ error: 'Failed to generate email content' }), { status: 500 });
}
// Format the AI‑generated paragraphs into HTML
let formattedBody: string;
try {
formattedBody = aiContent
.split(/\n{2,}|\n/)
.filter((para: string) => para.trim())
.map((para: string) => `<p>${para.trim()}</p>`)
.join('');
} catch (err) {
console.error('Error formatting AI content:', err);
return new Response(JSON.stringify({ error: 'Failed to format email content' }), { status: 500 });
}
const body = `
${formattedBody}
<p>Best regards,<br>CircleCI</p>
`;
try {
await sendEmail(email, 'Welcome to our newsletter!', body);
} catch (err) {
console.error('Email sending error:', err);
return new Response(JSON.stringify({ error: 'Failed to send welcome email' }), { status: 500 });
}
return new Response(JSON.stringify({ message: 'Subscribed and welcome email sent' }), { status: 200 });
}
This API route is designed to save email addresses in the database and uses AI to send a welcome email. The /api/subscribe
route is where all new newsletter sign-ups are handled.
When someone sends a POST
request to this route with their email address, the system first checks to make sure a valid email was provided. Then, it connects to your database to see if that email is already in use. If it is, the system returns a message saying “Already subscribed” and won’t create a duplicate entry.
If the email is new, it will create a new subscriber entry and use your AI helper from RapidAPI to write a friendly, personalized welcome email. After adding your signature, the finished email is sent to your mailer tool, which uses Mailtrap’s safe SMTP server to store the email in a test inbox. Finally, the system will respond with a success message or an error if something goes wrong.
The /api/send-email
route (one-off email)
In your api
directory, create a new folder called send-email
. Inside that folder, create a new file named route.ts
and paste this code:
// app/api/send-email/route.ts
import { connectDB } from '@/lib/db';
import Email from '@/models/Email';
import { generateEmailContent } from '@/lib/openai';
import { sendEmail } from '@/lib/mailer';
export async function POST(req: Request) {
let payload: { to?: string; subject?: string; body?: string };
try {
payload = await req.json();
} catch (err) {
console.error(`Failed to parse JSON:', err);
return new Response(
JSON.stringify({ error: 'Invalid JSON payload' }),
{ status: 400 }
);
}
const { to, subject, body } = payload;
if (!to || !subject || !body) {
console.error('Missing fields:', payload);
return new Response(
JSON.stringify({ error: 'Missing to, subject or body' }),
{ status: 400 }
);
}
try {
await connectDB();
} catch (err) {
console.error('Database connection error:', err);
return new Response(
JSON.stringify({ error: 'Database connection failed' }),
{ status: 500 }
);
}
let aiBody: string;
try {
aiBody = await generateEmailContent(body);
if (!aiBody) {
throw new Error('Empty AI response');
}
} catch (err) {
console.error('AI content generation error:', err);
return new Response(
JSON.stringify({ error: 'Failed to generate email content' }),
{ status: 500 }
);
}
try {
await sendEmail(to, subject, aiBody);
} catch (err) {
console.error('Email sending error:', err);
return new Response(
JSON.stringify({ error: 'Failed to send email' }),
{ status: 500 }
);
}
try {
await Email.create({ to, subject, content: aiBody });
} catch (err) {
console.error(' Database save error:', err);
return new Response(
JSON.stringify({ error: 'Failed to save email record' }),
{ status: 500 }
);
}
return new Response(
JSON.stringify({ message: 'Email sent and recorded successfully' }),
{ status: 200 }
);
}
The POST /api/send-email
route is a special endpoint that allows you to send a single email enhanced by AI to any address and saves it in your database. This is the endpoint you use to send welcome emails to new subscribers.
When you access this route with a JSON payload that includes to
, subject
, and body
. It first checks if all three fields are provided. If any are missing, it will return a 400 “Missing fields” error. Next, it makes sure your database connection is working by calling connectDB()
. Then it sends the raw body text to the generateEmailContent()
AI helper, which creates the final email text. If that process fails, you will receive a 500 error.
If the AI gives back valid content, the route uses the sendEmail()
from the mailer.ts
you created previously, which is set up to safely send the email. Finally, it saves the email details (recipient, subject, and AI-generated content) in an email collection for future reference.
The /api/send-newsletters
route (mass newsletter)
In your api
directory, create a new folder called send-newsletters
. Inside that folder, create a new file named route.ts
and paste this code:
// app/api/newsletter/route.ts
import { connectDB } from '@/lib/db';
import Subscriber from '@/models/Subscriber';
import { generateEmailContent } from '@/lib/openai';
import { sendEmail } from '@/lib/mailer';
const SIGNATURE = "\n\nBest regards,\nCircleCI";
const PROMPT_TEMPLATE =
process.env.NEWSLETTER_PROMPT ||
`You are an email assistant. Write a concise tech news update in clear prose—no lists, no bullet points—about recent developments in areas like web development, design, CI/CD and other tech aspects.
Do NOT include a greeting (e.g. "Hello"), closing, or signature—just the core content.`;
const SUBJECT =
process.env.NEWSLETTER_SUBJECT || "Tech News Update";
export async function GET() {
try {
await connectDB();
} catch (err) {
console.error('Newsletter error: Database connection failed', err);
return new Response(
JSON.stringify({ error: 'Database connection failed' }),
{ status: 500 }
);
}
let subscribers: { email: string }[];
try {
subscribers = await Subscriber.find();
} catch (err) {
console.error('Newsletter error: Failed to fetch subscribers', err);
return new Response(
JSON.stringify({ error: 'Failed to fetch subscribers' }),
{ status: 500 }
);
}
// Generate newsletter content
let aiContent: string;
try {
const prompt = PROMPT_TEMPLATE.replace('{{email}}', 'subscriber');
aiContent = await generateEmailContent(prompt);
if (!aiContent) {
throw new Error('Empty AI response');
}
} catch (err) {
console.error('Newsletter error: AI content generation failed', err);
return new Response(
JSON.stringify({ error: 'Failed to generate newsletter content' }),
{ status: 500 }
);
}
// Format HTML body
let htmlBody: string;
try {
const formattedBody = aiContent
.split(/\n{2,}|\n/)
.filter((para: string) => para.trim())
.map((para: string) => `<p>${para.trim()}</p>`)
.join('');
htmlBody = `
<h2>${SUBJECT}</h2>
${formattedBody}
<p>Best regards,<br>CircleCI</p>
`;
} catch (err) {
console.error('Newsletter error: Formatting HTML failed', err);
return new Response(
JSON.stringify({ error: 'Failed to format newsletter content' }),
{ status: 500 }
);
}
const textBody = aiContent + SIGNATURE;
for (const subscriber of subscribers) {
try {
await sendEmail(subscriber.email, SUBJECT, htmlBody, textBody);
console.log(`Sent newsletter to ${subscriber.email}`);
} catch (err) {
console.error(
`Newsletter error: Failed to send to ${subscriber.email}`,
err
);
// continue sending to others
}
}
return new Response(
JSON.stringify({ message: 'Newsletters processing complete' }),
{ status: 200 }
);
}
This feature lets you send one newsletter made by AI to all your subscribers at once. This is also where you set the prompt you want the AI to write about.
When you invoke it, the route first connects to your database to gather all the subscriber emails. Next, it creates an AI prompt using either your NEWSLETTER_PROMPT
environment variable or a basic template that makes a short tech news update without greetings or sign-offs.
This prompt is sent to generateEmailContent()
, which produces the main text for the newsletter. You then format that AI output in two ways:
- As HTML, by putting each paragraph inside
<p>
tags, adding a<h2>
subject header, and your signature. - As plain text, which includes the AI content along with your
SIGNATURE
.
The route then goes through each subscriber email and calls sendEmail()
for each one, providing both the HTML and text versions so that everyone receives a well-formatted newsletter, no matter what email client they use.
Deployment
To run the app on your machine before you push to GitHub, start the development server by running and by default this will spin up Next.js on http://localhost:3000
:
npm run dev
# or
yarn dev
Once your development server is running, you need to add a test email address. To do this, send a POST
request to /api/subscribe
by inputting your email in the form after loading the development server URl in your browser and then subscribe to the newsletter. Check the Mailtrap inbox to see if your welcome email, created by AI, arrived successfully. Since this is a test, you will receive the mail in your Mailtrap inbox. For production, you will have to use real SMTP server data.
To test the newsletter endpoint, run http://localhost:3000/api/send-newsletters
in your browser or use your terminal:
curl http://localhost:3000/api/send-newsletters
This will create and send the AI-designed newsletter to all the subscribers in your database. You will set up this to run automatically in CircleCI at a time you choose. This makes sure every push runs your complete email process smoothly.
After you’ve built and tested the app on your computer, the last step is to make it available online. You’ll use Vercel, a free hosting service created by the makers of Next.js. It’s quick, easy to use, and great for this type of project.
Make sure your project is saved and uploaded to a GitHub repository. Then connect your GitHub to Vercel. Click Add New to start a new project, and will be able to choose from a list of your GitHub repos. Click the one for this tutorial app.
Before you click Deploy, scroll down to the Environment Variables section and add the variables from env.local
. After deploying successfully, Vercel will give you a custom URL for your project. You’ll use this URL later to call the newsletter endpoint in CircleCI.
Your URL will be in the “domain” section beside the preview box.
Setting up CI/CD with CircleCI
Next, set up CircleCI to send your newsletter every day at 8:00 am. Create a .circleci
directory, add a config.yaml
file, and paste this code:
version: 2.1
executors:
node:
docker:
- image: cimg/node:20.11.1
working_directory: ~/repo
jobs:
install_build:
executor: node
steps:
- checkout
- run:
name: Install dependencies
command: npm ci
- run:
name: Build Next.js app
command: npm run build
send_newsletters:
executor: node
steps:
- run:
name: Trigger Newsletter API (Test)
command: curl -X GET "https://ai-email-automation.vercel.app/api/send-newsletters"
# replace the url here with the url of your website given by vercel
workflows:
version: 2
build_on_push:
jobs:
- install_build:
filters:
branches:
only: main
schedule_newsletters_test:
triggers:
- schedule:
cron: "0 8 * * *" # for testing use these crone code for 10 minutes "0,10,20,30,40,50 * * * *"
filters:
branches:
only:
- main
jobs:
- install_build
- send_newsletters:
requires:
- install_build
Sign up to CircleCI and connect your github account.
This CircleCI configuration sets up a Node.js Docker environment to run two tasks:
- The
install_build
task checks out the repository, installs the necessary dependencies usingnpm ci
, and builds your Next.js application. - The
send_newsletters
task sends acurl
GET
request to your live Vercel/api/send-newsletters
endpoint to make sure that your bulk-mail feature is working correctly.
The build_on_push
workflow runs the build task every time there is a push to the main branch, while the schedule_newsletters_test
workflow runs daily (or on a test schedule). This workflow rebuilds the app and then tests the newsletter endpoint, so you can regularly check that both your production build and mass-mail feature are functioning properly.
Push this to GitHub and link your repository to CircleCI. Choose Projects from the sidebar to review your repository. Then, click “Set Up” to get started.
CircleCI automatically detects that you have a CircleCI config file in your repository.
You also need to add the variables from env.local
to the environment settings in CircleCI. Go to Project settings from your Repository menu and enter the variables there.
Be sure the build was successful in CircleCI. Then, go to the Vercel URL and test it again by signing up with a new email. Then check your email inbox for the welcome message.
The process for sending out the newsletter starts every day at 8:00 am because of the schedule you set up in the config file.
After 3 days of receiving scheduled mail the result will be as illustrated here.
After 5 days of receiving scheduled mail the result will be as illustrated here.
Conclusion
In this tutorial, you created a simple yet effective AI-powered email automation system using Next.js, Nodemailer, MongoDB, and a text generation service from RapidAPI. You began by setting up the basic structure of the project, connecting it to a database, and configuring an SMTP service to send emails. Next, you added AI to automatically create personalized content for each user. You connected everything using a CI/CD pipeline with CircleCI to automate scheduling and deployment. Finally, you launched the project on Vercel, making it available to the public.
You can find the complete sample project here: AI Email Automation Repo
This setup is a strong starting point for anyone who wants to automate their email tasks with less manual work. There are still many ways to improve it, like adding tools to track how many people open and click on emails, supporting HTML templates for nicer formatting, or even letting users choose the type of content they want to receive. This project gives you a good foundation for handling important email automation tasks and can serve as a great base for future development.