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>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user