StackPractices
intermediate By Mathias Paulenko

Send Transactional Emails with SMTP in Python, Node.js

Learn to send transactional and bulk emails via SMTP with Python, Node.js, and Java. Covers auth, templates, attachments, rate limiting, and deliverability.

Overview

Most apps still rely on email for password resets, order confirmations, newsletters, and alerts. SMTP (Simple Mail Transfer Protocol) moves those messages from your app to the recipient’s inbox. Using it directly keeps you portable between providers and gives you full control over templates, attachments, and sender reputation. This recipe covers production-ready patterns in Python, Node.js, and Java, with TLS, plain + HTML bodies, attachments, and rate throttling.

When to Use

  • Your app sends transactional email and you want to swap providers without a rewrite.
  • You want live data in your templates, or you prefer to keep email logic in your own code.
  • You need to attach invoices, reports, or exports to outgoing messages and want full control over MIME construction.
  • You already run a message queue or worker pool and want SMTP senders to pull jobs from it.
  • You’re combining email with rate limiting and secret management best practices.

When to Avoid

  • Your volume is millions of marketing emails per month. Dedicated platforms like Mailchimp, Brevo, and Mailgun handle list hygiene and unsubscribe compliance far better.
  • Your team can’t keep up with DNS records (SPF, DKIM, DMARC), bounce handling, and sender reputation.
  • In-app notifications need low latency. Push notifications or WebSockets fit those cases better.

Solution

Python

import mimetypes
import smtplib
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
from string import Template

SMTP_HOST = "smtp.example.com"
SMTP_PORT = 587
SMTP_USER = "user@example.com"
SMTP_PASS = "app-password"


def build_message(from_addr, to, subject, text, html=None, attachments=None):
    """Build a multipart email with plain text, optional HTML, and attachments."""
    msg = MIMEMultipart("mixed")
    msg["Subject"] = subject
    msg["From"] = from_addr
    msg["To"] = to

    alt = MIMEMultipart("alternative")
    alt.attach(MIMEText(text, "plain"))
    if html:
        alt.attach(MIMEText(html, "html"))
    msg.attach(alt)

    if attachments:
        for path in attachments:
            ctype, _ = mimetypes.guess_type(path)
            if ctype is None:
                ctype = "application/octet-stream"
            maintype, subtype = ctype.split("/", 1)

            with open(path, "rb") as f:
                part = MIMEBase(maintype, subtype)
                part.set_payload(f.read())
            encoders.encode_base64(part)
            part.add_header(
                "Content-Disposition",
                "attachment",
                filename=Path(path).name,
            )
            msg.attach(part)

    return msg


def send_template_email(to, subject, template_text, template_html, context):
    text = Template(template_text).substitute(context)
    html = Template(template_html).substitute(context) if template_html else None
    msg = build_message(SMTP_USER, to, subject, text, html)

    with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
        server.starttls()
        server.login(SMTP_USER, SMTP_PASS)
        server.send_message(msg)


# Usage
text_template = "Hi $name, your order $order_id is confirmed."
html_template = "<p>Hi $name, your order <b>$order_id</b> is confirmed.</p>"
send_template_email(
    to="user@example.com",
    subject="Order #12345 confirmed",
    template_text=text_template,
    template_html=html_template,
    context={"name": "Alice", "order_id": "12345"},
)

JavaScript (Node.js)

const fs = require('fs');
const nodemailer = require('nodemailer');
const path = require('path');

const transporter = nodemailer.createTransport({
  host: 'smtp.example.com',
  port: 587,
  secure: false,
  auth: { user: 'user@example.com', pass: 'app-password' },
  tls: { rejectUnauthorized: true },
});

async function sendEmail(to, subject, text, html = null, attachments = []) {
  await transporter.sendMail({
    from: '"StackPractices" <noreply@example.com>',
    to,
    subject,
    text,
    html,
    attachments: attachments.map((file) => ({
      path: file,
      filename: path.basename(file),
    })),
  });
}

async function sendOrderConfirmation(to, context) {
  const text = `Hi ${context.name}, your order ${context.orderId} is confirmed.`;
  const html = `<p>Hi ${context.name}, your order <b>${context.orderId}</b> is confirmed.</p>`;
  await sendEmail(to, `Order #${context.orderId} confirmed`, text, html);
}

// Usage
sendOrderConfirmation('user@example.com', { name: 'Alice', orderId: '12345' });

Java

import jakarta.activation.DataHandler;
import jakarta.activation.DataSource;
import jakarta.activation.FileDataSource;
import jakarta.mail.*;
import jakarta.mail.internet.*;
import java.io.File;
import java.util.Properties;

public class SmtpSender {

    public static void send(String host, int port, String user, String pass,
                            String to, String subject, String text, String html,
                            File[] attachments) throws Exception {
        Properties props = new Properties();
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.starttls.enable", "true");
        props.put("mail.smtp.host", host);
        props.put("mail.smtp.port", port);

        Session session = Session.getInstance(props, new Authenticator() {
            @Override
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication(user, pass);
            }
        });

        Message msg = new MimeMessage(session);
        msg.setFrom(new InternetAddress(user));
        msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
        msg.setSubject(subject);

        Multipart mixed = new MimeMultipart("mixed");
        Multipart alt = new MimeMultipart("alternative");

        MimeBodyPart textPart = new MimeBodyPart();
        textPart.setText(text);
        alt.addBodyPart(textPart);

        if (html != null) {
            MimeBodyPart htmlPart = new MimeBodyPart();
            htmlPart.setContent(html, "text/html; charset=utf-8");
            alt.addBodyPart(htmlPart);
        }

        MimeBodyPart wrapper = new MimeBodyPart();
        wrapper.setContent(alt);
        mixed.addBodyPart(wrapper);

        if (attachments != null) {
            for (File f : attachments) {
                MimeBodyPart att = new MimeBodyPart();
                DataSource source = new FileDataSource(f);
                att.setDataHandler(new DataHandler(source));
                att.setFileName(f.getName());
                mixed.addBodyPart(att);
            }
        }

        msg.setContent(mixed);
        Transport.send(msg);
    }

    public static void main(String[] args) throws Exception {
        send(
            "smtp.example.com",
            587,
            "user@example.com",
            "app-password",
            "user@example.com",
            "Order #12345 confirmed",
            "Hi Alice, your order 12345 is confirmed.",
            "<p>Hi Alice, your order <b>12345</b> is confirmed.</p>",
            null
        );
    }
}

Explanation

SMTP works like a short negotiation between your client and the mail server. The client opens a connection on port 587 (STARTTLS) or 465 (implicit TLS), logs in with a username and app password or an OAuth2 token, then builds and sends a MIME message. The actual delivery uses the sequence MAIL FROM → RCPT TO → DATA. After that, the server replies with status codes; your job is to handle bounces, retries, and rate limits on the side.

High-volume sending shouldn’t happen inside an HTTP handler, because that path couples your API to the speed of the mail server and can cause timeouts. Instead, hand messages off to a queue and let workers handle delivery. This keeps response times low and gives you a clean place to retry transient failures.

flowchart diagram: App builds MIME

Variants

For Python, pair smtplib with the email package when you need custom MIME handling. Node.js tends to default to nodemailer for full-stack apps, SSR, and quick attachment support. In Java, Jakarta Mail is the standard choice for enterprise or Spring Boot codebases.

Go and C# are worth considering too. Go’s net/smtp and the jordan-wright/email package work well for low-latency services, while .NET developers usually reach for MailKit.

A minimal Go example using net/smtp:

package main

import (
    "net/smtp"
    "strings"
)

func sendEmail(host, port, user, pass, to, subject, body string) error {
    addr := host + ":" + port
    auth := smtp.PlainAuth("", user, pass, host)
    msg := strings.Join([]string{
        "From: " + user,
        "To: " + to,
        "Subject: " + subject,
        "",
        body,
    }, "\r\n")
    return smtp.SendMail(addr, auth, user, []string{to}, []byte(msg))
}

For attachments and HTML in Go, switch to github.com/jordan-wright/email, which handles MIME multipart construction for you.

Best Practices

Start with the basics: use app passwords or OAuth2 tokens, and never commit them to version control. Store them in a secret manager and rotate them on a schedule. Then add SPF, DKIM, and DMARC DNS records to your sending domain. These three records have more impact on deliverability than anything else you can do.

Always send a plain text fallback with your HTML messages, because some clients, especially watches and terminal readers, only render text. Throttle your sends, since most providers cap you at a few hundred emails per minute. A dedicated sending domain is also worth it: if a campaign damages its reputation, your main domain stays clean.

On the operational side, handle bounces and spam complaints asynchronously. Read SMTP responses and webhook events to keep your list clean. Finally, sanitize any user input that lands in a template or subject line; header injection and malicious links are real risks.

Common Mistakes

  • Sending from localhost or a shared server without valid SPF/DKIM records. Your messages go straight to the spam folder.
  • Embedding production credentials in source code or in environment files that get checked into version control.
  • Skipping recipient address validation before queuing. See input validation for safe patterns.
  • Sending mail synchronously inside HTTP handlers, where it can time out under load.
  • Ignoring rate limits and retries, which leads to temporary blocks or blacklisting.
  • Using To or Cc for bulk sends. Use Bcc or individual messages to protect recipients.
  • Running without logs or monitoring. Without them, you only find out about delivery problems when users complain.

Summary

SMTP sends email through a negotiation: connect on 587 (STARTTLS) or 465 (implicit TLS), authenticate with app passwords or OAuth2, build a MIME message, and send via MAIL FROM → RCPT TO → DATA. Always pair HTML with a plain text fallback, and throttle sends to stay under provider rate limits. Authenticate your domain with SPF, DKIM, and DMARC before sending anything, because no other change improves deliverability as much. Don’t send mail synchronously from HTTP handlers; hand messages to a queue and let workers handle delivery and retries. Handle bounces and spam complaints asynchronously via webhooks. Sanitize any user input that lands in templates or subject lines to prevent header injection.

See Also

Frequently Asked Questions

Should I use SMTP or a transactional email API?

SMTP makes sense when you care about portability and might switch providers later. A transactional email API like SendGrid, Mailgun, or Postmark is better when you want managed templates, analytics, and bounce webhooks out of the box. Most providers support both, so you're not locked into one approach.

How do I stop emails from landing in spam?

The first thing to do is set up SPF, DKIM, and DMARC on your sending domain. Send from a consistent address, keep the HTML clean and mobile-friendly, avoid spammy subject words, and check your sender reputation in Google Postmaster Tools. Engagement also matters, so prune inactive recipients from your list regularly.

Can I send bulk newsletters via SMTP?

Technically you can, but large newsletters aren't what SMTP is built for. It handles transactional email and small batches well. For tens of thousands of recipients, use a dedicated email marketing platform and connect it to your app through its API or list imports.

How do I handle attachments across languages?

Python's email.mime package lets you build MIMEBase parts and base64-encode them. In Node, nodemailer takes an attachments array with path or content fields. In Java, Jakarta Mail gives you MimeBodyPart with a DataHandler wrapping a FileDataSource. All three handle binary files the same way under the hood: base64-encode the payload and set Content-Disposition to attachment. Stay under 10 MB per file, or you'll bounce off provider limits.

What's the difference between port 587 and 465?

Port 587 uses STARTTLS, which begins as plaintext and upgrades to TLS after the client sends the EHLO command. Port 465 uses implicit TLS, encrypting from the start. Most providers support both, but 587 is the modern default because RFC 6409 requires it. If your provider has 465, use it for simplicity — TLS kicks in immediately, with no protocol upgrade step.