codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
Notes›backend

Email Delivery and OTP — SMTP

Published 2026-05-18·0 views

Email Delivery and OTP — SMTP

Signup confirmation, password reset, one-time verification codes — when a service needs to send something directly to a user, email is usually the first channel it reaches for. Unlike push notifications, it needs no separate permission grant, and practically every user already owns an address.

Email is also a channel where "if you send it, it arrives" is not guaranteed. Sending itself is a single line of code, but getting that mail into the inbox rather than the spam folder is a topic that pulls in sender authentication, send-path separation, and failure handling all at once.

1. SMTP and the email delivery path

SMTP (Simple Mail Transfer Protocol) is the standard protocol for moving email between mail servers. It was first defined in RFC 821 (1982); RFC 5321 is the current baseline. SMTP is the actual transport for the act of "sending" mail — reading an inbox is a different role handled by IMAP or POP3.

The path a single message takes to arrive looks roughly like this.

Compose   Mail client / application composes the message body
   │
Submit    Submitted to the submission server over SMTP   ← port 587
   │
Relay     Sending server looks up the recipient domain's MX record
   │
Deliver   Transferred to the receiving mail server via SMTP   ← port 25
   │
Store     Receiving server stores it in the mailbox
   │
Read      User reads it via IMAP · POP3 · webmail

Ports split by role.

Port Use Encryption
25 Server-to-server delivery (MTA-to-MTA) Usually plaintext, opportunistic STARTTLS
465 Client submission TLS from the moment the connection opens (SMTPS)
587 Client submission Plaintext connect, then upgraded via STARTTLS

The port an application uses to send mail is not 25 but 587 or 465. Port 25 is blocked for outbound by most ISPs and cloud providers as a spam-control measure.

STARTTLS vs SSL/TLS — the two differ in when encryption begins. SSL/TLS (port 465) starts with a TLS handshake the instant the connection opens. STARTTLS (port 587) opens the connection in plaintext, then issues a STARTTLS command to upgrade that same connection to encryption. Both are secure enough; in library config, 587 is often secure: false + STARTTLS while 465 is secure: true.

2. Gmail SMTP and app passwords

Small services and development setups commonly borrow Gmail's SMTP instead of running a mail server. The connection details are smtp.gmail.com:587 (STARTTLS).

There is one trap here. Your account's normal login password will not authenticate over SMTP. Since 2022, Google has blocked direct password use by "less secure apps." To send mail over SMTP you need this sequence.

  1. Enable two-factor authentication (2FA) on the account.
  2. In account security settings, generate an App Password — a 16-character string.
  3. Use that app password for SMTP authentication instead of the normal password.

An app password is a separate credential scoped to one use (this service's mail sending), so if it leaks you only revoke that one password. It also keeps your normal password out of code and environment variables.

The other thing to remember is the sending quota. SMTP sending from a free Gmail account is capped at roughly 500 messages per day (a paid Workspace account is around 2,000). Exceeding the cap temporarily blocks sending, so it is safest to treat Gmail SMTP as strictly low-volume transactional mail. For bulk sending, move to the managed services in section 8.

3. Libraries — nodemailer and JavaMailSender

You almost never touch SMTP commands (EHLO, AUTH, MAIL FROM, RCPT TO, DATA) directly. Per-language libraries wrap them.

Node.js — nodemailer. The de facto standard. Create the transporter once and reuse it.

import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: 'smtp.gmail.com',
  port: 587,
  secure: false,                 // 587 = STARTTLS; true would mean 465
  auth: { user: SMTP_USER, pass: SMTP_PASS },
});

await transporter.sendMail({
  from: '"My Service" <noreply@example.com>',
  to: 'user@example.com',
  subject: 'Verification code',
  text: 'Your verification code is 482915. Enter it within 5 minutes.',
  html: '<p>Your verification code is <b>482915</b>. Enter it within 5 minutes.</p>',
});

Java / Spring — JavaMailSender. Spring's spring-boot-starter-mail supplies a JavaMailSender bean.

@Autowired
private JavaMailSender mailSender;

public void sendOtp(String to, String code) {
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
    helper.setFrom("noreply@example.com");
    helper.setTo(to);
    helper.setSubject("Verification code");
    helper.setText(
        "Your verification code is " + code + ".",                 // plain-text
        "<p>Your verification code is <b>" + code + "</b>.</p>");   // HTML
    mailSender.send(message);
}

Where possible, send a multipart body carrying both an HTML body and a plain-text fallback. Text-only clients and preview environments use the plain-text part, and having a plain-text part can even improve scoring in some spam filters. If you must send only one, prefer plain-text.

4. OTP code generation and verification

An OTP (One-Time Password) is a short-lived authentication code used once and discarded. Email and SMS verification codes, and 2FA codes, all fall here. The two essentials are being unguessable and being short-lived.

First, generation. The code must be produced by a cryptographically secure random generator.

import { randomInt } from 'node:crypto';
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');  // '000000'–'999999'
import java.security.SecureRandom;
String code = String.format("%06d", new SecureRandom().nextInt(1_000_000));

Do not use Math.random() or java.util.Random. These are pseudo-random: once the seed is known, the next value is predictable. For security-bearing values like verification codes, use only a CSPRNG such as crypto.randomInt() or SecureRandom.

Next, storage and lifetime. Store the generated code together with an expiry (e.g., 5 minutes). With Redis, a TTL is natural.

SET otp:user@example.com 482915 EX 300        # auto-expires after 5 minutes

If you store it in a database, add an expires_at column and compare it on verification. The verification step checks three things.

  1. Does a code for that email exist (absent means expired or never issued)?
  2. Does the input match the stored value?
  3. Is it still before expiry?

On successful verification, delete the code immediately. A used code left alive is no longer "one-time." Also, when wrong inputs exceed a set count (e.g., 5), discard the code to block brute force.

Finally, rate-limiting. Cap the issuance request itself — for example, once per minute and five times per hour per email. Without a cap, an abuser can flood one person's inbox with code mails, and the sending quota drains fast.

5. Sender trust — SPF, DKIM, DMARC

When mail you sent lands in spam instead of the inbox, the most common cause is not the body but sender authentication. The receiving mail server checks "did this domain really send this?" through three DNS-based mechanisms.

Mechanism One-line definition DNS record
SPF A list of server IPs allowed to send mail for this domain TXT (v=spf1 ...)
DKIM Attaches the domain's private-key signature to the mail TXT (publishes the public key)
DMARC Policy for what to do when SPF/DKIM fail, plus reports TXT (v=DMARC1 ...)

SPF (Sender Policy Framework) declares in DNS that "mail for my domain only leaves from these IPs." The receiving server checks whether the originating server IP is on that list.

DKIM (DomainKeys Identified Mail) has the sending server attach a private-key signature over the mail headers and body; the receiving server verifies that signature with the public key published in DNS. It guarantees the mail was not altered in transit and was signed by the domain.

DMARC (Domain-based Message Authentication, Reporting and Conformance) sets the policy for when SPF/DKIM checks fail — none (observe only), quarantine (spam folder), reject (refuse delivery). It also sends authentication-failure statistics back to the sending domain's administrator as reports.

If these three are not set up, the mail is at high risk of being classified as spam. One point to watch in particular: the domain of the From address and the domain of the SMTP authentication account must be aligned. Sending from noreply@example.com while authenticating with an account on an entirely different domain will fail the DMARC alignment check.

6. Transactional mail vs marketing mail

Email splits into two kinds by nature, and each should be handled differently.

Aspect Transactional mail Marketing mail
Trigger A response to a user's action The service sends proactively
Examples Signup confirmation, OTP, password reset, order alerts Newsletters, promotions, announcements
Recipient The single user who acted Many subscribers
Unsubscribe Usually unnecessary (the user requested it) Required — an unsubscribe link is mandatory
Expected delivery time Immediate (seconds) Delay tolerated

Mixing the two causes trouble. Marketing mail draws frequent unsubscribes and spam complaints, which lower the reputation of the sending domain and IP. If that reputation drop pulls down transactional mail going out over the same infrastructure, the OTP mail a user was actually waiting for goes to spam.

So beyond a certain scale, it is recommended to separate the sending infrastructure (domain, IP, service) into transactional and marketing. The point is to isolate the deliverability of transactional mail from the side effects of marketing sends.

7. Send-disabled mode and failure handling

Development and test environments do not send real mail. Real mail going out on every run during development dirties inboxes, eats into the sending quota, and makes test code depend on an external SMTP. A common pattern is a flag like EMAIL_ENABLED that, when off, logs the code and body to the console instead of sending.

async function sendEmail(opts) {
  if (!EMAIL_ENABLED) {
    logger.info('[email disabled] would send', { to: opts.to, subject: opts.subject });
    return;                       // skip the actual send
  }
  await transporter.sendMail(opts);
}

This way, you can read the OTP code straight from the console in the development environment, which speeds up testing.

Keep failure handling separate from request handling. Mail sending is an external operation across the network and can fail at any time (timeout, SMTP error, quota exceeded). If the final step of signup is "send the welcome email," a mail-send failure must not fail the signup itself. Treat signup as successful and retry the mail separately.

Also, do not call mail sending synchronously inside the request thread. An SMTP round trip can take from hundreds of milliseconds to several seconds, so a synchronous call delays the user's response by exactly that long. Detach sending as an async task or put it on a queue (a message queue or job queue) and respond to the user immediately. A queue also lets you naturally layer on retries (backoff) for failed mail. On the user side, include a resend button for "the mail never arrived."

8. Managed email services

Once you outgrow Gmail SMTP's sending quota and the reputation-management and IP-warmup burden of a self-run mail server, you move to a managed email service. These accept mail over SMTP or an HTTP API and handle deliverability, retries, and reputation management for you.

Service Characteristics Notes
SendGrid Long track record, broad feature set, has a free tier Transactional + marketing
Amazon SES Very cheap, AWS integration, easy volume scaling Initial sandbox / sender verification needed
Postmark Specialized in transactional mail, strong fast-delivery reputation Marketing sends are separate
Mailgun API-centric, developer-friendly Logging and verification tools
Resend Relatively new, concise API and DX Transactional-focused

The strengths of a managed service are deliverability, analytics, and volume. Once you set up domain authentication (SPF, DKIM), it handles reputation management, bounce processing, and open/click statistics for you, and you can raise the volume by requesting a higher quota. The trade-offs are external dependency and cost. An outside vendor sits in the send path, and cost rises with volume. Since most services also offer an SMTP interface, you can often migrate by just swapping the SMTP config in nodemailer or JavaMailSender at first.

Gotchas

Spam classification from missing SPF/DKIM — no matter how polished the body, an empty sender authentication makes it hard to land in the inbox. If you use a domain, SPF/DKIM/DMARC setup is the first step.

Request blocked by synchronous sending — waiting on mail sending inside the request thread delays the user's response by the SMTP round-trip time. Detach it as an async task or queue.

Unlimited OTP resends — without a rate limit on the issuance request, an abuser can flood one person's inbox with code mails and drain the sending quota. Cap the per-minute and per-hour counts.

Storing OTPs in plaintext — leaving verification codes in plaintext in the database or logs means anyone with access to that store reads the codes directly. Keep them on a short TTL or store them hashed.

Inserting user input into the body without escaping — dropping user-provided names or messages straight into an HTML body lets malicious input plant fake links or content that can be abused for phishing. HTML-escape any user input that goes into the mail body.

From domain not matching the SMTP account — if the domain of the From address differs from the domain of the authenticated SMTP account, it fails the DMARC alignment check and gets blocked or marked as spam.

Exceeding Gmail's daily quota — free Gmail SMTP allows roughly 500 messages per day. Exceeding the cap temporarily blocks sending, so move to a managed service as volume grows.

Closing

The email-sending code is one line, but making that one line trustworthy lives outside the code. DNS settings like SPF and DKIM, separating transactional from marketing, async sending and retries — almost every place where mail becomes "sent but never arrived" is here. Spending more time on delivery and failure handling than on the send itself reduces the number of users who give up while waiting for an OTP.

Next

  • audit-log-pattern
  • api-handler-pattern

References: nodemailer · Gmail SMTP setup guide · Spring Email reference · OWASP Cheat Sheet Series · Cloudflare — SPF/DKIM/DMARC.

More in backend

All in this category →
  • Wrap public OpenAPIs with your own BFF
  • Audit Log — logAdminAction pattern
  • WebSocket and SSE — real-time communication
  • REST API introduction
  • OpenAPI Specification
  • Crawler ethics and tooling