Files
egutierrez ef3ae2aae9 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 <noreply@anthropic.com>
2026-04-13 02:01:13 +02:00

155 lines
4.4 KiB
Go

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()
}