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