Every week I had to send 100+ personalized emails.
Open Excel → copy name → paste → change email → repeat 100 times.
It was taking nearly 3 hours every week.
So instead of doing it manually again, I spent one evening building a tool that does the same job in about 2 minutes.
What it does
Upload Excel file → Parse data → Detect columns → Write template with {{variables}} → Preview emails → Send all via Gmail
✅ No database
✅ No .env
✅ No stored credentials
✅ No authentication setup
✅ Works with Gmail App Passwords
🔗 Live Demo: https://bulk-mail-sender-lilac.vercel.app/
📂 Source Code: https://github.com/Suresh4405/BulkMail-Sender
👨💻 Portfolio: https://sureshcodes.vercel.app/
Why I Built It
Most bulk email tools either:
- Charge monthly fees
- Require complicated setup
- Store your credentials somewhere
I wanted something simpler.
A tool where credentials exist only during the current request.
Once the emails are sent, everything disappears from memory.
That became the core design principle behind the project:
If there is no stored data, there is nothing to leak.
How It Works
Step 1: Login with Email & App Password
The user enters a Gmail address and App Password.
The credentials are verified instantly before sending any emails.
Code – app/api/testCredentials/route.ts
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: email,
pass: password,
},
});
await transporter.verify();
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ success: false },
{ status: 401 }
);
}
}
Step 2: Import Your Excel File
Upload any Excel spreadsheet.
The application automatically:
- Reads the file
- Detects columns
- Generates variables
- Shows a preview before sending
Example data:
| Name | Company | |
|---|---|---|
| John | Tesla | john@example.com |
| Sarah | Netflix | sarah@example.com |
The detected columns become variables like:
{{Name}}
{{Company}}
{{Email}}
Code – app/api/readExcel/route.ts
import { NextRequest, NextResponse } from 'next/server';
import * as XLSX from 'xlsx';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('excel') as File;
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const workbook = XLSX.read(buffer, {
type: 'buffer',
});
const sheet =
workbook.Sheets[workbook.SheetNames[0]];
const rows =
XLSX.utils.sheet_to_json(sheet);
const columns =
Object.keys(rows[0] || {});
return NextResponse.json({
columns,
allData: rows,
preview: rows.slice(0, 5),
});
} catch {
return NextResponse.json({
columns: [],
allData: [],
preview: [],
});
}
}
Step 3: Write Email & Send (Live Preview)
Write your email once using variables.
Example:
Hi {{Name}},
I came across {{Company}} and wanted to connect.
Best,
Suresh
The application automatically personalizes every email.
Frontend – app/page.tsx
const insertVariable = (variable: string) => {
const insertion = `{{${variable}}}`;
switch (activeField) {
case 'to':
setTo(
prev =>
prev +
(prev ? ' ' : '') +
insertion
);
break;
case 'body':
const start =
textarea.selectionStart;
const end =
textarea.selectionEnd;
const newText =
body.substring(0, start) +
insertion +
body.substring(end);
setBody(newText);
break;
}
};
Backend – app/api/sendEmails/route.ts
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: senderEmail,
pass: senderPassword,
},
});
for (const row of rows) {
await transporter.sendMail({
to: replaceVariables(toTemplate, row),
subject: replaceVariables(
subjectTemplate,
row
),
html: markdownToHtml(
bodyTemplate,
row
),
});
await new Promise(resolve =>
setTimeout(resolve, 1000)
);
}
Helper Functions
function replaceVariables(
template: string,
row: any
) {
return template.replace(
/{{(.*?)}}/g,
(_, key) => row[key.trim()] || ''
);
}
function markdownToHtml(
markdown: string,
row: any
) {
const withVars =
replaceVariables(markdown, row);
return withVars.replace(
/\*\*(.*?)\*\*/g,
'<strong>$1</strong>'
);
}
The Privacy Design
Most bulk email tools store your Gmail credentials somewhere.
This one doesn't.
The flow is simple:
Enter credentials
↓
Verify credentials
↓
Send emails
↓
Request ends
↓
Memory cleared
No database.
No Redis.
No log file.
No .env.
No stored credentials.
Tradeoff?
You enter credentials each session.
Benefit?
Nothing sensitive remains after the request completes.
What Broke The First Time
The first version worked perfectly.
Until I tried sending 100 emails.
I fired requests as fast as Node.js could loop.
By email #47 Gmail responded with:
User rate limit exceeded
The remaining emails never sent.
That forced me to learn Gmail's sending limits and add throttling.
Rate Limiting (The 1-Second Delay)
The fix was surprisingly simple:
await new Promise(resolve =>
setTimeout(resolve, 1000)
);
One second per email.
100 emails = roughly 100 seconds.
A little slower.
A lot more reliable.
Lessons Learned
Building the tool took a few hours.
Making it reliable took longer.
A few things I learned:
- Gmail rate limits matter
- Simplicity is often a security feature
- Excel is still the easiest format for non-technical users
- Small tools solving real problems are often the most useful projects
Sometimes the best side projects aren't startups.
They're solutions to annoyances you face every week.
Tech Stack
- Next.js 15
- TypeScript
- Nodemailer
- XLSX
- Tailwind CSS
- Vercel
Future Improvements
A few features I'm considering:
- Scheduled campaigns
- Retry queue
- CSV support
- Email analytics
- Progress tracking
- Background processing
Try It Yourself
🚀 Live Demo
https://bulk-mail-sender-lilac.vercel.app/
📂 Source Code
https://github.com/Suresh4405/BulkMail-Sender
👨💻 Portfolio
https://sureshcodes.vercel.app/
From 3 hours of copy-pasting to 2 minutes of automation.
Built in one evening with Next.js, Nodemailer, and zero stored credentials.
If you've ever had to send hundreds of personalized emails manually, leave a 🧡 and tell me how you're solving it.

Top comments (0)