sender.go

  1package sender
  2
  3import (
  4	"bytes"
  5	"crypto/rand"
  6	"encoding/base64"
  7	"fmt"
  8	"mime"
  9	"mime/multipart"
 10	"net/smtp"
 11	"net/textproto"
 12	"path/filepath"
 13	"strings"
 14	"time"
 15
 16	"github.com/floatpane/matcha/config"
 17)
 18
 19// generateMessageID creates a unique Message-ID header.
 20func generateMessageID(from string) string {
 21	buf := make([]byte, 16)
 22	_, err := rand.Read(buf)
 23	if err != nil {
 24		return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
 25	}
 26	return fmt.Sprintf("<%x@%s>", buf, from)
 27}
 28
 29// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
 30func SendEmail(account *config.Account, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error {
 31	smtpServer := account.GetSMTPServer()
 32	smtpPort := account.GetSMTPPort()
 33
 34	if smtpServer == "" {
 35		return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
 36	}
 37
 38	auth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
 39
 40	fromHeader := account.Email
 41	if account.Name != "" {
 42		fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.Email)
 43	}
 44
 45	// Main message buffer
 46	var msg bytes.Buffer
 47	mainWriter := multipart.NewWriter(&msg)
 48
 49	// Set top-level headers for a mixed message type to support content and attachments
 50	headers := map[string]string{
 51		"From":         fromHeader,
 52		"To":           to[0],
 53		"Subject":      subject,
 54		"Date":         time.Now().Format(time.RFC1123Z),
 55		"Message-ID":   generateMessageID(account.Email),
 56		"Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
 57	}
 58
 59	if inReplyTo != "" {
 60		headers["In-Reply-To"] = inReplyTo
 61		if len(references) > 0 {
 62			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
 63		} else {
 64			headers["References"] = inReplyTo
 65		}
 66	}
 67
 68	for k, v := range headers {
 69		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 70	}
 71	fmt.Fprintf(&msg, "\r\n") // End of headers
 72
 73	// --- Body Part (multipart/related) ---
 74	// This part contains the multipart/alternative (text/html) and any inline images.
 75	relatedHeader := textproto.MIMEHeader{}
 76	relatedBoundary := "related-" + mainWriter.Boundary()
 77	relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary)
 78	relatedPartWriter, err := mainWriter.CreatePart(relatedHeader)
 79	if err != nil {
 80		return err
 81	}
 82	relatedWriter := multipart.NewWriter(relatedPartWriter)
 83	relatedWriter.SetBoundary(relatedBoundary)
 84
 85	// --- Alternative Part (text and html) ---
 86	altHeader := textproto.MIMEHeader{}
 87	altBoundary := "alt-" + mainWriter.Boundary()
 88	altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
 89	altPartWriter, err := relatedWriter.CreatePart(altHeader)
 90	if err != nil {
 91		return err
 92	}
 93	altWriter := multipart.NewWriter(altPartWriter)
 94	altWriter.SetBoundary(altBoundary)
 95
 96	// Plain text part
 97	textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
 98	if err != nil {
 99		return err
100	}
101	fmt.Fprint(textPart, plainBody)
102
103	// HTML part
104	htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
105	if err != nil {
106		return err
107	}
108	fmt.Fprint(htmlPart, htmlBody)
109
110	altWriter.Close() // Finish the alternative part
111
112	// --- Inline Images ---
113	for cid, data := range images {
114		ext := filepath.Ext(strings.Split(cid, "@")[0])
115		mimeType := mime.TypeByExtension(ext)
116		if mimeType == "" {
117			mimeType = "application/octet-stream"
118		}
119
120		imgHeader := textproto.MIMEHeader{}
121		imgHeader.Set("Content-Type", mimeType)
122		imgHeader.Set("Content-Transfer-Encoding", "base64")
123		imgHeader.Set("Content-ID", "<"+cid+">")
124		imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
125
126		imgPart, err := relatedWriter.CreatePart(imgHeader)
127		if err != nil {
128			return err
129		}
130		imgPart.Write(data) // data is already base64 encoded
131	}
132
133	relatedWriter.Close() // Finish the related part
134
135	// --- Attachments ---
136	for filename, data := range attachments {
137		mimeType := mime.TypeByExtension(filepath.Ext(filename))
138		if mimeType == "" {
139			mimeType = "application/octet-stream"
140		}
141
142		partHeader := textproto.MIMEHeader{}
143		partHeader.Set("Content-Type", mimeType)
144		partHeader.Set("Content-Transfer-Encoding", "base64")
145		partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
146
147		attachmentPart, err := mainWriter.CreatePart(partHeader)
148		if err != nil {
149			return err
150		}
151		encodedData := base64.StdEncoding.EncodeToString(data)
152		attachmentPart.Write([]byte(encodedData))
153	}
154
155	mainWriter.Close() // Finish the main message
156
157	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
158	return smtp.SendMail(addr, auth, account.Email, to, msg.Bytes())
159}