ef3ae2aae9
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>
155 lines
4.4 KiB
Go
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()
|
|
}
|