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, cc, bcc []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.FetchEmail
 41	if account.Name != "" {
 42		fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
 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":           strings.Join(to, ", "),
 53		"Subject":      subject,
 54		"Date":         time.Now().Format(time.RFC1123Z),
 55		"Message-ID":   generateMessageID(account.FetchEmail),
 56		"Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
 57	}
 58
 59	if len(cc) > 0 {
 60		headers["Cc"] = strings.Join(cc, ", ")
 61	}
 62
 63	if inReplyTo != "" {
 64		headers["In-Reply-To"] = inReplyTo
 65		if len(references) > 0 {
 66			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
 67		} else {
 68			headers["References"] = inReplyTo
 69		}
 70	}
 71
 72	for k, v := range headers {
 73		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 74	}
 75	fmt.Fprintf(&msg, "\r\n") // End of headers
 76
 77	// --- Body Part (multipart/related) ---
 78	// This part contains the multipart/alternative (text/html) and any inline images.
 79	relatedHeader := textproto.MIMEHeader{}
 80	relatedBoundary := "related-" + mainWriter.Boundary()
 81	relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary)
 82	relatedPartWriter, err := mainWriter.CreatePart(relatedHeader)
 83	if err != nil {
 84		return err
 85	}
 86	relatedWriter := multipart.NewWriter(relatedPartWriter)
 87	relatedWriter.SetBoundary(relatedBoundary)
 88
 89	// --- Alternative Part (text and html) ---
 90	altHeader := textproto.MIMEHeader{}
 91	altBoundary := "alt-" + mainWriter.Boundary()
 92	altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
 93	altPartWriter, err := relatedWriter.CreatePart(altHeader)
 94	if err != nil {
 95		return err
 96	}
 97	altWriter := multipart.NewWriter(altPartWriter)
 98	altWriter.SetBoundary(altBoundary)
 99
100	// Plain text part
101	textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
102	if err != nil {
103		return err
104	}
105	fmt.Fprint(textPart, plainBody)
106
107	// HTML part
108	htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
109	if err != nil {
110		return err
111	}
112	fmt.Fprint(htmlPart, htmlBody)
113
114	altWriter.Close() // Finish the alternative part
115
116	// --- Inline Images ---
117	for cid, data := range images {
118		ext := filepath.Ext(strings.Split(cid, "@")[0])
119		mimeType := mime.TypeByExtension(ext)
120		if mimeType == "" {
121			mimeType = "application/octet-stream"
122		}
123
124		imgHeader := textproto.MIMEHeader{}
125		imgHeader.Set("Content-Type", mimeType)
126		imgHeader.Set("Content-Transfer-Encoding", "base64")
127		imgHeader.Set("Content-ID", "<"+cid+">")
128		imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
129
130		imgPart, err := relatedWriter.CreatePart(imgHeader)
131		if err != nil {
132			return err
133		}
134		// data is already base64 encoded, but needs MIME line wrapping (76 chars per line)
135		imgPart.Write([]byte(wrapBase64(string(data))))
136	}
137
138	relatedWriter.Close() // Finish the related part
139
140	// --- Attachments ---
141	for filename, data := range attachments {
142		mimeType := mime.TypeByExtension(filepath.Ext(filename))
143		if mimeType == "" {
144			mimeType = "application/octet-stream"
145		}
146
147		partHeader := textproto.MIMEHeader{}
148		partHeader.Set("Content-Type", mimeType)
149		partHeader.Set("Content-Transfer-Encoding", "base64")
150		partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
151
152		attachmentPart, err := mainWriter.CreatePart(partHeader)
153		if err != nil {
154			return err
155		}
156		encodedData := base64.StdEncoding.EncodeToString(data)
157		// MIME requires base64 to be line-wrapped at 76 characters
158		attachmentPart.Write([]byte(wrapBase64(encodedData)))
159	}
160
161	mainWriter.Close() // Finish the main message
162
163	// Combine all recipients for the envelope
164	allRecipients := append([]string{}, to...)
165	allRecipients = append(allRecipients, cc...)
166	allRecipients = append(allRecipients, bcc...)
167
168	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
169	return smtp.SendMail(addr, auth, account.Email, allRecipients, msg.Bytes())
170}
171
172// wrapBase64 wraps base64-encoded data at 76 characters per line as required by MIME.
173func wrapBase64(data string) string {
174	const lineLength = 76
175	var result strings.Builder
176	for i := 0; i < len(data); i += lineLength {
177		end := i + lineLength
178		if end > len(data) {
179			end = len(data)
180		}
181		result.WriteString(data[i:end])
182		if end < len(data) {
183			result.WriteString("\r\n")
184		}
185	}
186	return result.String()
187}