DEV Community

Cover image for SMTP outbound como ciudadano de primera clase: mandá emails sin pip install yagmail
Martin Palopoli
Martin Palopoli

Posted on

SMTP outbound como ciudadano de primera clase: mandá emails sin pip install yagmail

Notificaciones, password resets, magic links, alerts. Todos los lenguajes resuelven SMTP outbound con una librería externa. En Fitz, smtp.send(opts) es builtin del lenguaje. Async desde día uno. Paridad bit-a-bit fitz runfitz build. Sin pip install yagmail / npm install nodemailer / cargo add lettre / Maven JavaMail.

El detalle que se olvida

Cualquier aplicación seria termina necesitando mandar mails:

  • Notificación cuando se cae un servicio (incidents → email al on-call).
  • Password reset / magic-link auth (link único al email del user).
  • Welcome email después de un signup.
  • Reportes diarios / weekly digests despachados desde un cron job.
  • Alertas de jobs nocturnos que fallan.

Y todos los lenguajes lo resuelven con una librería externa.

Detectamos el gap construyendo fitzwatch (status page open-source escrito en Fitz puro): el corazón del producto avisa al on-call cuando un monitor cae. Sin SMTP nativo, la única opción era webhook → n8n/zapier → email. Dos sistemas externos para mandar un email.

Hoy, los 8 sub-bloques de la mini-tanda están en main. Una línea — smtp.send({...}).await? — y el email sale.

El stack típico de Python

pip install yagmail
# o usar smtplib del stdlib (RFC 5321 a mano)
Enter fullscreen mode Exit fullscreen mode
# Con yagmail (sintaxis amigable):
import yagmail

yag = yagmail.SMTP("user@example.com", "password")
yag.send("dest@example.com", "Subject", "Body text")

# Con smtplib stdlib (bajo nivel):
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

Dos opciones, ambas requieren leerte la RFC o aprender la API de yagmail.

El stack típico de JS/Node

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

Una sola lib, bien diseñada, pero npm install agrega ~50 paquetes transitivos al package.json.

El stack típico de Rust

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

Funcional, pero verboso. 25 líneas para mandar UN mail.

El stack típico de Java

<dependency>
    <groupId>com.sun.mail</groupId>
    <artifactId>jakarta.mail</artifactId>
    <version>2.0.1</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Y después la receta canónica de Properties + Session + MimeMessage + Transport.send(...) que probablemente sumás 40 líneas.

El stack de Fitz

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

Que es esto.

  • smtp es builtin del lenguaje. No hay pip install/npm install/cargo add.
  • body_text + body_html juntos → multipart/alternative automático.
  • .await? propaga errores como Result::Err(Str). El compilador exige manejo.
  • Config (host, port, user, password, TLS) se lee de env vars al primer send. Lo mismo que cualquier app en Kubernetes/Docker espera.

r.delivered, r.message_id y r.duration_ms salen tipados como Bool/Str/Int.

Lo que smtp.send te garantiza

1. Cero deps externas

El binario fitz ya viene con lettre 0.11 linkeado estático adentro. Cuando hacés fitz build, el resultado es UN ejecutable de ~10 MB que tiene cliente SMTP, TLS via rustls, sin openssl en el host destino.

ldd ./mi-app
# linux-vdso.so.1
# libgcc_s.so.1
# libc.so.6
# (nada de smtp libs ni openssl)
Enter fullscreen mode Exit fullscreen mode

2. Paridad bit-a-bit fitz runfitz build

El intérprete (fitz run) y el codegen a Rust (fitz build) emiten el mismo wire SMTP. Mismo handshake, mismo SCRAM auth, mismo Message-ID. Bug en un path = bug en el otro. Tests E2E lo validan en cada commit.

3. Async desde día uno

smtp.send(...) devuelve Future<Result<SmtpResult>>. Integra natural con el resto del stack:

@background
async fn send_welcome(email: Str) -> Null {
    let _ = smtp.send({
        "to": email,
        "subject": "Bienvenido",
        "body": "Hola, gracias por unirte.",
    }).await
    return null
}

@post("/signup")
fn signup(input: SignupInput) {
    // ... crear el user en la DB ...
    let _ = spawn(send_welcome(input.email))
    return 201 { "id": new_user_id }
}
Enter fullscreen mode Exit fullscreen mode

El handler responde 201 al cliente inmediato. El email se manda en otro task tokio. Si el SMTP server tarda 2 segundos, no le importa al cliente — ya respondió hace 2 segundos.

Y con @cron armás digests:

@cron("0 0 9 * * *")  // todos los días 09:00
async fn daily_digest() -> Null {
    let r = smtp.send({
        "to": "team@example.com",
        "subject": "Daily digest",
        "body_html": "<p>Hoy: ...</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> como modelo de error

Errores de transporte (DNS, auth, TLS, server reject) llegan como Result::Err(Str) con prefijo "smtp: ":

match smtp.send(opts).await {
    Ok(r) => log.info("smtp.delivered", message_id: r.message_id),
    Err(e) => {
        // El prefijo te permite clasificar:
        // "smtp: server rejected mail: ..."     → 5xx del server
        // "smtp: transient error: ..."          → 4xx temporario
        // "smtp: client error: ..."             → TLS/auth fail
        // "smtp: invalid `to` address ..."      → parse error
        log.warn("smtp.failed", error: e)
    }
}
Enter fullscreen mode Exit fullscreen mode

El checker estático exige manejo. Si tu fn retorna Result<...>, podés propagar con ?. Si no, te exige match. Cero excepciones runtime.

5. Magic-link auth en 30 líneas

El caso showcase del stack web first-class de Fitz: HTTP server-side + auth con jwt.encode + SMTP outbound, todo combinado en un binario.

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": "Tu link de login",
        "body_text": "Hacé click acá:\n{link}\n\nExpira en 5 min.",
        "body_html": "<p>Hacé click <a href=\"{link}\">acá</a>.</p>",
    }).await?
    return Ok(r.message_id)
}
Enter fullscreen mode Exit fullscreen mode

Sin Auth0, sin Supabase, sin Stripe webhooks. Un binario, un puerto, un comando para deployarlo.

Setup local con MailHog

Para dev no querés mandar mails reales. MailHog es un SMTP server fake con UI web que retiene los mails sin reenviar:

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
# Después abrís http://localhost:8025 en el browser.
Enter fullscreen mode Exit fullscreen mode

En producción cambiás las env vars a Gmail/SES/SendGrid/Mailgun/lo que sea — el código de Fitz no cambia.

Trade-offs honestos (MVP)

  • Sin attachments todavía. La key attachments se rechaza con error claro. Workaround: bundle attachments via S3/object storage + link en el body. Lo más limpio para producción serio igual.
  • Sin smtp.configure(...) programático. Config sale solo de env vars hoy. Si necesitás multi-tenant con un SMTP por tenant, te queda como deuda post-MVP.
  • Sin retry built-in. Si smtp.send falla, el caller decide qué hacer. El patrón canónico para producción es tabla de outbox + @cron con retry.

Esto NO es FUD — son decisiones explícitas del MVP. Los 3 son refinables si entra demanda real.

Cierre

smtp.send no es "una librería más fácil". Es el mismo modelo mental que ya usás en el resto de Fitz: Result<T> como retorno, ? para propagar, .await para async, @background + spawn para fire-and-forget.

Si ya sabés cómo encadenar http.get(...).await? o db.query(...).await?, ya sabés cómo mandar mails. Cero API nuevo que aprender.

Y cuando fitzwatch reanude, la línea que tenía pendiente para notificar incidents pasa de "webhook a n8n + traducción a SMTP" a smtp.send({...}).await?. Una línea. Eso es Fitz.


Probalo: fitz versión 0.18.0+ (ya en GitHub releases). Cap exhaustivo + 3 ejemplos runnable contra MailHog en la sub-sección "SMTP outbound" del cap 17 de la guía.

Mantenete al día: la serie Fitz se publica en dev.to cada vez que cerramos una mini-tanda grande. La próxima cubrirá una feature concreta detectada durante fitzwatch.

Top comments (0)