This version is in beta. Some features may change before release.

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

Code
rust
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

SettingEffect
email_smtp_host unsetConsole backend (default). Messages render to stderr; no network.
email_smtp_host setSMTP backend. Connects to the configured relay with STARTTLS.
email_api_provider + email_api_key setHTTP API backend (requires the api cargo feature). POSTs JSON to Resend / SendGrid.
UMBRAL_EMAIL_BACKEND=apiForces the HTTP API backend (still needs email_api_provider + email_api_key).
UMBRAL_EMAIL_BACKEND=consoleForces 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):

Code
bash
export UMBRAL_EMAIL_SMTP_HOST=smtp.example.com
export UMBRAL_EMAIL_SMTP_PORT=587
export UMBRAL_EMAIL_SMTP_USER=acme@example.com
export UMBRAL_EMAIL_SMTP_PASSWORD=secret
export UMBRAL_EMAIL_DEFAULT_FROM=noreply@acme.test
export UMBRAL_EMAIL_SMTP_TIMEOUT_SECS=10 # optional; default 10 s
Warning

The 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:

Code
toml
umbral-email = { version = "0.0.1", features = ["api"] }

Then configure the provider and key:

Code
bash
export UMBRAL_EMAIL_API_PROVIDER=resend # or: sendgrid
export UMBRAL_EMAIL_API_KEY=re_xxxxxxxxxxxx # the provider's bearer token
export UMBRAL_EMAIL_DEFAULT_FROM=noreply@acme.test

With 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:

Code
rust
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?;
ProviderEndpointBody shape
ResendPOST https://api.resend.com/emails{ from, to: [..], subject, html?, text? }
SendGridPOST 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.

Info

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.

Warning
Without the `api` feature, `umbral-email` compiles exactly as before (SMTP + console only) and pulls in no HTTP client. Selecting the API backend without the feature enabled returns an error at send time pointing you at `--features api` rather than failing to compile.

Building messages

Code
rust
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.

Info

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.

Code
rust
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:

Code
rust
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:

Code
text
multipart/mixed
├── multipart/alternative
│ ├── text/plain
│ └── text/html
├── attachment 1
└── attachment 2
Info

Why 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:

Code
rust
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-tasks is 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.