How to send an email from a Rust program
Short answer: Use lettre: build a Message, take the credentials from the environment, and hand both to an SmtpTransport. The program below sends to a sink inside its own process, so the whole round trip runs with no credentials at all. Set SMTP_HOST and the same code goes to a real provider.
The dependencies
Section titled “The dependencies”[dependencies]lettre = "0.11.23"cargo new send-mail writes the project; that line goes under the [dependencies] it already has. Default features are enough: the SMTP transport, and TLS verification through native-tls, which on macOS is the Security Framework trust store How to fix a TLS certificate error describes.
The code
Section titled “The code”One file. spawn_sink speaks just enough SMTP for one send (greeting, EHLO, any credentials, one message) and passes the message back over a channel. main reads the provider from the environment; with nothing set it starts the sink and sends to that instead.
use std::{ env, error::Error, io::{BufRead, BufReader, Write}, net::{SocketAddr, TcpListener}, sync::mpsc, thread,};
use lettre::transport::smtp::authentication::Credentials;use lettre::{Message, SmtpTransport, Transport};
/// Enough SMTP for one send: greet, answer EHLO, accept any credentials, take/// one message and hand it back over the channel. Plain text on purpose —/// nothing on the way to 127.0.0.1 is worth protecting.fn spawn_sink() -> (SocketAddr, mpsc::Receiver<String>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let (sender, receiver) = mpsc::channel(); thread::spawn(move || { for stream in listener.incoming() { let mut stream = stream.unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); write!(stream, "220 127.0.0.1 ESMTP sink\r\n").unwrap(); stream.flush().unwrap(); let mut line = String::new(); loop { line.clear(); if reader.read_line(&mut line).unwrap() == 0 { return; } let command = line.trim_end().to_owned(); if command.starts_with("EHLO") { write!( stream, "250-127.0.0.1\r\n250-SIZE 10485760\r\n250 AUTH PLAIN LOGIN\r\n" ) .unwrap(); } else if command.starts_with("AUTH") { // "AUTH PLAIN <base64>" arrives as one line; a bare "AUTH PLAIN" // means the client is waiting for the server's 334 challenge. if command == "AUTH PLAIN" || command == "AUTH LOGIN" { write!(stream, "334 \r\n").unwrap(); stream.flush().unwrap(); line.clear(); reader.read_line(&mut line).unwrap(); } write!(stream, "235 2.7.0 Authentication successful\r\n").unwrap(); } else if command.starts_with("MAIL FROM") { write!(stream, "250 2.1.0 Ok\r\n").unwrap(); } else if command.starts_with("RCPT TO") { write!(stream, "250 2.1.5 Ok\r\n").unwrap(); } else if command == "DATA" { write!(stream, "354 End data with <CR><LF>.<CR><LF>\r\n").unwrap(); stream.flush().unwrap(); let mut message = String::new(); loop { line.clear(); if reader.read_line(&mut line).unwrap() == 0 { break; } if line.trim_end() == "." { break; } // A line starting with a dot arrives doubled; undo that. if line.starts_with("..") { line.remove(0); } message.push_str(&line); } sender.send(message).unwrap(); write!(stream, "250 2.0.0 Ok: queued as sink01\r\n").unwrap(); } else if command == "QUIT" { write!(stream, "221 2.0.0 Bye\r\n").unwrap(); stream.flush().unwrap(); return; } stream.flush().unwrap(); } } }); (addr, receiver)}
fn main() -> Result<(), Box<dyn Error>> { // With SMTP_HOST set, the message goes to a real provider over STARTTLS. // With nothing set, it goes to the sink above instead, so the whole round // trip runs without credentials. let mut mailbox = None; let (host, port, user, password, real) = match env::var("SMTP_HOST") { Ok(host) => ( host, env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_owned()).parse()?, env::var("SMTP_USER")?, env::var("SMTP_PASSWORD")?, true, ), Err(_) => { let (addr, receiver) = spawn_sink(); mailbox = Some(receiver); ( addr.ip().to_string(), addr.port(), "reader".to_owned(), "the sink accepts anything".to_owned(), false, ) } };
let email = Message::builder() .from("Ada Lovelace <ada@example.com>".parse()?) .to("Grace Hopper <grace@example.com>".parse()?) .subject("The analytical engine, in Rust") .body("It works.\n".to_owned())?;
let credentials = Credentials::new(user, password); let mailer = if real { // STARTTLS on 587: the connection starts in the clear and is upgraded // before any credential is sent. SmtpTransport::starttls_relay(&host)? .port(port) .credentials(credentials) .build() } else { SmtpTransport::builder_dangerous(&host) .port(port) .credentials(credentials) .build() };
mailer.send(&email)?;
if let Some(receiver) = mailbox { println!("--- what the sink received ---"); print!("{}", receiver.recv()?); } Ok(())}Run it with cargo run (cargo’s own Compiling and Finished lines are not shown here):
--- what the sink received ---From: "Ada Lovelace" <ada@example.com>To: "Grace Hopper" <grace@example.com>Subject: The analytical engine, in RustContent-Transfer-Encoding: 7bitDate: Mon, 26 Oct 2026 01:50:59 +0000
It works.The Date line is the moment you ran it, so yours will differ. The lines above it are what lettre put on the wire: the three headers you set, plus Date and Content-Transfer-Encoding, which it adds itself. The display names arrive quoted, "Ada Lovelace" <ada@example.com>, because they contain a space; a single-word name goes out bare. Nothing here is encrypted, and builder_dangerous is only ever for the sink. The sink’s 250 reply is what send() waits for.
Where the credentials live
Section titled “Where the credentials live”No password appears in the program. Four environment variables decide where the mail goes: SMTP_HOST, SMTP_PORT (587 by default), SMTP_USER, SMTP_PASSWORD. A missing one stops the program with Error: NotPresent and exit code 1. That message names nothing, so a program meant for other people should say which variable is missing. Keep the values out of the source and out of the repository: a password in main.rs is in every clone, every diff and every backup.
Gmail is the usual first test: SMTP_HOST=smtp.gmail.com on 587 with STARTTLS. The account password no longer works for SMTP. Google wants an app password, a 16-character code generated with 2-Step Verification turned on, or OAuth 2.0. A free account also caps SMTP at roughly 500 recipients a day, which is one reason application mail usually goes to a provider instead.
When the provider does not answer
Section titled “When the provider does not answer”SMTP_HOST=127.0.0.1 SMTP_PORT=1 SMTP_USER=reader SMTP_PASSWORD=x cargo run -qError: lettre::transport::smtp::Error { kind: Connection, source: Os { code: 61, kind: ConnectionRefused, message: "Connection refused" } }Exit code 1, and no mail leaves the machine: starttls_relay upgrades the connection before any credential is sent, so a server that cannot be upgraded fails first. A network that drops the packets instead of refusing them shows up as the builder’s 60-second command timeout rather than an error.
Why an API instead
Section titled “Why an API instead”SMTP is a 1982 state machine, outbound port 25 is blocked on most networks, and 587 and 465 usually work but not always. Transactional providers (Resend, Postmark, SES) take the same message as one HTTPS POST with a bearer token: no port to open, no STARTTLS dance, and the provider owns the sending reputation. For a program that already has reqwest, that is a shorter path than SMTP; How to POST JSON to an API is the same request with a different body. The trade is real: an HTTP API is one more dependency and a provider-specific request shape, while SMTP with lettre works against any mail server.
- How to POST JSON to an API: the HTTP alternative to SMTP, end to end.
- How to fix a TLS certificate error: the previous article, and the certificate
starttls_relaychecks. - How to set a timeout on a request: what a timeout is for, and the one lettre applies to every command.