smtp - send mail (SMTP client)
Import with import "smtp.j" as smtp;. An SMTP send client: the line-oriented command/response dialogue of RFC 5321 over the net system library, with plaintext / implicit-TLS / STARTTLS transport and SASL AUTH (PLAIN / LOGIN / XOAUTH2 / CRAM-MD5 / SCRAM-SHA-1 / SCRAM-SHA-256). The message body is any string, typically built by the mime module. Because it uses net, this module needs the default jennifer binary; on the stock jennifer-tiny a send raises a friendly error.
On
jennifer-tiny: "needs the defaultjenniferbinary" refers to the stock tiny build, which ships without a network driver - not a TinyGo limitation. Ajennifer-tinyrebuilt with a network stack runs this module too; see the note onnetand TinyGo.
import "smtp.j" as smtp;
import "mime.j" as mime;
def msg as mime.Part init mime.text("text/plain", "Hello!");
$msg = mime.withHeader($msg, "Subject", "Hi");
def opts as smtp.Options init smtp.Options{host: "mail.example.com", port: 587,
security: "starttls", clientName: "me.example.com",
user: "me@example.com", pass: "secret"};
smtp.send($opts, "me@example.com", ["you@example.com"], mime.encode($msg));Runnable: examples/modules/smtp_demo.j.
Surface
| Call / type | Notes |
|---|---|
smtp.Options | host, port, security, clientName, user, pass, auth, allowInsecureAuth. |
smtp.send(opts, from, recipients, message) | One-shot: open a session, deliver message, close. Throws on a server rejection. |
smtp.Session | A live, authenticated connection (conn, open) for delivering many messages over one handshake. |
smtp.open(opts) | Connect, greet, STARTTLS, and authenticate once; returns a ready Session. |
smtp.sendOn(session, from, recipients, message) | Deliver one message over an open session (RSET + MAIL / RCPT / DATA); reusable afterwards. |
smtp.close(session) | QUIT (best-effort) and close the session's socket. |
Options fields:
| Field | Notes |
|---|---|
host | Server hostname. |
port | Server port (25 / 587 plaintext or STARTTLS, 465 implicit TLS). |
security | A transport.Security: .None (plaintext), .Starttls (upgrade after EHLO), or .Tls (implicit TLS on connect). Needs import "transport.j" as transport;. |
clientName | The EHLO identity; defaults to "localhost" when empty. |
auth | SASL mechanism: "" (none when user is empty, else PLAIN), "auto" (negotiate the strongest mechanism EHLO advertises, falling back to PLAIN), "plain", "login", "xoauth2", "cram" (CRAM-MD5), "scram-sha-1", or "scram-sha-256". |
user | SASL username; "" with auth: "" skips authentication. |
pass | SASL password. |
allowInsecureAuth | Force SASL AUTH over an unencrypted (security: "none") connection. Default false: credentials are refused over plaintext (use "tls" / "starttls", or set this to force). |
What send does
One call runs the whole delivery, throwing a catchable Error (kind "smtp") the moment the server rejects a step:
- Connect per
security(net.connect, ornet.connectTLSfor"tls"), bounded by a connection-establishment timeout. - Read the
220greeting, sendEHLO. - For
"starttls": confirm theEHLOresponse actually advertisedSTARTTLS(an anti-downgrade check - a MITM that strips the capability to keep the session in plaintext is refused), thenSTARTTLS,net.startTLS, and a secondEHLO. - Authenticate per
auth(via thesaslmechanisms):AUTH PLAIN, theAUTH LOGINtwo-step,AUTH XOAUTH2(an OAuth2 bearer token inpass- how Google / Microsoft 365 authenticate),AUTH CRAM-MD5, or the SCRAM challenge-response (AUTH SCRAM-SHA-1/SCRAM-SHA-256, whose server signature the client verifies). MAIL FROM:<from>, oneRCPT TO:<r>per recipient,DATA.- Send the message (CRLF-normalised and dot-stuffed) ended by
<CRLF>.<CRLF>. QUITand close.
The from / recipients are the envelope (who the server routes to), separate from the From: / To: header lines in the message - set both. Each envelope address is validated before the connection opens: control characters and CRLF (command injection) are rejected, and the address must be a well-formed local@domain per RFC 5321 (a missing @, an empty local part or domain, or a length over 254 throws).
When auth runs over an unencrypted (security: "none") connection, send refuses to hand SASL credentials to the server unless allowInsecureAuth: true is set - so a password does not cross the wire in the clear by accident. "tls" and a completed "starttls" upgrade are encrypted and unaffected.
Certificate verification for "tls" / "starttls" is the net default (on; see net.md for the opt-out).
Persistent session (many messages, one handshake)
smtp.send is the one-shot convenience: it opens a session, delivers one message, and closes. To send a queue of messages, do the handshake (connect + STARTTLS + AUTH) once with smtp.open, then smtp.sendOn each message, and smtp.close at the end - so N messages pay one TLS + auth round-trip instead of N:
def s as smtp.Session init smtp.open($opts);
for (def m in $queue) {
smtp.sendOn($s, $m.from, $m.recipients, $m.message);
}
smtp.close($s);The Session is value-semantic, but its conn is a shared net.Conn handle, so passing $s to each sendOn reuses the same socket. Each sendOn issues a RSET first, so a rejected message cannot bleed into the next. sendOn on a closed session throws kind "smtp"; close sends a best-effort QUIT (a dead connection is closed regardless). A rejected sendOn leaves the session open - close it (or keep sending) as you choose.
Errors
A rejection at any step throws Error{kind: "smtp", message: "..."} carrying the step and the server's reply, so wrap untrusted sends in try / catch:
try {
smtp.send($opts, $from, $rcpts, $wire);
} catch (e) {
io.printf("send failed: %s\n", $e.message);
}A connection failure (host down, port blocked) surfaces as the underlying net error through the same path.
Testing
The pure protocol logic - reply-code parsing (including multi-line 250- continuations), AUTH PLAIN base64, and dot-stuffing - is unit-tested in the overlay. The networked send path is covered end to end by an in-process fake SMTP server in the Go test suite (so it runs in CI without an external server); a live send against a real daemon is the demo's job.
Out of scope
- Send only. Receiving is POP3 / IMAP (later sub-milestones); this module does not fetch mail.
- Auth mechanisms: PLAIN, LOGIN, XOAUTH2, CRAM-MD5, and SCRAM-SHA-1 / SCRAM-SHA-256 (via
sasl).auth: "auto"selects the strongest the server'sEHLOadvertises. Server-side SASLprep is not applied. - No connection reuse / pipelining.
sendopens, delivers, and closes one connection per call. - Non-ASCII local parts only. An internationalized domain in the host or an envelope address (
user@münchen.de) is IDNA-encoded to itsxn--form automatically (viaidna). A non-ASCII local part (before the@) still throws - it needs SMTPUTF8 (RFC 6531), a later step - rather than sending a misrouted address.
Timeouts
Reads carry a 30 s idle timeout (a deadline re-armed before each read), so a hung server fails with a catchable error instead of blocking the caller forever. The initial connect and the STARTTLS handshake are bounded by their own connection-establishment timeout, so a slow or unreachable server fails the dial rather than blocking it indefinitely.
See also
- mime.md - build the
message(headers, multipart, encodings). - net.md - the transport (
connect/connectTLS/startTLS) and TLS optionssmtpbuilds on. - modules/index.md - the module catalog and import rules.