Skip to content

pimalaya/io-smtp

I/O SMTP Documentation Matrix Mastodon

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

Table of contents

Features

  • I/O-free coroutines: no_std state machines; no sockets, no async runtime, no std required, drive against any blocking, async, or fuzz harness.
  • Light standard, blocking client (requires client feature)
  • Full standard, blocking client with TLS support:
    • Rustls with ring crypto (requires rustls-ring feature)
    • Rustls with aws crypto (requires rustls-aws feature)
    • Native TLS (requires native-tls feature)
  • SASL mechanisms:
    • ANONYMOUS, LOGIN, PLAIN, XOAUTH2 and OAUTHBEARER built-in
    • SCRAM-SHA-256 (requires scram feature)
  • 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.

RFC coverage

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)

Usage

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 standard SmtpYield (WantsRead / WantsWrite(Vec<u8>)); the caller reads or writes bytes accordingly. Pass Some(&[]) to signal EOF on the next resume.
  • Complete(Return): terminal value. By convention Return = Result<Output, Error> where the ok arm carries the coroutine's final output and the error arm carries the cause. SmtpStartTls uses Result<Vec<u8>, _>: the ok arm's Vec<u8> carries any bytes the coroutine pre-read past the 220 reply (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.

Coroutine

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

Light client

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(())
}

Full client

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.

Examples

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

AI disclosure

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

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