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