Skip to content

pimalaya/io-smtp

I/O SMTP Documentation Matrix Mastodon

SMTP client library, written in Rust

Table of contents

Features

  • I/O-free coroutines: every SMTP command is exposed as a resume(arg: Option<&[u8]>) state machine. No sockets, no async runtime, no std required. Drive against any blocking, async, or fuzz harness.
  • Standard, blocking client:
    • Light client (requires client feature): SmtpClientStd::new(stream) wraps a connected Read + Write stream and exposes one method per coroutine. You still own TCP / TLS / STARTTLS.
    • Full std client (requires rustls-ring, rustls-aws, or native-tls feature): SmtpClientStd::connect(url, tls, starttls, domain, sasl) opens smtp:// / 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.
  • SASL mechanisms:
    • LOGIN, PLAIN, ANONYMOUS, XOAUTH2 and OAUTHBEARER built-in
    • SCRAM-SHA-256 (requires scram feature)

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.

RFC coverage

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)

Examples

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. Pass Some(&[]) to signal EOF.
  • WantsWrite(Vec<u8>): caller writes these bytes to the socket. The next call typically passes None.
  • Ok { … } (or unit Ok): terminal success.
  • Err(…): terminal failure.

As a no-std coroutine library

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}");
}

As a light std client (BYO stream)

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}");
}

As a full std client (TCP + TLS)

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 default
use 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.

More examples

Have a look at projects built on top of this library:

License

This project is licensed under either of:

at your option.

Social

Sponsoring

nlnet

Special thanks to the NLnet foundation and the European Commission that have been financially supporting the project for years:

If you appreciate the project, feel free to donate using one of the following providers:

GitHub Ko-fi Buy Me a Coffee Liberapay thanks.dev PayPal