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// loginAuth implements the SMTP LOGIN authentication mechanism.
27// Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
28type loginAuth struct {
29 username, password string
30}
31
32func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
33 return "LOGIN", nil, nil
34}
35
36func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
37 if !more {
38 return nil, nil
39 }
40 prompt := strings.TrimSpace(string(fromServer))
41 switch strings.ToLower(prompt) {
42 case "username:":
43 return []byte(a.username), nil
44 case "password:":
45 return []byte(a.password), nil
46 default:
47 return nil, fmt.Errorf("unexpected LOGIN prompt: %s", prompt)
48 }
49}
50
51// generateMessageID creates a unique Message-ID header.
52func generateMessageID(from string) string {
53 buf := make([]byte, 16)
54 _, err := rand.Read(buf)
55 if err != nil {
56 return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
57 }
58 return fmt.Sprintf("<%x@%s>", buf, from)
59}
60
61// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
62func 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 {
63 smtpServer := account.GetSMTPServer()
64 smtpPort := account.GetSMTPPort()
65
66 if smtpServer == "" {
67 return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
68 }
69
70 plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
71 loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
72
73 fromHeader := account.FetchEmail
74 if account.Name != "" {
75 fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
76 }
77
78 // Main message buffer
79 var innerMsg bytes.Buffer
80 innerWriter := multipart.NewWriter(&innerMsg)
81 innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
82
83 // Set top-level headers for a mixed message type to support content and attachments
84 headers := map[string]string{
85 "From": fromHeader,
86 "To": strings.Join(to, ", "),
87 "Subject": subject,
88 "Date": time.Now().Format(time.RFC1123Z),
89 "Message-ID": generateMessageID(account.FetchEmail),
90 "MIME-Version": "1.0",
91 }
92
93 if len(cc) > 0 {
94 headers["Cc"] = strings.Join(cc, ", ")
95 }
96
97 if inReplyTo != "" {
98 headers["In-Reply-To"] = inReplyTo
99 if len(references) > 0 {
100 headers["References"] = strings.Join(references, " ") + " " + inReplyTo
101 } else {
102 headers["References"] = inReplyTo
103 }
104 }
105
106 // --- Body Part (multipart/related) ---
107 // This part contains the multipart/alternative (text/html) and any inline images.
108 relatedHeader := textproto.MIMEHeader{}
109 relatedBoundary := "related-" + innerWriter.Boundary()
110 relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
111 relatedPartWriter, err := innerWriter.CreatePart(relatedHeader)
112 if err != nil {
113 return err
114 }
115 relatedWriter := multipart.NewWriter(relatedPartWriter)
116 relatedWriter.SetBoundary(relatedBoundary)
117
118 // --- Alternative Part (text and html) ---
119 altHeader := textproto.MIMEHeader{}
120 altBoundary := "alt-" + innerWriter.Boundary()
121 altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"")
122 altPartWriter, err := relatedWriter.CreatePart(altHeader)
123 if err != nil {
124 return err
125 }
126 altWriter := multipart.NewWriter(altPartWriter)
127 altWriter.SetBoundary(altBoundary)
128
129 // Plain text part
130 textHeader := textproto.MIMEHeader{
131 "Content-Type": {"text/plain; charset=UTF-8"},
132 "Content-Transfer-Encoding": {"quoted-printable"},
133 }
134 textPart, err := altWriter.CreatePart(textHeader)
135 if err != nil {
136 return err
137 }
138 qpText := quotedprintable.NewWriter(textPart)
139 fmt.Fprint(qpText, plainBody)
140 qpText.Close()
141
142 // HTML part
143 htmlHeader := textproto.MIMEHeader{
144 "Content-Type": {"text/html; charset=UTF-8"},
145 "Content-Transfer-Encoding": {"quoted-printable"},
146 }
147 htmlPart, err := altWriter.CreatePart(htmlHeader)
148 if err != nil {
149 return err
150 }
151 qpHTML := quotedprintable.NewWriter(htmlPart)
152 fmt.Fprint(qpHTML, htmlBody)
153 qpHTML.Close()
154
155 altWriter.Close() // Finish the alternative part
156
157 // --- Inline Images ---
158 for cid, data := range images {
159 ext := filepath.Ext(strings.Split(cid, "@")[0])
160 mimeType := mime.TypeByExtension(ext)
161 if mimeType == "" {
162 mimeType = "application/octet-stream"
163 }
164
165 imgHeader := textproto.MIMEHeader{}
166 imgHeader.Set("Content-Type", mimeType)
167 imgHeader.Set("Content-Transfer-Encoding", "base64")
168 imgHeader.Set("Content-ID", "<"+cid+">")
169 imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
170
171 imgPart, err := relatedWriter.CreatePart(imgHeader)
172 if err != nil {
173 return err
174 }
175 // data is already base64 encoded, but needs MIME line wrapping (76 chars per line)
176 imgPart.Write([]byte(wrapBase64(string(data))))
177 }
178
179 relatedWriter.Close() // Finish the related part
180
181 // --- Attachments ---
182 for filename, data := range attachments {
183 mimeType := mime.TypeByExtension(filepath.Ext(filename))
184 if mimeType == "" {
185 mimeType = "application/octet-stream"
186 }
187
188 partHeader := textproto.MIMEHeader{}
189 partHeader.Set("Content-Type", mimeType)
190 partHeader.Set("Content-Transfer-Encoding", "base64")
191 partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
192
193 attachmentPart, err := innerWriter.CreatePart(partHeader)
194 if err != nil {
195 return err
196 }
197 encodedData := base64.StdEncoding.EncodeToString(data)
198 // MIME requires base64 to be line-wrapped at 76 characters
199 attachmentPart.Write([]byte(wrapBase64(encodedData)))
200 }
201
202 innerWriter.Close() // Finish the inner message
203
204 var msg bytes.Buffer
205 for k, v := range headers {
206 fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
207 }
208
209 innerBodyBytes := append([]byte(innerHeaders), innerMsg.Bytes()...)
210
211 var payloadToEncrypt []byte
212
213 // Handle S/MIME Detached Signing
214 if signSMIME {
215 if account.SMIMECert == "" || account.SMIMEKey == "" {
216 return errors.New("S/MIME certificate or key path is missing")
217 }
218
219 certData, err := os.ReadFile(account.SMIMECert)
220 if err != nil {
221 return err
222 }
223 keyData, err := os.ReadFile(account.SMIMEKey)
224 if err != nil {
225 return err
226 }
227
228 certBlock, _ := pem.Decode(certData)
229 if certBlock == nil {
230 return errors.New("failed to parse certificate PEM")
231 }
232 cert, err := x509.ParseCertificate(certBlock.Bytes)
233 if err != nil {
234 return err
235 }
236
237 keyBlock, _ := pem.Decode(keyData)
238 if keyBlock == nil {
239 return errors.New("failed to parse private key PEM")
240 }
241 privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
242 if err != nil {
243 privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
244 if err != nil {
245 return err
246 }
247 }
248
249 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
250 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
251
252 signedData, err := pkcs7.NewSignedData(canonicalBody)
253 if err != nil {
254 return err
255 }
256 if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
257 return err
258 }
259 detachedSig, err := signedData.Finish()
260 if err != nil {
261 return err
262 }
263
264 outerBoundary := "signed-" + innerWriter.Boundary()
265 var signedMsg bytes.Buffer
266 fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
267 fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
268 fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
269 signedMsg.Write(canonicalBody)
270 fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
271 fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
272 fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
273 fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
274 signedMsg.WriteString(wrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
275 fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
276
277 if encryptSMIME {
278 payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
279 payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
280 } else {
281 msg.Write(signedMsg.Bytes())
282 }
283 } else {
284 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
285 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
286
287 if encryptSMIME {
288 payloadToEncrypt = canonicalBody
289 } else {
290 fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
291 msg.Write(innerMsg.Bytes())
292 }
293 }
294
295 // Handle S/MIME Encryption
296 if encryptSMIME {
297 // Include the sender's own email so it can be decrypted in the Sent folder
298 allRecipients := append([]string{account.Email}, to...)
299 allRecipients = append(allRecipients, cc...)
300 allRecipients = append(allRecipients, bcc...)
301
302 cfgDir, _ := config.GetConfigDir()
303 certsDir := filepath.Join(cfgDir, "certs")
304 var certs []*x509.Certificate
305
306 for _, em := range allRecipients {
307 em = strings.TrimSpace(em)
308 if strings.Contains(em, "<") {
309 parts := strings.Split(em, "<")
310 if len(parts) == 2 {
311 em = strings.TrimSuffix(parts[1], ">")
312 }
313 }
314
315 var certPath string
316 // If this is our own account, use the path from settings rather than requiring it in the certs folder
317 if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
318 certPath = account.SMIMECert
319 } else {
320 certPath = filepath.Join(certsDir, em+".pem")
321 }
322
323 if certData, err := os.ReadFile(certPath); err == nil {
324 if block, _ := pem.Decode(certData); block != nil {
325 if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
326 certs = append(certs, cert)
327 }
328 }
329 }
330 }
331
332 if len(certs) == 0 {
333 return errors.New("cannot encrypt: no valid public certificates found for recipients")
334 }
335
336 encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
337 if err != nil {
338 return err
339 }
340
341 msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
342 msg.WriteString("Content-Transfer-Encoding: base64\r\n")
343 msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
344 msg.WriteString(wrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
345 }
346
347 // Combine all recipients for the envelope
348 allRecipients := append([]string{}, to...)
349 allRecipients = append(allRecipients, cc...)
350 allRecipients = append(allRecipients, bcc...)
351
352 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
353
354 tlsConfig := &tls.Config{
355 ServerName: smtpServer,
356 InsecureSkipVerify: account.Insecure,
357 }
358
359 var c *smtp.Client
360
361 // Port 465 uses implicit TLS (the connection starts with TLS).
362 // All other ports use plain TCP with optional STARTTLS upgrade.
363 if smtpPort == 465 {
364 conn, err := tls.Dial("tcp", addr, tlsConfig)
365 if err != nil {
366 return err
367 }
368 c, err = smtp.NewClient(conn, smtpServer)
369 if err != nil {
370 conn.Close()
371 return err
372 }
373 } else {
374 var err error
375 c, err = smtp.Dial(addr)
376 if err != nil {
377 return err
378 }
379 }
380 defer c.Close()
381
382 if err = c.Hello("localhost"); err != nil {
383 return err
384 }
385
386 // Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
387 if smtpPort != 465 {
388 if ok, _ := c.Extension("STARTTLS"); ok {
389 if err = c.StartTLS(tlsConfig); err != nil {
390 return err
391 }
392 }
393 }
394
395 // Authenticate using the best available mechanism.
396 // c.Extension("AUTH") returns the list of supported mechanisms.
397 if ok, mechs := c.Extension("AUTH"); ok {
398 mechList := strings.ToUpper(mechs)
399 if strings.Contains(mechList, "PLAIN") {
400 err = c.Auth(plainAuth)
401 } else if strings.Contains(mechList, "LOGIN") {
402 err = c.Auth(loginAuthFallback)
403 } else {
404 // Fall back to PLAIN and let the server decide
405 err = c.Auth(plainAuth)
406 }
407 if err != nil {
408 return err
409 }
410 }
411
412 // Send Envelope
413 if err = c.Mail(account.Email); err != nil {
414 return err
415 }
416 for _, r := range allRecipients {
417 if err = c.Rcpt(r); err != nil {
418 return err
419 }
420 }
421
422 // Write Data
423 w, err := c.Data()
424 if err != nil {
425 return err
426 }
427 _, err = w.Write(msg.Bytes())
428 if err != nil {
429 return err
430 }
431 err = w.Close()
432 if err != nil {
433 return err
434 }
435
436 return c.Quit()
437}
438
439// wrapBase64 wraps base64-encoded data at 76 characters per line as required by MIME.
440func wrapBase64(data string) string {
441 const lineLength = 76
442 var result strings.Builder
443 for i := 0; i < len(data); i += lineLength {
444 end := i + lineLength
445 if end > len(data) {
446 end = len(data)
447 }
448 result.WriteString(data[i:end])
449 if end < len(data) {
450 result.WriteString("\r\n")
451 }
452 }
453 return result.String()
454}