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("[email protected]", "password")
yag.send("[email protected]", "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"] = "[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)
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: "[email protected]", pass: "password" },
})
await transporter.sendMail({
from: "[email protected]",
to: "[email protected]",
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("[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();
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": "[email protected]",
"from": "[email protected]",
"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": "[email protected]",
"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. ). Cap exhaustivo + 3 ejemplos runnable contra MailHog en la cada vez que cerramos una mini-tanda grande. La próxima cubrirá una feature concreta detectada durante fitzwatch.
SOCIAL SHARE CARD GENERATOR