Email / SMTP
rust-web-server ships a hand-rolled SMTP client in src/mailer/mod.rs — no third-party mail library required. Enable the mailer feature, set RWS_SMTP_* environment variables, and call Mailer::send() from any handler.
STARTTLS and SMTPS (TLS) additionally require the http-client or http2 feature at compile time. Plain SMTP on port 25 works with only mailer.
[dependencies]# STARTTLS or SMTPS (most SMTP providers)rust-web-server = { version = "17", features = ["mailer", "http-client"] }
# Plain SMTP / local relay onlyrust-web-server = { version = "17", features = ["mailer"] }Quick start
use rust_web_server::mailer::{Email, Mailer};
let mailer = Mailer::from_env().expect("SMTP not configured");
let email = Email::builder() .to("user@example.com") .subject("Welcome!") .text("Thanks for signing up.") .build() .unwrap();
mailer.send(&email).expect("send failed");Configuration
Mailer::from_env() reads the following environment variables:
| Variable | Default | Required | Description |
|---|---|---|---|
RWS_SMTP_HOST | — | Yes | SMTP server hostname |
RWS_SMTP_PORT | 587 | No | Port (25 = relay, 587 = STARTTLS, 465 = SMTPS) |
RWS_SMTP_USER | — | No | SMTP username; omit to skip AUTH |
RWS_SMTP_PASSWORD | — | No | SMTP password |
RWS_SMTP_FROM | — | Yes | Envelope and From: address |
RWS_SMTP_TLS | starttls | No | starttls, smtps, or none |
RWS_SMTP_TIMEOUT_MS | 10000 | No | Connect / read / write timeout in milliseconds |
Provider examples
Gmail (STARTTLS, port 587):
RWS_SMTP_HOST=smtp.gmail.comRWS_SMTP_PORT=587RWS_SMTP_TLS=starttlsRWS_SMTP_USER=you@gmail.comRWS_SMTP_PASSWORD=app-password # create an App Password in Google Account settingsRWS_SMTP_FROM=you@gmail.comSendGrid (STARTTLS, port 587):
RWS_SMTP_HOST=smtp.sendgrid.netRWS_SMTP_PORT=587RWS_SMTP_TLS=starttlsRWS_SMTP_USER=apikeyRWS_SMTP_PASSWORD=SG.xxxxRWS_SMTP_FROM=noreply@yourdomain.comAmazon SES (STARTTLS, port 587):
RWS_SMTP_HOST=email-smtp.us-east-1.amazonaws.comRWS_SMTP_PORT=587RWS_SMTP_TLS=starttlsRWS_SMTP_USER=AKIAIOSFODNN7EXAMPLERWS_SMTP_PASSWORD=<SES SMTP password>RWS_SMTP_FROM=noreply@yourdomain.comLocal relay (no TLS, port 25):
RWS_SMTP_HOST=127.0.0.1RWS_SMTP_PORT=25RWS_SMTP_TLS=noneRWS_SMTP_FROM=app@internalDirect construction
Bypass env vars and build a Mailer directly:
use rust_web_server::mailer::{Mailer, SmtpTls};
let mailer = Mailer { host: "smtp.gmail.com".into(), port: 587, user: Some("you@gmail.com".into()), password: Some("app-password".into()), from: "you@gmail.com".into(), tls: SmtpTls::Starttls, timeout_ms: 15_000,};Building emails
Email::builder() returns an EmailBuilder with a fluent API:
use rust_web_server::mailer::Email;
let email = Email::builder() .to("user@example.com") // one or more To: recipients .to("other@example.com") .cc("manager@example.com") // optional Cc: .bcc("archive@example.com") // envelope only; not in headers .reply_to("support@example.com") // optional Reply-To: .subject("Your order shipped") .text("Your package is on the way.") // plain-text body .html("<p>Your package is <b>on the way</b>.</p>") // HTML body .build() .unwrap();build() validates:
- At least one
toaddress - Non-empty
subject - At least one of
textorhtml
When both text and html are provided, the message is sent as multipart/alternative so email clients can display whichever format they support.
Sharing the mailer in handlers
For production use, put the mailer in your application state so every handler can access it:
use std::sync::Arc;use rust_web_server::app::App;use rust_web_server::mailer::{Email, Mailer};use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};use rust_web_server::range::Range;use rust_web_server::mime_type::MimeType;
struct State { mailer: Arc<Mailer>,}
let state = State { mailer: Arc::new(Mailer::from_env().unwrap()),};
let app = App::with_state(state) .post("/register", |req, _params, _conn, state| { // … validate the request body …
let email = Email::builder() .to("new_user@example.com") .subject("Confirm your email") .text("Please confirm: https://example.com/confirm?token=xxxx") .html("<p>Click <a href=\"https://example.com/confirm?token=xxxx\">here</a> to confirm.</p>") .build() .unwrap();
// In production, send in a background task (see scheduler docs) if let Err(e) = state.mailer.send(&email) { eprintln!("email send error: {e}"); }
let mut r = Response::new(); r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code; r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string(); r.content_range_list = vec![Range::get_content_range( b"registered".to_vec(), MimeType::TEXT_PLAIN.to_string(), )]; r });Use cases
| Use case | Setup |
|---|---|
| Password reset | Generate a CSPRNG token with crypto::generate_token(32), store it in the DB, email the link |
| Email verification | Same pattern — send a link containing the token; verify on callback |
| Transactional notifications | Order confirmation, invoice, shipping notification |
| Alerts | Background scheduler job sends alert emails on threshold events |
TLS modes
| Mode | Port | When to use |
|---|---|---|
SmtpTls::Starttls (default) | 587 | Supported by virtually all providers; upgrades from plain to TLS |
SmtpTls::Smtps | 465 | Implicit TLS from the first byte; required by some providers |
SmtpTls::None | 25 | Local relay (localhost), internal networks, or providers that handle TLS at the network edge |
Error handling
Mailer::send() returns Result<(), MailerError>:
use rust_web_server::mailer::{Mailer, MailerError};
match mailer.send(&email) { Ok(()) => println!("sent"), Err(MailerError::MissingConfig(var)) => eprintln!("missing env var: {var}"), Err(MailerError::Io(e)) => eprintln!("network error: {e}"), Err(MailerError::Smtp(msg)) => eprintln!("SMTP rejected: {msg}"), Err(MailerError::Build(msg)) => eprintln!("bad email: {msg}"),}Implementation notes
- No third-party SMTP library. The client hand-rolls RFC 5321 SMTP: TCP connect → EHLO → optional STARTTLS or SMTPS → optional AUTH PLAIN → MAIL FROM → RCPT TO (one per recipient) → DATA → message → QUIT.
- RFC 5322 message builder.
build_message()assembles headers (From,To,Cc,Subject,Reply-To,MIME-Version,Content-Type) and the body. When both text and HTML are provided, amultipart/alternativestructure is generated. - SMTP dot-stuffing. Lines in the message body that begin with
.are prefixed with an extra.(RFC 5321 §4.5.2) so the DATA terminator (\r\n.\r\n) is not triggered prematurely. - AUTH PLAIN. Credentials are encoded as base64(
\0username\0password) in a single AUTH PLAIN command using the built-incrate::core::base64::Base64::encode. - Sync only.
Mailer::send()blocks the calling thread. In async handlers (AsyncAppWithState), wrap the call intokio::task::spawn_blocking.