sender.go

  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}