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_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_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_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/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.