| SMTP (lettre) | REST API (reqwest) | |
|---|---|---|
| Best for | Existing code that already sends over SMTP | New code, retries, delivery status |
| Credential | SMTP credential (gmsmtp_…) |
API key (gm_…) |
| Response | 250 from the server |
Message ID (msg_…) and status, structured errors |
| Idempotency | X-Groundmail-Idempotency-Key header |
Idempotency-Key header |
Both use the same verified domains, suppressions, limits and webhooks.
Before you start
- Add and verify your sending domain. To test before DNS is set up, send from
noreply@mail.groundmail.devto your own account email. - Create the credential you need at app.groundmail.dev: an SMTP credential or an API key.
- Keep it out of source control. Read it from an environment variable.
Send with SMTP and lettre
lettre is Rust's most widely used email library.
cargo add lettre@0.11 --no-default-features --features builder,hostname,smtp-transport,tokio1,tokio1-rustls-tls,ring,rustls-native-certs
cargo add tokio --features macros,rt-multi-thread| Setting | Value |
|---|---|
| Host | smtp.groundmail.dev |
| Port | 587 |
| Security | STARTTLS |
| Username | groundmail |
| Password | Your SMTP credential (gmsmtp_…) |
use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let from: Mailbox = "Acme <billing@yourdomain.com>".parse()?;
let to: Mailbox = "user@example.com".parse()?;
let email = Message::builder()
.from(from)
.to(to)
.subject("Your receipt")
.multipart(
MultiPart::alternative()
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body("Thanks for your order.".to_owned()))
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body("<p>Thanks for your order.</p>".to_owned())),
)?;
let password = std::env::var("GROUNDMAIL_SMTP_PASSWORD")?;
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay("smtp.groundmail.dev")?
.port(587)
.credentials(Credentials::new("groundmail".to_owned(), password))
.build();
mailer.send(email).await?;
Ok(())
}To send many messages, reuse one transport: it keeps a pool of connections.
Send with reqwest and the REST API
cargo add reqwest --no-default-features --features json,rustls-tls
cargo add serde_json
cargo add tokio --features macros,rt-multi-threaduse serde_json::{json, Value};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("GROUNDMAIL_API_KEY")?;
let response = reqwest::Client::new()
.post("https://api.groundmail.dev/emails")
.bearer_auth(api_key)
.header("Idempotency-Key", "receipt-order-1042")
.json(&json!({
"from": "Acme <billing@yourdomain.com>",
"to": "user@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
}))
.send()
.await?;
let status = response.status();
let body: Value = response.json().await?;
if !status.is_success() {
return Err(format!("{status}: {}", body["error"]["message"]).into());
}
println!("{} {}", body["id"], body["status"]);
Ok(())
}Retrying with the same Idempotency-Key returns the first result instead of sending twice. See the Send email API for every field.