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