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-bitfitz run↔fitz build. Sinpip install yagmail/npm install nodemailer/cargo add lettre/ MavenJavaMail.
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)
# 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)
Dos opciones, ambas requieren leerte la RFC o aprender la API de yagmail.
El stack típico de JS/Node
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>",
})
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
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();
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>
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?
Que es esto.
-
smtpes builtin del lenguaje. No haypip install/npm install/cargo add. -
body_text+body_htmljuntos → multipart/alternative automático. -
.await?propaga errores comoResult::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)
2. Paridad bit-a-bit fitz run ↔ fitz 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 }
}
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
}
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)
}
}
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)
}
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.
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
attachmentsse 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.sendfalla, el caller decide qué hacer. El patrón canónico para producción es tabla de outbox +@croncon 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)