1package sender
2
3import (
4 "bytes"
5 "crypto/rand"
6 "crypto/tls"
7 "encoding/base64"
8 "fmt"
9 "mime"
10 "mime/multipart"
11 "net/smtp"
12 "net/textproto"
13 "path/filepath"
14 "strings"
15 "time"
16
17 "github.com/floatpane/matcha/config"
18)
19
20// generateMessageID creates a unique Message-ID header.
21func generateMessageID(from string) string {
22 buf := make([]byte, 16)
23 _, err := rand.Read(buf)
24 if err != nil {
25 return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
26 }
27 return fmt.Sprintf("<%x@%s>", buf, from)
28}
29
30// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
31func 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 {
32 smtpServer := account.GetSMTPServer()
33 smtpPort := account.GetSMTPPort()
34
35 if smtpServer == "" {
36 return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
37 }
38
39 auth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
40
41 fromHeader := account.FetchEmail
42 if account.Name != "" {
43 fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
44 }
45
46 // Main message buffer
47 var msg bytes.Buffer
48 mainWriter := multipart.NewWriter(&msg)
49
50 // Set top-level headers for a mixed message type to support content and attachments
51 headers := map[string]string{
52 "From": fromHeader,
53 "To": strings.Join(to, ", "),
54 "Subject": subject,
55 "Date": time.Now().Format(time.RFC1123Z),
56 "Message-ID": generateMessageID(account.FetchEmail),
57 "Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
58 }
59
60 if len(cc) > 0 {
61 headers["Cc"] = strings.Join(cc, ", ")
62 }
63
64 if inReplyTo != "" {
65 headers["In-Reply-To"] = inReplyTo
66 if len(references) > 0 {
67 headers["References"] = strings.Join(references, " ") + " " + inReplyTo
68 } else {
69 headers["References"] = inReplyTo
70 }
71 }
72
73 for k, v := range headers {
74 fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
75 }
76 fmt.Fprintf(&msg, "\r\n") // End of headers
77
78 // --- Body Part (multipart/related) ---
79 // This part contains the multipart/alternative (text/html) and any inline images.
80 relatedHeader := textproto.MIMEHeader{}
81 relatedBoundary := "related-" + mainWriter.Boundary()
82 relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary)
83 relatedPartWriter, err := mainWriter.CreatePart(relatedHeader)
84 if err != nil {
85 return err
86 }
87 relatedWriter := multipart.NewWriter(relatedPartWriter)
88 relatedWriter.SetBoundary(relatedBoundary)
89
90 // --- Alternative Part (text and html) ---
91 altHeader := textproto.MIMEHeader{}
92 altBoundary := "alt-" + mainWriter.Boundary()
93 altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
94 altPartWriter, err := relatedWriter.CreatePart(altHeader)
95 if err != nil {
96 return err
97 }
98 altWriter := multipart.NewWriter(altPartWriter)
99 altWriter.SetBoundary(altBoundary)
100
101 // Plain text part
102 textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
103 if err != nil {
104 return err
105 }
106 fmt.Fprint(textPart, plainBody)
107
108 // HTML part
109 htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
110 if err != nil {
111 return err
112 }
113 fmt.Fprint(htmlPart, htmlBody)
114
115 altWriter.Close() // Finish the alternative part
116
117 // --- Inline Images ---
118 for cid, data := range images {
119 ext := filepath.Ext(strings.Split(cid, "@")[0])
120 mimeType := mime.TypeByExtension(ext)
121 if mimeType == "" {
122 mimeType = "application/octet-stream"
123 }
124
125 imgHeader := textproto.MIMEHeader{}
126 imgHeader.Set("Content-Type", mimeType)
127 imgHeader.Set("Content-Transfer-Encoding", "base64")
128 imgHeader.Set("Content-ID", "<"+cid+">")
129 imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
130
131 imgPart, err := relatedWriter.CreatePart(imgHeader)
132 if err != nil {
133 return err
134 }
135 // data is already base64 encoded, but needs MIME line wrapping (76 chars per line)
136 imgPart.Write([]byte(wrapBase64(string(data))))
137 }
138
139 relatedWriter.Close() // Finish the related part
140
141 // --- Attachments ---
142 for filename, data := range attachments {
143 mimeType := mime.TypeByExtension(filepath.Ext(filename))
144 if mimeType == "" {
145 mimeType = "application/octet-stream"
146 }
147
148 partHeader := textproto.MIMEHeader{}
149 partHeader.Set("Content-Type", mimeType)
150 partHeader.Set("Content-Transfer-Encoding", "base64")
151 partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
152
153 attachmentPart, err := mainWriter.CreatePart(partHeader)
154 if err != nil {
155 return err
156 }
157 encodedData := base64.StdEncoding.EncodeToString(data)
158 // MIME requires base64 to be line-wrapped at 76 characters
159 attachmentPart.Write([]byte(wrapBase64(encodedData)))
160 }
161
162 mainWriter.Close() // Finish the main message
163
164 // Combine all recipients for the envelope
165 allRecipients := append([]string{}, to...)
166 allRecipients = append(allRecipients, cc...)
167 allRecipients = append(allRecipients, bcc...)
168
169 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
170
171 // Custom SMTP dialer to support skipping TLS verification for Proton Bridge
172 c, err := smtp.Dial(addr)
173 if err != nil {
174 return err
175 }
176 defer c.Close()
177
178 if err = c.Hello("localhost"); err != nil {
179 return err
180 }
181
182 // Trigger STARTTLS if supported
183 if ok, _ := c.Extension("STARTTLS"); ok {
184 tlsConfig := &tls.Config{
185 ServerName: smtpServer,
186 InsecureSkipVerify: account.Insecure,
187 }
188 if err = c.StartTLS(tlsConfig); err != nil {
189 return err
190 }
191 }
192
193 // Authenticate
194 if auth != nil {
195 if ok, _ := c.Extension("AUTH"); ok {
196 if err = c.Auth(auth); err != nil {
197 return err
198 }
199 }
200 }
201
202 // Send Envelope
203 if err = c.Mail(account.Email); err != nil {
204 return err
205 }
206 for _, r := range allRecipients {
207 if err = c.Rcpt(r); err != nil {
208 return err
209 }
210 }
211
212 // Write Data
213 w, err := c.Data()
214 if err != nil {
215 return err
216 }
217 _, err = w.Write(msg.Bytes())
218 if err != nil {
219 return err
220 }
221 err = w.Close()
222 if err != nil {
223 return err
224 }
225
226 return c.Quit()
227}
228
229// wrapBase64 wraps base64-encoded data at 76 characters per line as required by MIME.
230func wrapBase64(data string) string {
231 const lineLength = 76
232 var result strings.Builder
233 for i := 0; i < len(data); i += lineLength {
234 end := i + lineLength
235 if end > len(data) {
236 end = len(data)
237 }
238 result.WriteString(data[i:end])
239 if end < len(data) {
240 result.WriteString("\r\n")
241 }
242 }
243 return result.String()
244}