5e6a974a5d
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
16 KiB
id, title, status, type, domain, scope, priority, depends, blocks, related, created, updated, tags
| id | title | status | type | domain | scope | priority | depends | blocks | related | created | updated | tags |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0012 | Email & SMTP | completado | feature | multi-app | media | 2026-05-17 | 2026-05-17 |
0012 — Email & SMTP
Metadata
| Campo | Valor |
|---|---|
| ID | 0012 |
| Estado | pendiente |
| Prioridad | media |
| Tipo | feature |
Dependencias
Ninguna.
Objetivo
Crear funciones reutilizables de envio de email via SMTP en Go y Python (dominio infra), separando la construccion del mensaje (pure) del transporte (impure). Cualquier app del registry que necesite enviar notificaciones, alertas o reportes puede componer estas primitivas sin reimplementar SMTP desde cero.
Contexto
- Actualmente existen CERO funciones de email en el registry. Busqueda FTS5 sobre
emailysmtpsolo arroja coincidencias incidentales en funciones de Metabase (auth con email/password) y normalizacion de entidades OSINT. - El tipo
email_go_coreexiste pero es un tipo de cybersecurity (direccion de email como entidad de interes), no un mensaje de correo. - Casi toda app no trivial termina necesitando enviar notificaciones: alertas del bucle reactivo cuando assertions fallan, reportes periodicos de ejecuciones, confirmaciones de deploy, etc.
- Go stdlib tiene
net/smtpycrypto/tls— suficiente para SMTP con TLS/STARTTLS sin dependencias externas. Python tienesmtplibyemailen stdlib. - El patron pure core / impure shell aplica naturalmente: construir el mensaje es puro, enviarlo es impuro.
Arquitectura
functions/infra/
├── smtp_connect.go — NEW: conexion SMTP con TLS/STARTTLS
├── smtp_connect.md — NEW
├── smtp_send.go — NEW: enviar email via conexion SMTP
├── smtp_send.md — NEW
├── email_build_html.go — NEW: construir email multipart con HTML
├── email_build_html.md — NEW
├── email_build_text.go — NEW: construir email plain text
├── email_build_text.md — NEW
├── email_with_attachment.go — NEW: anadir adjunto a un EmailMessage
├── email_with_attachment.md — NEW
├── email_template_render.go — NEW: renderizar body desde Go template
├── email_template_render.md — NEW
python/functions/infra/
├── smtp_send.py — NEW: enviar email via SMTP (smtplib)
├── smtp_send.md — NEW
├── email_build_html.py — NEW: construir email HTML (email.mime)
├── email_build_html.md — NEW
types/infra/
├── email_message.md — NEW: metadata del tipo EmailMessage
├── smtp_config.md — NEW: metadata del tipo SMTPConfig
├── email_attachment.md — NEW: metadata del tipo EmailAttachment
Patron pure core / impure shell
- Pure:
email_build_html,email_build_text,email_with_attachment,email_template_render,email_build_html(Python) — construyen estructuras de datos sin I/O. - Impure:
smtp_connect,smtp_send(Go),smtp_send(Python) — abren conexiones de red, envian datos por el socket.
Diseno
Tipos
// EmailMessage representa un email completo listo para enviar.
type EmailMessage struct {
From string `json:"from"`
To []string `json:"to"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
Subject string `json:"subject"`
BodyHTML string `json:"body_html,omitempty"`
BodyText string `json:"body_text,omitempty"`
Attachments []EmailAttachment `json:"attachments,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
// SMTPConfig contiene los datos de conexion al servidor SMTP.
type SMTPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
TLSMode string `json:"tls_mode"` // "tls", "starttls", "none"
}
// EmailAttachment representa un archivo adjunto.
type EmailAttachment struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Data []byte `json:"data"`
}
# Python equivalentes (dataclasses)
@dataclass
class EmailMessage:
from_addr: str
to: list[str]
subject: str
body_html: str = ""
body_text: str = ""
cc: list[str] = field(default_factory=list)
bcc: list[str] = field(default_factory=list)
attachments: list[EmailAttachment] = field(default_factory=list)
@dataclass
class SMTPConfig:
host: str
port: int
username: str
password: str
tls_mode: str = "tls" # "tls", "starttls", "none"
@dataclass
class EmailAttachment:
filename: str
content_type: str
data: bytes
Funciones
| ID | Purity | Firma (simplificada) | Descripcion |
|---|---|---|---|
smtp_connect_go_infra |
impure | (cfg SMTPConfig) (*smtp.Client, error) |
Conecta al servidor SMTP con TLS o STARTTLS segun config, autentica con PLAIN/LOGIN y retorna el cliente listo para enviar |
smtp_send_go_infra |
impure | (client *smtp.Client, msg EmailMessage) error |
Serializa el EmailMessage a formato MIME, envia via el cliente SMTP abierto. Maneja To + CC + BCC como recipientes del sobre |
smtp_send_py_infra |
impure | (cfg: SMTPConfig, msg: EmailMessage) -> None |
Conecta, autentica y envia en un solo paso usando smtplib. Cierra la conexion al terminar |
email_build_html_go_infra |
pure | (from, subject string, to []string, html string) EmailMessage |
Construye un EmailMessage con body HTML. Setea BodyText vacio |
email_build_text_go_infra |
pure | (from, subject string, to []string, text string) EmailMessage |
Construye un EmailMessage con body plain text. Setea BodyHTML vacio |
email_build_html_py_infra |
pure | (from_addr, subject: str, to: list[str], html: str) -> EmailMessage |
Construye un EmailMessage con body HTML (Python) |
email_with_attachment_go_infra |
pure | (msg EmailMessage, filename, contentType string, data []byte) EmailMessage |
Retorna copia del EmailMessage con el adjunto anadido. No muta el original |
email_template_render_go_infra |
pure | (tmpl string, data any) (string, error) |
Renderiza un template Go (text/template) con los datos proporcionados. El resultado se usa como body del email |
Notas de implementacion
Go (smtp_connect):
- Puerto 465 → TLS directo con
tls.Dial - Puerto 587 → conexion plain + STARTTLS con
smtp.Client.StartTLS - Puerto 25 → sin cifrado (solo para redes internas, modo
none) - Auth:
smtp.PlainAuthcon identity vacio (compatible con la mayoria de proveedores) - Retorna
*smtp.Clientque el caller cierra condefer client.Close()
Go (smtp_send):
- Construye el mensaje MIME manualmente: headers (From, To, CC, Subject, MIME-Version, Content-Type) + body multipart si hay HTML + text o adjuntos
- BCC no se incluye en headers pero si en
smtp.Client.Rcpt() - Multipart boundary generado con
mime/multipart
Python (smtp_send):
smtplib.SMTP_SSLpara TLS directo,smtplib.SMTP+starttls()para STARTTLS- Construye el mensaje con
email.mime.multipart.MIMEMultipartyemail.mime.text.MIMEText - Adjuntos con
email.mime.base.MIMEBase+encoders.encode_base64 - Context manager para garantizar cierre de la conexion
Template render:
- Usa
text/templatede Go stdlib, nohtml/template(el caller puede pasar HTML ya sanitizado) - Soporta las funciones built-in de Go templates:
{{if}},{{range}},{{with}}, pipes - Error si el template no parsea o la ejecucion falla (campos faltantes en data)
Tareas
Fase 1: Tipos
- 1.1 Crear tipo
EmailMessageenfunctions/infra/email_message.gocon.mdentypes/infra/email_message.md— product type con los campos: From, To, CC, BCC, Subject, BodyHTML, BodyText, Attachments, Headers - 1.2 Crear tipo
SMTPConfigenfunctions/infra/smtp_config.gocon.mdentypes/infra/smtp_config.md— product type con: Host, Port, Username, Password, TLSMode - 1.3 Crear tipo
EmailAttachmentenfunctions/infra/email_attachment.gocon.mdentypes/infra/email_attachment.md— product type con: Filename, ContentType, Data - 1.4
fn indexy verificar los 3 tipos en registry.db
Fase 2: Funciones puras (builders)
- 2.1
email_build_html_go_infra— construir EmailMessage con HTML body - 2.2
email_build_text_go_infra— construir EmailMessage con plain text body - 2.3
email_with_attachment_go_infra— retornar copia del mensaje con adjunto anadido (sin mutar original) - 2.4
email_template_render_go_infra— renderizar body desde Go template string + data map - 2.5
email_build_html_py_infra— construir EmailMessage con HTML body (Python) - 2.6 Tests para las 5 funciones puras: verificar campos del EmailMessage resultante, verificar render de templates con variables, verificar que adjuntos se anaden correctamente
Fase 3: Funciones impuras (transporte SMTP)
- 3.1
smtp_connect_go_infra— conexion TLS directa (puerto 465), STARTTLS (puerto 587), sin cifrado (puerto 25). Auth con PLAIN. Retornar*smtp.Client - 3.2
smtp_send_go_infra— serializar EmailMessage a MIME, enviar viasmtp.Client. Manejar multipart (HTML + text + adjuntos), BCC en el sobre pero no en headers - 3.3
smtp_send_py_infra— conectar + enviar + cerrar en un paso. Usarsmtplib.SMTP_SSLosmtplib.SMTP+starttls()segun TLSMode - 3.4 Tests con stub SMTP server: Go con
net/smtptest server, Python consmtpd.DebuggingServero mock
Fase 4: Integracion y cleanup
- 4.1
fn indexy verificar que las 8 funciones y 3 tipos aparecen en registry.db - 4.2
go vet -tags fts5 ./functions/infra/ - 4.3 Verificar composabilidad:
email_build_html|email_with_attachment|smtp_sendfluye sin adaptadores
Ejemplo de uso
Go: enviar notificacion HTML con adjunto
// Configurar conexion SMTP
cfg := infra.SMTPConfig{
Host: "smtp.gmail.com",
Port: 587,
Username: "alerts@midominio.com",
Password: os.Getenv("SMTP_PASSWORD"),
TLSMode: "starttls",
}
// Construir el mensaje (puro)
msg := infra.EmailBuildHtml(
"alerts@midominio.com",
"Alerta: assertion critica fallida",
[]string{"lucas@midominio.com"},
"<h1>Assertion fallida</h1><p>Entity X tiene valor fuera de rango.</p>",
)
// Anadir adjunto (puro, retorna copia)
reportData, _ := os.ReadFile("report.csv")
msg = infra.EmailWithAttachment(msg, "report.csv", "text/csv", reportData)
// Enviar (impuro)
client, err := infra.SmtpConnect(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
if err := infra.SmtpSend(client, msg); err != nil {
log.Fatal(err)
}
Go: email desde template
tmpl := `Hola {{.Nombre}},
El pipeline {{.Pipeline}} finalizó con status: {{.Status}}.
Registros procesados: {{.Records}}
Duración: {{.Duration}}
— fn_registry`
body, err := infra.EmailTemplateRender(tmpl, map[string]any{
"Nombre": "Lucas",
"Pipeline": "sync_metabase",
"Status": "success",
"Records": 1523,
"Duration": "4.2s",
})
if err != nil {
log.Fatal(err)
}
msg := infra.EmailBuildText(
"system@midominio.com",
"Pipeline sync_metabase completado",
[]string{"lucas@midominio.com"},
body,
)
client, _ := infra.SmtpConnect(cfg)
defer client.Close()
infra.SmtpSend(client, msg)
Python: enviar alerta desde un analysis notebook
from infra import smtp_send, email_build_html, SMTPConfig, EmailMessage
cfg = SMTPConfig(
host="smtp.gmail.com",
port=587,
username="alerts@midominio.com",
password=os.environ["SMTP_PASSWORD"],
tls_mode="starttls",
)
msg = email_build_html(
from_addr="alerts@midominio.com",
subject="Anomalia detectada en dataset",
to=["lucas@midominio.com"],
html="<h2>Anomalia</h2><p>3 outliers detectados en columna revenue.</p>",
)
smtp_send(cfg, msg)
Composicion con el bucle reactivo
// En el paso MEJORAR del bucle reactivo, cuando una assertion critica falla:
func notifyAssertionFailure(cfg infra.SMTPConfig, entity Entity, assertion Assertion, result AssertionResult) error {
tmpl := `Assertion "{{.Assertion}}" FALLÓ para entity {{.Entity}}.
Valor actual: {{.Value}}
Regla: {{.Rule}}
Timestamp: {{.Timestamp}}`
body, err := infra.EmailTemplateRender(tmpl, map[string]any{
"Assertion": assertion.Name,
"Entity": entity.ID,
"Value": result.Value,
"Rule": assertion.Rule,
"Timestamp": result.CreatedAt,
})
if err != nil {
return err
}
msg := infra.EmailBuildText(cfg.Username, "ALERTA: "+assertion.Name, []string{"lucas@midominio.com"}, body)
client, err := infra.SmtpConnect(cfg)
if err != nil {
return err
}
defer client.Close()
return infra.SmtpSend(client, msg)
}
Decisiones de diseno
- Solo stdlib
net/smtp+crypto/tls(Go) ysmtplib+email(Python): sin dependencias externas. Go stdlib es suficiente para SMTP con TLS/STARTTLS y MIME multipart. No se necesita gomail, jordan-wright/email ni similares. - Separar conexion de envio en Go:
smtp_connectretorna el client,smtp_sendlo usa. Esto permite reusar una conexion para multiples envios (batch) sin reconectar cada vez. En Python se unifica porquesmtplibmaneja el ciclo de vida internamente con context managers. - Builders puros retornan structs, no bytes: las funciones
email_build_*retornanEmailMessage, no el MIME serializado. La serializacion a bytes MIME ocurre dentro desmtp_send(impuro). Esto permite inspeccionar, modificar y componer mensajes antes de enviar. email_with_attachmentno muta: retorna una copia delEmailMessagecon el adjunto anadido. Coherente con la regla de pureza — sin mutacion.email_template_renderes generico: renderiza cualquier template Go a string. No sabe nada de email — el resultado se pasa aemail_build_textoemail_build_html. Reutilizable para generar otros textos.- TLSMode como string (
"tls","starttls","none"): mas explicito que un booleano. Cubre los 3 escenarios reales: TLS directo (465), STARTTLS (587), sin cifrado (25/internal). - Headers como
map[string]string: permite anadir headers custom (Reply-To, X-Priority, List-Unsubscribe) sin cambiar la estructura del tipo.
Prerequisitos
Ninguno. Solo Go stdlib y Python stdlib.
Para tests: un servidor SMTP de prueba local o mocks. Go tiene primitivas en net para levantar un listener TCP. Python puede usar aiosmtpd o mocks de smtplib.
Riesgos
- Credenciales SMTP hardcodeadas: Mitigado por diseno —
SMTPConfigrecibe los valores, nunca los lee de archivos ni env vars directamente. El caller es responsable de obtener las credenciales (env vars, secrets manager, etc.). - Emails enviados accidentalmente en tests: Mitigado separando conexion de envio. Los tests de funciones puras no tocan la red. Los tests de funciones impuras usan un SMTP server mock local.
- Rate limiting del proveedor SMTP: No se maneja en v1. Si una app necesita enviar muchos emails, deberia implementar rate limiting en la capa de aplicacion (fuera del scope de estas funciones atomicas).
- HTML injection en templates:
email_template_renderusatext/template, nohtml/template, asi que no escapa HTML automaticamente. Documentar que el caller es responsable de sanitizar inputs si el template genera HTML. - Compatibilidad MIME con clientes de email: El formato multipart generado debe ser compatible con Gmail, Outlook, Apple Mail. Testar con al menos 2 clientes reales antes de cerrar la issue.