Files

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 email y smtp solo arroja coincidencias incidentales en funciones de Metabase (auth con email/password) y normalizacion de entidades OSINT.
  • El tipo email_go_core existe 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/smtp y crypto/tls — suficiente para SMTP con TLS/STARTTLS sin dependencias externas. Python tiene smtplib y email en 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.PlainAuth con identity vacio (compatible con la mayoria de proveedores)
  • Retorna *smtp.Client que el caller cierra con defer 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_SSL para TLS directo, smtplib.SMTP + starttls() para STARTTLS
  • Construye el mensaje con email.mime.multipart.MIMEMultipart y email.mime.text.MIMEText
  • Adjuntos con email.mime.base.MIMEBase + encoders.encode_base64
  • Context manager para garantizar cierre de la conexion

Template render:

  • Usa text/template de Go stdlib, no html/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 EmailMessage en functions/infra/email_message.go con .md en types/infra/email_message.md — product type con los campos: From, To, CC, BCC, Subject, BodyHTML, BodyText, Attachments, Headers
  • 1.2 Crear tipo SMTPConfig en functions/infra/smtp_config.go con .md en types/infra/smtp_config.md — product type con: Host, Port, Username, Password, TLSMode
  • 1.3 Crear tipo EmailAttachment en functions/infra/email_attachment.go con .md en types/infra/email_attachment.md — product type con: Filename, ContentType, Data
  • 1.4 fn index y 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 via smtp.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. Usar smtplib.SMTP_SSL o smtplib.SMTP + starttls() segun TLSMode
  • 3.4 Tests con stub SMTP server: Go con net/smtp test server, Python con smtpd.DebuggingServer o mock

Fase 4: Integracion y cleanup

  • 4.1 fn index y 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_send fluye 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) y smtplib + 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_connect retorna el client, smtp_send lo usa. Esto permite reusar una conexion para multiples envios (batch) sin reconectar cada vez. En Python se unifica porque smtplib maneja el ciclo de vida internamente con context managers.
  • Builders puros retornan structs, no bytes: las funciones email_build_* retornan EmailMessage, no el MIME serializado. La serializacion a bytes MIME ocurre dentro de smtp_send (impuro). Esto permite inspeccionar, modificar y componer mensajes antes de enviar.
  • email_with_attachment no muta: retorna una copia del EmailMessage con el adjunto anadido. Coherente con la regla de pureza — sin mutacion.
  • email_template_render es generico: renderiza cualquier template Go a string. No sabe nada de email — el resultado se pasa a email_build_text o email_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 — SMTPConfig recibe 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_render usa text/template, no html/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.