diff --git a/dev/issues/completed/0012-email-smtp.md b/dev/issues/completed/0012-email-smtp.md new file mode 100644 index 00000000..712b2346 --- /dev/null +++ b/dev/issues/completed/0012-email-smtp.md @@ -0,0 +1,348 @@ +# 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"}, + "
Entity X tiene valor fuera de rango.
", +) + +// 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="3 outliers detectados en columna revenue.
", +) + +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. diff --git a/functions/infra/email_build_html.go b/functions/infra/email_build_html.go new file mode 100644 index 00000000..0fc1177b --- /dev/null +++ b/functions/infra/email_build_html.go @@ -0,0 +1,17 @@ +package infra + +// EmailBuildHTML constructs an EmailMessage with an HTML body. +// to is a list of recipient addresses. +// subject is the email subject line. +// bodyHTML is the HTML content of the email. +// Returns a new EmailMessage with no CC, BCC, attachments or custom headers. +func EmailBuildHTML(from string, to []string, subject, bodyHTML string) EmailMessage { + toSlice := make([]string, len(to)) + copy(toSlice, to) + return EmailMessage{ + From: from, + To: toSlice, + Subject: subject, + BodyHTML: bodyHTML, + } +} diff --git a/functions/infra/email_build_html.md b/functions/infra/email_build_html.md new file mode 100644 index 00000000..2aebba15 --- /dev/null +++ b/functions/infra/email_build_html.md @@ -0,0 +1,51 @@ +--- +name: email_build_html +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func EmailBuildHTML(from string, to []string, subject, bodyHTML string) EmailMessage" +description: "Construye un EmailMessage con cuerpo HTML. Retorna una nueva estructura sin CC, BCC, adjuntos ni headers custom." +tags: [email, smtp, html, builder] +uses_functions: [] +uses_types: [EmailMessage_go_infra] +returns: [EmailMessage_go_infra] +returns_optional: false +error_type: "" +imports: [] +params: + - name: from + desc: "direccion del remitente (ej: 'AliceVer adjunto.
", +) +// msg.BodyHTML = "Ver adjunto.
" +// msg.BodyText = "" +``` + +## Notas + +Funcion pura. Copia el slice `to` para evitar aliasing con el caller. Para anadir adjuntos usar `EmailWithAttachment`. Para texto plano usar `EmailBuildText`. diff --git a/functions/infra/email_build_test.go b/functions/infra/email_build_test.go new file mode 100644 index 00000000..c3dcae0e --- /dev/null +++ b/functions/infra/email_build_test.go @@ -0,0 +1,125 @@ +package infra + +import ( + "testing" +) + +func TestEmailBuildHTML(t *testing.T) { + t.Run("construye mensaje html con campos basicos", func(t *testing.T) { + msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Hola", "Hola") + if msg.From != "alice@example.com" { + t.Errorf("From: got %q, want %q", msg.From, "alice@example.com") + } + if len(msg.To) != 1 || msg.To[0] != "bob@example.com" { + t.Errorf("To: got %v, want [bob@example.com]", msg.To) + } + if msg.Subject != "Hola" { + t.Errorf("Subject: got %q, want %q", msg.Subject, "Hola") + } + if msg.BodyHTML != "Hola" { + t.Errorf("BodyHTML: got %q, want %q", msg.BodyHTML, "Hola") + } + }) + + t.Run("copia el slice to para evitar aliasing", func(t *testing.T) { + to := []string{"a@example.com", "b@example.com"} + msg := EmailBuildHTML("x@x.com", to, "s", "h") + to[0] = "mutated@example.com" + if msg.To[0] == "mutated@example.com" { + t.Error("To slice was aliased — mutation affected the message") + } + }) + + t.Run("campos no usados quedan vacios", func(t *testing.T) { + msg := EmailBuildHTML("f@example.com", []string{"t@example.com"}, "s", "h") + if msg.BodyText != "" { + t.Errorf("BodyText should be empty, got %q", msg.BodyText) + } + if len(msg.CC) != 0 { + t.Errorf("CC should be empty, got %v", msg.CC) + } + if len(msg.BCC) != 0 { + t.Errorf("BCC should be empty, got %v", msg.BCC) + } + if len(msg.Attachments) != 0 { + t.Errorf("Attachments should be empty, got %v", msg.Attachments) + } + }) +} + +func TestEmailBuildText(t *testing.T) { + t.Run("construye mensaje text con campos basicos", func(t *testing.T) { + msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Alerta", "Servidor caido") + if msg.BodyText != "Servidor caido" { + t.Errorf("BodyText: got %q, want %q", msg.BodyText, "Servidor caido") + } + if msg.From != "alice@example.com" { + t.Errorf("From: got %q, want %q", msg.From, "alice@example.com") + } + if msg.Subject != "Alerta" { + t.Errorf("Subject: got %q, want %q", msg.Subject, "Alerta") + } + }) + + t.Run("body html queda vacio", func(t *testing.T) { + msg := EmailBuildText("f@x.com", []string{"t@x.com"}, "s", "body") + if msg.BodyHTML != "" { + t.Errorf("BodyHTML should be empty, got %q", msg.BodyHTML) + } + }) +} + +func TestEmailWithAttachment(t *testing.T) { + t.Run("agrega adjunto sin mutar el original", func(t *testing.T) { + orig := EmailBuildHTML("a@example.com", []string{"b@example.com"}, "s", "h") + att := EmailAttachment{Filename: "f.pdf", ContentType: "application/pdf", Data: []byte("data")} + msg2 := EmailWithAttachment(orig, att) + + if len(orig.Attachments) != 0 { + t.Errorf("original was mutated: got %d attachments, want 0", len(orig.Attachments)) + } + if len(msg2.Attachments) != 1 { + t.Errorf("got %d attachments, want 1", len(msg2.Attachments)) + } + if msg2.Attachments[0].Filename != "f.pdf" { + t.Errorf("Filename: got %q, want %q", msg2.Attachments[0].Filename, "f.pdf") + } + }) + + t.Run("multiples adjuntos se acumulan", func(t *testing.T) { + msg := EmailBuildHTML("a@x.com", []string{"b@x.com"}, "s", "h") + msg = EmailWithAttachment(msg, EmailAttachment{Filename: "a1.txt", ContentType: "text/plain", Data: []byte("1")}) + msg = EmailWithAttachment(msg, EmailAttachment{Filename: "a2.txt", ContentType: "text/plain", Data: []byte("2")}) + if len(msg.Attachments) != 2 { + t.Errorf("got %d attachments, want 2", len(msg.Attachments)) + } + }) + + t.Run("copia todos los campos del mensaje", func(t *testing.T) { + orig := EmailMessage{ + From: "a@x.com", + To: []string{"b@x.com"}, + CC: []string{"c@x.com"}, + BCC: []string{"d@x.com"}, + Subject: "Test", + BodyHTML: "Hi
", + BodyText: "Hi", + Headers: map[string]string{"X-Test": "value"}, + } + att := EmailAttachment{Filename: "f.png", ContentType: "image/png", Data: []byte("img")} + msg2 := EmailWithAttachment(orig, att) + + if msg2.From != orig.From { + t.Errorf("From mismatch: %q vs %q", msg2.From, orig.From) + } + if msg2.Subject != orig.Subject { + t.Errorf("Subject mismatch: %q vs %q", msg2.Subject, orig.Subject) + } + if len(msg2.CC) != 1 || msg2.CC[0] != "c@x.com" { + t.Errorf("CC not copied: %v", msg2.CC) + } + if msg2.Headers["X-Test"] != "value" { + t.Errorf("Headers not copied: %v", msg2.Headers) + } + }) +} diff --git a/functions/infra/email_build_text.go b/functions/infra/email_build_text.go new file mode 100644 index 00000000..bcacccaa --- /dev/null +++ b/functions/infra/email_build_text.go @@ -0,0 +1,17 @@ +package infra + +// EmailBuildText constructs an EmailMessage with a plain text body. +// to is a list of recipient addresses. +// subject is the email subject line. +// bodyText is the plain text content of the email. +// Returns a new EmailMessage with no CC, BCC, attachments or custom headers. +func EmailBuildText(from string, to []string, subject, bodyText string) EmailMessage { + toSlice := make([]string, len(to)) + copy(toSlice, to) + return EmailMessage{ + From: from, + To: toSlice, + Subject: subject, + BodyText: bodyText, + } +} diff --git a/functions/infra/email_build_text.md b/functions/infra/email_build_text.md new file mode 100644 index 00000000..b6911428 --- /dev/null +++ b/functions/infra/email_build_text.md @@ -0,0 +1,50 @@ +--- +name: email_build_text +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func EmailBuildText(from string, to []string, subject, bodyText string) EmailMessage" +description: "Construye un EmailMessage con cuerpo de texto plano. Retorna una nueva estructura sin CC, BCC, adjuntos ni headers custom." +tags: [email, smtp, text, builder] +uses_functions: [] +uses_types: [EmailMessage_go_infra] +returns: [EmailMessage_go_infra] +returns_optional: false +error_type: "" +imports: [] +params: + - name: from + desc: "direccion del remitente (ej: 'AliceVer adjunto
") +att := EmailAttachment{Filename: "report.pdf", ContentType: "application/pdf", Data: pdfBytes} +msg2 := EmailWithAttachment(msg, att) +// len(msg.Attachments) == 0 (original no mutado) +// len(msg2.Attachments) == 1 +``` + +## Notas + +Funcion pura. Implementa el patron immutable builder — cada llamada retorna un nuevo `EmailMessage` con copia profunda de todos los slices y mapas. Se puede encadenar varias veces para agregar multiples adjuntos. diff --git a/functions/infra/smtp_connect.go b/functions/infra/smtp_connect.go new file mode 100644 index 00000000..0cfa6622 --- /dev/null +++ b/functions/infra/smtp_connect.go @@ -0,0 +1,81 @@ +package infra + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" +) + +// SMTPConnect establishes an authenticated SMTP connection using the given config. +// TLSMode controls encryption: +// - "tls" → direct TLS (typical port 465) +// - "starttls" → plain connection upgraded with STARTTLS (typical port 587) +// - "" → no encryption (typical port 25) +// +// Returns an *smtp.Client ready to use with SMTPSend. +// The caller is responsible for calling client.Quit() when done. +func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error) { + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + switch cfg.TLSMode { + case "tls": + tlsCfg := &tls.Config{ServerName: cfg.Host} + conn, err := tls.Dial("tcp", addr, tlsCfg) + if err != nil { + return nil, fmt.Errorf("smtp_connect: tls dial %s: %w", addr, err) + } + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + conn.Close() + return nil, fmt.Errorf("smtp_connect: new client: %w", err) + } + if cfg.Username != "" { + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + if err := client.Auth(auth); err != nil { + client.Close() + return nil, fmt.Errorf("smtp_connect: auth: %w", err) + } + } + return client, nil + + case "starttls": + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("smtp_connect: dial %s: %w", addr, err) + } + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + conn.Close() + return nil, fmt.Errorf("smtp_connect: new client: %w", err) + } + tlsCfg := &tls.Config{ServerName: cfg.Host} + if err := client.StartTLS(tlsCfg); err != nil { + client.Close() + return nil, fmt.Errorf("smtp_connect: starttls: %w", err) + } + if cfg.Username != "" { + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + if err := client.Auth(auth); err != nil { + client.Close() + return nil, fmt.Errorf("smtp_connect: auth: %w", err) + } + } + return client, nil + + default: + // no encryption + client, err := smtp.Dial(addr) + if err != nil { + return nil, fmt.Errorf("smtp_connect: dial %s: %w", addr, err) + } + if cfg.Username != "" { + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + if err := client.Auth(auth); err != nil { + client.Close() + return nil, fmt.Errorf("smtp_connect: auth: %w", err) + } + } + return client, nil + } +} diff --git a/functions/infra/smtp_connect.md b/functions/infra/smtp_connect.md new file mode 100644 index 00000000..b5244d2c --- /dev/null +++ b/functions/infra/smtp_connect.md @@ -0,0 +1,43 @@ +--- +name: smtp_connect +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SMTPConnect(cfg SMTPConfig) (*smtp.Client, error)" +description: "Establece una conexion SMTP autenticada. TLSMode 'tls' usa TLS directo (port 465), 'starttls' hace upgrade STARTTLS (port 587), '' sin cifrado (port 25). Retorna un *smtp.Client listo para usar con SMTPSend." +tags: [email, smtp, connection, tls, starttls, auth] +uses_functions: [] +uses_types: [SMTPConfig_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["crypto/tls", "fmt", "net", "net/smtp"] +params: + - name: cfg + desc: "configuracion SMTP: host, port, usuario, password y modo TLS" +output: "*smtp.Client autenticado y listo para enviar mensajes; el caller debe llamar client.Quit() al terminar" +tested: true +tests: + - "conecta sin cifrado a servidor mock" + - "error si el servidor no existe" +test_file_path: "functions/infra/smtp_connect_test.go" +file_path: "functions/infra/smtp_connect.go" +--- + +## Ejemplo + +```go +cfg := SMTPConfig{Host: "smtp.gmail.com", Port: 587, Username: "u@gmail.com", Password: "app-pw", TLSMode: "starttls"} +client, err := SMTPConnect(cfg) +if err != nil { + log.Fatal(err) +} +defer client.Quit() +// pasar client a SMTPSend(...) +``` + +## Notas + +Funcion impura — abre conexion TCP real. El caller es responsable de cerrar el cliente con `client.Quit()`. Para test unitario usar un listener TCP local como mock. `SMTPSend` acepta el `*smtp.Client` retornado por esta funcion. diff --git a/functions/infra/smtp_connect_test.go b/functions/infra/smtp_connect_test.go new file mode 100644 index 00000000..dfdccf78 --- /dev/null +++ b/functions/infra/smtp_connect_test.go @@ -0,0 +1,120 @@ +package infra + +import ( + "bufio" + "fmt" + "net" + "strings" + "testing" +) + +// mockSMTPServer starts a minimal SMTP server on a random port that greets +// and accepts any commands. Returns the listener address and a function to stop it. +func mockSMTPServer(t *testing.T) (addr string, stop func()) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("mock smtp listen: %v", err) + } + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleMockSMTP(conn) + } + }() + return ln.Addr().String(), func() { ln.Close() } +} + +// handleMockSMTP is a minimal SMTP session handler (no TLS, no auth required). +func handleMockSMTP(conn net.Conn) { + defer conn.Close() + w := bufio.NewWriter(conn) + r := bufio.NewReader(conn) + fmt.Fprintf(w, "220 mock SMTP ready\r\n") + w.Flush() + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + fmt.Fprintf(w, "250 mock\r\n") + case strings.HasPrefix(upper, "MAIL FROM"): + fmt.Fprintf(w, "250 OK\r\n") + case strings.HasPrefix(upper, "RCPT TO"): + fmt.Fprintf(w, "250 OK\r\n") + case strings.HasPrefix(upper, "DATA"): + fmt.Fprintf(w, "354 End with .\r\n") + w.Flush() + // read until ".\r\n" + for { + dl, derr := r.ReadString('\n') + if derr != nil { + return + } + if strings.TrimSpace(dl) == "." { + break + } + } + fmt.Fprintf(w, "250 OK\r\n") + case strings.HasPrefix(upper, "QUIT"): + fmt.Fprintf(w, "221 Bye\r\n") + w.Flush() + return + default: + fmt.Fprintf(w, "502 Command not recognized\r\n") + } + w.Flush() + } +} + +func TestSMTPConnect(t *testing.T) { + t.Run("conecta sin cifrado a servidor mock", func(t *testing.T) { + addr, stop := mockSMTPServer(t) + defer stop() + + host, portStr, _ := net.SplitHostPort(addr) + port := 0 + fmt.Sscanf(portStr, "%d", &port) + + cfg := SMTPConfig{ + Host: host, + Port: port, + TLSMode: "", // no encryption + } + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + client.Quit() + }) + + t.Run("error si el servidor no existe", func(t *testing.T) { + // Use a port that is listening but immediately closes (RST) — we start + // and immediately close a listener so the port is known-closed. + ln2, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("setup: %v", err) + } + h2, p2str, _ := net.SplitHostPort(ln2.Addr().String()) + ln2.Close() // now nothing listens — connections get refused + p2 := 0 + fmt.Sscanf(p2str, "%d", &p2) + + cfg := SMTPConfig{ + Host: h2, + Port: p2, + TLSMode: "", + } + _, connErr := SMTPConnect(cfg) + if connErr == nil { + t.Error("expected error for refused connection, got nil") + } + }) +} diff --git a/functions/infra/smtp_send.go b/functions/infra/smtp_send.go new file mode 100644 index 00000000..ac4f4e63 --- /dev/null +++ b/functions/infra/smtp_send.go @@ -0,0 +1,154 @@ +package infra + +import ( + "bytes" + "encoding/base64" + "fmt" + "mime" + "mime/multipart" + "net/smtp" + "net/textproto" + "strings" + "time" +) + +// SMTPSend serializes msg to MIME and sends it using the given smtp.Client. +// Supports plain text, HTML, and mixed (with attachments) content types. +// The client must be authenticated (obtained from SMTPConnect). +func SMTPSend(client *smtp.Client, msg EmailMessage) error { + if err := client.Mail(extractAddr(msg.From)); err != nil { + return fmt.Errorf("smtp_send: MAIL FROM: %w", err) + } + + allTo := append(append([]string{}, msg.To...), msg.CC...) + allTo = append(allTo, msg.BCC...) + for _, addr := range allTo { + if err := client.Rcpt(extractAddr(addr)); err != nil { + return fmt.Errorf("smtp_send: RCPT TO %s: %w", addr, err) + } + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp_send: DATA: %w", err) + } + defer w.Close() + + body, err := buildMIME(msg) + if err != nil { + return fmt.Errorf("smtp_send: build mime: %w", err) + } + + if _, err := w.Write(body); err != nil { + return fmt.Errorf("smtp_send: write: %w", err) + } + return nil +} + +// extractAddr extracts the bare email address from a possibly formatted string +// like "AliceVer adjunto.
", +) +assert msg.body_html == "Ver adjunto.
" +assert msg.body_text == "" +assert isinstance(msg.to, tuple) +``` + +## Notas + +Funcion pura. `EmailMessagePy` es un dataclass frozen — inmutable por construccion. La lista `to` se convierte a tupla para evitar mutacion externa. Para usar con smtp_send, pasar los campos del mensaje como argumentos individuales. diff --git a/python/functions/infra/email_build_html.py b/python/functions/infra/email_build_html.py new file mode 100644 index 00000000..1131a82a --- /dev/null +++ b/python/functions/infra/email_build_html.py @@ -0,0 +1,48 @@ +"""Construccion de EmailMessage Python con cuerpo HTML — funcion pura.""" + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class EmailMessagePy: + """Mensaje de email inmutable listo para enviar. + + Construir con email_build_html() o email_build_text(). + """ + from_addr: str + to: tuple[str, ...] + subject: str + body_html: str = "" + body_text: str = "" + cc: tuple[str, ...] = field(default_factory=tuple) + bcc: tuple[str, ...] = field(default_factory=tuple) + headers: tuple[tuple[str, str], ...] = field(default_factory=tuple) + + +def email_build_html( + from_addr: str, + to: list[str], + subject: str, + body_html: str, +) -> EmailMessagePy: + """Construye un EmailMessagePy con cuerpo HTML. + + Funcion pura — no tiene efectos secundarios. El slice `to` se convierte a + tupla para garantizar inmutabilidad. + + Args: + from_addr: Direccion del remitente. + to: Lista de destinatarios principales. + subject: Asunto del correo. + body_html: Contenido HTML del cuerpo del mensaje. + + Returns: + EmailMessagePy inmutable con cuerpo HTML. body_text queda vacio. + """ + return EmailMessagePy( + from_addr=from_addr, + to=tuple(to), + subject=subject, + body_html=body_html, + body_text="", + ) diff --git a/python/functions/infra/email_build_html_test.py b/python/functions/infra/email_build_html_test.py new file mode 100644 index 00000000..7f767c6f --- /dev/null +++ b/python/functions/infra/email_build_html_test.py @@ -0,0 +1,44 @@ +"""Tests para email_build_html.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from infra.email_build_html import email_build_html, EmailMessagePy + + +def test_construye_mensaje_html_con_campos_correctos(): + msg = email_build_html( + from_addr="alice@example.com", + to=["bob@example.com"], + subject="Hola", + body_html="Hola Bob", + ) + assert msg.from_addr == "alice@example.com" + assert msg.subject == "Hola" + assert msg.body_html == "Hola Bob" + assert "bob@example.com" in msg.to + + +def test_convierte_lista_to_a_tupla_inmutable(): + to_list = ["a@example.com", "b@example.com"] + msg = email_build_html("f@x.com", to_list, "s", "h") + assert isinstance(msg.to, tuple) + # Mutating the original list must not affect the message + to_list[0] = "mutated@example.com" + assert msg.to[0] == "a@example.com" + + +def test_body_text_queda_vacio(): + msg = email_build_html("f@x.com", ["t@x.com"], "s", "html
") + assert msg.body_text == "" + + +def test_mensaje_es_inmutable(): + msg = email_build_html("f@x.com", ["t@x.com"], "s", "h") + try: + msg.subject = "changed" + assert False, "Should have raised FrozenInstanceError" + except Exception: + pass # frozen dataclass raises FrozenInstanceError diff --git a/python/functions/infra/smtp_send.md b/python/functions/infra/smtp_send.md new file mode 100644 index 00000000..5173faa3 --- /dev/null +++ b/python/functions/infra/smtp_send.md @@ -0,0 +1,67 @@ +--- +name: smtp_send +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "smtp_send(cfg: SMTPConfigPy, from_addr: str, to: list[str], subject: str, body_html: str = '', body_text: str = '', cc: list[str] | None = None, bcc: list[str] | None = None, attachments: list[EmailAttachmentPy] | None = None, headers: dict[str, str] | None = None) -> None" +description: "Conecta al servidor SMTP, construye el mensaje MIME y envia el email en un solo paso. Soporta TLS directo (port 465), STARTTLS (port 587) y sin cifrado (port 25). Cierra la conexion automaticamente." +tags: [email, smtp, send, python, smtplib, mime, tls] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["smtplib", "email.mime.multipart", "email.mime.text", "email.mime.base", "email.encoders", "dataclasses"] +params: + - name: cfg + desc: "configuracion SMTP: host, port, username, password, tls_mode ('tls', 'starttls' o '')" + - name: from_addr + desc: "direccion del remitente" + - name: to + desc: "lista de destinatarios principales" + - name: subject + desc: "asunto del correo" + - name: body_html + desc: "cuerpo HTML (opcional; puede estar vacio si body_text esta presente)" + - name: body_text + desc: "cuerpo de texto plano (opcional; puede estar vacio si body_html esta presente)" + - name: cc + desc: "lista de destinatarios en copia visible (opcional)" + - name: bcc + desc: "lista de destinatarios en copia oculta (opcional)" + - name: attachments + desc: "lista de EmailAttachmentPy con filename, content_type y data binarios (opcional)" + - name: headers + desc: "diccionario de headers MIME adicionales como X-Mailer (opcional)" +output: "None si el envio fue exitoso; lanza RuntimeError con descripcion del fallo SMTP" +tested: true +tests: + - "envia texto plano via mock smtpd" + - "envia html via mock smtpd" + - "envia con adjunto via mock smtpd" + - "error si host no existe" +test_file_path: "python/functions/infra/smtp_send_test.py" +file_path: "python/functions/infra/smtp_send.py" +--- + +## Ejemplo + +```python +from infra.smtp_send import smtp_send, SMTPConfigPy, EmailAttachmentPy + +cfg = SMTPConfigPy(host="smtp.gmail.com", port=587, username="u@gmail.com", password="app-pw") +smtp_send( + cfg, + from_addr="u@gmail.com", + to=["dest@example.com"], + subject="Reporte", + body_html="