Email plugin
SMTP + template-driven transactional email with file attachments.
umbral-email ships a single send(&EmailMessage) call backed by a
configured SMTP transport. The dev default is a console backend that
prints messages to stderr instead of opening connections - password
reset and welcome flows work on a fresh cargo run without anyone
wiring credentials.
Quickstart
use umbral::prelude::*;use umbral_email::{EmailMessage, EmailPlugin, send}; let app = App::builder() .plugin(EmailPlugin::default()) .build()?; // Anywhere a handler runs:let msg = EmailMessage::new("Welcome to Acme", vec!["alice@example.com".into()]) .from("noreply@acme.test") .text_body("Glad to have you."); send(&msg).await?;The plugin's job is to read settings, install a OnceLock<Backend>,
and forward send calls through it. The plugin has no routes or
models.
Backends
| Setting | Effect |
|---|---|
email_smtp_host unset | Console backend (default). Messages render to stderr; no network. |
email_smtp_host set | SMTP backend. Connects to the configured relay with STARTTLS. |
email_api_provider + email_api_key set | HTTP API backend (requires the api cargo feature). POSTs JSON to Resend / SendGrid. |
UMBRAL_EMAIL_BACKEND=api | Forces the HTTP API backend (still needs email_api_provider + email_api_key). |
UMBRAL_EMAIL_BACKEND=console | Forces the console backend even when SMTP / API keys exist. Useful in CI / tests. |
Selection order: UMBRAL_EMAIL_BACKEND=console wins above everything (the safety valve). Otherwise: API (env-forced, or both email_api_provider + email_api_key set) → SMTP (email_smtp_host set) → console.
SMTP settings (all read from UMBRAL_<KEY> env vars or umbral.toml):
export UMBRAL_EMAIL_SMTP_HOST=smtp.example.comexport UMBRAL_EMAIL_SMTP_PORT=587export UMBRAL_EMAIL_SMTP_USER=acme@example.comexport UMBRAL_EMAIL_SMTP_PASSWORD=secretexport UMBRAL_EMAIL_DEFAULT_FROM=noreply@acme.testexport UMBRAL_EMAIL_SMTP_TIMEOUT_SECS=10 # optional; default 10 sThe console backend is dev-only. In Environment::Prod (or when settings are absent), send() returns EmailError::ConsoleBackendInProduction instead of printing the message body. Printing full message bodies - including password-reset tokens and magic-link URLs - to stderr would expose secrets to log aggregators.
To fix: set UMBRAL_EMAIL_SMTP_HOST in production. If you intentionally want console output in a staging environment, set UMBRAL_EMAIL_BACKEND=console explicitly and use Environment::Dev or Environment::Test.
SMTP send timeout
The SMTP transport applies a configurable per-send timeout (default: 10 seconds). A hung relay will surface as EmailError::Smtp after that deadline rather than blocking the request indefinitely. Override via email_smtp_timeout_secs in umbral.toml or UMBRAL_EMAIL_SMTP_TIMEOUT_SECS. Set to 0 to remove the cap (not recommended in production).
HTTP API backend (Resend / SendGrid)
For deployments that prefer a transactional-email provider's HTTP API over an SMTP relay, umbral-email ships an API backend behind the optional api cargo feature. It POSTs the message as JSON over HTTPS - no SMTP connection, no relay to operate. It complements SMTP rather than replacing it; pick whichever your provider exposes.
Enable the feature in your app's Cargo.toml:
umbral-email = { version = "0.0.1", features = ["api"] }Then configure the provider and key:
export UMBRAL_EMAIL_API_PROVIDER=resend # or: sendgridexport UMBRAL_EMAIL_API_KEY=re_xxxxxxxxxxxx # the provider's bearer tokenexport UMBRAL_EMAIL_DEFAULT_FROM=noreply@acme.testWith both email_api_provider and email_api_key set, the API backend is selected automatically (or force it with UMBRAL_EMAIL_BACKEND=api). The send(&EmailMessage) call is unchanged - the same message you'd send over SMTP goes through the API path:
let msg = EmailMessage::new("Welcome to Acme", vec!["alice@example.com".into()]) .from("noreply@acme.test") // or rely on email_default_from .text_body("Glad to have you.") .html_body("<p>Glad to have you.</p>"); send(&msg).await?;| Provider | Endpoint | Body shape |
|---|---|---|
| Resend | POST https://api.resend.com/emails | { from, to: [..], subject, html?, text? } |
| SendGrid | POST https://api.sendgrid.com/v3/mail/send | { personalizations: [{ to: [{email}] }], from: {email}, subject, content: [{type, value}] } |
Both authenticate with Authorization: Bearer <email_api_key>. The email_default_from fallback applies to the API path too. A non-2xx response surfaces as EmailError::ApiResponse { status, body } (the provider's own error description is preserved in body); a transport failure surfaces as EmailError::ApiTransport.
Attachments over the API ride along as base64 in the JSON body for both providers. The same .attach(filename, content_type, bytes) builder you use for SMTP works on the API path.
Building messages
let msg = EmailMessage::new("Subject", vec!["a@b.test".into()]) .from("sender@acme.test") .add_to("c@d.test") // append another recipient .text_body("plain text") .html_body("<p>HTML</p>") // pair with text_body → multipart/alternative .reply_to("support@acme.test");text_body and html_body compose as multipart/alternative when
both are present so the recipient's client picks one.
Header-injection guard. Before composing the message, the plugin validates
every user-supplied header value (subject, from, reply_to, and each to
address) for bare CR (\r), LF (\n), NUL, and other RFC 5322 control
characters - the classic SMTP / Bcc-injection vector that lettre 0.11 does not
itself reject. A bad value surfaces as EmailError::InvalidHeaderValue { field, offending_char } rather than being silently accepted.
File attachments
Pass raw bytes plus the MIME type. For a file on disk, read it yourself - the plugin doesn't take paths because it doesn't want to guess at sync vs async I/O or content-type detection on your behalf.
use umbral_email::EmailMessage; let pdf = std::fs::read("invoice.pdf")?; let msg = EmailMessage::new("Your invoice", vec!["alice@example.com".into()]) .text_body("Please find your invoice attached.") .attach("invoice.pdf", "application/pdf", pdf); send(&msg).await?;Multiple attachments work - call .attach(...) once per file:
let msg = EmailMessage::new("Quarterly reports", recipients) .text_body("Three reports attached.") .attach("q1.pdf", "application/pdf", q1_pdf) .attach("q2.pdf", "application/pdf", q2_pdf) .attach("q3.pdf", "application/pdf", q3_pdf);When any attachment is present, the message renders as
multipart/mixed. If you also have both text and HTML bodies, the
two-body shape nests inside the mixed envelope:
multipart/mixed├── multipart/alternative│ ├── text/plain│ └── text/html├── attachment 1└── attachment 2Why bytes-only? Path-loading would force the plugin to decide
between sync std::fs::read (blocks the executor) and async
tokio::fs::read (changes the attach signature). Auto content-type
detection would pull in mime_guess. Both are easy enough to do at
the call site that the plugin's API stays narrow. If a real consumer
surfaces a need for either, adding .attach_file(path) is
non-breaking.
Templates
The render_email_body helper renders an umbral template into a
string and maps TemplateError into EmailError. Useful for the
HTML half of a multipart message:
use serde::Serialize;use umbral_email::render_email_body; #[derive(Serialize)]struct WelcomeContext<'a> { name: &'a str } let html = render_email_body( "emails/welcome.html", &WelcomeContext { name: "Alice" },)?; let msg = EmailMessage::new("Welcome", vec!["alice@example.com".into()]) .text_body("Welcome, Alice.") .html_body(html);What's not in v1
- No retry queue. Transient SMTP failures bubble up. Wiring sends
through
umbral-tasksis the natural follow-on. - No inline images (
cid:references from HTML). Use a hosted URL for embedded logos / branding. - No CC / BCC. Single
to:list only. - No S/MIME or DKIM signing. Configure signing at your relay.