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/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}