SMTP: The Server Said “Sent.” The Inbox Disagreed
A contact form on my site, PreReqs, said “Thanks — your enquiry is on its way.” every single time. The server answered every request with a…
SMTP: The Server Said “Sent.” The Inbox Disagreed
A contact form on my site, PreReqs, said “Thanks — your enquiry is on its way.” every single time. The server answered every request with a clean 200 OK. And nothing ever showed up in the inbox.
This is the story of how that happened, why “configure SMTP” is a smaller job than it sounds, and how a security feature I added on purpose ended up eating real customer enquiries.
Sending email isn’t running a server
The first mental block most people hit: “I need to set up an email server.” You don’t. You need to talk to one that already exists.
Your app doesn’t become a mail server just because it sends mail — it becomes one more client of whatever server already handles your mailbox (Hostinger, Google Workspace, Outlook, anything). Same role your phone’s mail app plays. You’re not hosting anything; you’re placing a call.

Comparison diagram: running your own mail server versus configuring your app as an SMTP client of a provider’s already-running server
That’s the entire job: three settings.
SMTP_HOST=smtp.yourprovider.com
SMTP_PORT=465 # or 587
SMTP_USER=you@yourdomain.com
SMTP_PASSWORD=your-mailbox-password
Port 465 means the connection is encrypted (TLS) from the very first byte. Port 587 means it starts in plain text and upgrades mid-conversation (STARTTLS). Both end up encrypted; they just get there differently. If 465 doesn’t cooperate on your network, 587 usually will.
Whatever library you use — nodemailer in my case — reads those four values and does the actual talking: connect, authenticate, hand off the message, get back a 250 OK, queued. That confirmation comes from the provider's server, not your code. Your code's only job was making the call correctly.
Docker doesn’t change any of this
The one thing that trips people up in a containerized app: the image itself should never contain those four values.
environment:
SMTP_HOST: ${SMTP_HOST:?}
SMTP_PORT: ${SMTP_PORT:?}
SMTP_USER: ${SMTP_USER:?}
SMTP_PASSWORD: ${SMTP_PASSWORD:?}
The :? means "refuse to start without this." That's deliberate — if any deploy skips wiring up these values, you want the container to fail loudly at boot, not start up fine and quietly discard every enquiry forever. A build that starts cleanly and a build that actually sends mail are two different claims, and only one of them is checked by "docker ps" showing healthy.
Same image, any environment: local dev gets no credentials and a harmless fake sender; staging and production get the real ones, injected at runtime, never baked into a layer.
Then it broke, and the response lied about it
Config was in place. I clicked “send” on the actual form. Got the success message. Checked the inbox five minutes later. Nothing.
No error. No 500. No angry log line. The server had, as far as I could tell, done everything right and produced nothing.
The form had a honeypot — a hidden field named “company” that a real visitor never sees, positioned off-screen with CSS. Spam bots fill in every field they find; a filled honeypot means “this wasn’t a human,” so the server quietly drops the message and still returns success — telling a bot it worked is exactly how you avoid teaching it to adapt.
Except my hidden field wasn’t hidden enough. I’d used a classic “visually-hidden” CSS trick — clipped to one pixel, moved off-screen — instead of display: none. That trick keeps the element technically present and rendered, and Chrome's address autofill doesn't check whether something's visible to a human before filling it. It saw a field named "company," matched it against my own saved address data, and filled it in behind my back.
I had, without realizing it, built a bot trap that caught actual me.

Flowchart: a hidden honeypot field and a genuine submission both return 200 OK from the server, making success and silent failure indistinguishable from outside
The fix was one CSS property:
/* before — still rendered, autofill can reach it */
.honeypot { position: absolute; clip-path: inset(50%); }
/* after — genuinely removed from the page */
.honeypot { display: none; }
display: none takes the element out of the render tree entirely. Real browsers skip autofilling anything that isn't actually rendered. A basic spam bot that just grabs every <input> from the raw HTML — without evaluating any CSS at all — still walks straight into it. One property line, and the trap goes back to catching only what it was built for.
The other ways to send mail from an app
Raw SMTP isn’t the only option, and it isn’t always the right one. Roughly, in order of “less magic, more control” to “more magic, less setup”:
mailto:links — zero backend, opens the visitor's own mail client. No server involved, but it hands the send action to the visitor, and it breaks entirely for anyone on a webmail-only device.- Raw SMTP (nodemailer, smtplib, etc.) — what this post covers. Works with any mail host, no vendor lock-in, but you own retries, deliverability, and reading provider-specific error codes yourself.
- Provider SDKs (SES, SendGrid, Postmark) — a proper HTTP API instead of the SMTP protocol: webhooks for bounces/opens, better deliverability tooling, dashboards. Trade-off: you’re now writing against one vendor’s API shape, and pulling their SDK into your codebase.
- Transactional email platforms (Mailgun, Brevo, Resend) — same idea as above, generally friendlier for smaller apps, same lock-in trade-off.
- Third-party form services (Formspree and similar) — fastest to wire up, but visitor data now passes through someone else’s infrastructure before it reaches you — worth a real look if you handle anything privacy-sensitive.
None of these are “correct.” SMTP earns its place when portability matters more than convenience — pointing at a different mail host later is a config change, not a rewrite.
If you’re building the kind of production systems this post is about — the parts that don’t show up in a demo but absolutely show up in an incident — I write and teach this stuff at PreReqs.
메타데이터
- post_id
- e5dcd83a0a7b
- slug
- smtp-the-server-said-sent-the-inbox-disagreed-e5dcd83a0a7b
- url
- https://medium.com/@vishalkumar9dec/smtp-the-server-said-sent-the-inbox-disagreed-e5dcd83a0a7b
- canonical_url
- https://medium.com/@vishalkumar9dec/smtp-the-server-said-sent-the-inbox-disagreed-e5dcd83a0a7b
- author_url
- https://medium.com/@vishalkumar9dec
- status
- ok
- fetched_at
- 2026-09-02 19:19:55