Skip to content

pimalaya/io-email

I/O Email Documentation Matrix Mastodon

Email client library, written in Rust.

This library is composed of 2 feature-gated layers:

  • Low-level I/O-free coroutines: these no_std-compatible state machines wrap the underlying io-imap, io-jmap, io-maildir, io-m2dir and io-smtp coroutines and surface a shared least-common-denominator type on completion
  • Mid-level std client: a standard, blocking unified client that dispatches the shared API across every registered backend

Table of contents

Features

  • Shared LCD types: Mailbox, Envelope, Address, Flag that fit IMAP, JMAP, Maildir, m2dir and SMTP.
  • I/O-free coroutines: no_std state machines per (backend, operation), wrapping the underlying io-* coroutine and producing a shared type on completion.
  • Unified std client (client feature): blocking dispatcher that routes shared-API calls to the highest-priority registered backend (Maildir → m2dir → JMAP → IMAP for storage, JMAP → SMTP for send).
  • TLS for the network backends (gated by the same rustls-ring / rustls-aws / native-tls features as the underlying io-* crates).
  • Optional search DSL (search feature) and serde round-trip on every shared type (serde feature).

Tip

I/O Email 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.

Backend coverage

Operation IMAP JMAP Maildir m2dir SMTP
list_mailboxes yes yes yes yes
create_mailbox yes yes yes yes
delete_mailbox yes yes yes yes
diff_mailboxes yes
list_envelopes yes yes yes yes
search_envelopes (feature search) yes yes yes yes
diff_envelopes yes yes
watch_mailbox yes yes
get_message yes yes yes yes
add_message yes yes yes yes
copy_messages yes yes yes yes
move_messages yes yes yes yes
delete_message yes yes yes yes
store_flags yes yes yes yes
send_message yes yes

Usage

I/O Email can be consumed two 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 shared-API coroutine implements the backend trait of the protocol it targets (ImapCoroutine, JmapCoroutine, MaildirCoroutine, M2dirCoroutine, SmtpCoroutine). The resume(...) method returns the matching <Backend>CoroutineState<Yield, Return> with two variants:

  • Yielded(Y): intermediate. Y is the backend's standard <Backend>Yield (WantsRead / WantsWrite for the network backends, WantsDirRead / WantsFileCreate / WantsRename etc. for the filesystem ones), plus a dedicated Event(WatchEvent) variant on watch coroutines.
  • Complete(R): terminal. By convention R = Result<Output, Error> carrying the operation's final value typed against the shared Mailbox / Envelope / shared payload.

The std client owns the resume loop for you; the I/O-free mode hands it back so you can drive the same coroutine under any blocking, async, or fuzz harness.

Coroutines

No client feature required: every wrapper lives under <domain>::<protocol>::<op> (for example mailbox::imap::list::ImapMailboxList, message::jmap::add::JmapMessageAdd) and is built straight from the shared inputs. You own the loop and the syscalls; the library only produces operations and consumes their results.

Create a fresh Maildir mailbox against a blocking caller (the same shape works under async or in-memory replay):

use std::fs;

use io_email::mailbox::maildir::create::MaildirMailboxCreate;
use io_maildir::{
    coroutine::*,
    path::{FsPath, MaildirPath},
    store::MaildirStore,
};

let store = MaildirStore { root: FsPath::new("/path/to/root"), maildirpp: false };

let mut coroutine = MaildirMailboxCreate::new(&store, "Archive").unwrap();
let mut arg: Option<MaildirReply> = None;

loop {
    match coroutine.resume(arg.take()) {
        MaildirCoroutineState::Complete(Ok(())) => break,
        MaildirCoroutineState::Complete(Err(err)) => panic!("{err}"),
        MaildirCoroutineState::Yielded(MaildirYield::WantsDirCreate(paths)) => {
            for path in paths {
                fs::create_dir_all(path.as_str()).unwrap();
            }
            arg = Some(MaildirReply::DirCreate);
        }
        MaildirCoroutineState::Yielded(other) => unreachable!("unexpected {other:?}"),
    }
}

println!("created Maildir mailbox Archive");

Network backends follow the same pattern but yield WantsRead / WantsWrite(Vec<u8>) instead; see io-imap, io-jmap and io-smtp for the full TCP / TLS / authentication setup that authenticates the stream before the wrapper coroutine runs.

Std client

Enable the client feature (pulled in by every backend feature) and at least one backend. EmailClientStd::new() starts empty; with_<protocol>(client) plugs in an already-built per-protocol client, while connect_<protocol>(url, tls, ...) opens the connection through the underlying io-* crate and fills the slot in one shot.

[dependencies]
io-email = "0.1.0"
use io_email::{client::EmailClientStd, maildir::client::MaildirClient};
use pimalaya_stream::{sasl::SaslLogin, tls::Tls};
use secrecy::SecretString;
use url::Url;

let url = Url::parse("imaps://imap.example.com").unwrap();
let tls = Tls::default();
let sasl = SaslLogin {
    username: "alice@example.com".into(),
    password: SecretString::from("hunter2".to_owned()),
};

let mut client = EmailClientStd::new()
    .with_maildir(MaildirClient::new("/home/alice/Maildir"))
    .connect_imap(&url, &tls, false, Some(sasl), None)
    .unwrap();

for mbox in client.list_mailboxes(/* with_counts */ true).unwrap() {
    println!("{}: total={:?} unread={:?}", mbox.name, mbox.total, mbox.unread);
}

Dispatch priority on storage reads walks the registered backends Maildir → m2dir → JMAP → IMAP (local before network, cheap before expensive); send routes JMAP → SMTP. Pick which slots to fill based on the workload (local-first sync vs network-first transactional client).

Examples

See complete examples at ./examples.

Have also a look at real-world projects built on top of this library:

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: 06/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