DEV Community

Cover image for SMTP outbound as a first-class citizen: send emails without pip install yagmail
Martin Palopoli
Martin Palopoli

Posted on

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)
Enter fullscreen mode Exit fullscreen mode
# 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)
Enter fullscreen mode Exit fullscreen mode

Two options, both require reading the RFC or learning the yagmail API.

The typical JS/Node stack

npm install nodemailer
Enter fullscreen mode Exit fullscreen mode
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>",
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

2. Bit-for-bit parity fitz runfitz 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 }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)