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 betweenfitz runandfitz build. Nopip install yagmail/npm install nodemailer/cargo add lettre/ MavenJavaMail.
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.
-
smtpis a language builtin. Nopip install/npm install/cargo add. -
body_text+body_htmltogether → automatic multipart/alternative. -
.await?propagates errors asResult::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
attachmentskey 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.sendfails, the caller decides what to do. The canonical pattern for production is outbox table +@cronwith 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.
Top comments (0)