merge: issue/0012-email-smtp — Email SMTP functions

This commit is contained in:
2026-04-13 02:05:03 +02:00
27 changed files with 2068 additions and 0 deletions
+17
View File
@@ -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,
}
}
+51
View File
@@ -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`.
+125
View File
@@ -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)
}
})
}
+17
View File
@@ -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,
}
}
+50
View File
@@ -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`.
+24
View File
@@ -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
}
+46
View File
@@ -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)
}
})
}
+36
View File
@@ -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)
}
+38
View File
@@ -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,
}
}
+44
View File
@@ -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.
+81
View File
@@ -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
}
}
+43
View File
@@ -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.
+120
View File
@@ -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")
}
})
}
+154
View File
@@ -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()
}
+46
View File
@@ -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`.
+152
View File
@@ -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)
}
})
}