fad4006f60
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
364 lines
16 KiB
Markdown
364 lines
16 KiB
Markdown
---
|
|
id: "0012"
|
|
title: "Email & SMTP"
|
|
status: completado
|
|
type: feature
|
|
domain: []
|
|
scope: multi-app
|
|
priority: media
|
|
depends: []
|
|
blocks: []
|
|
related: []
|
|
created: 2026-05-17
|
|
updated: 2026-05-17
|
|
tags: []
|
|
---
|
|
# 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
|
|
|
|
```go
|
|
// 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
|
|
# 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```go
|
|
// 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.
|