Our contact form lives on a Cloudflare Worker. Most guides say you need a transactional email service with an HTTP API for that. We already had a normal mailbox at our email host, and we did not want another account, another API key and another monthly bill just to forward a form. So the Worker speaks SMTP itself.
Why this works now
Workers can open outbound TCP connections with connect() from cloudflare:sockets, including TLS. That is all SMTP needs. Our provider accepts submissions on port 465 with implicit TLS, which is the simplest case: open a TLS socket and start talking.
import { connect } from "cloudflare:sockets";
const socket = connect({ hostname: host, port: 465 }, { secureTransport: "on" });
A minimal SMTP client
SMTP is a line-based conversation. The client sends a command, the server answers with a three-digit code. Multi-line answers use 250- on every line except the last, which uses 250 . Reading until a line matches ^\d{3} gives you one full reply:
async read() {
for (;;) {
const lines = this.buf.split("\r\n").filter(Boolean);
const last = lines[lines.length - 1];
if (last && /^\d{3} /.test(last)) { const out = this.buf; this.buf = ""; return out; }
const { value, done } = await this.reader.read();
if (done) break;
this.buf += this.dec.decode(value);
}
}
The conversation itself is short:
await s.read(); // 220 greeting
await s.cmd("EHLO thenextbeacon.com", 250);
await s.cmd("AUTH LOGIN", 334);
await s.cmd(btoa(user), 334);
await s.cmd(btoa(env.SMTP_PASS), 235); // 235 = authenticated
await s.cmd(`MAIL FROM:<${from}>`, 250);
await s.cmd(`RCPT TO:<${to}>`, 250);
await s.cmd("DATA", 354);
await s.cmd(dotStuff(message) + "\r\n.", 250);
await s.cmd("QUIT");
Each cmd writes a line and checks the reply code, so any failure throws with the server's own message, which makes debugging easy.
Three details that matter
Dot-stuffing. The message ends with a line containing a single dot. If your text contains a line starting with a dot, the server would read it as the end. Doubling those dots is the rule:
const dotStuff = (s) => s.replace(/\r?\n/g, "\r\n").replace(/\r\n\./g, "\r\n..");
Line endings. SMTP wants \r\n everywhere. Normalise before sending, or some servers will reject or mangle the mail.
Reply-To, not From. The From must be your own authenticated address, or the mail fails SPF and DMARC checks. Put the visitor's address in Reply-To, so pressing reply in your mail app answers them directly.
The message is a small multipart MIME body with a plain text and an HTML part, so it reads well in any client.
Keeping it safe
- The form is protected by Cloudflare Turnstile, verified server-side before anything is sent.
- A hidden honeypot field silently drops simple bots.
- Inputs are trimmed and length-limited, and the email address is validated.
- The SMTP password is a Worker secret, never in the code or the repository.
- If sending fails, the page falls back to opening the visitor's own mail app with the message prefilled, so no request is lost.
When not to do this
For newsletters or thousands of emails a day, use a real email service: deliverability, bounces and unsubscribes are their whole job. For a contact form that sends a handful of messages to yourself, plain SMTP from the edge is simple, free and has one less service to manage.