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