From 53deb8e9a8b70e60984b115af2c0e33bada9e230 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 13 Apr 2026 02:01:13 +0200 Subject: [PATCH] feat: tipos y funciones email SMTP en Go (infra) Tipos: EmailAttachment, EmailMessage, SMTPConfig. Puras: email_build_html, email_build_text, email_with_attachment, email_template_render. Impuras: smtp_connect (TLS/STARTTLS/plain), smtp_send (MIME multipart con adjuntos). Solo stdlib: net/smtp, crypto/tls, text/template, mime/multipart. Co-Authored-By: Claude Sonnet 4.6 --- functions/infra/email_build_html.go | 17 +++ functions/infra/email_build_html.md | 51 ++++++++ functions/infra/email_build_text.go | 17 +++ functions/infra/email_build_text.md | 50 ++++++++ functions/infra/email_template_render.go | 24 ++++ functions/infra/email_template_render.md | 46 +++++++ functions/infra/email_types.go | 36 ++++++ functions/infra/email_with_attachment.go | 38 ++++++ functions/infra/email_with_attachment.md | 44 +++++++ functions/infra/smtp_connect.go | 81 ++++++++++++ functions/infra/smtp_connect.md | 43 +++++++ functions/infra/smtp_send.go | 154 +++++++++++++++++++++++ functions/infra/smtp_send.md | 46 +++++++ types/infra/email_attachment.md | 31 +++++ types/infra/email_message.md | 33 +++++ types/infra/smtp_config.md | 35 ++++++ 16 files changed, 746 insertions(+) create mode 100644 functions/infra/email_build_html.go create mode 100644 functions/infra/email_build_html.md create mode 100644 functions/infra/email_build_text.go create mode 100644 functions/infra/email_build_text.md create mode 100644 functions/infra/email_template_render.go create mode 100644 functions/infra/email_template_render.md create mode 100644 functions/infra/email_types.go create mode 100644 functions/infra/email_with_attachment.go create mode 100644 functions/infra/email_with_attachment.md create mode 100644 functions/infra/smtp_connect.go create mode 100644 functions/infra/smtp_connect.md create mode 100644 functions/infra/smtp_send.go create mode 100644 functions/infra/smtp_send.md create mode 100644 types/infra/email_attachment.md create mode 100644 types/infra/email_message.md create mode 100644 types/infra/smtp_config.md 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.