SMTP outbound as a first-class citizen: send emails without pip install yagmail
> Notifications, password resets, magic links, alerts. Every language solves SMTP outbound with an external library. In Fitz, `smtp.send(opts)` is a language builtin. Async from day one. Bit-for-bit parity between `fitz run` and `fitz build`. No `pip install yagmail` / `npm install nodemailer` / `cargo add lettre` / Maven `JavaMail`.
## The detail that gets forgotten
Any serious application eventually needs to send emails:
* Notifications when a service goes down (incidents → on-call email).
* Password reset / magic-link auth (unique link to the user's email).
* Welcome email after a signup.
* Daily / weekly digest reports dispatched from a cron job.
* Alerts when a nightly job fails.
And every language solves this with an external library.
We hit the gap while building **fitzwatch** (open-source status page written in pure Fitz): the heart of the product alerts on-call when a monitor goes down. Without native SMTP, the only option was webhook → n8n/zapier → email. Two external systems just to send one email.
Today, the 8 sub-blocks of the mini-release are in `main`. One line — `smtp.send({...}).await?` — and the email goes out.
## The typical Python stack
pip install yagmail
# or use stdlib smtplib (RFC 5321 by hand)
# With yagmail (friendly syntax):
import yagmail
yag = yagmail.SMTP("user@example.com", "password")
yag.send("dest@example.com", "Subject", "Body text")
# With stdlib smtplib (low-level):
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart("alternative")
msg["Subject"] = "Subject"
msg["From"] = "user@example.com"
msg["To"] = "dest@example.com"
msg.attach(MIMEText("Body text", "plain"))
msg.attach(MIMEText("<p>Body HTML</p>", "html"))
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login("user@example.com", "password")
server.send_message(msg)
Two options, both require reading the RFC or learning the yagmail API.
## The typical JS/Node stack
npm install nodemailer
import nodemailer from "nodemailer"
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: { user: "user@example.com", pass: "password" },
})
await transporter.sendMail({
from: "user@example.com",
to: "dest@example.com",
subject: "Subject",
text: "Body text",
html: "<p>Body HTML</p>",
})
One library, well-designed, but `npm install` brings ~50 transitive packages to `package.json`.
## The typical Rust stack
cargo add lettre
cargo add tokio --features full
use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let creds = Credentials::new("user@example.com".into(), "password".into());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay("smtp.gmail.com")
.unwrap()
.port(587)
.credentials(creds)
.build();
let email = Message::builder()
.from("user@example.com".parse::<Mailbox>().unwrap())
.to("dest@example.com".parse::<Mailbox>().unwrap())
.subject("Subject")
.multipart(MultiPart::alternative()
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body("Body text".to_string()))
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body("<p>Body HTML</p>".to_string()))
)
.unwrap();
mailer.send(email).await.unwrap();
Functional, but verbose. 25 lines to send ONE email.
## The typical Java stack
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
And then the canonical recipe of `Properties` + `Session` + `MimeMessage` + `Transport.send(...)` that probably adds 40 lines.
## The Fitz stack
let r = smtp.send({
"to": "dest@example.com",
"from": "user@example.com",
"subject": "Subject",
"body_text": "Body text",
"body_html": "<p>Body HTML</p>",
}).await?
That's it.
* `smtp` is a language builtin. No `pip install`/`npm install`/`cargo add`.
* `body_text` + `body_html` together → automatic multipart/alternative.
* `.await?` propagates errors as `Result::Err(Str)`. The compiler enforces handling.
* Config (host, port, user, password, TLS) is read from env vars at first send. Same convention any app in Kubernetes/Docker expects.
`r.delivered`, `r.message_id`, and `r.duration_ms` come out typed as `Bool`/`Str`/`Int`.
## What `smtp.send` guarantees you
### 1. Zero external deps
The `fitz` binary ships with `lettre 0.11` statically linked inside. When you `fitz build`, the result is ONE executable of ~10 MB containing the SMTP client, TLS via rustls, no openssl on the target host.
ldd ./my-app
# linux-vdso.so.1
# libgcc_s.so.1
# libc.so.6
# (no smtp libs, no openssl)
### 2. Bit-for-bit parity `fitz run` ↔ `fitz build`
The interpreter (`fitz run`) and codegen to Rust (`fitz build`) emit the same wire SMTP. Same handshake, same SCRAM auth, same Message-ID. Bug in one path = bug in the other. E2E tests validate this on every commit.
### 3. Async from day one
`smtp.send(...)` returns `Future<Result<SmtpResult>>`. Integrates naturally with the rest of the stack:
@background
async fn send_welcome(email: Str) -> Null {
let _ = smtp.send({
"to": email,
"subject": "Welcome",
"body": "Hi, thanks for joining.",
}).await
return null
}
@post("/signup")
fn signup(input: SignupInput) {
// ... create the user in the DB ...
let _ = spawn(send_welcome(input.email))
return 201 { "id": new_user_id }
}
The handler returns 201 to the client **immediately**. The email goes out in another tokio task. If the SMTP server takes 2 seconds, the client doesn't care — it already responded 2 seconds ago.
And with `@cron` you build digests:
@cron("0 0 9 * * *") // every day at 09:00
async fn daily_digest() -> Null {
let r = smtp.send({
"to": "team@example.com",
"subject": "Daily digest",
"body_html": "<p>Today: ...</p>",
}).await
match r {
Ok(_) => log.info("digest.sent"),
Err(e) => log.error("digest.failed", error: e),
}
return null
}
### 4. `Result<T>` as error model
Transport errors (DNS, auth, TLS, server reject) come as `Result::Err(Str)` with `"smtp: "` prefix:
match smtp.send(opts).await {
Ok(r) => log.info("smtp.delivered", message_id: r.message_id),
Err(e) => {
// The prefix lets you classify:
// "smtp: server rejected mail: ..." → 5xx from server
// "smtp: transient error: ..." → 4xx temporary
// "smtp: client error: ..." → TLS/auth fail
// "smtp: invalid `to` address ..." → parse error
log.warn("smtp.failed", error: e)
}
}
The static checker enforces handling. If your fn returns `Result<...>`, you can propagate with `?`. If not, the checker demands `match`. Zero runtime exceptions.
### 5. Magic-link auth in 30 lines
The showcase case of Fitz's first-class web stack: HTTP server-side + auth with `jwt.encode` + SMTP outbound, all combined in one binary.
type EmailRequest {
email: Str
}
@post("/auth/magic-link")
async fn magic_link(input: EmailRequest) -> Result<Str> {
let payload = { "email": input.email }
let token = jwt.encode(payload, "secret")
let link = "https://app.example.com/verify?t={token}"
let r = smtp.send({
"to": input.email,
"subject": "Your login link",
"body_text": "Click here:\n{link}\n\nExpires in 5 min.",
"body_html": "<p>Click <a href=\"{link}\">here</a>.</p>",
}).await?
return Ok(r.message_id)
}
No Auth0, no Supabase, no Stripe webhooks. One binary, one port, one command to deploy.
## Local setup with MailHog
For dev you don't want to send real emails. MailHog is a fake SMTP server with a web UI that retains emails without forwarding:
docker run -d --name mailhog -p 1025:1025 -p 8025:8025 mailhog/mailhog
export SMTP_HOST=localhost
export SMTP_PORT=1025
export SMTP_TLS=none
fitz run app.fitz
# Then open http://localhost:8025 in the browser.
In production you change the env vars to Gmail/SES/SendGrid/Mailgun/whatever — your Fitz code doesn't change.
## Honest trade-offs (MVP)
* **No attachments yet**. The `attachments` key is rejected with a clear error. Workaround: bundle attachments via S3/object storage + link in the body. The cleanest pattern for serious production anyway.
* **No programmatic`smtp.configure(...)`**. Config only comes from env vars today. If you need multi-tenant with a different SMTP per tenant, that's pending as post-MVP debt.
* **No built-in retry**. If `smtp.send` fails, the caller decides what to do. The canonical pattern for production is outbox table + `@cron` with retry.
This is NOT FUD — these are explicit MVP decisions. All 3 are refinable if real demand shows up.
## Closing
`smtp.send` isn't "another easier library". It's **the same mental model** you already use elsewhere in Fitz: `Result<T>` as return type, `?` to propagate, `.await` for async, `@background + spawn` for fire-and-forget.
If you already know how to chain `http.get(...).await?` or `db.query(...).await?`, you already know how to send emails. Zero new API to learn.
And when fitzwatch resumes, the line that was pending to notify incidents goes from "webhook to n8n + translation to SMTP" to `smtp.send({...}).await?`. One line. That's Fitz.
**Try it** : `fitz` version `0.18.0+` (already in GitHub releases). Exhaustive chapter + 3 runnable examples against MailHog in the "SMTP outbound" subsection of chapter 17 of the guide.
**Stay tuned** : the Fitz series publishes on dev.to every time we close a major mini-release. The next one will cover a concrete feature detected during fitzwatch development.