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}