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