SMTP client library, written in Rust
- I/O-free coroutines: every SMTP command is exposed as a
resume(arg: Option<&[u8]>)state machine. No sockets, no async runtime, nostdrequired. Drive against any blocking, async, or fuzz harness. - Standard, blocking client:
- Light client (requires
clientfeature):SmtpClientStd::new(stream)wraps a connectedRead + Writestream and exposes one method per coroutine. You still own TCP / TLS / STARTTLS. - Full std client (requires
rustls-ring,rustls-aws, ornative-tlsfeature):SmtpClientStd::connect(url, tls, starttls, domain, sasl)openssmtp:///smtps://URLs via pimalaya/stream, reads the greeting, sends the initial EHLO, drives the optional STARTTLS upgrade with a fresh EHLO over TLS, and runs the chosen SASL mechanism, returning a ready-to-use authenticated client.
- Light client (requires
- SASL mechanisms:
LOGIN,PLAIN,ANONYMOUS,XOAUTH2andOAUTHBEARERbuilt-inSCRAM-SHA-256(requiresscramfeature)
The io-smtp library is written in Rust, and relies on cargo features to enable or disable functionalities. Default features can be found in the features section of the Cargo.toml, or on docs.rs.
This library implements SMTP as I/O-agnostic coroutines: no sockets, no async runtime, no std required.
| Module | What it covers |
|---|---|
login |
LOGIN: legacy de-facto AUTH mechanism (no RFC) |
| 1870 | SIZE: maximum message size declaration |
| 3207 | STARTTLS: upgrade a plain connection to TLS |
| 3461 | DSN: RET, ENVID, NOTIFY, ORCPT ESMTP parameters for MAIL FROM / RCPT TO |
| 3463 | Enhanced status codes: EnhancedStatusCode type |
| 4505 | ANONYMOUS: SASL ANONYMOUS mechanism |
| 4616 | PLAIN: SASL PLAIN authentication mechanism |
| 4954 | AUTH: SASL exchange protocol |
| 5321 | SMTP: greeting, EHLO, HELO, MAIL FROM, RCPT TO, DATA, NOOP, RSET, QUIT |
| 7628 | OAUTHBEARER: OAuth 2.0 bearer token SASL mechanism; also XOAUTH2 |
| 7677 | SCRAM-SHA-256: SASL SCRAM-SHA-256 mechanism (feature scram) |
io-smtp can be consumed three ways, depending on how much of the I/O stack you want to own. Each mode is gated by cargo features.
Whichever mode you pick, every coroutine exposes resume(arg: Option<&[u8]>) returning a result enum with four shapes:
WantsRead: caller reads more bytes from the socket and feeds them back on the next call. PassSome(&[])to signal EOF.WantsWrite(Vec<u8>): caller writes these bytes to the socket. The next call typically passesNone.Ok { … }(or unitOk): terminal success.Err(…): terminal failure.
No features required: works in #![no_std], no sockets, no async runtime. You own the loop and the bytes; the library only produces command bytes and consumes server responses.
Read the SMTP greeting against a blocking TCP socket (the same shape works under async, fuzzing, or in-memory replay):
use std::{io::Read, net::TcpStream};
use io_smtp::rfc5321::greeting::*;
let mut stream = TcpStream::connect("smtp.example.com:25").unwrap();
let mut buf = [0u8; 4 * 1024];
let mut coroutine = GetSmtpGreeting::new();
let mut arg: Option<&[u8]> = None;
let greeting = loop {
match coroutine.resume(arg.take()) {
GetSmtpGreetingResult::Ok { greeting, .. } => break greeting,
GetSmtpGreetingResult::WantsRead => {
let n = stream.read(&mut buf).unwrap();
arg = Some(&buf[..n]);
}
GetSmtpGreetingResult::Err(err) => panic!("{err}"),
}
};
println!("{greeting:?}");Drive a multi-step command (EHLO) the same way:
use std::{io::{Read, Write}, net::TcpStream};
use io_smtp::rfc5321::{
ehlo::*,
types::{domain::Domain, ehlo_domain::EhloDomain},
};
# let mut stream = TcpStream::connect("smtp.example.com:25").unwrap();
# let mut buf = [0u8; 4 * 1024];
let domain: EhloDomain<'_> = Domain::parse(b"localhost").unwrap().into();
let mut coroutine = SmtpEhlo::new(domain);
let mut arg: Option<&[u8]> = None;
let capabilities = loop {
match coroutine.resume(arg.take()) {
SmtpEhloResult::Ok { capabilities, .. } => break capabilities,
SmtpEhloResult::WantsRead => {
let n = stream.read(&mut buf).unwrap();
arg = Some(&buf[..n]);
}
SmtpEhloResult::WantsWrite(bytes) => {
stream.write_all(&bytes).unwrap();
arg = None;
}
SmtpEhloResult::Err(err) => panic!("{err}"),
}
};
for line in capabilities {
println!("{line}");
}Enable the client feature. SmtpClientStd::new(stream) wraps any blocking Read + Write and exposes one method per SMTP command. You still open the TCP socket, run TLS / STARTTLS yourself, and hand over a ready-to-talk stream; the client takes it from there.
[dependencies]
io-smtp = { version = "0.0.1", default-features = false, features = ["client"] }use std::net::TcpStream;
use io_smtp::{
client::SmtpClientStd,
rfc5321::types::{domain::Domain, ehlo_domain::EhloDomain},
};
let stream = TcpStream::connect("smtp.example.com:25")?;
let mut client = SmtpClientStd::new(stream);
let (greeting, _) = client.greeting()?;
println!("server greeting: {greeting:?}");
let domain: EhloDomain<'_> = Domain::parse(b"localhost")?.into();
let (capabilities, _) = client.ehlo(domain)?;
for line in capabilities {
println!("{line}");
}Enable one of the TLS feature flags: rustls-ring (default), rustls-aws, or native-tls. SmtpClientStd::connect(url, tls, starttls, domain, sasl) opens smtp:// (plain TCP) or smtps:// (implicit TLS) via pimalaya/stream, reads the greeting, sends the initial EHLO, drives the optional STARTTLS upgrade plus a fresh EHLO over TLS, then runs the chosen SASL mechanism, returning a ready-to-use authenticated client.
[dependencies]
io-smtp = "0.0.1" # rustls-ring is enabled by defaultuse io_smtp::{
client::SmtpClientStd,
rfc5321::types::{
domain::Domain, ehlo_domain::EhloDomain,
forward_path::ForwardPath, reverse_path::ReversePath,
},
};
use pimalaya_stream::{sasl::SaslPlain, tls::Tls};
use secrecy::SecretString;
use url::Url;
let url = Url::parse("smtps://smtp.example.com")?;
let tls = Tls::default();
let domain: EhloDomain<'_> = Domain::parse(b"localhost")?.into();
let sasl = SaslPlain {
authzid: None,
authcid: "alice@example.com".into(),
passwd: SecretString::from("hunter2".to_owned()),
};
let mut client = SmtpClientStd::connect(&url, &tls, false, domain, Some(sasl))?;
// session is already authenticated; send a message
let from: ReversePath = "<alice@example.com>".parse()?;
let to: ForwardPath = "<bob@example.com>".parse()?;
let message =
b"From: alice@example.com\r\nTo: bob@example.com\r\nSubject: Test\r\n\r\nHello!".to_vec();
client.send(from, [to], message)?;
client.quit()?;The sasl argument is Option<impl Into<Sasl>>, so any of the per-mechanism structs (SaslLogin, SaslPlain, SaslOauthbearer, SaslScramSha256 behind the scram feature) can be passed in Some(...) directly without wrapping in a Sasl variant. SaslAnonymous and SaslXoauth2 are not supported by SMTP.
See complete examples at ./examples.
Have a look at projects built on top of this library:
- himalaya: CLI to manage emails
This project is licensed under either of:
at your option.
- Chat on Matrix
- News on Mastodon or RSS
- Mail at pimalaya.org@posteo.net
Special thanks to the NLnet foundation and the European Commission that have been financially supporting the project for years:
- 2022 → 2023: NGI Assure
- 2023 → 2024: NGI Zero Entrust
- 2024 → 2026: NGI Zero Core
- 2027 in preparation…
If you appreciate the project, feel free to donate using one of the following providers: