SMTP client library, written in Rust
This library is composed of 3 feature-gated layers:
- Low-level I/O-free coroutines: these
no_std-compatible state machines contain the whole SMTP logic and can be used anywhere - Mid-level light client: a standard, blocking SMTP client using a
Stream: Read + Write - High-level full client: light client + TCP connections and TLS negotiations handled for you
- I/O-free coroutines:
no_stdstate machines; no sockets, no async runtime, nostdrequired, drive against any blocking, async, or fuzz harness. - Light standard, blocking client (requires
clientfeature) - Full standard, blocking client with TLS support:
- Rustls with ring crypto (requires
rustls-ringfeature) - Rustls with aws crypto (requires
rustls-awsfeature) - Native TLS (requires
native-tlsfeature)
- Rustls with ring crypto (requires
- SASL mechanisms:
ANONYMOUS,LOGIN,PLAIN,XOAUTH2andOAUTHBEARERbuilt-inSCRAM-SHA-256(requiresscramfeature)
- SMTP extensions:
STARTTLS,AUTH,SIZE,DSN,ENHANCEDSTATUSCODES(see RFC coverage)
Tip
I/O SMTP is written in Rust and uses cargo features to gate backend support. The default feature set is declared in Cargo.toml or on docs.rs.
| Module | What it covers |
|---|---|
| 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 |
| 4954 | AUTH: SASL exchange protocol (verb, command, continuation data) |
| 5321 | SMTP: greeting, EHLO, HELO, MAIL FROM, RCPT TO, DATA, NOOP, RSET, QUIT |
| 7628 | OAUTHBEARER: OAuth 2.0 bearer token SASL mechanism |
| 7677 | SCRAM-SHA-256: SASL SCRAM-SHA-256 mechanism (feature scram) |
sasl::auth_anonymous (4505) |
ANONYMOUS: SASL ANONYMOUS mechanism |
sasl::auth_login |
LOGIN: legacy de-facto AUTH mechanism (no RFC) |
sasl::auth_plain (4616) |
PLAIN: SASL PLAIN authentication mechanism |
sasl::auth_xoauth2 |
XOAUTH2: Google's pre-standard OAuth 2.0 SASL mechanism (no RFC) |
I/O-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 implements the SmtpCoroutine trait. Its resume(Option<&[u8]>) method returns SmtpCoroutineState<Yield, Return> with two shapes:
Yielded(Yield): intermediate progress. Every coroutine in this crate picks the standardSmtpYield(WantsRead/WantsWrite(Vec<u8>)); the caller reads or writes bytes accordingly. PassSome(&[])to signal EOF on the next resume.Complete(Return): terminal value. By conventionReturn = Result<Output, Error>where the ok arm carries the coroutine's final output and the error arm carries the cause.SmtpStartTlsusesResult<Vec<u8>, _>: the ok arm'sVec<u8>carries any bytes the coroutine pre-read past the220reply (a non-empty value signals STARTTLS-injection per RFC 3207 §6).
Each higher-level coroutine internally delegates to a shared SendSmtpCommand<Cmd> base coroutine (in crate::send) that owns the serialise → write → read → parse loop.
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::{coroutine::*, rfc5321::greeting::*};
let mut stream = TcpStream::connect("smtp.example.com:25").unwrap();
let mut buf = [0u8; 16 * 1024];
let mut coroutine = SmtpGreetingGet::new();
let mut arg: Option<&[u8]> = None;
let greeting = loop {
match coroutine.resume(arg.take()) {
SmtpCoroutineState::Complete(Ok(greeting)) => break greeting,
SmtpCoroutineState::Complete(Err(err)) => panic!("{err}"),
SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {
let n = stream.read(&mut buf).unwrap();
arg = Some(&buf[..n]);
}
SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(_)) => unreachable!(),
}
};
println!("{greeting:?}");Drive a multi-step command (EHLO) the same way:
use std::{borrow::Cow, io::{Read, Write}, net::TcpStream};
use io_smtp::{
coroutine::*,
rfc5321::{
ehlo::*,
types::{domain::Domain, ehlo_domain::EhloDomain},
},
};
# let mut stream = TcpStream::connect("smtp.example.com:25").unwrap();
# let mut buf = [0u8; 16 * 1024];
let domain = EhloDomain::Domain(Domain(Cow::Borrowed("localhost")));
let mut coroutine = SmtpEhlo::new(domain);
let mut arg: Option<&[u8]> = None;
let capabilities = loop {
match coroutine.resume(arg.take()) {
SmtpCoroutineState::Complete(Ok(capabilities)) => break capabilities,
SmtpCoroutineState::Complete(Err(err)) => panic!("{err}"),
SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {
let n = stream.read(&mut buf).unwrap();
arg = Some(&buf[..n]);
}
SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => {
stream.write_all(&bytes).unwrap();
arg = None;
}
}
};
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.1.0", default-features = false, features = ["client"] }use std::{borrow::Cow, error::Error, net::TcpStream};
use io_smtp::{
client::SmtpClientStd,
rfc5321::types::{domain::Domain, ehlo_domain::EhloDomain},
};
fn main() -> Result<(), Box<dyn Error>> {
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(Domain(Cow::Borrowed("localhost")));
let capabilities = client.ehlo(domain)?;
for line in capabilities {
println!("{line}");
}
Ok(())
}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 = { version = "0.1.0", default-features = false, features = ["rustls-ring"] }use std::{borrow::Cow, error::Error};
use io_smtp::{
client::SmtpClientStd,
rfc5321::types::{
domain::Domain, ehlo_domain::EhloDomain, forward_path::ForwardPath,
local_part::LocalPart, mailbox::Mailbox, reverse_path::ReversePath,
},
};
use pimalaya_stream::{sasl::SaslPlain, tls::Tls};
use secrecy::SecretString;
use url::Url;
fn main() -> Result<(), Box<dyn Error>> {
let url = Url::parse("smtps://smtp.example.com")?;
let tls = Tls::default();
let domain = EhloDomain::Domain(Domain(Cow::Borrowed("localhost")));
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 alice = Mailbox {
local_part: LocalPart(Cow::Borrowed("alice")),
domain: EhloDomain::Domain(Domain(Cow::Borrowed("example.com"))),
};
let bob = Mailbox {
local_part: LocalPart(Cow::Borrowed("bob")),
domain: EhloDomain::Domain(Domain(Cow::Borrowed("example.com"))),
};
let message =
b"From: alice@example.com\r\nTo: bob@example.com\r\nSubject: Test\r\n\r\nHello!".to_vec();
client.send(ReversePath::Mailbox(alice), [ForwardPath(bob)], message)?;
client.quit()?;
Ok(())
}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 also a look at real-world projects built on top of this library:
- Himalaya CLI: CLI to manage emails
- Himalaya TUI: TUI to manage emails
- Sirup: CLI to spawn pre-authenticated IMAP/SMTP sessions and expose them via Unix sockets
This project is developed with AI assistance. This section documents how, so users and downstream packagers can make informed decisions.
-
Tools: Claude Code (Anthropic), Opus 4.7, invoked locally with a persistent project-scoped memory and a small set of repo-specific rules.
-
Used for: Refactors, mechanical multi-file edits, boilerplate (feature gates, error enums, derive macros, trait impls), test scaffolding, doc polish, exploratory design conversations.
-
Not used for: Engineering, critical code, git manipulation (commit, merge, rebase…), real-world tests.
-
Verification: Every AI-assisted change is read, compiled, tested, and formatted before commit (
nix develop --command cargo check / cargo test / cargo fmt). Behavioural correctness is verified against the relevant RFC or upstream spec, not assumed from the model output. Tests are never adjusted to fit AI-generated code; the code is adjusted to fit correct behaviour. -
Limitations: AI models occasionally produce code that compiles and passes tests but is subtly wrong: off-by-one errors, missed edge cases, plausible but nonexistent APIs, stale RFC references. The verification workflow catches most of this; it does not catch all of it. Bug reports are welcome and taken seriously.
-
Last reviewed: 03/06/2026
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:
