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}