1package sender
2
3import (
4 "bytes"
5 "crypto/rand"
6 "crypto/tls"
7 "crypto/x509"
8 "encoding/base64"
9 "encoding/pem"
10 "errors"
11 "fmt"
12 "mime"
13 "mime/multipart"
14 "mime/quotedprintable"
15 "net/smtp"
16 "net/textproto"
17 "os"
18 "path/filepath"
19 "strings"
20 "time"
21
22 "github.com/floatpane/matcha/config"
23 "go.mozilla.org/pkcs7"
24)
25
26// generateMessageID creates a unique Message-ID header.
27func generateMessageID(from string) string {
28 buf := make([]byte, 16)
29 _, err := rand.Read(buf)
30 if err != nil {
31 return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
32 }
33 return fmt.Sprintf("<%x@%s>", buf, from)
34}
35
36// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
37func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error {
38 smtpServer := account.GetSMTPServer()
39 smtpPort := account.GetSMTPPort()
40
41 if smtpServer == "" {
42 return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
43 }
44
45 auth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
46
47 fromHeader := account.FetchEmail
48 if account.Name != "" {
49 fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
50 }
51
52 // Main message buffer
53 var innerMsg bytes.Buffer
54 innerWriter := multipart.NewWriter(&innerMsg)
55 innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
56
57 // Set top-level headers for a mixed message type to support content and attachments
58 headers := map[string]string{
59 "From": fromHeader,
60 "To": strings.Join(to, ", "),
61 "Subject": subject,
62 "Date": time.Now().Format(time.RFC1123Z),
63 "Message-ID": generateMessageID(account.FetchEmail),
64 "MIME-Version": "1.0",
65 }
66
67 if len(cc) > 0 {
68 headers["Cc"] = strings.Join(cc, ", ")
69 }
70
71 if inReplyTo != "" {
72 headers["In-Reply-To"] = inReplyTo
73 if len(references) > 0 {
74 headers["References"] = strings.Join(references, " ") + " " + inReplyTo
75 } else {
76 headers["References"] = inReplyTo
77 }
78 }
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-" + innerWriter.Boundary()
84 relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
85 relatedPartWriter, err := innerWriter.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-" + innerWriter.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 textHeader := textproto.MIMEHeader{
105 "Content-Type": {"text/plain; charset=UTF-8"},
106 "Content-Transfer-Encoding": {"quoted-printable"},
107 }
108 textPart, err := altWriter.CreatePart(textHeader)
109 if err != nil {
110 return err
111 }
112 qpText := quotedprintable.NewWriter(textPart)
113 fmt.Fprint(qpText, plainBody)
114 qpText.Close()
115
116 // HTML part
117 htmlHeader := textproto.MIMEHeader{
118 "Content-Type": {"text/html; charset=UTF-8"},
119 "Content-Transfer-Encoding": {"quoted-printable"},
120 }
121 htmlPart, err := altWriter.CreatePart(htmlHeader)
122 if err != nil {
123 return err
124 }
125 qpHTML := quotedprintable.NewWriter(htmlPart)
126 fmt.Fprint(qpHTML, htmlBody)
127 qpHTML.Close()
128
129 altWriter.Close() // Finish the alternative part
130
131 // --- Inline Images ---
132 for cid, data := range images {
133 ext := filepath.Ext(strings.Split(cid, "@")[0])
134 mimeType := mime.TypeByExtension(ext)
135 if mimeType == "" {
136 mimeType = "application/octet-stream"
137 }
138
139 imgHeader := textproto.MIMEHeader{}
140 imgHeader.Set("Content-Type", mimeType)
141 imgHeader.Set("Content-Transfer-Encoding", "base64")
142 imgHeader.Set("Content-ID", "<"+cid+">")
143 imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
144
145 imgPart, err := relatedWriter.CreatePart(imgHeader)
146 if err != nil {
147 return err
148 }
149 // data is already base64 encoded, but needs MIME line wrapping (76 chars per line)
150 imgPart.Write([]byte(wrapBase64(string(data))))
151 }
152
153 relatedWriter.Close() // Finish the related part
154
155 // --- Attachments ---
156 for filename, data := range attachments {
157 mimeType := mime.TypeByExtension(filepath.Ext(filename))
158 if mimeType == "" {
159 mimeType = "application/octet-stream"
160 }
161
162 partHeader := textproto.MIMEHeader{}
163 partHeader.Set("Content-Type", mimeType)
164 partHeader.Set("Content-Transfer-Encoding", "base64")
165 partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
166
167 attachmentPart, err := innerWriter.CreatePart(partHeader)
168 if err != nil {
169 return err
170 }
171 encodedData := base64.StdEncoding.EncodeToString(data)
172 // MIME requires base64 to be line-wrapped at 76 characters
173 attachmentPart.Write([]byte(wrapBase64(encodedData)))
174 }
175
176 innerWriter.Close() // Finish the inner message
177
178 var msg bytes.Buffer
179 for k, v := range headers {
180 fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
181 }
182
183 innerBodyBytes := append([]byte(innerHeaders), innerMsg.Bytes()...)
184
185 var payloadToEncrypt []byte
186
187 // Handle S/MIME Detached Signing
188 if signSMIME {
189 if account.SMIMECert == "" || account.SMIMEKey == "" {
190 return errors.New("S/MIME certificate or key path is missing")
191 }
192
193 certData, err := os.ReadFile(account.SMIMECert)
194 if err != nil {
195 return err
196 }
197 keyData, err := os.ReadFile(account.SMIMEKey)
198 if err != nil {
199 return err
200 }
201
202 certBlock, _ := pem.Decode(certData)
203 if certBlock == nil {
204 return errors.New("failed to parse certificate PEM")
205 }
206 cert, err := x509.ParseCertificate(certBlock.Bytes)
207 if err != nil {
208 return err
209 }
210
211 keyBlock, _ := pem.Decode(keyData)
212 if keyBlock == nil {
213 return errors.New("failed to parse private key PEM")
214 }
215 privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
216 if err != nil {
217 privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
218 if err != nil {
219 return err
220 }
221 }
222
223 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
224 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
225
226 signedData, err := pkcs7.NewSignedData(canonicalBody)
227 if err != nil {
228 return err
229 }
230 if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
231 return err
232 }
233 detachedSig, err := signedData.Finish()
234 if err != nil {
235 return err
236 }
237
238 outerBoundary := "signed-" + innerWriter.Boundary()
239 var signedMsg bytes.Buffer
240 fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
241 fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
242 fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
243 signedMsg.Write(canonicalBody)
244 fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
245 fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
246 fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
247 fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
248 signedMsg.WriteString(wrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
249 fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
250
251 if encryptSMIME {
252 payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
253 payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
254 } else {
255 msg.Write(signedMsg.Bytes())
256 }
257 } else {
258 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
259 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
260
261 if encryptSMIME {
262 payloadToEncrypt = canonicalBody
263 } else {
264 fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
265 msg.Write(innerMsg.Bytes())
266 }
267 }
268
269 // Handle S/MIME Encryption
270 if encryptSMIME {
271 // Include the sender's own email so it can be decrypted in the Sent folder
272 allRecipients := append([]string{account.Email}, to...)
273 allRecipients = append(allRecipients, cc...)
274 allRecipients = append(allRecipients, bcc...)
275
276 cfgDir, _ := config.GetConfigDir()
277 certsDir := filepath.Join(cfgDir, "certs")
278 var certs []*x509.Certificate
279
280 for _, em := range allRecipients {
281 em = strings.TrimSpace(em)
282 if strings.Contains(em, "<") {
283 parts := strings.Split(em, "<")
284 if len(parts) == 2 {
285 em = strings.TrimSuffix(parts[1], ">")
286 }
287 }
288
289 var certPath string
290 // If this is our own account, use the path from settings rather than requiring it in the certs folder
291 if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
292 certPath = account.SMIMECert
293 } else {
294 certPath = filepath.Join(certsDir, em+".pem")
295 }
296
297 if certData, err := os.ReadFile(certPath); err == nil {
298 if block, _ := pem.Decode(certData); block != nil {
299 if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
300 certs = append(certs, cert)
301 }
302 }
303 }
304 }
305
306 if len(certs) == 0 {
307 return errors.New("cannot encrypt: no valid public certificates found for recipients")
308 }
309
310 encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
311 if err != nil {
312 return err
313 }
314
315 msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
316 msg.WriteString("Content-Transfer-Encoding: base64\r\n")
317 msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
318 msg.WriteString(wrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
319 }
320
321 // Combine all recipients for the envelope
322 allRecipients := append([]string{}, to...)
323 allRecipients = append(allRecipients, cc...)
324 allRecipients = append(allRecipients, bcc...)
325
326 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
327
328 // Custom SMTP dialer to support skipping TLS verification for Proton Bridge
329 c, err := smtp.Dial(addr)
330 if err != nil {
331 return err
332 }
333 defer c.Close()
334
335 if err = c.Hello("localhost"); err != nil {
336 return err
337 }
338
339 // Trigger STARTTLS if supported
340 if ok, _ := c.Extension("STARTTLS"); ok {
341 tlsConfig := &tls.Config{
342 ServerName: smtpServer,
343 InsecureSkipVerify: account.Insecure,
344 }
345 if err = c.StartTLS(tlsConfig); err != nil {
346 return err
347 }
348 }
349
350 // Authenticate
351 if auth != nil {
352 if ok, _ := c.Extension("AUTH"); ok {
353 if err = c.Auth(auth); err != nil {
354 return err
355 }
356 }
357 }
358
359 // Send Envelope
360 if err = c.Mail(account.Email); err != nil {
361 return err
362 }
363 for _, r := range allRecipients {
364 if err = c.Rcpt(r); err != nil {
365 return err
366 }
367 }
368
369 // Write Data
370 w, err := c.Data()
371 if err != nil {
372 return err
373 }
374 _, err = w.Write(msg.Bytes())
375 if err != nil {
376 return err
377 }
378 err = w.Close()
379 if err != nil {
380 return err
381 }
382
383 return c.Quit()
384}
385
386// wrapBase64 wraps base64-encoded data at 76 characters per line as required by MIME.
387func wrapBase64(data string) string {
388 const lineLength = 76
389 var result strings.Builder
390 for i := 0; i < len(data); i += lineLength {
391 end := i + lineLength
392 if end > len(data) {
393 end = len(data)
394 }
395 result.WriteString(data[i:end])
396 if end < len(data) {
397 result.WriteString("\r\n")
398 }
399 }
400 return result.String()
401}