I built a bulk email tool in one evening — no database, no .env, credentials never stored

typescript dev.to

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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 Email
John Tesla john@example.com
Sarah Netflix sarah@example.com

The detected columns become variables like:

{{Name}}
{{Company}}
{{Email}}
Enter fullscreen mode Exit fullscreen mode

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: [],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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)
  );
}
Enter fullscreen mode Exit fullscreen mode

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>'
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials