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(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error {
 31	var smtpServer string
 32	var smtpPort int
 33
 34	switch cfg.ServiceProvider {
 35	case "gmail":
 36		smtpServer = "smtp.gmail.com"
 37		smtpPort = 587
 38	case "icloud":
 39		smtpServer = "smtp.mail.me.com"
 40		smtpPort = 587
 41	default:
 42		return fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider)
 43	}
 44
 45	auth := smtp.PlainAuth("", cfg.Email, cfg.Password, smtpServer)
 46
 47	fromHeader := cfg.Email
 48	if cfg.Name != "" {
 49		fromHeader = fmt.Sprintf("%s <%s>", cfg.Name, cfg.Email)
 50	}
 51
 52	// Main message buffer
 53	var msg bytes.Buffer
 54	mainWriter := multipart.NewWriter(&msg)
 55
 56	// Set top-level headers for a mixed message type to support content and attachments
 57	headers := map[string]string{
 58		"From":         fromHeader,
 59		"To":           to[0],
 60		"Subject":      subject,
 61		"Date":         time.Now().Format(time.RFC1123Z),
 62		"Message-ID":   generateMessageID(cfg.Email),
 63		"Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
 64	}
 65
 66	if inReplyTo != "" {
 67		headers["In-Reply-To"] = inReplyTo
 68		if len(references) > 0 {
 69			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
 70		} else {
 71			headers["References"] = inReplyTo
 72		}
 73	}
 74
 75	for k, v := range headers {
 76		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 77	}
 78	fmt.Fprintf(&msg, "\r\n") // End of headers
 79
 80	// --- Body Part (multipart/related) ---
 81	// This part contains the multipart/alternative (text/html) and any inline images.
 82	relatedHeader := textproto.MIMEHeader{}
 83	relatedBoundary := "related-" + mainWriter.Boundary()
 84	relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary)
 85	relatedPartWriter, err := mainWriter.CreatePart(relatedHeader)
 86	if err != nil {
 87		return err
 88	}
 89	relatedWriter := multipart.NewWriter(relatedPartWriter)
 90	relatedWriter.SetBoundary(relatedBoundary)
 91
 92	// --- Alternative Part (text and html) ---
 93	altHeader := textproto.MIMEHeader{}
 94	altBoundary := "alt-" + mainWriter.Boundary()
 95	altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
 96	altPartWriter, err := relatedWriter.CreatePart(altHeader)
 97	if err != nil {
 98		return err
 99	}
100	altWriter := multipart.NewWriter(altPartWriter)
101	altWriter.SetBoundary(altBoundary)
102
103	// Plain text part
104	textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
105	if err != nil {
106		return err
107	}
108	fmt.Fprint(textPart, plainBody)
109
110	// HTML part
111	htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
112	if err != nil {
113		return err
114	}
115	fmt.Fprint(htmlPart, htmlBody)
116
117	altWriter.Close() // Finish the alternative part
118
119	// --- Inline Images ---
120	for cid, data := range images {
121		ext := filepath.Ext(strings.Split(cid, "@")[0])
122		mimeType := mime.TypeByExtension(ext)
123		if mimeType == "" {
124			mimeType = "application/octet-stream"
125		}
126
127		imgHeader := textproto.MIMEHeader{}
128		imgHeader.Set("Content-Type", mimeType)
129		imgHeader.Set("Content-Transfer-Encoding", "base64")
130		imgHeader.Set("Content-ID", "<"+cid+">")
131		imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
132
133		imgPart, err := relatedWriter.CreatePart(imgHeader)
134		if err != nil {
135			return err
136		}
137		imgPart.Write(data) // data is already base64 encoded
138	}
139
140	relatedWriter.Close() // Finish the related part
141
142	// --- Attachments ---
143	for filename, data := range attachments {
144		mimeType := mime.TypeByExtension(filepath.Ext(filename))
145		if mimeType == "" {
146			mimeType = "application/octet-stream"
147		}
148
149		partHeader := textproto.MIMEHeader{}
150		partHeader.Set("Content-Type", mimeType)
151		partHeader.Set("Content-Transfer-Encoding", "base64")
152		partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
153
154		attachmentPart, err := mainWriter.CreatePart(partHeader)
155		if err != nil {
156			return err
157		}
158		encodedData := base64.StdEncoding.EncodeToString(data)
159		attachmentPart.Write([]byte(encodedData))
160	}
161
162	mainWriter.Close() // Finish the main message
163
164	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
165	return smtp.SendMail(addr, auth, cfg.Email, to, msg.Bytes())
166}