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"}, + "

Assertion fallida

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="

Anomalia

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: 'Alice ')" + - name: to + desc: "lista de direcciones de destinatarios principales" + - name: subject + desc: "asunto del correo" + - name: bodyHTML + desc: "contenido HTML del cuerpo del mensaje" +output: "EmailMessage listo para enviar con cuerpo HTML" +tested: true +tests: + - "construye mensaje html con campos basicos" + - "copia el slice to para evitar aliasing" + - "campos no usados quedan vacios" +test_file_path: "functions/infra/email_build_test.go" +file_path: "functions/infra/email_build_html.go" +--- + +## Ejemplo + +```go +msg := EmailBuildHTML( + "alice@example.com", + []string{"bob@example.com", "carol@example.com"}, + "Reporte mensual", + "

Hola

Ver adjunto.

", +) +// msg.BodyHTML = "

Hola

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: 'Alice ')" + - name: to + desc: "lista de direcciones de destinatarios principales" + - name: subject + desc: "asunto del correo" + - name: bodyText + desc: "contenido de texto plano del cuerpo del mensaje" +output: "EmailMessage listo para enviar con cuerpo de texto plano" +tested: true +tests: + - "construye mensaje text con campos basicos" + - "body html queda vacio" +test_file_path: "functions/infra/email_build_test.go" +file_path: "functions/infra/email_build_text.go" +--- + +## Ejemplo + +```go +msg := EmailBuildText( + "alice@example.com", + []string{"bob@example.com"}, + "Alerta critica", + "El servidor cayó a las 03:42 UTC.", +) +// msg.BodyText = "El servidor cayó a las 03:42 UTC." +// msg.BodyHTML = "" +``` + +## Notas + +Funcion pura. Analogo de `EmailBuildHTML` para texto plano. Para anadir adjuntos usar `EmailWithAttachment`. diff --git a/functions/infra/email_template_render.go b/functions/infra/email_template_render.go new file mode 100644 index 00000000..afbd9829 --- /dev/null +++ b/functions/infra/email_template_render.go @@ -0,0 +1,24 @@ +package infra + +import ( + "bytes" + "fmt" + "text/template" +) + +// EmailTemplateRender executes a Go text/template with the given data map +// and returns the rendered string. +// tmplStr is the template source (Go text/template syntax). +// data is a map of string keys to any values available inside the template. +// Returns an error if the template fails to parse or execute. +func EmailTemplateRender(tmplStr string, data map[string]any) (string, error) { + tmpl, err := template.New("email").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("email_template_render: parse: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("email_template_render: execute: %w", err) + } + return buf.String(), nil +} diff --git a/functions/infra/email_template_render.md b/functions/infra/email_template_render.md new file mode 100644 index 00000000..de4ce1d0 --- /dev/null +++ b/functions/infra/email_template_render.md @@ -0,0 +1,46 @@ +--- +name: email_template_render +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func EmailTemplateRender(tmplStr string, data map[string]any) (string, error)" +description: "Renderiza un Go text/template con el data map dado y retorna el string resultante. Falla si el template tiene sintaxis invalida o si la ejecucion produce un error." +tags: [email, template, render, text/template] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["bytes", "fmt", "text/template"] +params: + - name: tmplStr + desc: "fuente del template en sintaxis Go text/template (ej: 'Hola {{.Name}}')" + - name: data + desc: "mapa de variables disponibles dentro del template (clave string, valor any)" +output: "string resultado del template renderizado con los datos provistos" +tested: true +tests: + - "renderiza template simple con datos" + - "sustituye multiples variables" + - "error en template invalido" + - "template vacio retorna string vacio" +test_file_path: "functions/infra/email_template_render_test.go" +file_path: "functions/infra/email_template_render.go" +--- + +## Ejemplo + +```go +tmpl := "Hola {{.Name}}, tu pedido {{.OrderID}} esta listo." +body, err := EmailTemplateRender(tmpl, map[string]any{ + "Name": "Alice", + "OrderID": "ORD-1234", +}) +// body = "Hola Alice, tu pedido ORD-1234 esta listo." +``` + +## Notas + +Usa `text/template` de la stdlib de Go. Para HTML con escape automatico usar `html/template` en su lugar. Util para generar el body de emails con datos dinamicos antes de llamar a `EmailBuildHTML` o `EmailBuildText`. diff --git a/functions/infra/email_template_render_test.go b/functions/infra/email_template_render_test.go new file mode 100644 index 00000000..598badad --- /dev/null +++ b/functions/infra/email_template_render_test.go @@ -0,0 +1,46 @@ +package infra + +import ( + "strings" + "testing" +) + +func TestEmailTemplateRender(t *testing.T) { + t.Run("renderiza template simple con datos", func(t *testing.T) { + got, err := EmailTemplateRender("Hola {{.Name}}", map[string]any{"Name": "Alice"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Hola Alice" { + t.Errorf("got %q, want %q", got, "Hola Alice") + } + }) + + t.Run("sustituye multiples variables", func(t *testing.T) { + tmpl := "Pedido {{.OrderID}} para {{.Customer}} listo." + got, err := EmailTemplateRender(tmpl, map[string]any{"OrderID": "ORD-42", "Customer": "Bob"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(got, "ORD-42") || !strings.Contains(got, "Bob") { + t.Errorf("got %q, expected OrderID and Customer", got) + } + }) + + t.Run("error en template invalido", func(t *testing.T) { + _, err := EmailTemplateRender("{{.Unclosed", nil) + if err == nil { + t.Error("expected error for invalid template, got nil") + } + }) + + t.Run("template vacio retorna string vacio", func(t *testing.T) { + got, err := EmailTemplateRender("", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("got %q, want empty string", got) + } + }) +} diff --git a/functions/infra/email_types.go b/functions/infra/email_types.go new file mode 100644 index 00000000..cb76885e --- /dev/null +++ b/functions/infra/email_types.go @@ -0,0 +1,36 @@ +package infra + +// EmailAttachment represents a file attached to an email. +type EmailAttachment struct { + Filename string // nombre del archivo (ej: "report.pdf") + ContentType string // MIME type (ej: "application/pdf", "image/png") + Data []byte // contenido binario del adjunto +} + +// EmailMessage is an immutable email ready to send. +// Build with EmailBuildHTML, EmailBuildText, or EmailWithAttachment. +type EmailMessage struct { + From string // direccion del remitente (ej: "Alice ") + To []string // destinatarios principales + CC []string // copia (puede ser nil) + BCC []string // copia oculta (puede ser nil) + Subject string // asunto del mensaje + BodyHTML string // cuerpo HTML (puede ser vacio si BodyText esta presente) + BodyText string // cuerpo texto plano (puede ser vacio si BodyHTML esta presente) + Attachments []EmailAttachment // adjuntos (puede ser nil) + Headers map[string]string // headers MIME adicionales (ej: "X-Mailer": "fn_registry") +} + +// SMTPConfig holds connection parameters for an SMTP server. +// TLSMode controls the encryption strategy: +// +// "tls" — TLS directo (port 465) +// "starttls" — STARTTLS upgrade (port 587) +// "" — sin cifrado (port 25) +type SMTPConfig struct { + Host string // hostname del servidor SMTP (ej: "smtp.gmail.com") + Port int // puerto (465, 587, 25) + Username string // usuario/email de autenticacion + Password string // contrasena o app password + TLSMode string // "tls", "starttls" o "" (sin cifrado) +} diff --git a/functions/infra/email_with_attachment.go b/functions/infra/email_with_attachment.go new file mode 100644 index 00000000..b38d9a9a --- /dev/null +++ b/functions/infra/email_with_attachment.go @@ -0,0 +1,38 @@ +package infra + +// EmailWithAttachment returns a copy of msg with the given attachment appended. +// Does not mutate the original message — always returns a new EmailMessage. +func EmailWithAttachment(msg EmailMessage, att EmailAttachment) EmailMessage { + newAtts := make([]EmailAttachment, len(msg.Attachments)+1) + copy(newAtts, msg.Attachments) + newAtts[len(msg.Attachments)] = att + + toSlice := make([]string, len(msg.To)) + copy(toSlice, msg.To) + + ccSlice := make([]string, len(msg.CC)) + copy(ccSlice, msg.CC) + + bccSlice := make([]string, len(msg.BCC)) + copy(bccSlice, msg.BCC) + + var headers map[string]string + if len(msg.Headers) > 0 { + headers = make(map[string]string, len(msg.Headers)) + for k, v := range msg.Headers { + headers[k] = v + } + } + + return EmailMessage{ + From: msg.From, + To: toSlice, + CC: ccSlice, + BCC: bccSlice, + Subject: msg.Subject, + BodyHTML: msg.BodyHTML, + BodyText: msg.BodyText, + Attachments: newAtts, + Headers: headers, + } +} diff --git a/functions/infra/email_with_attachment.md b/functions/infra/email_with_attachment.md new file mode 100644 index 00000000..a1d5bb92 --- /dev/null +++ b/functions/infra/email_with_attachment.md @@ -0,0 +1,44 @@ +--- +name: email_with_attachment +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func EmailWithAttachment(msg EmailMessage, att EmailAttachment) EmailMessage" +description: "Retorna una copia del EmailMessage con el adjunto añadido al final. No muta el mensaje original." +tags: [email, smtp, attachment, immutable, builder] +uses_functions: [] +uses_types: [EmailMessage_go_infra, EmailAttachment_go_infra] +returns: [EmailMessage_go_infra] +returns_optional: false +error_type: "" +imports: [] +params: + - name: msg + desc: "mensaje email existente del que se obtiene la copia base" + - name: att + desc: "adjunto a agregar: nombre, MIME type y datos binarios" +output: "nuevo EmailMessage con todos los campos del original mas el adjunto añadido" +tested: true +tests: + - "agrega adjunto sin mutar el original" + - "multiples adjuntos se acumulan" + - "copia todos los campos del mensaje" +test_file_path: "functions/infra/email_build_test.go" +file_path: "functions/infra/email_with_attachment.go" +--- + +## Ejemplo + +```go +msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Informe", "

Ver 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 "Alice " → "alice@example.com". +func extractAddr(addr string) string { + if i := strings.Index(addr, "<"); i >= 0 { + if j := strings.Index(addr[i:], ">"); j >= 0 { + return addr[i+1 : i+j] + } + } + return strings.TrimSpace(addr) +} + +// buildMIME constructs the full MIME email bytes. +func buildMIME(msg EmailMessage) ([]byte, error) { + var buf bytes.Buffer + + // Headers + buf.WriteString("Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n") + buf.WriteString("From: " + msg.From + "\r\n") + buf.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n") + if len(msg.CC) > 0 { + buf.WriteString("Cc: " + strings.Join(msg.CC, ", ") + "\r\n") + } + buf.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", msg.Subject) + "\r\n") + buf.WriteString("MIME-Version: 1.0\r\n") + for k, v := range msg.Headers { + buf.WriteString(k + ": " + v + "\r\n") + } + + hasText := msg.BodyText != "" + hasHTML := msg.BodyHTML != "" + hasAtts := len(msg.Attachments) > 0 + + switch { + case hasAtts: + // multipart/mixed: text or html parts + attachments + mw := multipart.NewWriter(&buf) + buf.WriteString("Content-Type: multipart/mixed; boundary=\"" + mw.Boundary() + "\"\r\n\r\n") + + if hasText && hasHTML { + // nested multipart/alternative inside mixed + var altBuf bytes.Buffer + aw := multipart.NewWriter(&altBuf) + writeTextPart(aw, msg.BodyText) + writeHTMLPart(aw, msg.BodyHTML) + aw.Close() + altHeader := textproto.MIMEHeader{} + altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+aw.Boundary()+"\"") + pw, _ := mw.CreatePart(altHeader) + pw.Write(altBuf.Bytes()) + } else if hasHTML { + writeHTMLPart(mw, msg.BodyHTML) + } else { + writeTextPart(mw, msg.BodyText) + } + + for _, att := range msg.Attachments { + writeAttachment(mw, att) + } + mw.Close() + + case hasText && hasHTML: + mw := multipart.NewWriter(&buf) + buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + mw.Boundary() + "\"\r\n\r\n") + writeTextPart(mw, msg.BodyText) + writeHTMLPart(mw, msg.BodyHTML) + mw.Close() + + case hasHTML: + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + buf.WriteString(msg.BodyHTML) + + default: + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + buf.WriteString(msg.BodyText) + } + + return buf.Bytes(), nil +} + +func writeTextPart(mw *multipart.Writer, body string) { + h := textproto.MIMEHeader{} + h.Set("Content-Type", "text/plain; charset=utf-8") + h.Set("Content-Transfer-Encoding", "quoted-printable") + pw, _ := mw.CreatePart(h) + pw.Write([]byte(body)) +} + +func writeHTMLPart(mw *multipart.Writer, body string) { + h := textproto.MIMEHeader{} + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("Content-Transfer-Encoding", "quoted-printable") + pw, _ := mw.CreatePart(h) + pw.Write([]byte(body)) +} + +func writeAttachment(mw *multipart.Writer, att EmailAttachment) { + h := textproto.MIMEHeader{} + h.Set("Content-Type", att.ContentType) + h.Set("Content-Transfer-Encoding", "base64") + h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename)) + pw, _ := mw.CreatePart(h) + enc := base64.NewEncoder(base64.StdEncoding, pw) + enc.Write(att.Data) + enc.Close() +} diff --git a/functions/infra/smtp_send.md b/functions/infra/smtp_send.md new file mode 100644 index 00000000..93d128db --- /dev/null +++ b/functions/infra/smtp_send.md @@ -0,0 +1,46 @@ +--- +name: smtp_send +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func SMTPSend(client *smtp.Client, msg EmailMessage) error" +description: "Serializa un EmailMessage a MIME y lo envia usando el smtp.Client dado. Soporta texto plano, HTML, multipart/alternative y adjuntos (multipart/mixed)." +tags: [email, smtp, send, mime, multipart] +uses_functions: [smtp_connect_go_infra] +uses_types: [EmailMessage_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: ["bytes", "encoding/base64", "fmt", "mime", "mime/multipart", "net/smtp", "net/textproto", "strings", "time"] +params: + - name: client + desc: "cliente SMTP autenticado obtenido de SMTPConnect" + - name: msg + desc: "mensaje email con From, To, Subject y al menos un body (HTML o text)" +output: "nil si el envio fue exitoso; error con contexto si falla MAIL FROM, RCPT TO, DATA o la escritura MIME" +tested: true +tests: + - "envia mensaje texto plano via mock smtp" + - "envia mensaje html via mock smtp" + - "envia con adjunto via mock smtp" +test_file_path: "functions/infra/smtp_send_test.go" +file_path: "functions/infra/smtp_send.go" +--- + +## Ejemplo + +```go +cfg := SMTPConfig{Host: "smtp.gmail.com", Port: 587, Username: "u@gmail.com", Password: "pw", TLSMode: "starttls"} +client, err := SMTPConnect(cfg) +if err != nil { log.Fatal(err) } +defer client.Quit() + +msg := EmailBuildHTML("u@gmail.com", []string{"dest@example.com"}, "Asunto", "Hola") +if err := SMTPSend(client, msg); err != nil { log.Fatal(err) } +``` + +## Notas + +Funcion impura — envia datos por red. El cliente debe estar autenticado (obtenido de `SMTPConnect`). BCC se incluye en RCPT TO pero no en las cabeceras MIME visibles. Los adjuntos se codifican en base64. Para HTML+texto usa `multipart/alternative`; con adjuntos usa `multipart/mixed`. diff --git a/functions/infra/smtp_send_test.go b/functions/infra/smtp_send_test.go new file mode 100644 index 00000000..215cc01e --- /dev/null +++ b/functions/infra/smtp_send_test.go @@ -0,0 +1,152 @@ +package infra + +import ( + "fmt" + "net" + "strings" + "testing" +) + +// startMockSMTPForSend starts a mock SMTP server and returns host/port separately. +func startMockSMTPForSend(t *testing.T) (host string, port int, 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) + } + }() + h, ps, _ := net.SplitHostPort(ln.Addr().String()) + p := 0 + fmt.Sscanf(ps, "%d", &p) + return h, p, func() { ln.Close() } +} + + +func TestSMTPSend(t *testing.T) { + t.Run("envia mensaje texto plano via mock smtp", func(t *testing.T) { + host, port, stop := startMockSMTPForSend(t) + defer stop() + + cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""} + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + defer client.Quit() + + msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Test", "Hello Bob") + if err := SMTPSend(client, msg); err != nil { + t.Fatalf("SMTPSend: %v", err) + } + }) + + t.Run("envia mensaje html via mock smtp", func(t *testing.T) { + host, port, stop := startMockSMTPForSend(t) + defer stop() + + cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""} + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + defer client.Quit() + + msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "HTML Test", "Hello") + if err := SMTPSend(client, msg); err != nil { + t.Fatalf("SMTPSend: %v", err) + } + }) + + t.Run("envia con adjunto via mock smtp", func(t *testing.T) { + host, port, stop := startMockSMTPForSend(t) + defer stop() + + cfg := SMTPConfig{Host: host, Port: port, TLSMode: ""} + client, err := SMTPConnect(cfg) + if err != nil { + t.Fatalf("SMTPConnect: %v", err) + } + defer client.Quit() + + msg := EmailBuildText("alice@example.com", []string{"bob@example.com"}, "Att Test", "See attachment") + att := EmailAttachment{Filename: "data.txt", ContentType: "text/plain", Data: []byte("file content")} + msg = EmailWithAttachment(msg, att) + + if err := SMTPSend(client, msg); err != nil { + t.Fatalf("SMTPSend with attachment: %v", err) + } + }) +} + +// TestExtractAddr tests the internal extractAddr helper. +func TestExtractAddr(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"Alice ", "alice@example.com"}, + {"bob@example.com", "bob@example.com"}, + {" carol@example.com ", "carol@example.com"}, + } + for _, c := range cases { + got := extractAddr(c.input) + if got != c.want { + t.Errorf("extractAddr(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +// TestBuildMIME tests the MIME builder with different body combinations. +func TestBuildMIME(t *testing.T) { + t.Run("solo texto plano", func(t *testing.T) { + msg := EmailBuildText("a@a.com", []string{"b@b.com"}, "Subj", "Hello plain") + body, err := buildMIME(msg) + if err != nil { + t.Fatalf("buildMIME: %v", err) + } + s := string(body) + if !strings.Contains(s, "text/plain") { + t.Errorf("expected text/plain in MIME, got:\n%s", s) + } + if !strings.Contains(s, "Hello plain") { + t.Errorf("expected body text in MIME, got:\n%s", s) + } + }) + + t.Run("solo html", func(t *testing.T) { + msg := EmailBuildHTML("a@a.com", []string{"b@b.com"}, "Subj", "Bold") + body, err := buildMIME(msg) + if err != nil { + t.Fatalf("buildMIME: %v", err) + } + s := string(body) + if !strings.Contains(s, "text/html") { + t.Errorf("expected text/html in MIME, got:\n%s", s) + } + }) + + t.Run("multipart con adjunto", func(t *testing.T) { + msg := EmailBuildText("a@a.com", []string{"b@b.com"}, "Subj", "body") + att := EmailAttachment{Filename: "f.pdf", ContentType: "application/pdf", Data: []byte("pdf")} + msg = EmailWithAttachment(msg, att) + body, err := buildMIME(msg) + if err != nil { + t.Fatalf("buildMIME: %v", err) + } + s := string(body) + if !strings.Contains(s, "multipart/mixed") { + t.Errorf("expected multipart/mixed in MIME, got:\n%s", s) + } + if !strings.Contains(s, "f.pdf") { + t.Errorf("expected attachment filename in MIME, got:\n%s", s) + } + }) +} diff --git a/python/functions/infra/email_build_html.md b/python/functions/infra/email_build_html.md new file mode 100644 index 00000000..5cb470b8 --- /dev/null +++ b/python/functions/infra/email_build_html.md @@ -0,0 +1,54 @@ +--- +name: email_build_html +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: pure +signature: "email_build_html(from_addr: str, to: list[str], subject: str, body_html: str) -> EmailMessagePy" +description: "Construye un EmailMessagePy inmutable con cuerpo HTML. El campo body_text queda vacio. Funcion pura sin efectos secundarios." +tags: [email, html, builder, pure, python, dataclass] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: ["dataclasses"] +params: + - name: from_addr + desc: "direccion del remitente (ej: 'alice@example.com')" + - name: to + desc: "lista de direcciones de destinatarios principales" + - name: subject + desc: "asunto del correo" + - name: body_html + desc: "contenido HTML del cuerpo del mensaje" +output: "EmailMessagePy inmutable con from_addr, to (como tupla), subject y body_html; body_text vacio" +tested: true +tests: + - "construye mensaje html con campos correctos" + - "convierte lista to a tupla inmutable" + - "body_text queda vacio" +test_file_path: "python/functions/infra/email_build_html_test.py" +file_path: "python/functions/infra/email_build_html.py" +--- + +## Ejemplo + +```python +from infra.email_build_html import email_build_html + +msg = email_build_html( + from_addr="alice@example.com", + to=["bob@example.com", "carol@example.com"], + subject="Reporte mensual", + body_html="

Hola

Ver adjunto.

", +) +assert msg.body_html == "

Hola

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="

Hola

", + body_text="Hola", +) +``` + +## Notas + +Funcion impura — abre conexion TCP real. Usa solo stdlib Python (smtplib, email). Para TLS directo (port 465) usa `SMTP_SSL`; para STARTTLS (port 587) usa `SMTP` + `starttls()`. Los adjuntos se codifican en base64. BCC se incluye en `sendmail` pero no en las cabeceras MIME visibles. diff --git a/python/functions/infra/smtp_send.py b/python/functions/infra/smtp_send.py new file mode 100644 index 00000000..8d530511 --- /dev/null +++ b/python/functions/infra/smtp_send.py @@ -0,0 +1,169 @@ +"""Envio de email via SMTP con smtplib — sin dependencias externas.""" + +import smtplib +from dataclasses import dataclass, field +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email import encoders + + +@dataclass(frozen=True) +class EmailAttachmentPy: + """Archivo adjunto a un email.""" + filename: str + content_type: str # MIME type, ej: "application/pdf" + data: bytes + + +@dataclass(frozen=True) +class SMTPConfigPy: + """Parametros de conexion SMTP. + + tls_mode puede ser: + "tls" — TLS directo (port 465) + "starttls" — upgrade STARTTLS (port 587) + "" — sin cifrado (port 25) + """ + host: str + port: int + username: str = "" + password: str = "" + tls_mode: str = "starttls" # "tls", "starttls" o "" + + +def 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: + """Conecta al servidor SMTP, envia el email y cierra la conexion. + + Construye el mensaje MIME segun el contenido: + - Solo texto: text/plain + - Solo HTML: text/html + - Ambos: multipart/alternative + - Con adjuntos: multipart/mixed + + Args: + cfg: Configuracion SMTP (host, port, usuario, password, tls_mode). + from_addr: Direccion del remitente. + to: Lista de destinatarios principales. + subject: Asunto del correo. + body_html: Cuerpo HTML (opcional). + body_text: Cuerpo texto plano (opcional). + cc: Lista de destinatarios en copia (opcional). + bcc: Lista de destinatarios en copia oculta (opcional). + attachments: Lista de adjuntos EmailAttachmentPy (opcional). + headers: Headers MIME adicionales como diccionario (opcional). + + Raises: + RuntimeError: Si la conexion SMTP falla, la autenticacion es incorrecta + o el envio no se puede completar. + """ + cc = cc or [] + bcc = bcc or [] + attachments = attachments or [] + headers = headers or {} + + msg = _build_mime(from_addr, to, cc, subject, body_html, body_text, attachments, headers) + + all_recipients = list(to) + list(cc) + list(bcc) + + try: + if cfg.tls_mode == "tls": + smtp = smtplib.SMTP_SSL(cfg.host, cfg.port) + else: + smtp = smtplib.SMTP(cfg.host, cfg.port) + + with smtp: + if cfg.tls_mode == "starttls": + smtp.starttls() + if cfg.username: + smtp.login(cfg.username, cfg.password) + smtp.sendmail(from_addr, all_recipients, msg.as_string()) + + except smtplib.SMTPException as e: + raise RuntimeError(f"smtp_send: SMTP error: {e}") from e + except OSError as e: + raise RuntimeError(f"smtp_send: connection error to {cfg.host}:{cfg.port}: {e}") from e + + +def _build_mime( + from_addr: str, + to: list[str], + cc: list[str], + subject: str, + body_html: str, + body_text: str, + attachments: list[EmailAttachmentPy], + headers: dict[str, str], +) -> MIMEMultipart | MIMEText: + """Construye la estructura MIME del mensaje.""" + has_text = bool(body_text) + has_html = bool(body_html) + has_atts = bool(attachments) + + if has_atts: + outer = MIMEMultipart("mixed") + _set_headers(outer, from_addr, to, cc, subject, headers) + body_part = _build_body_part(body_html, body_text, has_html, has_text) + outer.attach(body_part) + for att in attachments: + outer.attach(_build_attachment(att)) + return outer + + if has_html and has_text: + alt = MIMEMultipart("alternative") + _set_headers(alt, from_addr, to, cc, subject, headers) + alt.attach(MIMEText(body_text, "plain", "utf-8")) + alt.attach(MIMEText(body_html, "html", "utf-8")) + return alt + + if has_html: + msg = MIMEText(body_html, "html", "utf-8") + _set_headers(msg, from_addr, to, cc, subject, headers) + return msg + + msg = MIMEText(body_text or "", "plain", "utf-8") + _set_headers(msg, from_addr, to, cc, subject, headers) + return msg + + +def _build_body_part( + body_html: str, body_text: str, has_html: bool, has_text: bool +) -> MIMEBase: + if has_html and has_text: + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(body_text, "plain", "utf-8")) + alt.attach(MIMEText(body_html, "html", "utf-8")) + return alt + if has_html: + return MIMEText(body_html, "html", "utf-8") + return MIMEText(body_text, "plain", "utf-8") + + +def _set_headers(msg: MIMEBase, from_addr: str, to: list[str], cc: list[str], subject: str, extra: dict[str, str]) -> None: + msg["From"] = from_addr + msg["To"] = ", ".join(to) + if cc: + msg["Cc"] = ", ".join(cc) + msg["Subject"] = subject + for k, v in extra.items(): + msg[k] = v + + +def _build_attachment(att: EmailAttachmentPy) -> MIMEBase: + maintype, subtype = att.content_type.split("/", 1) if "/" in att.content_type else ("application", "octet-stream") + part = MIMEBase(maintype, subtype) + part.set_payload(att.data) + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=att.filename) + return part diff --git a/python/functions/infra/smtp_send_test.py b/python/functions/infra/smtp_send_test.py new file mode 100644 index 00000000..1e336f11 --- /dev/null +++ b/python/functions/infra/smtp_send_test.py @@ -0,0 +1,149 @@ +"""Tests para smtp_send usando un mock SMTP server con smtpd/aiosmtpd o threading.""" + +import smtplib +import socket +import sys +import os +import threading +import time +from io import StringIO + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from infra.smtp_send import smtp_send, SMTPConfigPy, EmailAttachmentPy + + +def _find_free_port() -> int: + """Encuentra un puerto libre en 127.0.0.1.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _MockSMTPHandler: + """Handler de conexion SMTP minimal para tests.""" + + def __init__(self, conn: socket.socket): + self.conn = conn + + def run(self): + f = self.conn.makefile("rb") + try: + self._send(b"220 mock SMTP\r\n") + while True: + line = f.readline() + if not line: + break + cmd = line.decode("utf-8", errors="replace").strip().upper() + if cmd.startswith("EHLO") or cmd.startswith("HELO"): + self._send(b"250 mock\r\n") + elif cmd.startswith("MAIL FROM"): + self._send(b"250 OK\r\n") + elif cmd.startswith("RCPT TO"): + self._send(b"250 OK\r\n") + elif cmd.startswith("DATA"): + self._send(b"354 End with .\r\n") + while True: + dl = f.readline() + if dl.strip() == b".": + break + self._send(b"250 OK\r\n") + elif cmd.startswith("QUIT"): + self._send(b"221 Bye\r\n") + break + else: + self._send(b"502 Not recognized\r\n") + finally: + self.conn.close() + + def _send(self, data: bytes): + self.conn.sendall(data) + + +class _MockSMTPServer: + """SMTP server minimal corriendo en thread para tests.""" + + def __init__(self): + self.port = _find_free_port() + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind(("127.0.0.1", self.port)) + self._sock.listen(5) + self._sock.settimeout(2.0) + self._stop = threading.Event() + self._thread = threading.Thread(target=self._serve, daemon=True) + + def start(self): + self._thread.start() + return self + + def stop(self): + self._stop.set() + self._sock.close() + + def _serve(self): + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + t = threading.Thread(target=_MockSMTPHandler(conn).run, daemon=True) + t.start() + except OSError: + break + + def cfg(self) -> SMTPConfigPy: + return SMTPConfigPy(host="127.0.0.1", port=self.port, tls_mode="") + + +def test_envia_texto_plano_via_mock_smtpd(): + srv = _MockSMTPServer().start() + try: + smtp_send( + srv.cfg(), + from_addr="alice@example.com", + to=["bob@example.com"], + subject="Test plain", + body_text="Hello Bob", + ) + finally: + srv.stop() + + +def test_envia_html_via_mock_smtpd(): + srv = _MockSMTPServer().start() + try: + smtp_send( + srv.cfg(), + from_addr="alice@example.com", + to=["bob@example.com"], + subject="Test HTML", + body_html="Hello", + ) + finally: + srv.stop() + + +def test_envia_con_adjunto_via_mock_smtpd(): + srv = _MockSMTPServer().start() + try: + att = EmailAttachmentPy(filename="f.txt", content_type="text/plain", data=b"file data") + smtp_send( + srv.cfg(), + from_addr="alice@example.com", + to=["bob@example.com"], + subject="Test attachment", + body_text="See attachment", + attachments=[att], + ) + finally: + srv.stop() + + +def test_error_si_host_no_existe(): + port = _find_free_port() + # Port is free but nothing listens — should get connection refused + cfg = SMTPConfigPy(host="127.0.0.1", port=port, tls_mode="") + try: + smtp_send(cfg, "a@x.com", ["b@x.com"], "sub", body_text="hi") + assert False, "Expected RuntimeError" + except RuntimeError: + pass diff --git a/types/infra/email_attachment.md b/types/infra/email_attachment.md new file mode 100644 index 00000000..5b43bc65 --- /dev/null +++ b/types/infra/email_attachment.md @@ -0,0 +1,31 @@ +--- +name: EmailAttachment +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type EmailAttachment struct { + Filename string + ContentType string + Data []byte + } +description: "Archivo adjunto a un email. Contiene nombre, MIME type y datos binarios." +tags: [email, smtp, attachment, mime] +uses_types: [] +file_path: "functions/infra/email_types.go" +--- + +## Ejemplo + +```go +att := EmailAttachment{ + Filename: "report.pdf", + ContentType: "application/pdf", + Data: pdfBytes, +} +``` + +## Notas + +Tipo producto — todos los campos obligatorios. `Data` es el contenido binario crudo; se codifica en base64 al serializar en MIME. Usado por `EmailMessage.Attachments`. diff --git a/types/infra/email_message.md b/types/infra/email_message.md new file mode 100644 index 00000000..fc43f4d9 --- /dev/null +++ b/types/infra/email_message.md @@ -0,0 +1,33 @@ +--- +name: EmailMessage +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type EmailMessage struct { + From string + To []string + CC []string + BCC []string + Subject string + BodyHTML string + BodyText string + Attachments []EmailAttachment + Headers map[string]string + } +description: "Mensaje de email listo para enviar. Inmutable — construir con EmailBuildHTML, EmailBuildText o EmailWithAttachment." +tags: [email, smtp, message, mime] +uses_types: [EmailAttachment_go_infra] +file_path: "functions/infra/email_types.go" +--- + +## Ejemplo + +```go +msg := EmailBuildHTML("alice@example.com", []string{"bob@example.com"}, "Hola", "Hola Bob") +``` + +## Notas + +Tipo producto. `BodyHTML` y `BodyText` son opcionales pero al menos uno debe estar presente para que el mensaje sea valido. `Attachments` puede ser nil. `Headers` permite agregar cabeceras MIME custom como `X-Mailer`. diff --git a/types/infra/smtp_config.md b/types/infra/smtp_config.md new file mode 100644 index 00000000..be3c3331 --- /dev/null +++ b/types/infra/smtp_config.md @@ -0,0 +1,35 @@ +--- +name: SMTPConfig +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type SMTPConfig struct { + Host string + Port int + Username string + Password string + TLSMode string + } +description: "Parametros de conexion a un servidor SMTP. TLSMode controla el cifrado: 'tls' (port 465), 'starttls' (port 587) o '' sin cifrado (port 25)." +tags: [email, smtp, config, tls] +uses_types: [] +file_path: "functions/infra/email_types.go" +--- + +## Ejemplo + +```go +cfg := SMTPConfig{ + Host: "smtp.gmail.com", + Port: 587, + Username: "user@gmail.com", + Password: "app-password", + TLSMode: "starttls", +} +``` + +## Notas + +Tipo producto. `TLSMode` debe ser `"tls"`, `"starttls"` o `""`. Por convencion: port 465 usa TLS directo, 587 usa STARTTLS, 25 sin cifrado. Para Gmail y proveedores modernos usar `"starttls"` en port 587 o `"tls"` en 465.