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/ProtonMail/go-crypto/openpgp"
 23	messagetextproto "github.com/emersion/go-message/textproto"
 24	"github.com/emersion/go-pgpmail"
 25	"github.com/floatpane/matcha/clib"
 26	"github.com/floatpane/matcha/config"
 27	"github.com/floatpane/matcha/pgp"
 28	"github.com/yuin/goldmark"
 29	"github.com/yuin/goldmark/ast"
 30	"github.com/yuin/goldmark/text"
 31	"go.mozilla.org/pkcs7"
 32)
 33
 34// xoauth2Auth implements the SMTP XOAUTH2 authentication mechanism for OAuth2.
 35// See https://developers.google.com/gmail/imap/xoauth2-protocol
 36type xoauth2Auth struct {
 37	username, token string
 38}
 39
 40func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) {
 41	resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
 42	return "XOAUTH2", []byte(resp), nil
 43}
 44
 45func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
 46	if more {
 47		// Server sent an error challenge; respond with empty to finish.
 48		return []byte{}, nil
 49	}
 50	return nil, nil
 51}
 52
 53// loginAuth implements the SMTP LOGIN authentication mechanism.
 54// Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
 55type loginAuth struct {
 56	username, password string
 57}
 58
 59func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
 60	return "LOGIN", nil, nil
 61}
 62
 63func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
 64	if !more {
 65		return nil, nil
 66	}
 67	prompt := strings.TrimSpace(string(fromServer))
 68	switch strings.ToLower(prompt) {
 69	case "username:":
 70		return []byte(a.username), nil
 71	case "password:":
 72		return []byte(a.password), nil
 73	default:
 74		return nil, fmt.Errorf("unexpected LOGIN prompt: %s", prompt)
 75	}
 76}
 77
 78// generateMessageID creates a unique Message-ID header.
 79func generateMessageID(from string) string {
 80	buf := make([]byte, 16)
 81	_, err := rand.Read(buf)
 82	if err != nil {
 83		return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
 84	}
 85	return fmt.Sprintf("<%x@%s>", buf, from)
 86}
 87
 88// containsMarkup returns true if the string contains Markdown or HTML elements.
 89func containsMarkup(body string) bool {
 90	// Parse the Markdown into an AST. We will consider most AST node kinds as
 91	// markup, but treat bare/autolinks (raw URLs) as plaintext for this
 92	// detection: if a link node's visible text equals its destination (or is
 93	// the destination wrapped in <>), we allow it.
 94	source := []byte(body)
 95	md := goldmark.New()
 96	reader := text.NewReader(source)
 97	doc := md.Parser().Parse(reader)
 98
 99	var hasMarkup bool
100	ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
101		if !entering {
102			return ast.WalkContinue, nil
103		}
104
105		switch node.Kind() {
106		case ast.KindDocument, ast.KindParagraph, ast.KindText:
107			// not considered formatting
108			return ast.WalkContinue, nil
109		case ast.KindLink:
110			// Check if this is an autolink/raw URL: the link's text equals the
111			// destination. If so, don't treat it as markup for our purposes.
112			linkNode, ok := node.(*ast.Link)
113			if !ok {
114				hasMarkup = true
115				return ast.WalkStop, nil
116			}
117
118			// Collect the visible text of the link
119			var b strings.Builder
120			for c := node.FirstChild(); c != nil; c = c.NextSibling() {
121				if txt, ok := c.(*ast.Text); ok {
122					b.Write(txt.Segment.Value(source))
123				} else {
124					// non-text content inside link -> treat as markup
125					hasMarkup = true
126					return ast.WalkStop, nil
127				}
128			}
129			linkText := b.String()
130			dest := string(linkNode.Destination)
131
132			// Normalize common autolink representations and allow them.
133			if linkText == dest || linkText == "<"+dest+">" {
134				return ast.WalkContinue, nil
135			}
136
137			// Otherwise treat as markup
138			hasMarkup = true
139			return ast.WalkStop, nil
140		default:
141			hasMarkup = true
142			return ast.WalkStop, nil
143		}
144	})
145	return hasMarkup
146}
147
148// detectPlaintextOnly returns true when the body contains only plain text
149// (no images, no attachments, no markdown/HTML formatting that requires multipart).
150func detectPlaintextOnly(body string, images, attachments map[string][]byte) bool {
151	if len(images) > 0 || len(attachments) > 0 {
152		return false
153	}
154	return !containsMarkup(body)
155}
156
157// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
158func 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, signPGP bool, encryptPGP bool) ([]byte, error) {
159	smtpServer := account.GetSMTPServer()
160	smtpPort := account.GetSMTPPort()
161
162	if smtpServer == "" {
163		return nil, fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
164	}
165
166	plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
167	loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
168
169	fromHeader := account.FormatFromHeader()
170
171	// Set top-level headers (From/To/Subject/Date/etc)
172	headers := map[string]string{
173		"From":         fromHeader,
174		"To":           strings.Join(to, ", "),
175		"Subject":      subject,
176		"Date":         time.Now().Format(time.RFC1123Z),
177		"Message-ID":   generateMessageID(account.GetSendAsEmail()),
178		"MIME-Version": "1.0",
179	}
180
181	if len(cc) > 0 {
182		headers["Cc"] = strings.Join(cc, ", ")
183	}
184
185	if inReplyTo != "" {
186		headers["In-Reply-To"] = inReplyTo
187		if len(references) > 0 {
188			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
189		} else {
190			headers["References"] = inReplyTo
191		}
192	}
193
194	// prepare final message buffer and S/MIME payload placeholder
195	var msg bytes.Buffer
196	headerOrder := []string{"From", "To", "Cc", "Subject", "Date", "Message-ID", "MIME-Version", "In-Reply-To", "References"}
197	for _, k := range headerOrder {
198		if v, ok := headers[k]; ok {
199			fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
200		}
201	}
202
203	var payloadToEncrypt []byte
204	var innerBodyBytes []byte
205	var err error
206
207	// Detect plaintext-only mode
208	plaintextOnly := detectPlaintextOnly(plainBody, images, attachments)
209
210	// If plaintext-only mode is requested, build a single text/plain part (or a multipart/signed wrapper when signing)
211	if plaintextOnly {
212		if len(images) > 0 || len(attachments) > 0 {
213			return nil, errors.New("plaintext-only messages cannot contain attachments or inline images")
214		}
215
216		// Build quoted-printable encoded body
217		var encBody bytes.Buffer
218		qp := quotedprintable.NewWriter(&encBody)
219		fmt.Fprint(qp, plainBody)
220		qp.Close()
221		encodedBody := encBody.Bytes()
222
223		// Build the canonical MIME part (headers + body) used for signing/encryption
224		var partBuf bytes.Buffer
225		fmt.Fprintf(&partBuf, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
226		fmt.Fprintf(&partBuf, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
227		partBuf.Write(encodedBody)
228		canonicalPart := partBuf.Bytes()
229
230		if signSMIME {
231			if account.SMIMECert == "" || account.SMIMEKey == "" {
232				return nil, errors.New("S/MIME certificate or key path is missing")
233			}
234
235			certData, err := os.ReadFile(account.SMIMECert)
236			if err != nil {
237				return nil, err
238			}
239			keyData, err := os.ReadFile(account.SMIMEKey)
240			if err != nil {
241				return nil, err
242			}
243
244			certBlock, _ := pem.Decode(certData)
245			if certBlock == nil {
246				return nil, errors.New("failed to parse certificate PEM")
247			}
248			cert, err := x509.ParseCertificate(certBlock.Bytes)
249			if err != nil {
250				return nil, err
251			}
252
253			keyBlock, _ := pem.Decode(keyData)
254			if keyBlock == nil {
255				return nil, errors.New("failed to parse private key PEM")
256			}
257			privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
258			if err != nil {
259				privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
260				if err != nil {
261					return nil, err
262				}
263			}
264
265			// canonicalize the part (normalize newlines)
266			canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
267			canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
268
269			signedData, err := pkcs7.NewSignedData(canonicalBody)
270			if err != nil {
271				return nil, err
272			}
273			if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
274				return nil, err
275			}
276			detachedSig, err := signedData.Finish()
277			if err != nil {
278				return nil, err
279			}
280
281			var rb [12]byte
282			var outerBoundary string
283			if _, rerr := rand.Read(rb[:]); rerr == nil {
284				outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:])
285			} else {
286				// fallback to time-based boundary if crypto/rand fails
287				outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano())
288			}
289			var signedMsg bytes.Buffer
290			fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
291			fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
292			fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
293			signedMsg.Write(canonicalBody)
294			fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
295			fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
296			fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
297			fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
298			signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
299			fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
300
301			if encryptSMIME {
302				payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
303				payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
304			} else {
305				msg.Write(signedMsg.Bytes())
306			}
307		} else {
308			// Not signing: either encrypt the canonical part or send as plain single-part
309			canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
310			canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
311			if encryptSMIME {
312				payloadToEncrypt = canonicalBody
313			} else {
314				// Write Content-Type and body as top-level single part
315				fmt.Fprintf(&msg, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
316				fmt.Fprintf(&msg, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
317				msg.Write(encodedBody)
318			}
319		}
320
321	} else {
322		// --- Non-plaintext path: build multipart/mixed with related/alternative, images and attachments ---
323		var innerMsg bytes.Buffer
324		innerWriter := multipart.NewWriter(&innerMsg)
325		innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
326
327		// --- Body Part (multipart/related) ---
328		relatedHeader := textproto.MIMEHeader{}
329		relatedBoundary := "related-" + innerWriter.Boundary()
330		relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
331		relatedPartWriter, err := innerWriter.CreatePart(relatedHeader)
332		if err != nil {
333			return nil, err
334		}
335		relatedWriter := multipart.NewWriter(relatedPartWriter)
336		relatedWriter.SetBoundary(relatedBoundary)
337
338		// --- Alternative Part (text and html) ---
339		altHeader := textproto.MIMEHeader{}
340		altBoundary := "alt-" + innerWriter.Boundary()
341		altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"")
342		altPartWriter, err := relatedWriter.CreatePart(altHeader)
343		if err != nil {
344			return nil, err
345		}
346		altWriter := multipart.NewWriter(altPartWriter)
347		altWriter.SetBoundary(altBoundary)
348
349		// Plain text part
350		textHeader := textproto.MIMEHeader{
351			"Content-Type":              {"text/plain; charset=UTF-8"},
352			"Content-Transfer-Encoding": {"quoted-printable"},
353		}
354		textPart, err := altWriter.CreatePart(textHeader)
355		if err != nil {
356			return nil, err
357		}
358		qpText := quotedprintable.NewWriter(textPart)
359		fmt.Fprint(qpText, plainBody)
360		qpText.Close()
361
362		// HTML part
363		htmlHeader := textproto.MIMEHeader{
364			"Content-Type":              {"text/html; charset=UTF-8"},
365			"Content-Transfer-Encoding": {"quoted-printable"},
366		}
367		htmlPart, err := altWriter.CreatePart(htmlHeader)
368		if err != nil {
369			return nil, err
370		}
371		qpHTML := quotedprintable.NewWriter(htmlPart)
372		fmt.Fprint(qpHTML, htmlBody)
373		qpHTML.Close()
374
375		altWriter.Close() // Finish the alternative part
376
377		// --- Inline Images ---
378		for cid, data := range images {
379			ext := filepath.Ext(strings.Split(cid, "@")[0])
380			mimeType := mime.TypeByExtension(ext)
381			if mimeType == "" {
382				mimeType = "application/octet-stream"
383			}
384
385			imgHeader := textproto.MIMEHeader{}
386			imgHeader.Set("Content-Type", mimeType)
387			imgHeader.Set("Content-Transfer-Encoding", "base64")
388			imgHeader.Set("Content-ID", "<"+cid+">")
389			imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
390
391			imgPart, err := relatedWriter.CreatePart(imgHeader)
392			if err != nil {
393				return nil, err
394			}
395			// Encode raw image bytes to base64, then wrap at 76 chars per MIME rules
396			encodedImg := base64.StdEncoding.EncodeToString(data)
397			imgPart.Write([]byte(clib.WrapBase64(encodedImg)))
398		}
399
400		relatedWriter.Close() // Finish the related part
401
402		// --- Attachments ---
403		for filename, data := range attachments {
404			mimeType := mime.TypeByExtension(filepath.Ext(filename))
405			if mimeType == "" {
406				mimeType = "application/octet-stream"
407			}
408
409			partHeader := textproto.MIMEHeader{}
410			partHeader.Set("Content-Type", mimeType)
411			partHeader.Set("Content-Transfer-Encoding", "base64")
412			partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
413
414			attachmentPart, err := innerWriter.CreatePart(partHeader)
415			if err != nil {
416				return nil, err
417			}
418			encodedData := base64.StdEncoding.EncodeToString(data)
419			// MIME requires base64 to be line-wrapped at 76 characters
420			attachmentPart.Write([]byte(clib.WrapBase64(encodedData)))
421		}
422
423		innerWriter.Close() // Finish the inner message
424
425		innerBodyBytes = append([]byte(innerHeaders), innerMsg.Bytes()...)
426
427		// If not signing, and not encrypting, write the multipart body now
428		if !signSMIME && !encryptSMIME {
429			fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
430			msg.Write(innerMsg.Bytes())
431		}
432	}
433
434	// Handle S/MIME Detached Signing for non-plaintext messages
435	if signSMIME && len(innerBodyBytes) > 0 {
436		if account.SMIMECert == "" || account.SMIMEKey == "" {
437			return nil, errors.New("S/MIME certificate or key path is missing")
438		}
439
440		certData, err := os.ReadFile(account.SMIMECert)
441		if err != nil {
442			return nil, err
443		}
444		keyData, err := os.ReadFile(account.SMIMEKey)
445		if err != nil {
446			return nil, err
447		}
448
449		certBlock, _ := pem.Decode(certData)
450		if certBlock == nil {
451			return nil, errors.New("failed to parse certificate PEM")
452		}
453		cert, err := x509.ParseCertificate(certBlock.Bytes)
454		if err != nil {
455			return nil, err
456		}
457
458		keyBlock, _ := pem.Decode(keyData)
459		if keyBlock == nil {
460			return nil, errors.New("failed to parse private key PEM")
461		}
462		privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
463		if err != nil {
464			privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
465			if err != nil {
466				return nil, err
467			}
468		}
469
470		canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
471		canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
472
473		signedData, err := pkcs7.NewSignedData(canonicalBody)
474		if err != nil {
475			return nil, err
476		}
477		if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
478			return nil, err
479		}
480		detachedSig, err := signedData.Finish()
481		if err != nil {
482			return nil, err
483		}
484
485		var rb [12]byte
486		var outerBoundary string
487		if _, rerr := rand.Read(rb[:]); rerr == nil {
488			outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:])
489		} else {
490			// fallback to time-based boundary if crypto/rand fails
491			outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano())
492		}
493		var signedMsg bytes.Buffer
494		fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
495		fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
496		fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
497		signedMsg.Write(canonicalBody)
498		fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
499		fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
500		fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
501		fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
502		signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
503		fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
504
505		if encryptSMIME {
506			payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
507			payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
508		} else {
509			msg.Write(signedMsg.Bytes())
510		}
511	}
512
513	// Handle S/MIME Encryption
514	if encryptSMIME {
515		// Include the sender's own email so it can be decrypted in the Sent folder
516		allRecipients := append([]string{account.Email}, to...)
517		allRecipients = append(allRecipients, cc...)
518		allRecipients = append(allRecipients, bcc...)
519
520		cfgDir, _ := config.GetConfigDir()
521		certsDir := filepath.Join(cfgDir, "certs")
522		var certs []*x509.Certificate
523		var missingCerts []string
524
525		for _, em := range allRecipients {
526			em = strings.TrimSpace(em)
527			if strings.Contains(em, "<") {
528				parts := strings.Split(em, "<")
529				if len(parts) == 2 {
530					em = strings.TrimSuffix(parts[1], ">")
531				}
532			}
533
534			var certPath string
535			// If this is our own account, use the path from settings rather than requiring it in the certs folder
536			if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
537				certPath = account.SMIMECert
538			} else {
539				certPath = filepath.Join(certsDir, em+".pem")
540			}
541
542			certData, err := os.ReadFile(certPath)
543			if err != nil {
544				missingCerts = append(missingCerts, em)
545				continue
546			}
547			block, _ := pem.Decode(certData)
548			if block == nil {
549				missingCerts = append(missingCerts, em)
550				continue
551			}
552			cert, err := x509.ParseCertificate(block.Bytes)
553			if err != nil {
554				missingCerts = append(missingCerts, em)
555				continue
556			}
557			certs = append(certs, cert)
558		}
559
560		if len(missingCerts) > 0 {
561			return nil, fmt.Errorf("cannot encrypt: missing or invalid S/MIME certificates for: %s", strings.Join(missingCerts, ", "))
562		}
563
564		encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
565		if err != nil {
566			return nil, err
567		}
568
569		msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
570		msg.WriteString("Content-Transfer-Encoding: base64\r\n")
571		msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
572		msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
573	}
574
575	// Handle PGP Signing (if enabled and not already signed with S/MIME)
576	var pgpPayload []byte
577	if signPGP && !signSMIME {
578		// Determine what to sign
579		var toSign []byte
580		if len(payloadToEncrypt) > 0 {
581			// We have content prepared for encryption
582			toSign = payloadToEncrypt
583		} else {
584			// Use what we've built so far
585			toSign = msg.Bytes()
586		}
587
588		signed, err := signEmailPGP(toSign, account)
589		if err != nil {
590			return nil, fmt.Errorf("PGP signing failed: %w", err)
591		}
592
593		if encryptPGP {
594			// Will encrypt the signed message
595			pgpPayload = signed
596		} else {
597			// Not encrypting, so write signed message now
598			msg.Reset()
599			msg.Write(signed)
600		}
601	}
602
603	// Handle PGP Encryption (if enabled and not already encrypted with S/MIME)
604	if encryptPGP && !encryptSMIME {
605		allRecipients := append([]string{}, to...)
606		allRecipients = append(allRecipients, cc...)
607		allRecipients = append(allRecipients, bcc...)
608
609		var toEncrypt []byte
610		if len(pgpPayload) > 0 {
611			// Encrypt the signed message
612			toEncrypt = pgpPayload
613		} else if len(payloadToEncrypt) > 0 {
614			// Encrypt pre-prepared payload
615			toEncrypt = payloadToEncrypt
616		} else {
617			// Encrypt what we've built so far
618			toEncrypt = msg.Bytes()
619		}
620
621		encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
622		if err != nil {
623			return nil, fmt.Errorf("PGP encryption failed: %w", err)
624		}
625
626		msg.Reset()
627		msg.Write(encrypted)
628	}
629
630	// Combine all recipients for the envelope
631	allRecipients := append([]string{}, to...)
632	allRecipients = append(allRecipients, cc...)
633	allRecipients = append(allRecipients, bcc...)
634
635	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
636
637	tlsConfig := &tls.Config{
638		ServerName:         smtpServer,
639		InsecureSkipVerify: account.Insecure,
640	}
641
642	var c *smtp.Client
643
644	// Port 465 uses implicit TLS (the connection starts with TLS).
645	// All other ports use plain TCP with optional STARTTLS upgrade.
646	if smtpPort == 465 {
647		conn, err := tls.Dial("tcp", addr, tlsConfig)
648		if err != nil {
649			return nil, err
650		}
651		c, err = smtp.NewClient(conn, smtpServer)
652		if err != nil {
653			conn.Close()
654			return nil, err
655		}
656	} else {
657		var err error
658		c, err = smtp.Dial(addr)
659		if err != nil {
660			return nil, err
661		}
662	}
663	defer c.Close()
664
665	if err = c.Hello("localhost"); err != nil {
666		return nil, err
667	}
668
669	// Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
670	if smtpPort != 465 {
671		if ok, _ := c.Extension("STARTTLS"); ok {
672			if err = c.StartTLS(tlsConfig); err != nil {
673				return nil, err
674			}
675		}
676	}
677
678	// Authenticate using the best available mechanism.
679	// c.Extension("AUTH") returns the list of supported mechanisms.
680	if ok, mechs := c.Extension("AUTH"); ok {
681		mechList := strings.ToUpper(mechs)
682
683		if account.IsOAuth2() {
684			// Use XOAUTH2 for OAuth2-enabled accounts
685			token, tokenErr := config.GetOAuth2Token(account.Email)
686			if tokenErr != nil {
687				return nil, fmt.Errorf("oauth2: %w", tokenErr)
688			}
689			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
690		} else if strings.Contains(mechList, "PLAIN") {
691			err = c.Auth(plainAuth)
692		} else if strings.Contains(mechList, "LOGIN") {
693			err = c.Auth(loginAuthFallback)
694		} else {
695			// Fall back to PLAIN and let the server decide
696			err = c.Auth(plainAuth)
697		}
698		if err != nil {
699			return nil, err
700		}
701	}
702
703	// Send Envelope
704	if err = c.Mail(account.GetFetchEmail()); err != nil {
705		return nil, err
706	}
707	for _, r := range allRecipients {
708		if err = c.Rcpt(r); err != nil {
709			return nil, err
710		}
711	}
712
713	// Write Data
714	w, err := c.Data()
715	if err != nil {
716		return nil, err
717	}
718	_, err = w.Write(msg.Bytes())
719	if err != nil {
720		return nil, err
721	}
722	err = w.Close()
723	if err != nil {
724		return nil, err
725	}
726
727	rawMsg := make([]byte, len(msg.Bytes()))
728	copy(rawMsg, msg.Bytes())
729
730	if err := c.Quit(); err != nil {
731		return nil, err
732	}
733
734	return rawMsg, nil
735}
736
737// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
738// Supports both file-based keys and YubiKey hardware tokens.
739func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
740	// Check if using YubiKey
741	if account.PGPKeySource == "yubikey" {
742		return signEmailPGPWithYubiKey(payload, account)
743	}
744
745	// Default to file-based signing
746	if account.PGPPrivateKey == "" {
747		return nil, errors.New("PGP private key path is missing")
748	}
749
750	// Load private key
751	keyFile, err := os.ReadFile(account.PGPPrivateKey)
752	if err != nil {
753		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
754	}
755
756	// Try to parse as armored keyring first
757	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
758	if err != nil {
759		// Try binary format
760		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
761		if err != nil {
762			return nil, fmt.Errorf("failed to parse PGP key: %w", err)
763		}
764	}
765
766	if len(entityList) == 0 {
767		return nil, errors.New("no PGP keys found in keyring")
768	}
769
770	// Decrypt the private key if it's encrypted
771	entity := entityList[0]
772	if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
773		passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
774		if err := entity.DecryptPrivateKeys(passphrase); err != nil {
775			return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
776		}
777	}
778
779	// Split payload into transport headers (From, To, Subject, etc.) and body.
780	// pgpmail.Sign needs the transport headers in its header param so they
781	// appear at the top level of the output, not inside the signed part.
782	// Content headers (Content-Type, etc.) stay with the body as the signed part.
783	var header messagetextproto.Header
784	var bodyPayload []byte
785	if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
786		headerBytes := payload[:idx]
787		rawBody := payload[idx+4:]
788
789		var contentHeaders bytes.Buffer
790		for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
791			if len(line) == 0 {
792				continue
793			}
794			parts := bytes.SplitN(line, []byte(": "), 2)
795			if len(parts) != 2 {
796				continue
797			}
798			key := string(parts[0])
799			val := string(parts[1])
800			upper := strings.ToUpper(key)
801			if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
802				// Keep content headers with the body for the signed part
803				contentHeaders.Write(line)
804				contentHeaders.WriteString("\r\n")
805			} else {
806				// Transport headers go to the top-level message
807				header.Set(key, val)
808			}
809		}
810
811		// Reconstruct body payload: content headers + blank line + body
812		contentHeaders.WriteString("\r\n")
813		contentHeaders.Write(rawBody)
814		bodyPayload = contentHeaders.Bytes()
815	} else {
816		bodyPayload = payload
817	}
818
819	// Create multipart/signed message using go-pgpmail
820	var signed bytes.Buffer
821
822	mw, err := pgpmail.Sign(&signed, header, entity, nil)
823	if err != nil {
824		return nil, fmt.Errorf("failed to create PGP signer: %w", err)
825	}
826
827	// Write the body (content headers + body) to be signed
828	if _, err := mw.Write(bodyPayload); err != nil {
829		return nil, fmt.Errorf("failed to write message for signing: %w", err)
830	}
831
832	if err := mw.Close(); err != nil {
833		return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
834	}
835
836	return signed.Bytes(), nil
837}
838
839// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
840func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
841	// Get PIN from account (loaded from keyring)
842	pin := account.PGPPIN
843	if pin == "" {
844		return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
845	}
846
847	if account.PGPPublicKey == "" {
848		return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
849	}
850
851	// Use the pgp package to sign with YubiKey
852	signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
853	if err != nil {
854		return nil, fmt.Errorf("YubiKey signing failed: %w", err)
855	}
856	return signed, nil
857}
858
859// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
860func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
861	var entityList openpgp.EntityList
862
863	cfgDir, err := config.GetConfigDir()
864	if err != nil {
865		return nil, err
866	}
867	pgpDir := filepath.Join(cfgDir, "pgp")
868
869	// Add recipient keys
870	for _, recipient := range recipients {
871		// Extract email address from "Name <email>" format
872		email := strings.TrimSpace(recipient)
873		if strings.Contains(email, "<") {
874			parts := strings.Split(email, "<")
875			if len(parts) == 2 {
876				email = strings.TrimSuffix(parts[1], ">")
877			}
878		}
879
880		// Try .asc (armored) first, then .gpg (binary)
881		var keyData []byte
882		keyPath := filepath.Join(pgpDir, email+".asc")
883		keyData, err = os.ReadFile(keyPath)
884		if err != nil {
885			keyPath = filepath.Join(pgpDir, email+".gpg")
886			keyData, err = os.ReadFile(keyPath)
887			if err != nil {
888				return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
889			}
890		}
891
892		// Try armored format first
893		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
894		if err != nil {
895			// Try binary format
896			entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
897			if err != nil {
898				return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
899			}
900		}
901
902		if len(entities) > 0 {
903			entityList = append(entityList, entities[0])
904		}
905	}
906
907	// Add sender's own key (to read in Sent folder)
908	if account.PGPPublicKey != "" {
909		senderKey, err := os.ReadFile(account.PGPPublicKey)
910		if err == nil {
911			entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
912			if entities == nil {
913				entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
914			}
915			if entities != nil && len(entities) > 0 {
916				entityList = append(entityList, entities[0])
917			}
918		}
919	}
920
921	if len(entityList) == 0 {
922		return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
923	}
924
925	// Encrypt using go-pgpmail
926	var encrypted bytes.Buffer
927
928	// Create a minimal header for the encrypted content
929	var header messagetextproto.Header
930
931	mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
932	if err != nil {
933		return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
934	}
935
936	if _, err := mw.Write(payload); err != nil {
937		return nil, fmt.Errorf("failed to write message for encryption: %w", err)
938	}
939
940	if err := mw.Close(); err != nil {
941		return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
942	}
943
944	return encrypted.Bytes(), nil
945}