merge: issue/0012-email-smtp — Email SMTP functions
This commit is contained in:
@@ -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"},
|
||||
"<h1>Assertion fallida</h1><p>Entity X tiene valor fuera de rango.</p>",
|
||||
)
|
||||
|
||||
// Anadir adjunto (puro, retorna copia)
|
||||
reportData, _ := os.ReadFile("report.csv")
|
||||
msg = infra.EmailWithAttachment(msg, "report.csv", "text/csv", reportData)
|
||||
|
||||
// Enviar (impuro)
|
||||
client, err := infra.SmtpConnect(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := infra.SmtpSend(client, msg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Go: email desde template
|
||||
|
||||
```go
|
||||
tmpl := `Hola {{.Nombre}},
|
||||
|
||||
El pipeline {{.Pipeline}} finalizó con status: {{.Status}}.
|
||||
Registros procesados: {{.Records}}
|
||||
Duración: {{.Duration}}
|
||||
|
||||
— fn_registry`
|
||||
|
||||
body, err := infra.EmailTemplateRender(tmpl, map[string]any{
|
||||
"Nombre": "Lucas",
|
||||
"Pipeline": "sync_metabase",
|
||||
"Status": "success",
|
||||
"Records": 1523,
|
||||
"Duration": "4.2s",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
msg := infra.EmailBuildText(
|
||||
"system@midominio.com",
|
||||
"Pipeline sync_metabase completado",
|
||||
[]string{"lucas@midominio.com"},
|
||||
body,
|
||||
)
|
||||
|
||||
client, _ := infra.SmtpConnect(cfg)
|
||||
defer client.Close()
|
||||
infra.SmtpSend(client, msg)
|
||||
```
|
||||
|
||||
### Python: enviar alerta desde un analysis notebook
|
||||
|
||||
```python
|
||||
from infra import smtp_send, email_build_html, SMTPConfig, EmailMessage
|
||||
|
||||
cfg = SMTPConfig(
|
||||
host="smtp.gmail.com",
|
||||
port=587,
|
||||
username="alerts@midominio.com",
|
||||
password=os.environ["SMTP_PASSWORD"],
|
||||
tls_mode="starttls",
|
||||
)
|
||||
|
||||
msg = email_build_html(
|
||||
from_addr="alerts@midominio.com",
|
||||
subject="Anomalia detectada en dataset",
|
||||
to=["lucas@midominio.com"],
|
||||
html="<h2>Anomalia</h2><p>3 outliers detectados en columna revenue.</p>",
|
||||
)
|
||||
|
||||
smtp_send(cfg, msg)
|
||||
```
|
||||
|
||||
### Composicion con el bucle reactivo
|
||||
|
||||
```go
|
||||
// En el paso MEJORAR del bucle reactivo, cuando una assertion critica falla:
|
||||
func notifyAssertionFailure(cfg infra.SMTPConfig, entity Entity, assertion Assertion, result AssertionResult) error {
|
||||
tmpl := `Assertion "{{.Assertion}}" FALLÓ para entity {{.Entity}}.
|
||||
Valor actual: {{.Value}}
|
||||
Regla: {{.Rule}}
|
||||
Timestamp: {{.Timestamp}}`
|
||||
|
||||
body, err := infra.EmailTemplateRender(tmpl, map[string]any{
|
||||
"Assertion": assertion.Name,
|
||||
"Entity": entity.ID,
|
||||
"Value": result.Value,
|
||||
"Rule": assertion.Rule,
|
||||
"Timestamp": result.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := infra.EmailBuildText(cfg.Username, "ALERTA: "+assertion.Name, []string{"lucas@midominio.com"}, body)
|
||||
|
||||
client, err := infra.SmtpConnect(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
return infra.SmtpSend(client, msg)
|
||||
}
|
||||
```
|
||||
|
||||
## Decisiones de diseno
|
||||
|
||||
- **Solo stdlib `net/smtp` + `crypto/tls` (Go) y `smtplib` + `email` (Python):** sin dependencias externas. Go stdlib es suficiente para SMTP con TLS/STARTTLS y MIME multipart. No se necesita gomail, jordan-wright/email ni similares.
|
||||
- **Separar conexion de envio en Go:** `smtp_connect` retorna el client, `smtp_send` lo usa. Esto permite reusar una conexion para multiples envios (batch) sin reconectar cada vez. En Python se unifica porque `smtplib` maneja el ciclo de vida internamente con context managers.
|
||||
- **Builders puros retornan structs, no bytes:** las funciones `email_build_*` retornan `EmailMessage`, no el MIME serializado. La serializacion a bytes MIME ocurre dentro de `smtp_send` (impuro). Esto permite inspeccionar, modificar y componer mensajes antes de enviar.
|
||||
- **`email_with_attachment` no muta:** retorna una copia del `EmailMessage` con el adjunto anadido. Coherente con la regla de pureza — sin mutacion.
|
||||
- **`email_template_render` es generico:** renderiza cualquier template Go a string. No sabe nada de email — el resultado se pasa a `email_build_text` o `email_build_html`. Reutilizable para generar otros textos.
|
||||
- **TLSMode como string (`"tls"`, `"starttls"`, `"none"`):** mas explicito que un booleano. Cubre los 3 escenarios reales: TLS directo (465), STARTTLS (587), sin cifrado (25/internal).
|
||||
- **Headers como `map[string]string`:** permite anadir headers custom (Reply-To, X-Priority, List-Unsubscribe) sin cambiar la estructura del tipo.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
Ninguno. Solo Go stdlib y Python stdlib.
|
||||
|
||||
Para tests: un servidor SMTP de prueba local o mocks. Go tiene primitivas en `net` para levantar un listener TCP. Python puede usar `aiosmtpd` o mocks de `smtplib`.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Credenciales SMTP hardcodeadas:** Mitigado por diseno — `SMTPConfig` recibe los valores, nunca los lee de archivos ni env vars directamente. El caller es responsable de obtener las credenciales (env vars, secrets manager, etc.).
|
||||
- **Emails enviados accidentalmente en tests:** Mitigado separando conexion de envio. Los tests de funciones puras no tocan la red. Los tests de funciones impuras usan un SMTP server mock local.
|
||||
- **Rate limiting del proveedor SMTP:** No se maneja en v1. Si una app necesita enviar muchos emails, deberia implementar rate limiting en la capa de aplicacion (fuera del scope de estas funciones atomicas).
|
||||
- **HTML injection en templates:** `email_template_render` usa `text/template`, no `html/template`, asi que no escapa HTML automaticamente. Documentar que el caller es responsable de sanitizar inputs si el template genera HTML.
|
||||
- **Compatibilidad MIME con clientes de email:** El formato multipart generado debe ser compatible con Gmail, Outlook, Apple Mail. Testar con al menos 2 clientes reales antes de cerrar la issue.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 <alice@example.com>')"
|
||||
- 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",
|
||||
"<h1>Hola</h1><p>Ver adjunto.</p>",
|
||||
)
|
||||
// msg.BodyHTML = "<h1>Hola</h1><p>Ver adjunto.</p>"
|
||||
// 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`.
|
||||
@@ -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", "<b>Hola</b>")
|
||||
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 != "<b>Hola</b>" {
|
||||
t.Errorf("BodyHTML: got %q, want %q", msg.BodyHTML, "<b>Hola</b>")
|
||||
}
|
||||
})
|
||||
|
||||
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: "<p>Hi</p>",
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 <alice@example.com>')"
|
||||
- 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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 <alice@example.com>")
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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", "<p>Ver adjunto</p>")
|
||||
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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>" → "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()
|
||||
}
|
||||
@@ -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", "<b>Hola</b>")
|
||||
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`.
|
||||
@@ -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", "<b>Hello</b>")
|
||||
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>", "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", "<b>Bold</b>")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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="<h1>Hola</h1><p>Ver adjunto.</p>",
|
||||
)
|
||||
assert msg.body_html == "<h1>Hola</h1><p>Ver adjunto.</p>"
|
||||
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.
|
||||
@@ -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="",
|
||||
)
|
||||
@@ -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="<b>Hola Bob</b>",
|
||||
)
|
||||
assert msg.from_addr == "alice@example.com"
|
||||
assert msg.subject == "Hola"
|
||||
assert msg.body_html == "<b>Hola Bob</b>"
|
||||
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", "<p>html</p>")
|
||||
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
|
||||
@@ -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="<h1>Hola</h1>",
|
||||
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.
|
||||
@@ -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
|
||||
@@ -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="<b>Hello</b>",
|
||||
)
|
||||
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
|
||||
@@ -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`.
|
||||
@@ -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", "<b>Hola Bob</b>")
|
||||
```
|
||||
|
||||
## 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`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user