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("[email protected]", "password")
yag.send("[email protected]", "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"] = "[email protected]"
msg["To"] = "[email protected]"
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("[email protected]", "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: "[email protected]", pass: "password" },
})
await transporter.sendMail({
from: "[email protected]",
to: "[email protected]",
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("[email protected]".into(), "password".into());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay("smtp.gmail.com")
.unwrap()
.port(587)
.credentials(creds)
.build();
let email = Message::builder()
.from("[email protected]".parse::<Mailbox>().unwrap())
.to("[email protected]".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": "[email protected]",
"from": "[email protected]",
"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": "[email protected]",
"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. ). Exhaustive chapter + 3 runnable examples against MailHog in the every time we close a major mini-release. The next one will cover a concrete feature detected during fitzwatch development.
SOCIAL SHARE CARD GENERATOR